Skip to content

fix(task-board): fix session-task linkage and stuck workflow advancement - #436

Merged
dimakis merged 8 commits into
mainfrom
fix/task-session-linkage
Jul 4, 2026
Merged

fix(task-board): fix session-task linkage and stuck workflow advancement#436
dimakis merged 8 commits into
mainfrom
fix/task-session-linkage

Conversation

@dimakis

@dimakis dimakis commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Summary

  • Orphan detection was broken: getActiveSessionIds() returned SDK sessionId UUIDs but task.session_id stores clientId — they never matched, so spawned tasks were never reclaimed when their session died
  • No bidirectional linkage: spawned task sessions never got taskContext set, so the session overview couldn't show which task a session was working on
  • Stuck workflows: when a spawned session ended (success or crash), nothing called orchestrator.tick() — the workflow stayed frozen on the completed task forever

Test plan

  • Existing 47 orchestrator tests pass
  • New test: reclaims spawned task sessions using clientId matching — verifies task: prefixed clientIds are correctly matched in orphan detection
  • Centaur review
  • Manual: start a PR shepherd workflow, verify spawned session appears in session list with task linkage
  • Manual: verify workflow advances after spawned session completes

🤖 Generated with Claude Code

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Centaur Review

Found 3 issue(s).

server/index.ts

Correct fix for a real bug — tasks stored clientIds but orphan detection compared against SDK sessionIds, so spawned tasks were never matched. The .finally() cleanup for NullTransport sessions is the right approach. Two minor style nits, one suggestion for wiring-level test coverage.

  • 🔵 style (L275): The if (registry.get(clientId)) guard before registry.remove(clientId) is redundant — remove() already handles missing entries gracefully (checks if (session) internally). The guard adds visual noise without preventing anything. Consider just calling registry.remove(clientId) directly. [fixable]
  • 🔵 style (L239): The loop for (const [clientId] of registry.entries()) { ids.add(clientId); } destructures tuples just to get the key. If SessionRegistry exposes a keys() iterator or a size/clientIds accessor, that would be cleaner. Minor — current form is clear enough. [fixable]

server/__tests__/task-orchestrator.test.ts

Correct fix for a real bug — tasks stored clientIds but orphan detection compared against SDK sessionIds, so spawned tasks were never matched. The .finally() cleanup for NullTransport sessions is the right approach. Two minor style nits, one suggestion for wiring-level test coverage.

  • 🔵 missing_tests: The .finally() cleanup in spawnSession (registry removal + tick trigger) is critical to fixing stuck workflows but is only covered by integration-level wiring in index.ts, not by a unit test. The orchestrator tests validate orphan detection logic itself, which is the important part, but a test confirming that a completed spawn removes its registry entry and triggers tick would guard against regressions in the wiring.

Comment thread server/index.ts Outdated
// Session ended (success or failure) — clean up registry entry
// (no WS close to trigger normal removal) and advance the
// orchestrator so orphan reclaim picks up unfinished tasks.
if (registry.get(clientId)) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 style: The if (registry.get(clientId)) guard before registry.remove(clientId) is redundant — remove() already handles missing entries gracefully (checks if (session) internally). The guard adds visual noise without preventing anything. Consider just calling registry.remove(clientId) directly. [fixable]

