From 84f1fa6fc1c57d9ac469b112b16a5faf0b47eed1 Mon Sep 17 00:00:00 2001 From: dimakis Date: Thu, 2 Jul 2026 03:00:16 +0100 Subject: [PATCH 1/3] fix(task-board): set task context on spawned sessions Spawned task sessions never had taskContext set on their registry entry, so TaskComplete calls failed with 400 "No active task context" and tasks stayed stuck on "running" forever. Add setTaskContextForClient dep to OrchestratorDeps and call it in the spawn success path, so spawned agents can complete tasks and trigger the next step. Co-Authored-By: Claude Opus 4.6 --- server/__tests__/task-orchestrator.test.ts | 49 ++++++++++++++++++++++ server/index.ts | 6 +++ server/task-orchestrator.ts | 3 ++ 3 files changed, 58 insertions(+) diff --git a/server/__tests__/task-orchestrator.test.ts b/server/__tests__/task-orchestrator.test.ts index 6bee3465..65eb4e37 100644 --- a/server/__tests__/task-orchestrator.test.ts +++ b/server/__tests__/task-orchestrator.test.ts @@ -839,6 +839,55 @@ describe('TaskOrchestrator', () => { expect(deps.setTaskContext).not.toHaveBeenCalled(); }); + it('sets task context on spawned session via setTaskContextForClient', async () => { + const spawnSession = vi.fn().mockResolvedValue('spawned-client-1'); + const deps = createTestDeps(store); + deps.spawnSession = spawnSession; + deps.setTaskContextForClient = vi.fn(); + const orch = new TaskOrchestrator(deps); + + const goal = store.create({ title: 'Goal' }); + const task = store.create({ + title: 'Spawn task', + parentId: goal.id, + sessionPolicy: 'spawn', + }); + + orch.start(goal.id); + + await vi.waitFor(() => { + expect(deps.setTaskContextForClient).toHaveBeenCalledWith( + 'spawned-client-1', + task.id, + goal.id, + ); + }); + }); + + it('does not set task context when spawn returns null (fallback path)', async () => { + const spawnSession = vi.fn().mockResolvedValue(null); + const deps = createTestDeps(store); + deps.spawnSession = spawnSession; + deps.setTaskContextForClient = vi.fn(); + const orch = new TaskOrchestrator(deps); + + const goal = store.create({ title: 'Goal' }); + store.create({ + title: 'Spawn task', + parentId: goal.id, + sessionPolicy: 'spawn', + }); + + orch.start(goal.id); + + await vi.waitFor(() => { + // Fallback path uses setTaskContext (pinned), not setTaskContextForClient + expect(deps.setTaskContext).toHaveBeenCalled(); + }); + + expect(deps.setTaskContextForClient).not.toHaveBeenCalled(); + }); + it('reuse policy tasks use pinned session as before', () => { const spawnSession = vi.fn().mockResolvedValue('spawned-client-1'); const deps = createTestDeps(store); diff --git a/server/index.ts b/server/index.ts index 8cb4640d..f9b78b08 100644 --- a/server/index.ts +++ b/server/index.ts @@ -206,6 +206,12 @@ const orchestrator = new TaskOrchestrator({ } } }, + setTaskContextForClient: (clientId, taskId, goalId) => { + const session = registry.get(clientId); + if (session) { + session.taskContext = { currentTaskId: taskId, goalId }; + } + }, clearTaskContext: () => { for (const [clientId] of registry.entries()) { const session = registry.get(clientId); diff --git a/server/task-orchestrator.ts b/server/task-orchestrator.ts index aa5d68f7..d60579d6 100644 --- a/server/task-orchestrator.ts +++ b/server/task-orchestrator.ts @@ -37,6 +37,8 @@ export interface OrchestratorDeps { getActiveSessionIds?: () => Set; /** Register a signal watch for a wait_for_signal task */ watchSignal?: (taskId: string, gateConfig: GateConfig) => void; + /** Set task context on a specific client (for spawned sessions). */ + setTaskContextForClient?: (clientId: string, taskId: string, goalId: string) => void; /** Spawn a new headless session for a task. Returns clientId or null on failure. */ spawnSession?: (taskId: string, prompt: string, goalId: string) => Promise; } @@ -365,6 +367,7 @@ export class TaskOrchestrator { if (clientId) { this.deps.store.setSessionId(next.id, clientId); + this.deps.setTaskContextForClient?.(clientId, next.id, capturedGoalId); log.info('spawned session for task', { taskId: next.id, clientId }); } else { // Spawn returned null (e.g. worktree failure) — fall back to pinned session From 2da3ca3eded03ee51908d08f1ff770d57614828b Mon Sep 17 00:00:00 2001 From: dimakis Date: Thu, 2 Jul 2026 23:27:34 +0100 Subject: [PATCH 2/3] fix(task-board): pass taskContext to startChat for spawned sessions Address Centaur finding: buildTaskMcpServer runs synchronously during startChat before setTaskContextForClient could be called, so spawned sessions never got the task-board MCP tools wired up. Pass taskContext as a startChat option so it's set on the session during registration, before MCP server construction. Co-Authored-By: Claude Opus 4.6 --- server/chat.ts | 5 +++++ server/index.ts | 1 + 2 files changed, 6 insertions(+) diff --git a/server/chat.ts b/server/chat.ts index 50579b77..0a8e681b 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -745,6 +745,7 @@ export async function startChat( onSessionResolved?: (sessionId: string) => void; telosTaskId?: string; agentName?: string; + taskContext?: { currentTaskId: string; goalId: string }; }, ) { return withSpanAsync( @@ -775,6 +776,7 @@ async function _startChatInner( onSessionResolved?: (sessionId: string) => void; telosTaskId?: string; agentName?: string; + taskContext?: { currentTaskId: string; goalId: string }; }, ) { const abortController = new AbortController(); @@ -862,6 +864,9 @@ async function _startChatInner( const session = registry.get(clientId)!; session.model = options.model ?? session.model; session.inputQueue = inputQueue as { push: (msg: unknown) => void; close: () => void }; + if (options.taskContext) { + session.taskContext = options.taskContext; + } _onSessionChange?.(clientId, 'start'); // Session state machine: mark CREATED (Phase 1 — write only, no behavior change) diff --git a/server/index.ts b/server/index.ts index f9b78b08..4a95a080 100644 --- a/server/index.ts +++ b/server/index.ts @@ -261,6 +261,7 @@ const orchestrator = new TaskOrchestrator({ mode: 'agent', isolation: true, telosTaskId: goalId, + taskContext: { currentTaskId: taskId, goalId }, onSessionResolved: (sessionId) => { log.info('spawned headless session resolved', { taskId, sessionId, clientId }); sseRegistry.broadcast('sessions_changed', {}); From f11ab313ea879aa330fbb783b39aa456553f8cdc Mon Sep 17 00:00:00 2001 From: dimakis Date: Thu, 2 Jul 2026 23:42:25 +0100 Subject: [PATCH 3/3] refactor(task-board): consolidate taskContext ownership in startChat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove setTaskContextForClient — startChat options are the single owner of taskContext for spawned sessions. Removes redundant double-set found by Centaur review. Strengthen fallback test assertion with exact args. Co-Authored-By: Claude Opus 4.6 --- server/__tests__/task-orchestrator.test.ts | 24 ++++++++-------------- server/index.ts | 6 ------ server/task-orchestrator.ts | 3 --- 3 files changed, 9 insertions(+), 24 deletions(-) diff --git a/server/__tests__/task-orchestrator.test.ts b/server/__tests__/task-orchestrator.test.ts index 65eb4e37..a3e026f4 100644 --- a/server/__tests__/task-orchestrator.test.ts +++ b/server/__tests__/task-orchestrator.test.ts @@ -839,15 +839,14 @@ describe('TaskOrchestrator', () => { expect(deps.setTaskContext).not.toHaveBeenCalled(); }); - it('sets task context on spawned session via setTaskContextForClient', async () => { + it('does not call setTaskContext (pinned) for spawned sessions', async () => { const spawnSession = vi.fn().mockResolvedValue('spawned-client-1'); const deps = createTestDeps(store); deps.spawnSession = spawnSession; - deps.setTaskContextForClient = vi.fn(); const orch = new TaskOrchestrator(deps); const goal = store.create({ title: 'Goal' }); - const task = store.create({ + store.create({ title: 'Spawn task', parentId: goal.id, sessionPolicy: 'spawn', @@ -856,23 +855,21 @@ describe('TaskOrchestrator', () => { orch.start(goal.id); await vi.waitFor(() => { - expect(deps.setTaskContextForClient).toHaveBeenCalledWith( - 'spawned-client-1', - task.id, - goal.id, - ); + expect(spawnSession).toHaveBeenCalled(); }); + + // Spawned sessions get taskContext via startChat options, not setTaskContext + expect(deps.setTaskContext).not.toHaveBeenCalled(); }); - it('does not set task context when spawn returns null (fallback path)', async () => { + it('falls back to setTaskContext (pinned) when spawn returns null', async () => { const spawnSession = vi.fn().mockResolvedValue(null); const deps = createTestDeps(store); deps.spawnSession = spawnSession; - deps.setTaskContextForClient = vi.fn(); const orch = new TaskOrchestrator(deps); const goal = store.create({ title: 'Goal' }); - store.create({ + const task = store.create({ title: 'Spawn task', parentId: goal.id, sessionPolicy: 'spawn', @@ -881,11 +878,8 @@ describe('TaskOrchestrator', () => { orch.start(goal.id); await vi.waitFor(() => { - // Fallback path uses setTaskContext (pinned), not setTaskContextForClient - expect(deps.setTaskContext).toHaveBeenCalled(); + expect(deps.setTaskContext).toHaveBeenCalledWith(task.id, goal.id); }); - - expect(deps.setTaskContextForClient).not.toHaveBeenCalled(); }); it('reuse policy tasks use pinned session as before', () => { diff --git a/server/index.ts b/server/index.ts index 4a95a080..77d0bc7e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -206,12 +206,6 @@ const orchestrator = new TaskOrchestrator({ } } }, - setTaskContextForClient: (clientId, taskId, goalId) => { - const session = registry.get(clientId); - if (session) { - session.taskContext = { currentTaskId: taskId, goalId }; - } - }, clearTaskContext: () => { for (const [clientId] of registry.entries()) { const session = registry.get(clientId); diff --git a/server/task-orchestrator.ts b/server/task-orchestrator.ts index d60579d6..aa5d68f7 100644 --- a/server/task-orchestrator.ts +++ b/server/task-orchestrator.ts @@ -37,8 +37,6 @@ export interface OrchestratorDeps { getActiveSessionIds?: () => Set; /** Register a signal watch for a wait_for_signal task */ watchSignal?: (taskId: string, gateConfig: GateConfig) => void; - /** Set task context on a specific client (for spawned sessions). */ - setTaskContextForClient?: (clientId: string, taskId: string, goalId: string) => void; /** Spawn a new headless session for a task. Returns clientId or null on failure. */ spawnSession?: (taskId: string, prompt: string, goalId: string) => Promise; } @@ -367,7 +365,6 @@ export class TaskOrchestrator { if (clientId) { this.deps.store.setSessionId(next.id, clientId); - this.deps.setTaskContextForClient?.(clientId, next.id, capturedGoalId); log.info('spawned session for task', { taskId: next.id, clientId }); } else { // Spawn returned null (e.g. worktree failure) — fall back to pinned session