Răsfoiți Sursa

fix(tasks): continue after reconciled background result

Alvin Unreal 1 lună în urmă
părinte
comite
734ca0ff0c

+ 10 - 1
.slim/clonedeps.json

@@ -1,6 +1,6 @@
 {
   "version": "1.0.0",
-  "updatedAt": "2026-06-15T00:00:00.000Z",
+  "updatedAt": "2026-08-03T19:51:07.000Z",
   "dependencies": [
     {
       "name": "@opencode-ai/plugin",
@@ -46,6 +46,15 @@
       "path": ".slim/clonedeps/repos/agentclientprotocol__agent-client-protocol",
       "packagePath": ".",
       "reason": "Authoritative ACP specification and schema source for implementing ACP client/server compatibility."
+    },
+    {
+      "name": "code-yeongyu/oh-my-openagent",
+      "resolvedVersion": "master",
+      "repoUrl": "https://github.com/code-yeongyu/oh-my-openagent.git",
+      "ref": "master@86db5c02df8568f669c8b42789934ea0753f135b",
+      "path": ".slim/clonedeps/repos/code-yeongyu__oh-my-openagent",
+      "packagePath": ".",
+      "reason": "Inspected comparative source for task-stall recovery patterns."
     }
   ]
 }

+ 1 - 0
AGENTS.md

@@ -183,6 +183,7 @@ Read-only dependency source repositories are available under
 - `.slim/clonedeps/repos/opencode/` - `https://github.com/anomalyco/opencode.git` at `dev@356f6841865d68adf6d0123c37357ad50814497a`; inspect `packages/opencode` for latest TypeScript runtime internals and experimental background subagent support.
 - `.slim/clonedeps/repos/modelcontextprotocol__typescript-sdk/` - `https://github.com/modelcontextprotocol/typescript-sdk.git` at `v1.29.0@e12cbd7078db388152f6e839abdbe09ba01f3f32`; inspect it for MCP protocol and server integration internals.
 - `.slim/clonedeps/repos/agentclientprotocol__agent-client-protocol/` - `https://github.com/agentclientprotocol/agent-client-protocol.git` at `main@8110fde4e8283b4bef1329d1ef7b074fd14cee1e`; inspect it for ACP protocol specification and schema details.
+- `.slim/clonedeps/repos/code-yeongyu__oh-my-openagent/` - `https://github.com/code-yeongyu/oh-my-openagent.git` at `master@86db5c02df8568f669c8b42789934ea0753f135b`; read-only comparison source for task-stall recovery patterns.
 
 ## Agent Operating Context
 

+ 7 - 1
docs/background-orchestration.md

@@ -164,7 +164,9 @@ changes before launching a replacement lane.
 Terminal jobs are reconciled automatically after their result is injected into
 the orchestrator session. That lifecycle state is not proof the output was used;
 the orchestrator must still verify it consumed the relevant result before
-finalizing.
+finalizing. When idle reconciliation performs that reconciliation, the opt-in
+continuation evaluator can run in the same idle cycle, subject to its existing
+guards.
 
 Specialist outputs are inputs, not final truth. The orchestrator reconciles them
 against each other and the original user goal.
@@ -350,6 +352,10 @@ unavailable or malformed. A matching reply, or a rejected question, clears its
 tool-backed wait but does not itself inject a nudge; the normal session lifecycle
 decides whether a later nudge is needed.
 
+When idle reconciliation first reconciles an injected terminal result, the
+opt-in evaluator may run in that same idle cycle; the existing liveness,
+wait, fallback, and one-attempt guards still apply.
+
 For external manual work, the orchestrator first gives the user concrete steps,
 then calls `wait_for_user` as its final tool action. This explicit signal covers
 text-only HITL turns without attempting to infer intent from assistant prose. The

+ 3 - 2
src/hooks/task-session-manager/codemap.md

@@ -51,11 +51,11 @@ All modules depend on `BackgroundJobBoard` from `src/utils/background-job-board.
     - Injects a `<system-reminder>` part containing the `### Background Job Board` section into user messages for managed sessions
     - Lists active, unreconciled, and reusable sessions
     - Remembers injected terminal jobs to reconcile them on the next request after the completion was surfaced to the model (via `reconcileConsumedTerminalJobs`)
-    - The idle timer remains as a backstop for when the model ends its turn without further requests
+    - The idle timer remains a backstop for when the model ends its turn without further requests; after reconciling injected terminal results, the opt-in continuation evaluator can run in the same idle cycle under its existing guards
 
 5. **Lifecycle Events (`event`)**
     - `session.created`: Adds new task IDs to pending managed set
-    - `session.idle` / `session.status` (idle): Reconciles injected terminal jobs for the parent session (backstop path)
+    - `session.idle` / `session.status` (idle): Reconciles injected terminal jobs for the parent session (backstop path), then can run the opt-in continuation evaluator in the same idle cycle under its existing guards
     - `session.status` (busy): Marks sessions as running from live session state
     - `session.deleted`: Clears job state, child jobs, and pending call records for the session
 
@@ -72,6 +72,7 @@ User task call → tool.execute.before → PendingTaskCall created → task ID r
 → tool.execute.after → BackgroundJobBoard.registerLaunch() → context extracted/added
 → Message transform → BackgroundJobBoard.formatForPrompt() injected as a system-reminder message part
 → session.idle → reconcileInjectedTerminalJobs() → BackgroundJobBoard.markReconciled()
+→ opt-in continuation evaluator (same idle cycle, existing guards)
 ```
 
 ## Integration

+ 1 - 5
src/hooks/task-session-manager/idle-reconciliation.ts

@@ -43,12 +43,8 @@ export function createIdleReconciler(options: {
       if (!options.isCurrentContinuation(parentSessionID, sessionToken)) {
         return;
       }
-      const hadTerminalUnreconciled =
-        options.backgroundJobBoard.hasTerminalUnreconciled(parentSessionID);
       options.reconcileInjectedTerminalJobs(parentSessionID);
-      if (!hadTerminalUnreconciled) {
-        void options.evaluateContinuation(parentSessionID, sessionToken);
-      }
+      void options.evaluateContinuation(parentSessionID, sessionToken);
     }, options.idleReconcileDelayMs).unref?.();
     idleReconcileTimers.set(parentSessionID, timer);
   }

+ 43 - 0
src/hooks/task-session-manager/index.test.ts

@@ -4007,6 +4007,43 @@ describe('task-session-manager hook', () => {
     expect(promptAsync).not.toHaveBeenCalled();
   });
 
+  test('continues after reconciling an injected parent terminal job', async () => {
+    const board = new BackgroundJobBoard();
+    setupCompletedJob(board);
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createContinuationHook({
+      backgroundJobBoard: board,
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: {} })),
+        promptAsync,
+      },
+    });
+
+    await hook.injectBackgroundJobBoard({}, createMessages('parent-1'));
+    expect(board.get('child-1')?.terminalUnreconciled).toBe(true);
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'reconciled',
+      terminalUnreconciled: false,
+    });
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+    expect(promptAsync).toHaveBeenCalledWith(
+      expect.objectContaining({
+        body: expect.objectContaining({
+          parts: [expect.objectContaining({ synthetic: true })],
+        }),
+      }),
+    );
+  });
+
   test('nudges once for incomplete todos when parent and children are inactive', async () => {
     const promptAsync = mock(async () => ({}));
     const { hook } = createContinuationHook({
@@ -4036,8 +4073,11 @@ describe('task-session-manager hook', () => {
   });
 
   test('paired idle events submit at most one continuation', async () => {
+    const board = new BackgroundJobBoard();
+    setupCompletedJob(board);
     const promptAsync = mock(async () => ({}));
     const { hook } = createContinuationHook({
+      backgroundJobBoard: board,
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({ data: [{ status: 'pending' }] })),
@@ -4047,6 +4087,8 @@ describe('task-session-manager hook', () => {
       },
     });
 
+    await hook.injectBackgroundJobBoard({}, createMessages('parent-1'));
+
     await Promise.all([
       hook.event({
         event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
@@ -4060,6 +4102,7 @@ describe('task-session-manager hook', () => {
     ]);
     await flushContinuation();
 
+    expect(board.get('child-1')?.terminalUnreconciled).toBe(false);
     expect(promptAsync).toHaveBeenCalledTimes(1);
   });