Comment thread server/index.ts
},
getActiveSessionIds: () => {
// Return clientIds — task.session_id stores clientId, not SDK sessionId.
// Using sessionId here caused orphan detection to never match spawned tasks.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 style: The loop for (const [clientId] of registry.entries()) { ids.add(clientId); } destructures tuples just to get the key. If SessionRegistry exposes a keys() iterator or a size/clientIds accessor, that would be cleaner. Minor — current form is clear enough. [fixable]

@dimakis

dimakis commented Jul 4, 2026

Copy link
Copy Markdown
Owner Author

Centaur Review

Found 3 issue(s) (1 warning).

server/__tests__/task-orchestrator.test.ts

Correct fix — the clientId/sessionId mismatch in orphan detection was a real bug that caused spawned tasks to never be reclaimed, and the missing .finally() cleanup left NullTransport sessions stranded in the registry. The fix is well-scoped with good test additions; minor suggestions for test coverage of the cleanup path and prefix consistency.

  • 🔵 missing_tests: The .finally() cleanup in index.ts (registry removal + re-tick) is untested. This is the primary mechanism that unblocks stuck workflows — when a spawned task session ends without the agent calling TaskComplete, registry cleanup + tick triggers orphan reclaim. A test exercising the spawnSession→finally→tick→reclaim flow would guard the core fix. Admittedly this is integration-level wiring in index.ts that's harder to unit test, but a test with a mock spawnSession that resolves immediately and verifies orphan reclaim would add confidence. [fixable]

server/index.ts

Correct fix — the clientId/sessionId mismatch in orphan detection was a real bug that caused spawned tasks to never be reclaimed, and the missing .finally() cleanup left NullTransport sessions stranded in the registry. The fix is well-scoped with good test additions; minor suggestions for test coverage of the cleanup path and prefix consistency.

  • 🔵 style (L241): The loop for (const [clientId] of registry.entries()) { ids.add(clientId); } can be simplified to return new Set(Array.from(registry.entries(), ([id]) => id)) or even return new Set(registry.keys()) if ConnectionRegistry exposes a keys() method. Minor — the current form is clear enough. [fixable]

server/app.ts

Correct fix — the clientId/sessionId mismatch in orphan detection was a real bug that caused spawned tasks to never be reclaimed, and the missing .finally() cleanup left NullTransport sessions stranded in the registry. The fix is well-scoped with good test additions; minor suggestions for test coverage of the cleanup path and prefix consistency.

  • 🟡 regressions (L501): The REST API session-creation path in app.ts still uses the headless: prefix for its clientIds. These sessions are now included in getActiveSessionIds() (which returns all clientIds from the registry). This is correct behavior — API-created headless sessions should not be orphan-reclaimed. However, the inconsistent prefix naming (headless: vs task:) makes it less clear which sessions are orchestrator-spawned vs API-spawned. Consider renaming the app.ts prefix too (or documenting the distinction), since both are NullTransport-based headless sessions that differ only in origin. [fixable]

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Centaur Review

Found 3 issue(s) (1 warning).

server/__tests__/task-orchestrator.test.ts

Solid bug fix — the clientId/sessionId mismatch in orphan detection was a clear correctness issue, and the .finally() wiring properly handles spawned session lifecycle. Minor gaps in test coverage for the failure path and a fragile synchrony assumption on taskContext assignment.

  • 🔵 missing_tests (L869): The new test 'tick after spawn session ends reclaims orphaned task and advances' covers the happy path (task completes, then session ends). There's no test for the failure path: a spawned session dies without completing the task. In production, .finally() fires → registry.remove()tick() → orphan reclaim resets the still-active task to pending. This is the primary scenario the .finally() wiring exists to handle and should be tested explicitly. [fixable]
  • 🔵 style (L900): The test comment says 'Simulate .finally() wiring' but calls orch.onTaskCompleted(t1.id), while the actual .finally() block calls orchestratorRef?.tick(). These are different entry points: onTaskCompleted has a state !== 'running' guard and resets spawnDepth before calling tick(). The test works because tick is idempotent, but it doesn't precisely model the production code path. Using orch.tick() directly (after manually clearing the active set) would be a more faithful simulation. [fixable]

server/index.ts

Solid bug fix — the clientId/sessionId mismatch in orphan detection was a clear correctness issue, and the .finally() wiring properly handles spawned session lifecycle. Minor gaps in test coverage for the failure path and a fragile synchrony assumption on taskContext assignment.

  • 🟡 unsafe_assumptions (L277): The comment says 'register() is synchronous inside startChat, so the session exists here', but startChat is fire-and-forget (not awaited). This depends on startChat synchronously calling registry.register() before its first await. If startChat is ever refactored to defer registration (e.g. behind an async worktree check), registry.get(clientId) will silently return undefined, the if (session) guard will skip silently, and taskContext won't be set — breaking session overview visibility with no error logged. Consider logging when session is null, or moving this assignment into startChat itself via the options bag. [fixable]


expect(spawnSession).toHaveBeenCalledWith(task.id, expect.any(String), goal.id);
expect(store.get(task.id)?.status).toBe('active');
expect(deps.setTaskContext).not.toHaveBeenCalled();

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 missing_tests: The new test 'tick after spawn session ends reclaims orphaned task and advances' covers the happy path (task completes, then session ends). There's no test for the failure path: a spawned session dies without completing the task. In production, .finally() fires → registry.remove()tick() → orphan reclaim resets the still-active task to pending. This is the primary scenario the .finally() wiring exists to handle and should be tested explicitly. [fixable]


// Simulate .finally() wiring: session removed from registry, tick called
activeClients = new Set();
orch.onTaskCompleted(t1.id);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 style: The test comment says 'Simulate .finally() wiring' but calls orch.onTaskCompleted(t1.id), while the actual .finally() block calls orchestratorRef?.tick(). These are different entry points: onTaskCompleted has a state !== 'running' guard and resets spawnDepth before calling tick(). The test works because tick is idempotent, but it doesn't precisely model the production code path. Using orch.tick() directly (after manually clearing the active set) would be a more faithful simulation. [fixable]

Comment thread server/index.ts Outdated

// Link session to task for session overview visibility.
// register() is synchronous inside startChat, so the session exists here.
const session = registry.get(clientId);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 unsafe_assumptions: The comment says 'register() is synchronous inside startChat, so the session exists here', but startChat is fire-and-forget (not awaited). This depends on startChat synchronously calling registry.register() before its first await. If startChat is ever refactored to defer registration (e.g. behind an async worktree check), registry.get(clientId) will silently return undefined, the if (session) guard will skip silently, and taskContext won't be set — breaking session overview visibility with no error logged. Consider logging when session is null, or moving this assignment into startChat itself via the options bag. [fixable]

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Centaur Review

Found 4 issue(s) (2 warning).

server/__tests__/task-orchestrator.test.ts

Correct fix for a real bug (clientId/sessionId mismatch broke orphan detection for spawned tasks), with good test coverage of the happy path, but missing test coverage for the failure-path orphan reclaim scenario that motivated the fix.

  • 🟡 missing_tests (L869): The new 'tick after spawn session ends' test simulates the .finally() wiring using orch.onTaskCompleted(t1.id), but the actual .finally() in index.ts calls orchestratorRef?.tick() directly. onTaskCompleted resets spawnDepth to 0 (line 197 of task-orchestrator.ts) while tick() does not, making the test more permissive than reality. The test should call orch.tick() directly (after manually updating store state) to match the real code path. [fixable]
  • 🟡 missing_tests: No test covers the failure path — the primary scenario this fix addresses. When a spawned session fails (.catch branch) without calling TaskComplete, the .finally() fires, registry.remove() makes the task orphaned, and tick() should reclaim it back to 'pending'. This scenario (task still 'active' when session ends) is distinct from the tested happy path (task already 'done' when session ends) and exercises the orphan detection that was previously broken. [fixable]

server/index.ts

Correct fix for a real bug (clientId/sessionId mismatch broke orphan detection for spawned tasks), with good test coverage of the happy path, but missing test coverage for the failure-path orphan reclaim scenario that motivated the fix.

  • 🔵 unsafe_assumptions (L274): The comment 'register() is synchronous inside startChat, so the session exists here' is correct today (registry.register at chat.ts:848 runs before any await in _startChatInner), but this is a fragile implicit contract. If a future refactor adds an await before register() (e.g. async validation), the registry.get(clientId) here would silently return undefined and skip the taskContext assignment. Consider moving the taskContext assignment into the spawnSession callback after setSessionId in task-orchestrator.ts, or documenting the contract at the register() call site. [fixable]
  • 🔵 style (L241): Array.from(registry.entries(), ([clientId]) => clientId) allocates an intermediate array just to feed it into a Set. A clearer alternative: new Set(registry.keys()) if SessionRegistry exposes a keys() method, or the existing loop pattern (for...of with ids.add) which avoids the allocation. [fixable]


expect(spawnSession).toHaveBeenCalledWith(task.id, expect.any(String), goal.id);
expect(store.get(task.id)?.status).toBe('active');
expect(deps.setTaskContext).not.toHaveBeenCalled();

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 missing_tests: The new 'tick after spawn session ends' test simulates the .finally() wiring using orch.onTaskCompleted(t1.id), but the actual .finally() in index.ts calls orchestratorRef?.tick() directly. onTaskCompleted resets spawnDepth to 0 (line 197 of task-orchestrator.ts) while tick() does not, making the test more permissive than reality. The test should call orch.tick() directly (after manually updating store state) to match the real code path. [fixable]

Comment thread server/index.ts
orchestratorRef?.tick();
});
});

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 unsafe_assumptions: The comment 'register() is synchronous inside startChat, so the session exists here' is correct today (registry.register at chat.ts:848 runs before any await in _startChatInner), but this is a fragile implicit contract. If a future refactor adds an await before register() (e.g. async validation), the registry.get(clientId) here would silently return undefined and skip the taskContext assignment. Consider moving the taskContext assignment into the spawnSession callback after setSessionId in task-orchestrator.ts, or documenting the contract at the register() call site. [fixable]

