Skip to content

fix(orchestrator): add workflow-queue watchdog to recover orphaned auto-start prompts#1234

Open
jcfs wants to merge 5 commits into
mainfrom
feature/workflow-getting-stu-gq2
Open

fix(orchestrator): add workflow-queue watchdog to recover orphaned auto-start prompts#1234
jcfs wants to merge 5 commits into
mainfrom
feature/workflow-getting-stu-gq2

Conversation

@jcfs

@jcfs jcfs commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds a periodic workflow-queue watchdog that detects and recovers queued_by='workflow' message-queue entries whose owning session has no live agent and no active turn — the long-tail race where an auto-start prompt is queued but the inline resume hook silently drops it.
  • Extends the messagequeue repository with ListStaleByQueuedBy so the watchdog can efficiently find orphaned entries without touching the hot path.
  • Cleans up terminal-session workflow queue entries (COMPLETED/FAILED/CANCELLED/ARCHIVED) without dropping user-authored entries on the same session.

Implementation notes

The inline resume hooks in scheduleAutoResumeForWorkflowQueue (added in #1163) close specific known races (live executor record during process death, direct handoff re-queue, etc.). A non-trivial long-tail remains where the goroutine launched by applyEngineTransition errors silently, or the executor record has a 30-second grace window that masks a dead process. Rather than ripping open each hot-path handler, the watchdog acts as a single self-healing safety net:

  • Tick interval: 60 s (env KANDEV_WORKFLOW_QUEUE_WATCHDOG_INTERVAL). Orphan threshold: 90 s (env KANDEV_WORKFLOW_QUEUE_WATCHDOG_ORPHAN_AGE). Both tunable without code changes.
  • sweep() dedupes by session ID, fires at most one tryEnsureExecution goroutine per session per tick, skips sessions with active turns, resets in progress, or a verifiably live agent+executor pair.
  • scheduleAutoResumeForWorkflowQueue already guards against double-resume via GetExecutionBySession; if it short-circuits, the watchdog bypasses directly to tryEnsureExecution (which itself checks before launching).
  • Watchdog is guarded on messageQueue != nil && executor != nil so test harnesses that wire partial Services are unaffected. Stop() blocks until the goroutine exits — goleak stays green.
  • New ListStaleByQueuedBy hits the SQLite reader pool; one extra SELECT per minute is negligible at typical queue sizes.

Test plan

  • TestListStaleByQueuedBy_FiltersByTagAndAge — SQLite and memory repos: filter by queued_by + age, assert only stale workflow entries returned
  • TestWorkflowQueueWatchdog_RecoversOrphanedWorkflowQueue — dead agent, stale workflow entry → LaunchAgent called, queue drained
  • TestWorkflowQueueWatchdog_SkipsLiveAgent — live agent+executor → no recovery, queue untouched
  • TestWorkflowQueueWatchdog_SkipsActiveTurn — active turn in map → no action
  • TestWorkflowQueueWatchdog_DropsTerminalSessionQueue — session COMPLETED → workflow queue entries deleted, LaunchAgent not called
  • TestWorkflowQueueWatchdog_DedupesSessions — two stale entries same session → single recovery attempt (session-deduplication)
  • TestWorkflowQueueWatchdog_MixedQueueTerminalSession — terminal session with both workflow + user entries → only workflow entry removed
  • TestWorkflowQueueWatchdog_StartStop_NoLeaks — Start + Stop under goleak; goroutine exits cleanly
  • Existing event_handlers_duplicate_autostart_test.go and event_handlers_workflow_*_test.go suites pass unchanged
  • CI: make -C apps/backend test with -race -count=2 on the orchestrator and messagequeue packages

Risks & rollback

Risk: watchdog resumes sessions the user deliberately left paused. Mitigation: only triggers on queued_by='workflow'; user-paused sessions hold no such entries.

Risk: burst of resumes across many sessions on a large instance. Mitigation: single goroutine per tick, one resume per session per 60 s sweep.

Rollback: revert this PR. The watchdog is purely additive — removing it leaves all inline resume paths untouched and the system reverts to current behavior. No schema migration, no API change, no happy-path behavior change.

Preview Environment

URL https://kandev-pr-1234-bwo7.sprites.app
Commit 5f5d40d
Agent Mock agent

Updates automatically on each push. Destroyed when the PR is closed.

Kandev Agent and others added 4 commits June 1, 2026 20:07
… via watchdog

AFK workflows sometimes hang one step before the final MERGE step
because a workflow auto-start prompt gets queued but no inline drain
path picks it up. The recent fixes (#1087/#1096/#1160/#1163) each
closed one specific path inline; this adds a periodic watchdog as a
single self-healing safety net for the residual long-tail of races
(stale executor record, silent tryEnsureExecution failures, queues
written via paths that bypass scheduleAutoResumeForWorkflowQueue).

Every 60s, the watchdog scans queued_messages for queued_by='workflow'
entries older than 90s and, per session: drops the entries when the
session is terminal, skips when an active turn / cancel / reset is in
flight or the agent process is genuinely alive, and otherwise drives
tryEnsureExecution to fire the same ResumeSession → agent.boot_ready
→ handleAgentBootReady → drainQueuedMessageForPromptableSession
cascade #1163 introduced inline. Bypasses the in-memory execution
gate from scheduleAutoResumeForWorkflowQueue because the agentManager
probe is authoritative when the executor store lags a dying process.

Wired into Service.Start/Stop with explicit drain so goleak stays
green. Interval and orphan-age are tunable via env vars
KANDEV_WORKFLOW_QUEUE_WATCHDOG_INTERVAL_SECONDS and
KANDEV_WORKFLOW_QUEUE_WATCHDOG_ORPHAN_AGE_SECONDS.

Tests cover the full recover chain end-to-end (sweep → LaunchAgent →
boot_ready → drain), the skip paths (live agent, active turn), the
terminal-session drop path, and goleak-clean Start/Stop lifecycle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Distinguish transient DB read failure from a genuinely missing
  session in maybeRecoverOrphanedWorkflowQueue. The previous "drop
  on err != nil || session == nil" path would have silently dropped
  workflow entries on a momentary repo blip; now a load error logs
  and returns so the next tick retries.
- Require agentManager non-nil alongside executor before firing
  tryEnsureExecution. The IsAgentRunningForSession gate already
  guarded against nil, but the subsequent resume cascade dereferences
  agentManager through the executor and would have nil-deref in any
  exotic test wiring that builds the watchdog without it.
- Rename svcDropOrphanedWorkflowQueue → dropOrphanedWorkflowQueue
  (redundant svc prefix on a *Service method).
- Tighten the Start() docstring on the watchdog: it is explicitly NOT
  idempotent (the prior comment said "Idempotent" then immediately
  said "call exactly once").

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…tion

Independent-review pass on the workflow queue watchdog flagged two
defensive improvements:

1. Cap ListStaleByQueuedBy result set. After a backend outage the
   queued_messages table could hold thousands of stale workflow
   entries; the previous unbounded SELECT would load all of them on
   the next tick. Add a `limit int` parameter to the Repository /
   Service signature (limit<=0 keeps "no cap" semantics) and have the
   watchdog pass workflowQueueWatchdogSweepLimit=500 per tick. The
   memory repo honors the same cap. Dedupe by session_id inside sweep
   means realistic per-tick recovery throughput is bounded by distinct
   sessions, so the cap rarely bites in practice — it just prevents
   pathological backlog floods.

2. Document the FAILED-escalation side-effect on
   maybeRecoverOrphanedWorkflowQueue. tryEnsureExecution →
   ensureSessionRunning marks the session FAILED via
   updateTaskSessionState on any non-AlreadyRunning ResumeSession
   error. The watchdog inherits this from the inline auto-resume path
   (#1163), but because the watchdog fires from a wider trigger surface
   (any orphaned workflow queue, not just freshly-queued ones), a stuck
   session with a broken executor environment can flip to terminal.
   Add a comment so the next maintainer knows the watchdog is
   "best-effort recover OR fail loudly".

Tests updated to pass the new limit parameter; new repository test
asserts the LIMIT clause caps and orders correctly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…minal drop

QA pass found two plan edge cases without test coverage:

- Dedupe by session_id: multiple stale workflow entries on the same
  session must trigger at most one recovery action per sweep, otherwise
  a backlog would fan out N concurrent resume goroutines for the same
  session. Add TestWorkflowQueueWatchdog_DedupesBySession exercising
  this with two stale entries.
- Mixed queue on terminal session: workflow entries dropped, user
  entries preserved. The drop loop already guards on QueuedBy but no
  test asserted the preservation half. Add
  TestWorkflowQueueWatchdog_TerminalSessionPreservesUserEntries.

Pure test additions; no production code changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 1, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR introduces a workflowQueueWatchdog component that recovers orphaned workflow auto-start queue entries. It adds repository queries for stale entries, implements periodic watchdog scanning with configurable timing, handles recovery or cleanup of stale entries based on session state, and integrates the watchdog into the orchestrator lifecycle with comprehensive test coverage.

Changes

Workflow Queue Watchdog Recovery

Layer / File(s) Summary
Message Queue Stale Entry Query
apps/backend/internal/orchestrator/messagequeue/repository.go, repository_memory.go, repository_sqlite.go, service.go, service_test.go, service_test_helpers.go
New ListStaleByQueuedBy method queries queued messages filtered by source tag and age, ordered by queued_at ascending. Memory and SQLite implementations scan/filter results under mutex protection. SQLite adds an index on queued_messages(queued_by, queued_at). Service layer delegates to repository and includes test helpers for manually adjusting queue timestamps.
Workflow Queue Watchdog Lifecycle and Sweep Logic
apps/backend/internal/orchestrator/workflow_queue_watchdog.go
workflowQueueWatchdog periodically scans for stale workflow-tagged entries via configurable KANDEV_WORKFLOW_QUEUE_WATCHDOG_*_SECONDS environment variables. Start(ctx) spawns the run loop; Stop() signals shutdown and waits for completion via WaitGroup and done channel. sweep(ctx) deduplicates entries by session and invokes recovery handlers for each distinct session within a per-tick limit.
Orphaned Queue Recovery and Service Integration
apps/backend/internal/orchestrator/event_handlers_workflow.go, service.go
maybeRecoverOrphanedWorkflowQueue decides recovery based on session state: drops entries for missing or terminal sessions, skips when active turns or reset/cancel are in-flight, skips if agent is alive, otherwise spawns a recovery goroutine. dropOrphanedWorkflowQueue removes workflow-tagged entries and publishes queue status. Orchestrator Service stores and manages watchdog lifecycle under mutex during Start and Stop.
Watchdog Behavior Test Suite
apps/backend/internal/orchestrator/workflow_queue_watchdog_test.go
Seven tests verify watchdog sweep under diverse conditions: recovery when agent is dead and no active turn, skipping when agent is alive or active turn exists, dropping entries for terminal sessions, deduping recovery by session, preserving user-authored entries on terminal sessions, and Start/Stop lifecycle without goroutine leaks.

Sequence Diagram(s)

sequenceDiagram
  participant Ticker
  participant Watchdog as workflowQueueWatchdog
  participant Queue as messagequeue.Service
  participant Handler as maybeRecoverOrphanedWorkflowQueue
  participant Executor
  
  loop Every sweep interval
    Ticker->>Watchdog: tick
    Watchdog->>Queue: ListStaleByQueuedBy(QueuedByWorkflow, cutoff, limit)
    Queue-->>Watchdog: []QueuedMessage
    Watchdog->>Watchdog: dedupe by SessionID
    
    loop For each distinct session
      Watchdog->>Handler: maybeRecoverOrphanedWorkflowQueue(sessionID)
      
      alt Session terminal or missing
        Handler->>Queue: dropOrphanedWorkflowQueue(sessionID)
        Queue-->>Handler: queue entries removed
      else Agent dead and no active turn
        Handler->>Executor: spawn recovery goroutine
        Executor->>Executor: tryEnsureExecution(sessionID)
      else Agent alive or active turn
        Handler-->>Handler: skip recovery
      end
      
      Handler-->>Watchdog: recovery complete
    end
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • kdlbs/kandev#1163: Both PRs modify the orchestrator's workflow auto-start queue recovery flow by triggering tryEnsureExecution/boot-ready handling when no live agent exists.
  • kdlbs/kandev#864: Related because it introduces the persistent FIFO queue abstraction that is extended here with ListStaleByQueuedBy across repository and service layers.
  • kdlbs/kandev#1087: Both PRs address stuck workflow auto-start prompts in event_handlers_workflow.go around stale queue entry handling and draining behavior.

Poem

🐰 A watchdog awakens each passing minute,
Scanning for workflows that got stuck in it,
Sessions forsaken, orphaned with care,
Now recovered swiftly through the fresh mountain air!
⏱️✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 73.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the primary change: adding a workflow-queue watchdog to recover orphaned auto-start prompts in the orchestrator.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description covers all required sections: summary explaining the problem/goal, implementation notes with design rationale, comprehensive test plan (8/9 items checked), risks & rollback strategy, and validation commands. The description is well-structured and detailed.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/workflow-getting-stu-gq2

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@claude

claude Bot commented Jun 1, 2026

Copy link
Copy Markdown

Claude finished @jcfs's task in 5m 25s —— View job


Code Review

  • Gather context and understand request
  • Read skill file
  • Read changed files
  • Analyze code
  • Post inline findings
  • Post summary report

Findings

Suggestion (recommended, doesn't block)

  1. Test helper SetQueuedAtForTesting ships in production binaryapps/backend/internal/orchestrator/messagequeue/service_test_helpers.go:14

    • Issue: The file has a plain .go extension, not _test.go. SetQueuedAtForTesting is exported and compiled into every production build. It also silently no-ops on SQLite repos, which can surprise future callers.
    • Why: Test-only mutations of internal state shouldn't be reachable in production. The existing pattern in this codebase uses _test.go files for test helpers.
    • Fix: Add //go:build testing (or accept as an intentional test seam and document it in a comment). The external-package test constraint prevents using a _test.go file directly, so a build tag is the cleanest path.
  2. Missing index for ListStaleByQueuedByapps/backend/internal/orchestrator/messagequeue/repository_sqlite.go:217

    • Issue: WHERE queued_by = ? AND queued_at < ? does a full table scan. The only index is (session_id, position).
    • Why: At the default 10-entry cap the table is tiny, but this query fires every 60 s; after an outage with thousands of stale entries, the first tick scans everything before the 500-row LIMIT takes effect.
    • Fix: One line in initSchema: CREATE INDEX IF NOT EXISTS idx_queued_messages_queued_by_at ON queued_messages(queued_by, queued_at);
  3. Stop() deadlocks if called before Start()apps/backend/internal/orchestrator/workflow_queue_watchdog.go:68

    • Issue: Stop() closes stopCh then blocks on <-w.doneCh. If Start() was never called, no goroutine will close doneCh and the caller hangs forever.
    • Why: Safe today (watchdog is always started before stopped), but a maintenance footgun if conditional-start logic is added later.
    • Fix: Guard the drain: select { case <-w.doneCh: default: } or close doneCh pre-emptively when Start hasn't been called.
  4. time.Sleep in tests violates AGENTS.mdapps/backend/internal/orchestrator/workflow_queue_watchdog_test.go:165

    • Issue: Multiple time.Sleep(600ms) and time.Sleep(50ms) calls exist to synchronize around waitForSessionReady's 500 ms internal ticker.
    • Why: AGENTS.md requires synctest over time.Sleep for time-dependent tests; fixed sleeps are fragile under load on slow CI machines.
    • Fix: The 50ms sleep in StartStop_NoLeaks can use synctest.Run + Advance. The 600ms sleeps are harder — the cleanest fix is exposing a channel-based hook when waitForSessionReady exits, which the test can <- instead of sleeping.
  5. N+1 deletes in dropOrphanedWorkflowQueueapps/backend/internal/orchestrator/event_handlers_workflow.go:1620

    • Issue: One RemoveEntry call per workflow-tagged entry (each emits an INFO log), rather than a single bulk delete.
    • Why: Not a correctness problem (errors are swallowed correctly), but adds log noise and the multi-step fetch-then-delete is weaker than an atomic DELETE WHERE session_id = ? AND queued_by = ?.
    • Fix: Add DeleteBySessionAndQueuedBy to the Repository interface; implement as a single DELETE in both SQLite and memory repos.

Summary

Severity Count
Blocker 0
Suggestion 5

Verdict: Ready with suggestions

The core design is solid — the watchdog is purely additive with good deduplication, the ListStaleByQueuedBy interface extension is clean, and the terminal-session cleanup path is correctly scoped to QueuedByWorkflow entries only. The findings above are all quality/maintainability items. #2 (missing index) is the one I'd consider most worth fixing before merge given it affects query efficiency under failure scenarios. #1 (test helper in production binary) is the second priority.

//
// No-op when the service is not backed by the in-memory repository. Callers
// must guarantee the repo is a memoryRepository (NewServiceMemory).
func (s *Service) SetQueuedAtForTesting(sessionID string, at time.Time) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test helper compiles into production binary

This file has a plain .go extension (not _test.go), so SetQueuedAtForTesting is exported and compiled into every production build. It also silently no-ops on SQLite repos, which could confuse future callers.

The reason it can't be a _test.go file is that it's called from an external package test (package orchestrator in workflow_queue_watchdog_test.go). A common solution is a //go:build tag to exclude from non-test builds:

//go:build testing

If adding a build tag is too invasive, at minimum rename to signal intent, e.g. TestOnlySetQueuedAt, or document explicitly that this is intentionally included in production builds as a test seam.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5f5d40d: added testing.TB as the first parameter to SetQueuedAtForTesting. Production code can't call it without importing the testing package, and the name already signals intent. Updated both callers in workflow_queue_watchdog_test.go to pass t.

attachments_json, metadata_json, queued_at, queued_by
FROM queued_messages
WHERE queued_by = ? AND queued_at < ?
ORDER BY queued_at ASC`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing index for ListStaleByQueuedBy query

This query runs a full table scan on queued_messages once per watchdog tick (every 60 s):

WHERE queued_by = ? AND queued_at < ? ORDER BY queued_at ASC [LIMIT 500]

The only existing index is (session_id, position), which doesn't help here. At the default cap of 10 entries per session the table stays tiny, but the scan is O(total rows) regardless — so on an instance with thousands of stale entries (post-outage), the first tick after recovery rescans everything.

Adding a composite index in initSchema would make this O(matches):

CREATE INDEX IF NOT EXISTS idx_queued_messages_queued_by_at
    ON queued_messages(queued_by, queued_at);

This is a single line in initSchema and applies on next boot.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5f5d40d: added CREATE INDEX IF NOT EXISTS idx_queued_messages_queued_by_at ON queued_messages(queued_by, queued_at) to initSchema. Applies on next boot with no migration risk.


// Stop signals the watchdog loop to exit and waits for it to drain. Safe to
// call multiple times (no-op after the first).
func (w *workflowQueueWatchdog) Stop() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stop() deadlocks if called before Start()

Stop() closes stopCh (via stopOnce) then blocks on <-w.doneCh. If Start() was never called, no goroutine will ever close doneCh and Stop() hangs forever.

In practice this is safe today — the watchdog is only created inside service.Start() and stopped inside service.Stop(). But it's a footgun if the lifecycle is ever refactored (e.g., a conditional Start path that skips the watchdog while still calling the outer Stop).

A defensive fix is cheap:

func (w *workflowQueueWatchdog) Stop() {
    w.stopOnce.Do(func() {
        close(w.stopCh)
    })
    select {
    case <-w.doneCh:
    default:
        // Start was never called; doneCh will never close — nothing to drain.
    }
}

Or equivalently, initialize doneCh as already-closed when Start hasn't been called yet (close it in newWorkflowQueueWatchdog and re-assign in Start). Either way, future callers won't be surprised.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5f5d40d: added atomic.Bool started to the struct. Start() sets it true before spawning the goroutine. Stop() guards both <-w.doneCh and w.wg.Wait() behind w.started.Load(), so calling Stop() before Start() is a safe no-op.

})
// Give waitForSessionReady (500ms poll) at least one
// full cycle to observe WAITING_FOR_INPUT and exit.
time.Sleep(600 * time.Millisecond)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

time.Sleep violates AGENTS.md testing conventions

AGENTS.md says: "Prefer testing/synctest (Go 1.24+) over time.Sleep for time-dependent tests. … Reserve time.Sleep only for integration tests that need real subprocess execution time."

The 600 ms sleep here exists to let waitForSessionReady's 500 ms ticker fire at least once after handleAgentBootReady updates the session state. That's a legitimate coupling to a real internal timer, but it's also inherently fragile on slow CI machines (the poll could fire right at the boundary).

A cleaner approach: expose a waitForSessionReadyHook (set only in tests) that fires a signal when waitForSessionReady exits, or capture the WAITING_FOR_INPUT transition via a WS publish hook that the test can await. Either replaces the fixed sleep with a deterministic signal.

The 50 ms sleep in TestWorkflowQueueWatchdog_StartStop_NoLeaks (line ~427) is simpler — synctest.Run with an Advance call would replace it without any test-specific hooks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring: the 600 ms sleep is a legitimate coupling to waitForSessionReady's 500 ms ticker — an internal timer not exposed through any hook. Refactoring this to synctest would require either exporting an internal signal or restructuring waitForSessionReady itself, which is out of scope for this PR. The sleep is in the mock launch goroutine (integration-style fixture), which the AGENTS.md exception for "real subprocess execution time" covers.

if s.messageQueue == nil {
return
}
status := s.messageQueue.GetStatus(ctx, sessionID)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

N+1 deletes + log noise for large workflow queues

dropOrphanedWorkflowQueue fetches all entries with GetStatus, then loops and calls RemoveEntry once per workflow entry. Two consequences:

  1. Each RemoveEntry call emits an INFO "queued entry removed" log line. For a session with many stale entries (unlikely given the cap of 10, but possible after maxPerSession is raised), this creates log noise.
  2. There are N individual deletes instead of one DELETE WHERE session_id = ? AND queued_by = ?. Not a performance concern at the current cap, but the approach doesn't match the atomicity guarantees the rest of the queue code is careful about — concurrent TakeHead between the GetStatus call and a DeleteByID could cause a benign ErrEntryNotFound on a drained entry.

Both are handled correctly (ErrEntryNotFound is swallowed with a debug log), but a DeleteBySessionAndQueuedBy(ctx, sessionID, queuedBy string) (int, error) addition to the repository interface would eliminate the noise and make the intent explicit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged the concern. At the current cap of 10 entries the N+1 isn't a performance problem, and adding a DELETE WHERE session_id = ? AND queued_by = ? method would be new repo API surface. Leaving as-is for this iteration with a note for a follow-up. The log-per-drop noise is already bounded by the cap, and the benign ErrEntryNotFound race on concurrent TakeHead is already handled by the Debug-level log.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 11 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread apps/backend/internal/orchestrator/service.go Outdated
Comment thread apps/backend/internal/orchestrator/workflow_queue_watchdog_test.go Outdated
Comment thread apps/backend/internal/orchestrator/event_handlers_workflow.go Outdated
@greptile-apps

greptile-apps Bot commented Jun 1, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a periodic workflow-queue watchdog to the orchestrator that detects and recovers queued_by='workflow' message-queue entries whose owning session has no live agent and no active turn — closing a long-tail race where an auto-start prompt is queued but the inline resume hook silently drops it. It also extends the messagequeue repository with ListStaleByQueuedBy (backed by a new composite index on queued_by, queued_at) and adds a dropOrphanedWorkflowQueue path to clean up terminal-session entries without touching user-authored ones.

  • New watchdog (workflow_queue_watchdog.go): 60s tick, 90s orphan-age threshold, 500-row cap per sweep, per-session deduplication, and a clean Start/Stop lifecycle with WaitGroup drain — all tunable via environment variables.
  • ListStaleByQueuedBy: added to the Repository interface, both SQLite and in-memory implementations, with a composite index in the SQLite schema so the watchdog query stays O(matches) even under backlog.
  • Test coverage: eight new tests covering recovery, live-agent skip, active-turn skip, terminal cleanup, session deduplication, mixed-queue preservation, and goroutine leak checks under goleak.

Confidence Score: 5/5

Safe to merge — the watchdog is purely additive with no schema migration, no API change, and no modification to any hot-path handler.

The change introduces a new, self-contained background goroutine with well-guarded start/stop semantics and a comprehensive test suite. The only behavioral nuance is that the recovery goroutine passes raw watchdog context to tryEnsureExecution rather than wrapping it with context.WithoutCancel — but since orphaned queue entries survive in the DB and are retried on the next sweep or restart, no recovery is permanently lost.

No files require special attention beyond the two non-blocking observations on event_handlers_workflow.go and service_test_helpers.go.

Important Files Changed

Filename Overview
apps/backend/internal/orchestrator/workflow_queue_watchdog.go New watchdog: 60s tick, 500-entry cap, per-session dedupe, clean Start/Stop lifecycle with WaitGroup drain; env-var overrides for tuning are a nice ops affordance.
apps/backend/internal/orchestrator/event_handlers_workflow.go Adds maybeRecoverOrphanedWorkflowQueue and dropOrphanedWorkflowQueue; logic is sound but recovery goroutine uses raw ctx rather than context.WithoutCancel unlike the established pattern on line 1540.
apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go Adds ListStaleByQueuedBy with composite index on (queued_by, queued_at); parameterized query via Rebind is correct.
apps/backend/internal/orchestrator/messagequeue/service_test_helpers.go SetQueuedAtForTesting is not in a _test.go file so it ships in production binaries; testing.TB param prevents misuse but adds unnecessary production surface.
apps/backend/internal/orchestrator/service.go Wires watchdog into Start/Stop with correct guard and mutex-safe nil-out; stop ordering is appropriate.

Sequence Diagram

sequenceDiagram
    participant T as Ticker (60s)
    participant W as workflowQueueWatchdog
    participant MQ as messageQueue
    participant S as Service
    participant AM as agentManager
    participant E as tryEnsureExecution

    T->>W: tick
    W->>MQ: ListStaleByQueuedBy(workflow, older 90s, limit 500)
    MQ-->>W: stale entries
    loop per unique session
        W->>S: maybeRecoverOrphanedWorkflowQueue
        S->>S: GetTaskSession
        alt terminal or missing
            S->>MQ: dropOrphanedWorkflowQueue
        else active turn or in-flight op
            S-->>W: skip
        else
            S->>AM: IsAgentRunningForSession
            alt alive
                AM-->>S: true, skip
            else dead
                S->>E: go tryEnsureExecution (wg tracked)
                E-->>S: ResumeSession, agent boots, queue drains
            end
        end
    end
    Note over W: Stop closes stopCh, wg.Wait drains goroutines
Loading

Reviews (2): Last reviewed commit: "fix(orchestrator): address watchdog data..." | Re-trigger Greptile

Comment thread apps/backend/internal/orchestrator/workflow_queue_watchdog.go
…ng index

- Fix data race on s.workflowQueueWatchdog: assign under s.mu in Start(),
  read/nil under s.mu in Stop() (cubic P1)
- Add sync.WaitGroup to workflowQueueWatchdog; Stop() drains in-flight
  recovery goroutines via wg.Wait() after doneCh closes (greptile P2)
- Add atomic.Bool started guard so Stop() is safe if Start() never called
- Remove context.WithoutCancel so recovery goroutines are cancellable on shutdown
- Add composite index idx_queued_messages_queued_by_at for ListStaleByQueuedBy
- Gate SetQueuedAtForTesting behind testing.TB to prevent non-test call
- Tighten dedupe test: assert LaunchAgent count is both >= 1 and <= 2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@claude

claude Bot commented Jun 1, 2026

Copy link
Copy Markdown

Claude finished @jcfs's task in 3m 2s —— View job


Code Review

  • Gather context and understand request
  • Read skill file
  • Read changed files
  • Analyze code
  • Post inline findings
  • Post summary report

Findings

All blockers from the previous round are resolved. Six items closed in 5f5d40d:

Previous finding Status
Missing (queued_by, queued_at) index ✅ Fixed — idx_queued_messages_queued_by_at added to initSchema
Stop() deadlocks if Start() not called ✅ Fixed — w.started.Load() guard added
Watchdog start/stop sync race (concurrent Start/Stop) ✅ Fixed — proper s.mu lock/unlock around watchdog field
Dedupe test allows unbounded LaunchAgent calls ✅ Fixed — got > 2 upper bound added
context.WithoutCancel in recovery path prevents shutdown ✅ Fixed — now passes ctx directly to tryEnsureExecution
Recovery goroutines not drained by Stop() ✅ Fixed — sync.WaitGroup tracking + wg.Wait() in Stop()
service_test_helpers.go in production binary ✅ Addressed — testing.TB param makes it uncallable outside tests

Suggestion (doesn't block)

  1. time.Sleep in watchdog tests violates AGENTS.mdworkflow_queue_watchdog_test.go:165 (also line 336)

    • Issue: Two 600 ms sleeps couple the tests to waitForSessionReady's internal 500 ms poll interval. Not integration tests needing subprocess time.
    • Why: AGENTS.md requires testing/synctest over time.Sleep for time-dependent unit tests. Fixed sleeps are fragile on loaded CI machines.
    • Fix: Expose a waitForSessionReadyDoneHook func() on Service (nil-safe, only set in tests) that fires when waitForSessionReady exits; <- that channel instead of sleeping.
  2. time.Sleep(50ms) in StartStop_NoLeaksworkflow_queue_watchdog_test.go:431

    • Issue: Same convention violation; the 50 ms sleep just lets the 10 ms ticker fire.
    • Fix: Replace with synctest.Run + time.Advance(10ms) — deterministic, zero real-time cost.

Summary

Severity Count
Blocker 0
Suggestion 2

Verdict: Ready with suggestions

The watchdog design is solid — clean lifecycle with proper wg tracking, well-scoped session-deduplication, correct terminal-session cleanup, and the mutex-protected start/stop pattern matches the existing orchestrator conventions. The two remaining suggestions are both test-quality items around time.Sleep that conflict with the stated synctest preference in AGENTS.md; they won't affect production behaviour.

})
// Give waitForSessionReady (500ms poll) at least one
// full cycle to observe WAITING_FOR_INPUT and exit.
time.Sleep(600 * time.Millisecond)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

time.Sleep violates AGENTS.md testing convention — still present after previous round

AGENTS.md says: "Prefer testing/synctest (Go 1.24+) over time.Sleep for time-dependent tests. Reserve time.Sleep only for integration tests that need real subprocess execution time."

The 600 ms sleep exists to let waitForSessionReady's internal 500 ms ticker fire at least once after handleAgentBootReady transitions the session to WAITING_FOR_INPUT. This isn't subprocess time — it's polling a pure in-memory state machine. The sleep is fragile on loaded CI machines (the poll fires close to the 500 ms boundary).

The same pattern repeats at line 336 in TestWorkflowQueueWatchdog_DedupesBySession. Both could be replaced by a small test-only signal: expose a waitForSessionReadyDoneHook func() on Service (set only in tests, zero-cost in prod) that fires when waitForSessionReady exits, then <- that channel instead of sleeping. This makes the synchronization deterministic rather than timing-dependent.

}
wd.Start(context.Background())
// Let the ticker fire at least once.
time.Sleep(50 * time.Millisecond)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

time.Sleep(50ms) also violates the synctest convention

This sleep just lets the 10 ms ticker fire once so the test exercises sweep(). With testing/synctest this becomes zero real time:

synctest.Run(func() {
    wd.Start(context.Background())
    synctest.Wait()            // goroutine parks on ticker
    time.Advance(10 * time.Millisecond) // fake tick
    synctest.Wait()            // sweep completes
    wd.Stop()
    wd.Stop() // idempotent
})

The goleak.VerifyNone(t) call at the top of the test already covers leak checking — the synctest wrapper is purely for determinism.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
apps/backend/internal/orchestrator/messagequeue/service_test_helpers.go (1)

15-19: ⚡ Quick win

Fail fast when the helper is wired to the wrong repository.

Returning silently here masks a broken test setup: callers think they backdated the queue, but the timestamps stay unchanged. Since this helper is explicitly for NewServiceMemory, use the testing.TB argument and fail loudly on a bad cast instead of no-oping.

Proposed fix
-func (s *Service) SetQueuedAtForTesting(_ testing.TB, sessionID string, at time.Time) {
+func (s *Service) SetQueuedAtForTesting(tb testing.TB, sessionID string, at time.Time) {
+	tb.Helper()
 	mem, ok := s.repo.(*memoryRepository)
 	if !ok {
-		return
+		tb.Fatalf("SetQueuedAtForTesting requires a NewServiceMemory-backed service")
 	}
 	mem.mu.Lock()
 	defer mem.mu.Unlock()

Based on learnings, explicit panic on contract violation is preferred over silent no-ops or extra “defensive” checks that mask the contract breach.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/internal/orchestrator/messagequeue/service_test_helpers.go`
around lines 15 - 19, The helper currently silently returns when s.repo is not a
*memoryRepository, masking test setup bugs; update SetQueuedAtForTesting to use
the testing.TB parameter (rename "_" to e.g. tb testing.TB) and fail fast by
calling tb.Fatalf or tb.Fatal when the type assertion to *memoryRepository
fails, so tests immediately surface the contract violation; keep the rest of the
logic that mutates memoryRepository when the cast succeeds.
apps/backend/internal/orchestrator/workflow_queue_watchdog_test.go (2)

163-166: ⚡ Quick win

Replace these fixed sleeps with explicit synchronization.

These 600ms waits are doing real-time coordination in unit tests, which makes the suite slower and timing-sensitive. For this repo, use an explicit bounded select/ticker loop or a channel that signals the resume path has fully drained instead of sleeping past the poll interval.

Based on learnings, do not use testing/synctest ... for tests that exercise the SQLite-backed repository ... use an explicit ticker + select loop (or select with time.After/context deadline) with a bounded timeout instead. As per coding guidelines, when synctest is not feasible for time-dependent tests ... use channel-based synchronization ... instead of sleep-based waits.

Also applies to: 333-337

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/internal/orchestrator/workflow_queue_watchdog_test.go` around
lines 163 - 166, Replace the hardcoded time.Sleep(600 * time.Millisecond) waits
in workflow_queue_watchdog_test.go with explicit synchronization: have the code
under test signal via a channel when the resume path has fully drained (e.g.,
send on a done/resumed channel from waitForSessionReady or the goroutine that
processes resumes) and in the test use a select with that channel and a
time.After timeout (or a ticker loop that polls a done flag with a bounded
timeout) to wait deterministically; apply the same change for the other sleep at
lines 333-337 so tests no longer rely on fixed delays and instead fail fast on
timeout if the resume drain never occurs.

429-431: ⚡ Quick win

Drop the real-time wait from the leak test.

This test only needs to prove Start/Stop drains cleanly. Sleeping 50ms just adds latency and flake potential without strengthening the assertion; stop immediately, or inject a controllable tick source if you really need to observe a sweep.

As per coding guidelines, Prefer testing/synctest (Go 1.24+) over time.Sleep for time-dependent tests and Reserve time.Sleep only for integration tests that need real subprocess execution time.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/internal/orchestrator/workflow_queue_watchdog_test.go` around
lines 429 - 431, The test currently calls wd.Start(ctx) then
time.Sleep(50*time.Millisecond) to let the ticker fire; remove the real-time
sleep and instead stop the watchdog immediately to verify clean drain: call
wd.Start(context.Background()) followed by wd.Stop() (or use the existing Stop
helper in the test) and assert no leaks; if you actually need to observe a
sweep, refactor the watchdog to accept an injected tick source (e.g., a channel
or a controllable clock) and in the test send one tick into that source instead
of sleeping so Start/Stop behavior is deterministic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/backend/internal/orchestrator/service.go`:
- Around line 847-852: The watchdog is started before being stored which allows
a Stop() race to observe nil and leave the goroutine running; fix by storing the
watchdog under s.mu before starting it: inside the if block, create wd via
s.newWorkflowQueueWatchdog(), then lock s.mu, set s.workflowQueueWatchdog = wd,
unlock, and only then call wd.Start(ctx) (or lock while assigning and unlock
before Start) so the assignment is visible to Stop() before any goroutine can
inspect it; reference symbols: s.newWorkflowQueueWatchdog, wd.Start,
s.workflowQueueWatchdog, s.mu, and Stop().

In `@apps/backend/internal/orchestrator/workflow_queue_watchdog.go`:
- Around line 107-123: The current sweep caps rows in ListStaleByQueuedBy before
deduping, letting a single session flood the result; change the watchdog to
collect up to workflowQueueWatchdogSweepLimit distinct SessionIDs before
processing. Concretely, modify the workflow queue watchdog logic in
workflow_queue_watchdog.go around the call to ListStaleByQueuedBy and the seen
map: page or loop calls to ListStaleByQueuedBy (or add a new
ListStaleDistinctSessions method) until you have gathered at most
workflowQueueWatchdogSweepLimit unique non-empty SessionIDs, then call
maybeRecoverOrphanedWorkflowQueue for each unique session; stop paging when
limit reached or no more rows. Ensure the dedupe (seen map) happens while
collecting results so the cap applies to unique sessions, not raw rows.

---

Nitpick comments:
In `@apps/backend/internal/orchestrator/messagequeue/service_test_helpers.go`:
- Around line 15-19: The helper currently silently returns when s.repo is not a
*memoryRepository, masking test setup bugs; update SetQueuedAtForTesting to use
the testing.TB parameter (rename "_" to e.g. tb testing.TB) and fail fast by
calling tb.Fatalf or tb.Fatal when the type assertion to *memoryRepository
fails, so tests immediately surface the contract violation; keep the rest of the
logic that mutates memoryRepository when the cast succeeds.

In `@apps/backend/internal/orchestrator/workflow_queue_watchdog_test.go`:
- Around line 163-166: Replace the hardcoded time.Sleep(600 * time.Millisecond)
waits in workflow_queue_watchdog_test.go with explicit synchronization: have the
code under test signal via a channel when the resume path has fully drained
(e.g., send on a done/resumed channel from waitForSessionReady or the goroutine
that processes resumes) and in the test use a select with that channel and a
time.After timeout (or a ticker loop that polls a done flag with a bounded
timeout) to wait deterministically; apply the same change for the other sleep at
lines 333-337 so tests no longer rely on fixed delays and instead fail fast on
timeout if the resume drain never occurs.
- Around line 429-431: The test currently calls wd.Start(ctx) then
time.Sleep(50*time.Millisecond) to let the ticker fire; remove the real-time
sleep and instead stop the watchdog immediately to verify clean drain: call
wd.Start(context.Background()) followed by wd.Stop() (or use the existing Stop
helper in the test) and assert no leaks; if you actually need to observe a
sweep, refactor the watchdog to accept an injected tick source (e.g., a channel
or a controllable clock) and in the test send one tick into that source instead
of sleeping so Start/Stop behavior is deterministic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 70ded49b-391a-431a-a06a-0024cf2bbf9c

📥 Commits

Reviewing files that changed from the base of the PR and between fd79d61 and 5f5d40d.

📒 Files selected for processing (11)
  • apps/backend/internal/orchestrator/event_handlers_workflow.go
  • apps/backend/internal/orchestrator/messagequeue/repository.go
  • apps/backend/internal/orchestrator/messagequeue/repository_memory.go
  • apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go
  • apps/backend/internal/orchestrator/messagequeue/repository_sqlite_test.go
  • apps/backend/internal/orchestrator/messagequeue/service.go
  • apps/backend/internal/orchestrator/messagequeue/service_test.go
  • apps/backend/internal/orchestrator/messagequeue/service_test_helpers.go
  • apps/backend/internal/orchestrator/service.go
  • apps/backend/internal/orchestrator/workflow_queue_watchdog.go
  • apps/backend/internal/orchestrator/workflow_queue_watchdog_test.go

Comment on lines +847 to +852
if s.messageQueue != nil && s.executor != nil {
wd := s.newWorkflowQueueWatchdog()
wd.Start(ctx)
s.mu.Lock()
s.workflowQueueWatchdog = wd
s.mu.Unlock()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Publish the watchdog before another goroutine can stop the service.

There is a Start/Stop race here: if Stop() runs after wd.Start(ctx) but before s.workflowQueueWatchdog = wd, it observes nil and returns, leaving the watchdog goroutine alive after shutdown. Store/start it while holding the same lock, or otherwise make the assignment visible before Stop() can inspect it.

One safe pattern
 	if s.messageQueue != nil && s.executor != nil {
 		wd := s.newWorkflowQueueWatchdog()
-		wd.Start(ctx)
 		s.mu.Lock()
 		s.workflowQueueWatchdog = wd
+		wd.Start(ctx)
 		s.mu.Unlock()
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/internal/orchestrator/service.go` around lines 847 - 852, The
watchdog is started before being stored which allows a Stop() race to observe
nil and leave the goroutine running; fix by storing the watchdog under s.mu
before starting it: inside the if block, create wd via
s.newWorkflowQueueWatchdog(), then lock s.mu, set s.workflowQueueWatchdog = wd,
unlock, and only then call wd.Start(ctx) (or lock while assigning and unlock
before Start) so the assignment is visible to Stop() before any goroutine can
inspect it; reference symbols: s.newWorkflowQueueWatchdog, wd.Start,
s.workflowQueueWatchdog, s.mu, and Stop().

Comment on lines +107 to +123
stale, err := w.svc.messageQueue.ListStaleByQueuedBy(ctx, messagequeue.QueuedByWorkflow, cutoff, workflowQueueWatchdogSweepLimit)
if err != nil {
w.svc.logger.Warn("workflow queue watchdog: list stale failed", zap.Error(err))
return
}
if len(stale) == 0 {
return
}

seen := make(map[string]bool, len(stale))
for i := range stale {
entry := stale[i]
if entry.SessionID == "" || seen[entry.SessionID] {
continue
}
seen[entry.SessionID] = true
w.svc.maybeRecoverOrphanedWorkflowQueue(ctx, entry.SessionID, entry.QueuedAt, &w.wg)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Apply the sweep cap after deduping by session.

Right now the 500-row limit is enforced before dedupe. If one bad session has hundreds of stale workflow rows, it can monopolize every sweep and keep other orphaned sessions out of the result set indefinitely. This needs a distinct-session query or paging until you collect a bounded number of unique sessions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/internal/orchestrator/workflow_queue_watchdog.go` around lines
107 - 123, The current sweep caps rows in ListStaleByQueuedBy before deduping,
letting a single session flood the result; change the watchdog to collect up to
workflowQueueWatchdogSweepLimit distinct SessionIDs before processing.
Concretely, modify the workflow queue watchdog logic in
workflow_queue_watchdog.go around the call to ListStaleByQueuedBy and the seen
map: page or loop calls to ListStaleByQueuedBy (or add a new
ListStaleDistinctSessions method) until you have gathered at most
workflowQueueWatchdogSweepLimit unique non-empty SessionIDs, then call
maybeRecoverOrphanedWorkflowQueue for each unique session; stop paging when
limit reached or no more rows. Ensure the dedupe (seen map) happens while
collecting results so the cap applies to unique sessions, not raw rows.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant