From cbdf12eb30ce0fdd410c89733a8f9a761d4dae1d Mon Sep 17 00:00:00 2001 From: jackjinke Date: Tue, 30 Jun 2026 01:25:08 +0800 Subject: [PATCH 1/2] Defer goal continuation while tasks are active --- dist/server.js | 15 +++++++++++++-- src/server.ts | 14 ++++++++++++-- test/server.test.ts | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/dist/server.js b/dist/server.js index 24c26c1..4887790 100644 --- a/dist/server.js +++ b/dist/server.js @@ -1144,7 +1144,7 @@ class TaskTracker { } async refreshLiveChildren(client, parentSessionID) { const session = client.session; - if (!session.children || !session.status) + if (!session.children) return; let childIDs; try { @@ -1154,7 +1154,8 @@ class TaskTracker { } catch { return; } - if (childIDs.length === 0) + this.markAbsentRunningChildren(parentSessionID, new Set(childIDs)); + if (childIDs.length === 0 || !session.status) return; let statuses; try { @@ -1240,6 +1241,16 @@ class TaskTracker { continue; this.snapshotIdleHolds.delete(key); this.settledSnapshotIdleTasks.add(key); + const task = this.tasks.get(hold.taskID); + if (task?.parentSessionID === hold.parentSessionID && task.state === "running") + this.tasks.delete(hold.taskID); + } + } + markAbsentRunningChildren(parentSessionID, liveChildIDs) { + for (const task of this.tasks.values()) { + if (task.parentSessionID !== parentSessionID || task.state !== "running" || liveChildIDs.has(task.taskID)) + continue; + this.markSnapshotIdle(parentSessionID, task.taskID); } } snapshotIdleKey(parentSessionID, taskID) { diff --git a/src/server.ts b/src/server.ts index d562807..1073247 100644 --- a/src/server.ts +++ b/src/server.ts @@ -438,7 +438,7 @@ class TaskTracker { children?: (input: { path: { id: string } }) => Promise<{ data?: unknown } | unknown[]> status?: () => Promise<{ data?: unknown } | Record> } - if (!session.children || !session.status) return + if (!session.children) return let childIDs: string[] try { const result = await session.children({ path: { id: parentSessionID } }) @@ -447,7 +447,8 @@ class TaskTracker { } catch { return } - if (childIDs.length === 0) return + this.markAbsentRunningChildren(parentSessionID, new Set(childIDs)) + if (childIDs.length === 0 || !session.status) return let statuses: Record try { const result = await session.status() @@ -540,6 +541,15 @@ class TaskTracker { if (hold.expiresAt > now) continue this.snapshotIdleHolds.delete(key) this.settledSnapshotIdleTasks.add(key) + const task = this.tasks.get(hold.taskID) + if (task?.parentSessionID === hold.parentSessionID && task.state === "running") this.tasks.delete(hold.taskID) + } + } + + private markAbsentRunningChildren(parentSessionID: string, liveChildIDs: Set) { + for (const task of this.tasks.values()) { + if (task.parentSessionID !== parentSessionID || task.state !== "running" || liveChildIDs.has(task.taskID)) continue + this.markSnapshotIdle(parentSessionID, task.taskID) } } diff --git a/test/server.test.ts b/test/server.test.ts index 41188bd..ef9800f 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -839,6 +839,38 @@ test("idle live child bounded retry does not inject while parent session is busy expect(JSON.stringify(calls[0])).toContain("Continue working toward the active session goal") }) +test("tracked running child absent from live children stops blocking after grace period", async () => { + const calls: unknown[] = [] + let children = [{ id: "task_1" }] + const hooks = await plugin.server( + { + client: { + session: { + children: async () => ({ data: children }), + status: async () => ({ data: { task_1: { type: "busy" } } }), + promptAsync: async (input: unknown) => { + calls.push(input) + }, + }, + }, + } as never, + { auto_continue: true, max_auto_turns: 1, min_continue_interval_seconds: 0 }, + ) + const tools = hooks.tool + if (!tools) throw new Error("expected goal tools to be registered") + + await requireTool(tools.create_goal, "create_goal").execute({ objective: "keep going" }, { sessionID: "ses_1" } as never) + await hooks.event!({ event: { type: "session.idle", properties: { sessionID: "ses_1" } } as never }) + expect(calls).toHaveLength(0) + + children = [] + await hooks.event!({ event: { type: "session.idle", properties: { sessionID: "ses_1" } } as never }) + + expect(calls).toHaveLength(0) + await waitForContinuation(calls) + expect(JSON.stringify(calls[0])).toContain("Continue working toward the active session goal") +}) + test("task deferral can be disabled with config", async () => { const calls: unknown[] = [] const hooks = await plugin.server( From c824afa429fd04e7275f70fb4fe20e42aae675a6 Mon Sep 17 00:00:00 2001 From: jackjinke Date: Tue, 30 Jun 2026 14:19:11 +0800 Subject: [PATCH 2/2] Fix task deferral terminal edge cases --- dist/server.js | 2 ++ src/server.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/dist/server.js b/dist/server.js index 4887790..e1e1520 100644 --- a/dist/server.js +++ b/dist/server.js @@ -1375,6 +1375,8 @@ var server = async ({ client }, options) => { await pauseGoalForPlanMode(sessionID); return; } + if (busySessions.has(sessionID)) + return; if (!fromTaskDeferral && taskDeferredSessions.has(sessionID)) { scheduleSettledContinuation(sessionID); return; diff --git a/src/server.ts b/src/server.ts index 1073247..b038375 100644 --- a/src/server.ts +++ b/src/server.ts @@ -672,6 +672,7 @@ const server: Plugin = async ({ client }, options?: Options) => { if (current.status === "active") await pauseGoalForPlanMode(sessionID) return } + if (busySessions.has(sessionID)) return if (!fromTaskDeferral && taskDeferredSessions.has(sessionID)) { scheduleSettledContinuation(sessionID) return