Comment thread server/index.ts
// Return clientIds — task.session_id stores clientId, not SDK sessionId.
// Using sessionId here caused orphan detection to never match spawned tasks.
return new Set(Array.from(registry.entries(), ([clientId]) => clientId));
},

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 style: Array.from(registry.entries(), ([clientId]) => clientId) allocates an intermediate array just to feed it into a Set. A clearer alternative: new Set(registry.keys()) if SessionRegistry exposes a keys() method, or the existing loop pattern (for...of with ids.add) which avoids the allocation. [fixable]

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Centaur Review

Found 4 issue(s).

server/__tests__/task-orchestrator.test.ts

Correct fix for a real bug — getActiveSessionIds was returning SDK sessionIds but setSessionId stores clientIds, causing orphan detection to silently skip all spawned tasks. The .finally() wiring and taskContext linkage are well-structured. All findings are suggestions; no blocking issues.

  • 🔵 missing_tests (L895): The 'tick after spawn session completes advances workflow' test doesn't assert that spawnSession was called exactly once. Adding expect(spawnSession).toHaveBeenCalledTimes(1) after verifying t2 is active would prove t2 used the reuse path (not spawn), which is the key workflow-advancement contract being tested. [fixable]

server/index.ts

Correct fix for a real bug — getActiveSessionIds was returning SDK sessionIds but setSessionId stores clientIds, causing orphan detection to silently skip all spawned tasks. The .finally() wiring and taskContext linkage are well-structured. All findings are suggestions; no blocking issues.

  • 🔵 style (L240): Minor: Array.from(registry.entries(), ([clientId]) => clientId) iterates key-value pairs but only uses keys. Consider adding a keys() method to SessionRegistry (it wraps a Map) for cleaner intent, or at minimum a comment explaining why entries() is used (because no keys() exists). [fixable]
  • 🔵 unsafe_assumptions (L289): The taskContext linkage assumes registry.get(clientId) succeeds because register() runs synchronously before startChat's first await. The assumption is currently valid (verified: no await before register() in _startChatInner), and the log.warn fallback is good defensive coding. However, this is a fragile coupling — if someone adds an await before register() in chat.ts, this breaks silently. Consider adding a brief code comment in chat.ts near register() noting that callers depend on its synchronous execution relative to the function's return. [fixable]
  • 🔵 style (L243): The task: prefix for spawned sessions is a good improvement over headless:, but app.ts:501 still uses headless: for externally-created headless sessions (POST /api/sessions). The inconsistency is intentional (different use cases), but worth noting that both code paths create NullTransport sessions — only the task path gets .finally() cleanup. If external headless sessions also need orphan-aware cleanup in the future, the same pattern would be needed there.


// Agent completed the task during the session
store.update(t1.id, { status: 'done' });
store.cascadeStatus(t1.id);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 missing_tests: The 'tick after spawn session completes advances workflow' test doesn't assert that spawnSession was called exactly once. Adding expect(spawnSession).toHaveBeenCalledTimes(1) after verifying t2 is active would prove t2 used the reuse path (not spawn), which is the key workflow-advancement contract being tested. [fixable]

Comment thread server/index.ts Outdated
return ids;
// Return clientIds — task.session_id stores clientId, not SDK sessionId.
// Using sessionId here caused orphan detection to never match spawned tasks.
return new Set(Array.from(registry.entries(), ([clientId]) => clientId));

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 style: Minor: Array.from(registry.entries(), ([clientId]) => clientId) iterates key-value pairs but only uses keys. Consider adding a keys() method to SessionRegistry (it wraps a Map) for cleaner intent, or at minimum a comment explaining why entries() is used (because no keys() exists). [fixable]

Comment thread server/index.ts Outdated
// so the session exists here. Log if that assumption ever breaks.
const session = registry.get(clientId);
if (session) {
session.taskContext = { currentTaskId: taskId, goalId };

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 unsafe_assumptions: The taskContext linkage assumes registry.get(clientId) succeeds because register() runs synchronously before startChat's first await. The assumption is currently valid (verified: no await before register() in _startChatInner), and the log.warn fallback is good defensive coding. However, this is a fragile coupling — if someone adds an await before register() in chat.ts, this breaks silently. Consider adding a brief code comment in chat.ts near register() noting that callers depend on its synchronous execution relative to the function's return. [fixable]

Comment thread server/index.ts
},
spawnSession: async (taskId: string, prompt: string, goalId: string) => {
const clientId = `headless:${generateWtId()}`;
const clientId = `task:${generateWtId()}`;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 style: The task: prefix for spawned sessions is a good improvement over headless:, but app.ts:501 still uses headless: for externally-created headless sessions (POST /api/sessions). The inconsistency is intentional (different use cases), but worth noting that both code paths create NullTransport sessions — only the task path gets .finally() cleanup. If external headless sessions also need orphan-aware cleanup in the future, the same pattern would be needed there.

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Centaur Review

Found 3 issue(s) (1 warning).

server/index.ts

Solid fix for the clientId-vs-sessionId mismatch in orphan detection and the missing spawn lifecycle cleanup. The .finally() handler should guard against stale goals to avoid overriding manual pauses.

  • 🟡 unsafe_assumptions (L275): The .finally() handler resumes a paused orchestrator without checking if it's still on the same goal. If a user manually pauses the orchestrator (/api/loop/pause) while a spawned session is still in-flight, the .finally() will override the manual pause by calling resume(). The spawnSession closure already has goalId in scope — add a guard like orchestratorRef.getStatus().goalId === goalId to only advance the same goal's workflow, matching the existing guard pattern in the orchestrator's spawn .then() callback (task-orchestrator.ts:364). [fixable]
  • 🔵 style (L240): new Set(Array.from(registry.entries(), ([clientId]) => clientId)) allocates an intermediate array. The registry is a Map wrapper, so this could be simplified to new Set(registry.sessions.keys()) — though that would require exposing keys() on SessionRegistry. Consider adding a keys() method to SessionRegistry for a cleaner API. Minor — current approach is correct, just slightly more allocation than needed. [fixable]

server/__tests__/task-orchestrator.test.ts

Solid fix for the clientId-vs-sessionId mismatch in orphan detection and the missing spawn lifecycle cleanup. The .finally() handler should guard against stale goals to avoid overriding manual pauses.

  • 🔵 missing_tests: No test covers the .finally() path when the orchestrator has been stopped (state='idle') before the spawned session finishes. In production, tick() returns immediately (guarded by state !== 'running'), so it's safe — but a test would document this behavior and prevent regressions if the guard changes. [fixable]

Comment thread server/index.ts Outdated
// After all tasks are spawned, the orchestrator pauses (no pending
// tasks left). tick() is a no-op when paused, so resume() is needed
// to re-enter the running state and trigger orphan reclaim.
if (orchestratorRef) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 unsafe_assumptions: The .finally() handler resumes a paused orchestrator without checking if it's still on the same goal. If a user manually pauses the orchestrator (/api/loop/pause) while a spawned session is still in-flight, the .finally() will override the manual pause by calling resume(). The spawnSession closure already has goalId in scope — add a guard like orchestratorRef.getStatus().goalId === goalId to only advance the same goal's workflow, matching the existing guard pattern in the orchestrator's spawn .then() callback (task-orchestrator.ts:364). [fixable]

Comment thread server/index.ts Outdated
return ids;
// Return clientIds — task.session_id stores clientId, not SDK sessionId.
// Using sessionId here caused orphan detection to never match spawned tasks.
return new Set(Array.from(registry.entries(), ([clientId]) => clientId));

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 style: new Set(Array.from(registry.entries(), ([clientId]) => clientId)) allocates an intermediate array. The registry is a Map wrapper, so this could be simplified to new Set(registry.sessions.keys()) — though that would require exposing keys() on SessionRegistry. Consider adding a keys() method to SessionRegistry for a cleaner API. Minor — current approach is correct, just slightly more allocation than needed. [fixable]

@dimakis

dimakis commented Jul 4, 2026

Copy link
Copy Markdown
Owner Author

Centaur Review

Found 3 issue(s).

server/task-orchestrator.ts

Correct fix for a real bug — getActiveSessionIds was returning SDK sessionIds while tasks store clientIds, causing orphan detection to never match. The .finally() handler for lifecycle cleanup is well-structured. Main suggestion: rename getActiveSessionIdsgetActiveClientIds to prevent re-introduction of the semantic mismatch.

  • 🔵 style (L37): The interface field getActiveSessionIds and its JSDoc ("Get active session IDs for orphan detection") now returns clientIds, not SDK sessionIds. The comment in index.ts:238-239 explains this, but the contract-level name is misleading — a future contributor wiring a new implementation could reasonably return SDK sessionIds and re-introduce the bug this PR fixes. Consider renaming to getActiveClientIds (with a matching update to the orphan detection call site) to make the contract self-documenting. [fixable]

server/__tests__/task-orchestrator.test.ts

Correct fix for a real bug — getActiveSessionIds was returning SDK sessionIds while tasks store clientIds, causing orphan detection to never match. The .finally() handler for lifecycle cleanup is well-structured. Main suggestion: rename getActiveSessionIdsgetActiveClientIds to prevent re-introduction of the semantic mismatch.

  • 🔵 missing_tests: No test covers the .finally() handler firing after the orchestrator has been stop()-ed (e.g., user manually stops the loop while spawned sessions are still running). The code is safe — tick() guards on state !== 'running' and resume() guards on state !== 'paused' — but a test would document that invariant and prevent regressions if the guards change. [fixable]

server/index.ts

Correct fix for a real bug — getActiveSessionIds was returning SDK sessionIds while tasks store clientIds, causing orphan detection to never match. The .finally() handler for lifecycle cleanup is well-structured. Main suggestion: rename getActiveSessionIdsgetActiveClientIds to prevent re-introduction of the semantic mismatch.

  • 🔵 unsafe_assumptions (L287): The comment says register() runs synchronously before startChat's first await — this is true today but is a coupling to startChat's internal implementation order. The fallback log.warn mitigates this well. Just flagging that if startChat is ever refactored to defer registration (e.g., after worktree creation), this would silently degrade. The logging is the right defense; no action needed.

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Centaur Review

Found 3 issue(s).

server/index.ts

Solid bug fix — the root cause (clientId vs sessionId mismatch in orphan detection) is correctly identified and fixed, with good test coverage for the new .finally() lifecycle wiring. No correctness issues found.

  • 🔵 unsafe_assumptions (L275): The .finally() handler reads orchestratorRef.getStatus().state and branches on paused vs other. If the orchestrator was stop()ped between spawn and session completion (user stopped the loop), calling tick() is harmless (returns early due to state !== 'running'), and resume() won't fire since state is idle not paused. This is safe — but a brief log line in the else branch of the orchestratorRef check (or when state is idle) would help debug "session ended after loop stopped" situations in production. [fixable]
  • 🔵 style (L240): new Set(Array.from(registry.entries(), ([clientId]) => clientId)) can be simplified to new Set(registry.keys()) if SessionRegistry exposes a keys() method, or new Set(Array.from(registry.entries()).map(([k]) => k)) isn't more readable. Current form works but Array.from(iterable, mapFn) with destructuring is slightly unusual. Consider adding a keys() method to SessionRegistry — it would make this intent obvious. [fixable]

server/__tests__/task-orchestrator.test.ts

Solid bug fix — the root cause (clientId vs sessionId mismatch in orphan detection) is correctly identified and fixed, with good test coverage for the new .finally() lifecycle wiring. No correctness issues found.

  • 🔵 missing_tests (L906): The "resume after spawn session dies" test covers the crash → reclaim → re-dispatch path well, but doesn't verify the reclaimed task's status transitions (active → pending → active). Adding expect(store.get(task.id)!.status).toBe('pending') before the resume() call would document the pre-condition more clearly and guard against store-level regressions. [fixable]

Comment thread server/index.ts Outdated
// After all tasks are spawned, the orchestrator pauses (no pending
// tasks left). tick() is a no-op when paused, so resume() is needed
// to re-enter the running state and trigger orphan reclaim.
if (orchestratorRef) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 unsafe_assumptions: The .finally() handler reads orchestratorRef.getStatus().state and branches on paused vs other. If the orchestrator was stop()ped between spawn and session completion (user stopped the loop), calling tick() is harmless (returns early due to state !== 'running'), and resume() won't fire since state is idle not paused. This is safe — but a brief log line in the else branch of the orchestratorRef check (or when state is idle) would help debug "session ended after loop stopped" situations in production. [fixable]

Comment thread server/index.ts Outdated
return ids;
// Return clientIds — task.session_id stores clientId, not SDK sessionId.
// Using sessionId here caused orphan detection to never match spawned tasks.
return new Set(Array.from(registry.entries(), ([clientId]) => clientId));

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 style: new Set(Array.from(registry.entries(), ([clientId]) => clientId)) can be simplified to new Set(registry.keys()) if SessionRegistry exposes a keys() method, or new Set(Array.from(registry.entries()).map(([k]) => k)) isn't more readable. Current form works but Array.from(iterable, mapFn) with destructuring is slightly unusual. Consider adding a keys() method to SessionRegistry — it would make this intent obvious. [fixable]

expect(orch.getStatus().activeTaskId).toBe(t2.id);
});

it('resume after spawn session dies reclaims unfinished task', async () => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 missing_tests: The "resume after spawn session dies" test covers the crash → reclaim → re-dispatch path well, but doesn't verify the reclaimed task's status transitions (active → pending → active). Adding expect(store.get(task.id)!.status).toBe('pending') before the resume() call would document the pre-condition more clearly and guard against store-level regressions. [fixable]

@dimakis

dimakis commented Jul 4, 2026

Copy link
Copy Markdown
Owner Author

Centaur Review

Found 4 issue(s) (1 warning).

server/task-orchestrator.ts

Correct fix for a real bug (clientId vs sessionId mismatch in orphan detection). The .finally() handler for spawn lifecycle is well-guarded. A few stale comments and minor test gaps, plus a subtle change in getActiveSessionIds scope that's benign today but could surprise future callers.

  • 🔵 style (L40): JSDoc still says "Spawn a new headless session" and line 349 says "Spawn a dedicated headless session" despite the PR renaming the clientId prefix from headless: to task:. These comments should be updated for consistency. [fixable]

server/__tests__/task-orchestrator.test.ts

Correct fix for a real bug (clientId vs sessionId mismatch in orphan detection). The .finally() handler for spawn lifecycle is well-guarded. A few stale comments and minor test gaps, plus a subtle change in getActiveSessionIds scope that's benign today but could surprise future callers.

  • 🔵 missing_tests: No test covers the .finally() handler's goal-change guard: if the user starts a new goal while an old spawned session is still in-flight, the .finally() handler should not resume/tick the orchestrator (because getStatus().goalId !== oldGoalId). The 'stopped' test covers the idle-state guard but not the goal-mismatch case. [fixable]
  • 🔵 missing_tests: No test covers the taskContext assignment path (index.ts lines 289-297) — specifically the case where registry.get(clientId) returns undefined (the warn-log branch). This is a defensive guard worth exercising even if it can't happen today, since it documents an assumption about startChat's synchronous register behavior. [fixable]

server/index.ts

Correct fix for a real bug (clientId vs sessionId mismatch in orphan detection). The .finally() handler for spawn lifecycle is well-guarded. A few stale comments and minor test gaps, plus a subtle change in getActiveSessionIds scope that's benign today but could surprise future callers.

  • 🟡 regressions (L241): getActiveSessionIds() now returns ALL clientIds from the registry (including headless:* sessions created via POST /api/sessions in app.ts). Previously it returned SDK sessionIds which effectively excluded headless sessions. This is benign today — getOrphaned() only matches tasks with a non-null session_id, and only the orchestrator's setSessionId sets it (using task:* prefix) — but any future code that calls store.setSessionId() with a headless:* clientId would create a phantom liveness match. Consider filtering to task:* prefixed entries if the active set should only cover orchestrator-managed sessions. [fixable]

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Centaur Review

Found 5 issue(s) (2 warning).

server/index.ts

Solid fix — correctly aligns orphan detection to use clientIds instead of SDK sessionIds, adds proper cleanup via .finally(), and includes good test coverage for the main scenarios. The few findings are low-severity: a missing edge-case test for goal-change, and minor style/coupling observations.

  • 🟡 unsafe_assumptions (L275): The .finally() callback checks orchestratorRef.getStatus().goalId === goalId to decide whether to advance. However, when the orchestrator is in running state with the same goalId, calling tick() is safe but could double-dispatch if another source (e.g., SignalProcessor at line 183, or onTaskCompleted) triggers tick() in the same microtask queue. Since Node.js is single-threaded and tick() is synchronous this is safe today, but the lack of a re-entrancy guard makes it fragile if tick() ever becomes async. Low risk given current design, noting for awareness.
  • 🟡 unsafe_assumptions (L294): The comment says register() runs synchronously before startChat's first await — this assumes startChat calls registry.register(clientId, ...) synchronously before its first await. If startChat ever changes to do an async operation before registering (e.g., async worktree creation), registry.get(clientId) would return undefined, and taskContext would silently not be set. The log.warn fallback correctly detects this, but the session would run without taskContext, which could cause the session overview to miss it. The defensive logging is good; just flagging the coupling.
  • 🔵 style (L267): The .finally() block is 22 lines with branching logic (paused vs running, goal match vs mismatch). Consider extracting this into a named function (e.g., onTaskSessionEnded(clientId, taskId, goalId)) to improve readability and make the intent self-documenting. The current inline approach works but is dense for a fire-and-forget chain. [fixable]
  • 🔵 style (L243): The prefix was renamed from headless: to task: which is clearer. However, log.info on line 256 still says 'task session resolved' and line 261 says 'task session failed' — these are fine. But the catch block at line 306 still says 'failed to spawn session for task', which could be confused with line 261's 'task session failed' during log searching. Consider differentiating: one is the async session failure (agent error) and the other is the synchronous spawn failure (e.g., transport creation). Minor log clarity issue. [fixable]

server/__tests__/task-orchestrator.test.ts

Solid fix — correctly aligns orphan detection to use clientIds instead of SDK sessionIds, adds proper cleanup via .finally(), and includes good test coverage for the main scenarios. The few findings are low-severity: a missing edge-case test for goal-change, and minor style/coupling observations.

  • 🔵 missing_tests: The new tests cover the happy paths (tick advances, resume reclaims, idle no-op), but there's no test for the goal-changed guard: what happens when .finally() fires but the orchestrator has moved to a different goalId (via stop()+start(newGoal))? The production code handles it (line 275 checks goalId === goalId), but there's no test asserting that the old goal's tasks are left untouched. [fixable]

Comment thread server/index.ts Outdated
// Only advance if the orchestrator is still on the same goal.
// A user may have paused manually or started a new goal while
// this session was in-flight — don't override that.
if (orchestratorRef && orchestratorRef.getStatus().goalId === goalId) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 unsafe_assumptions: The .finally() callback checks orchestratorRef.getStatus().goalId === goalId to decide whether to advance. However, when the orchestrator is in running state with the same goalId, calling tick() is safe but could double-dispatch if another source (e.g., SignalProcessor at line 183, or onTaskCompleted) triggers tick() in the same microtask queue. Since Node.js is single-threaded and tick() is synchronous this is safe today, but the lack of a re-entrancy guard makes it fragile if tick() ever becomes async. Low risk given current design, noting for awareness.

Comment thread server/index.ts Outdated
// Link session to task for session overview visibility.
// register() runs synchronously before startChat's first await,
// so the session exists here. Log if that assumption ever breaks.
const session = registry.get(clientId);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 unsafe_assumptions: The comment says register() runs synchronously before startChat's first await — this assumes startChat calls registry.register(clientId, ...) synchronously before its first await. If startChat ever changes to do an async operation before registering (e.g., async worktree creation), registry.get(clientId) would return undefined, and taskContext would silently not be set. The log.warn fallback correctly detects this, but the session would run without taskContext, which could cause the session overview to miss it. The defensive logging is good; just flagging the coupling.

Comment thread server/index.ts
error: (err as Error).message,
});
})
.finally(() => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 style: The .finally() block is 22 lines with branching logic (paused vs running, goal match vs mismatch). Consider extracting this into a named function (e.g., onTaskSessionEnded(clientId, taskId, goalId)) to improve readability and make the intent self-documenting. The current inline approach works but is dense for a fire-and-forget chain. [fixable]

Comment thread server/index.ts
},
spawnSession: async (taskId: string, prompt: string, goalId: string) => {
const clientId = `headless:${generateWtId()}`;
const clientId = `task:${generateWtId()}`;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 style: The prefix was renamed from headless: to task: which is clearer. However, log.info on line 256 still says 'task session resolved' and line 261 says 'task session failed' — these are fine. But the catch block at line 306 still says 'failed to spawn session for task', which could be confused with line 261's 'task session failed' during log searching. Consider differentiating: one is the async session failure (agent error) and the other is the synchronous spawn failure (e.g., transport creation). Minor log clarity issue. [fixable]

dimakis and others added 5 commits July 4, 2026 11:50
Three bugs fixed:

1. Orphan detection used SDK sessionIds but task.session_id stores
   clientIds — IDs never matched, so spawned tasks were never reclaimed.
   Changed getActiveSessionIds to return clientIds from the registry.

2. Spawned task sessions never got taskContext set, making them invisible
   in the session overview (no link back to the task they're working on).
   Now sets taskContext immediately after spawn.

3. When a spawned session ended (success or crash), nothing triggered
   orchestrator.tick() — the workflow stayed stuck on the completed task
   forever. Added .finally() cleanup that removes the registry entry and
   advances the orchestrator.

Also renamed clientId prefix from 'headless:' to 'task:' — these are
task-spawned sessions, not headless.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove redundant registry.get() guard before remove() (style)
- Simplify getActiveSessionIds loop with Array.from (style)
- Add test for spawn-end → reclaim → advance workflow (missing_tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…trator

Centaur review surfaced that tick() is a no-op when the orchestrator is
paused. After spawning all tasks, the orchestrator pauses (no pending
tasks left). When a spawned session ends, .finally() must call resume()
(not tick()) to re-enter the running state and trigger orphan reclaim.

Also:
- Add failure-path test: session dies without completing → resume →
  orphan reclaim → task re-dispatched (spawnSession called twice)
- Fix happy-path test to use tick() directly instead of onTaskCompleted
- Add log warning when taskContext can't be set (sync assumption guard)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Guard .finally() handler with goalId check to prevent overriding
  manual pauses or new goals started while session was in-flight
- Avoid intermediate array allocation in getActiveSessionIds
- Add test: tick is a no-op when orchestrator stopped before session ends

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…og, test assertion

- Add SessionRegistry.keys() method and use it in getActiveSessionIds
- Log when .finally() fires after orchestrator loop has already stopped
- Add status pre-condition assertion in resume-after-crash test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dimakis
dimakis force-pushed the fix/task-session-linkage branch from 2a1b80f to a3fabae Compare July 4, 2026 10:53

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Centaur Review

Found 3 issue(s).

server/__tests__/task-orchestrator.test.ts

Solid fix for a real bug — orphan detection was comparing clientIds against SDK sessionIds, so it never matched spawned tasks. The `.finally() handler for registry cleanup and orchestrator advancement is well-guarded against all edge cases (stopped, goal changed, paused). Tests cover the critical paths. Only minor suggestions.

  • 🔵 missing_tests: The .finally() handler in server/index.ts has an else branch for when the goal has changed or the orchestrator was stopped (logs 'task session ended after loop stopped or goal changed'). The 'stopped' case is tested ('tick is a no-op when orchestrator is stopped'), but the 'goal changed while session in-flight' scenario is not — e.g., user starts a new goal while a spawned session from the old goal is still running. Consider adding a test where goalId no longer matches. [fixable]

packages/harness/src/session-registry.ts

Solid fix for a real bug — orphan detection was comparing clientIds against SDK sessionIds, so it never matched spawned tasks. The `.finally() handler for registry cleanup and orchestrator advancement is well-guarded against all edge cases (stopped, goal changed, paused). Tests cover the critical paths. Only minor suggestions.

  • 🔵 missing_tests: No unit test file exists for SessionRegistry. The new keys() method is a trivial one-liner, but there are no tests for any of the registry's methods (entries, get, remove, etc.). Low priority since these are thin wrappers over Map, but worth noting for coverage. [fixable]

server/index.ts

Solid fix for a real bug — orphan detection was comparing clientIds against SDK sessionIds, so it never matched spawned tasks. The `.finally() handler for registry cleanup and orchestrator advancement is well-guarded against all edge cases (stopped, goal changed, paused). Tests cover the critical paths. Only minor suggestions.

  • 🔵 style: Minor: orchestratorRef.getStatus() is called twice in the .finally() handler (once to check goalId, once to check state). Since this is synchronous single-threaded code there's no correctness issue, but a single const status = orchestratorRef.getStatus() would be marginally cleaner. [fixable]

dimakis and others added 2 commits July 4, 2026 12:04
Add test for goal-changed-while-session-in-flight scenario. Extract
double getStatus() call to single variable in .finally() handler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Early return when orchestratorRef is null so TypeScript can narrow
the type for subsequent method calls.

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

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Centaur Review

Found 3 issue(s) (1 warning).

server/index.ts

Solid fix for a real bug where orphan detection never matched spawned tasks due to clientId vs sessionId mismatch. The .finally() handler for session lifecycle advancement is well-guarded with goal comparison. Tests are comprehensive. Minor concern: wrap orchestrator calls in .finally() with try-catch to avoid unhandled rejections on store errors.

  • 🟡 bugs (L268): The .finally() handler calls orchestratorRef.tick() or .resume() without a try-catch. These methods call into TaskStore (SQLite queries) which can throw on database errors. Since .finally() is on a promise chain, an exception here becomes an unhandled promise rejection — the session cleanup (registry.remove) has already happened, but the error is silently swallowed in production or crashes the process depending on Node.js --unhandled-rejections mode. Wrap the orchestrator calls in try-catch with a log.error fallback. [fixable]
  • 🔵 style (L243): The prefix changed from 'headless:' to 'task:' which is a clearer name. However, no migration handling exists for tasks in SQLite that may still reference 'headless:' session_ids from before this change. On server restart, such tasks would be orphaned and reclaimed (correct behavior), but worth noting in the commit message that this is intentional — the prefix change applies only to new sessions.

server/__tests__/task-orchestrator.test.ts

Solid fix for a real bug where orphan detection never matched spawned tasks due to clientId vs sessionId mismatch. The .finally() handler for session lifecycle advancement is well-guarded with goal comparison. Tests are comprehensive. Minor concern: wrap orchestrator calls in .finally() with try-catch to avoid unhandled rejections on store errors.

  • 🔵 missing_tests (L946): The 'resume after spawn session dies' test relies on the .then() callback from the mock spawnSession setting sessionId on the task (via queueMicrotask flushing during vi.waitFor). This implicit dependency makes the test fragile — if vi.waitFor timing changes, the sessionId might not be set when resume() runs, causing orphan detection to miss the task (no session_id = no orphan). Consider adding an explicit assertion that task.sessionId is set before simulating the crash, e.g. expect(store.get(task.id)!.sessionId).toBe('task:spawned-1') before clearing activeClients. [fixable]

Comment thread server/index.ts
error: (err as Error).message,
});
})
.finally(() => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 bugs: The .finally() handler calls orchestratorRef.tick() or .resume() without a try-catch. These methods call into TaskStore (SQLite queries) which can throw on database errors. Since .finally() is on a promise chain, an exception here becomes an unhandled promise rejection — the session cleanup (registry.remove) has already happened, but the error is silently swallowed in production or crashes the process depending on Node.js --unhandled-rejections mode. Wrap the orchestrator calls in try-catch with a log.error fallback. [fixable]

Comment thread server/index.ts
},
spawnSession: async (taskId: string, prompt: string, goalId: string) => {
const clientId = `headless:${generateWtId()}`;
const clientId = `task:${generateWtId()}`;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 style: The prefix changed from 'headless:' to 'task:' which is a clearer name. However, no migration handling exists for tasks in SQLite that may still reference 'headless:' session_ids from before this change. On server restart, such tasks would be orphaned and reclaimed (correct behavior), but worth noting in the commit message that this is intentional — the prefix change applies only to new sessions.

expect(orch.getStatus().activeTaskId).toBe(t2.id);
});

it('resume after spawn session dies reclaims unfinished task', async () => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 missing_tests: The 'resume after spawn session dies' test relies on the .then() callback from the mock spawnSession setting sessionId on the task (via queueMicrotask flushing during vi.waitFor). This implicit dependency makes the test fragile — if vi.waitFor timing changes, the sessionId might not be set when resume() runs, causing orphan detection to miss the task (no session_id = no orphan). Consider adding an explicit assertion that task.sessionId is set before simulating the crash, e.g. expect(store.get(task.id)!.sessionId).toBe('task:spawned-1') before clearing activeClients. [fixable]

…ardening

Wrap orchestrator calls in .finally() handler with try-catch to prevent
unhandled rejections on store errors. Add explicit sessionId assertion
in crash-reclaim test to guard against timing fragility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dimakis

dimakis commented Jul 4, 2026

Copy link
Copy Markdown
Owner Author

Centaur Review

Found 5 issue(s) (1 warning).

packages/harness/src/session-registry.ts

Core bug fix is correct — orphan detection was comparing clientIds against SDK sessionIds, causing spawned tasks to never match. The .finally() lifecycle hook and goal-guard logic are well-designed. Tests cover the main scenarios (orphan reclaim, idle guard, goal switch) but one test title is misleading and the startChat-rejection retry loop is untested.

  • 🔵 missing_tests (L158): New keys() method has no unit test in packages/harness/__tests__/session-registry.test.ts. It's a trivial delegation to Map.keys(), but every other accessor (get, entries) is exercised through tests. A one-liner test would keep coverage consistent. [fixable]

server/task-orchestrator.ts

Core bug fix is correct — orphan detection was comparing clientIds against SDK sessionIds, causing spawned tasks to never match. The .finally() lifecycle hook and goal-guard logic are well-designed. Tests cover the main scenarios (orphan reclaim, idle guard, goal switch) but one test title is misleading and the startChat-rejection retry loop is untested.

  • 🔵 style (L36): The JSDoc for getActiveSessionIds says "session IDs" but the PR's whole point is that these are clientIds, not SDK sessionIds. Updating the doc to /** Get active client IDs (registry keys) for orphan detection */ would prevent the same confusion from recurring. [fixable]

server/__tests__/task-orchestrator.test.ts

Core bug fix is correct — orphan detection was comparing clientIds against SDK sessionIds, causing spawned tasks to never match. The .finally() lifecycle hook and goal-guard logic are well-designed. Tests cover the main scenarios (orphan reclaim, idle guard, goal switch) but one test title is misleading and the startChat-rejection retry loop is untested.

  • 🔵 missing_tests (L915): Test "tick after spawn session completes advances workflow" is misleading: t2 (reuse policy) is already dispatched to active during the initial start() tick chain (spawn t1 → microtask tick → dispatch t2 as reuse). The subsequent orch.tick() is actually a no-op that pauses the orchestrator. The test passes but doesn't verify that .finally() tick advances anything — it only confirms it doesn't break already-advanced state. Consider either (a) making both tasks spawn-policy so t2 stays pending until t1 completes, or (b) renaming the test to reflect what it actually asserts. [fixable]

server/index.ts

Core bug fix is correct — orphan detection was comparing clientIds against SDK sessionIds, causing spawned tasks to never match. The .finally() lifecycle hook and goal-guard logic are well-designed. Tests cover the main scenarios (orphan reclaim, idle guard, goal switch) but one test title is misleading and the startChat-rejection retry loop is untested.

  • 🟡 missing_tests (L261): The .catch() handler (startChat rejection) logs the error but doesn't mark the task as failed/blocked. When startChat throws, .finally() fires → registry.remove()tick() → orphan reclaim resets the task to pending → re-dispatch → potentially infinite retry loop. This differs from the orchestrator's own spawn-failure path (line 397 in task-orchestrator.ts) which marks the task blocked with an annotation. No test covers the startChat-rejection → .finally() → orphan-reclaim cycle to verify it terminates. [fixable]
  • 🔵 unsafe_assumptions (L282): When .finally() calls orchestratorRef.tick() directly (state is 'running'), spawnDepth is not reset — only broadcastAndTick() and explicit event handlers (onTaskCompleted, etc.) reset it. In practice this is safe because reaching state='running' implies a prior reset, but calling tick() with a potentially stale spawnDepth is fragile if new call sites are added later. The resume() path correctly resets via broadcastAndTick(). [fixable]

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Centaur Review

Found 3 issue(s).

server/__tests__/task-orchestrator.test.ts

Solid bug fix — the core issue (sessionId vs clientId mismatch in orphan detection) is correctly identified and fixed, the .finally() handler has proper guards against goal drift and state transitions, and the new tests cover the key lifecycle scenarios (success advancement, crash recovery, idle no-op, goal change).

  • 🔵 missing_tests: The .finally() handler in index.ts has a resume() path (when status.state === 'paused') and a tick() path (otherwise). The 'resume after spawn session dies' test at line 946 exercises the resume path by calling orch.resume() directly, but doesn't test the goal-change guard on that path. Consider adding a test where the orchestrator is paused AND the goal has changed, verifying that .resume() on the new goal doesn't cause unexpected behavior from the old session's finally handler. This is low-risk since the .finally() guard in index.ts checks status.goalId === goalId before either branch. [fixable]

server/index.ts

Solid bug fix — the core issue (sessionId vs clientId mismatch in orphan detection) is correctly identified and fixed, the .finally() handler has proper guards against goal drift and state transitions, and the new tests cover the key lifecycle scenarios (success advancement, crash recovery, idle no-op, goal change).

  • 🔵 style (L243): Prefix changed from headless: to task: — this is a good semantic improvement, but any external logging/monitoring/alerting that greps for headless: will silently stop matching. If there are log queries or dashboards filtering on the old prefix, they should be updated. Not a code issue, but worth noting in the PR description.
  • 🔵 unsafe_assumptions (L240): The new getActiveSessionIds returns ALL clientIds from the registry via registry.keys(), including regular browser-connected sessions. Previously it only returned SDK sessionIds from active sessions. This means orphan detection now compares task session_id (which stores clientIds like task:abc123) against ALL registry keys (which also include browser client IDs). This works correctly because browser clientIds will never match a task:* sessionId stored on a task, but it's a broader set than strictly necessary. Not a bug — just a noted semantic change.

Comment thread server/index.ts
},
spawnSession: async (taskId: string, prompt: string, goalId: string) => {
const clientId = `headless:${generateWtId()}`;
const clientId = `task:${generateWtId()}`;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 style: Prefix changed from headless: to task: — this is a good semantic improvement, but any external logging/monitoring/alerting that greps for headless: will silently stop matching. If there are log queries or dashboards filtering on the old prefix, they should be updated. Not a code issue, but worth noting in the PR description.

Comment thread server/index.ts
return ids;
// Return clientIds — task.session_id stores clientId, not SDK sessionId.
// Using sessionId here caused orphan detection to never match spawned tasks.
return new Set(registry.keys());

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 unsafe_assumptions: The new getActiveSessionIds returns ALL clientIds from the registry via registry.keys(), including regular browser-connected sessions. Previously it only returned SDK sessionIds from active sessions. This means orphan detection now compares task session_id (which stores clientIds like task:abc123) against ALL registry keys (which also include browser client IDs). This works correctly because browser clientIds will never match a task:* sessionId stored on a task, but it's a broader set than strictly necessary. Not a bug — just a noted semantic change.

@dimakis
dimakis merged commit b828456 into main Jul 4, 2026
1 check passed

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Centaur Review

Found 2 issue(s) (2 warning).

server/index.ts

The core fix (clientId-based orphan detection replacing broken sessionId matching) is correct and well-tested with comprehensive edge-case coverage; the one gap is that .finally() cannot distinguish user-initiated pause from auto-pause, which could override a manual pause.

  • 🟡 bugs (L286): The .finally() handler calls resume() whenever state === 'paused', but cannot distinguish between auto-pause (all tasks dispatched — correct to resume) and user-initiated pause() (should stay paused). If a user manually pauses the orchestrator while spawned sessions are in-flight, the next session to end will call resume() and override the user's intent. Consider tracking a manuallyPaused flag on the orchestrator, or changing the .finally() to always call tick() — and having tick() internally handle orphan reclaim even in paused state. [fixable]

server/__tests__/task-orchestrator.test.ts

The core fix (clientId-based orphan detection replacing broken sessionId matching) is correct and well-tested with comprehensive edge-case coverage; the one gap is that .finally() cannot distinguish user-initiated pause from auto-pause, which could override a manual pause.

  • 🟡 missing_tests: No test for 'user manually pauses while a spawned session is in-flight; session ends; orchestrator should remain paused'. The existing 'does not resume old goal when paused orchestrator moved to a new goal' test covers the goal-change guard but not the same-goal manual-pause scenario. This is the complement of the auto-pause/resume test at line 939 and would expose the warning above. [fixable]

Comment thread server/index.ts
orchestratorRef.tick();
}
} else {
log.info('task session ended after loop stopped or goal changed', {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 bugs: The .finally() handler calls resume() whenever state === 'paused', but cannot distinguish between auto-pause (all tasks dispatched — correct to resume) and user-initiated pause() (should stay paused). If a user manually pauses the orchestrator while spawned sessions are in-flight, the next session to end will call resume() and override the user's intent. Consider tracking a manuallyPaused flag on the orchestrator, or changing the .finally() to always call tick() — and having tick() internally handle orphan reclaim even in paused state. [fixable]

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