Browse Source

Merge pull request #871 from tsankotsanev/fix/running-task-cache-stability

fix: keep running task tool_result parts byte-stable to protect prompt cache
Alvin 1 week ago
parent
commit
01cfe092e9

+ 53 - 1
src/hooks/task-session-manager/board-injection.ts

@@ -12,7 +12,12 @@ import type {
   BackgroundJobStore,
   ContextFile,
 } from '../../utils';
-import { isInternalInitiatorPart, parseTaskStatusOutput } from '../../utils';
+import {
+  isInternalInitiatorPart,
+  parseTaskStatusOutput,
+  renderRunningTaskPlaceholder,
+} from '../../utils';
+import { isRecord } from '../../utils/guards';
 import { log } from '../../utils/logger';
 import {
   appendTrailingVolatileMessage,
@@ -111,6 +116,53 @@ function createOccurrenceId(
 
 // ── Exported functions ─────────────────────────────────────────────────
 
+/**
+ * Normalize the `output` of every still-running `task` tool result to a
+ * static, deterministic placeholder keyed only on the task ID.
+ *
+ * OpenCode core stores a fixed running placeholder in `state.output` when a
+ * background task launches and materializes the terminal result separately as
+ * a synthetic completion message. However, the runtime is free to stream live
+ * child progress into a running task part's `state.output` (foreground
+ * promotion, future core versions). Any such mid-history mutation invalidates
+ * the provider prompt cache from that byte onward, re-writing the entire tail
+ * every request while a background lane runs (write-never-read loop).
+ *
+ * This makes running task parts byte-stable at the plugin layer: it only ever
+ * touches parts whose parsed state is `running`, so terminal
+ * (completed/error/cancelled) results — which must reach the orchestrator
+ * intact and mutate exactly once on completion — are never altered. It is a
+ * pure normalization: re-running it on an already-stabilized part is a no-op.
+ * Foreground (`wait:true`) tasks block and return a terminal state, so their
+ * parts are never running here and keep their real output.
+ */
+export function stabilizeRunningTaskParts(messages: unknown[]): void {
+  for (const message of messages) {
+    if (!isMessageWithParts(message)) continue;
+    for (const part of message.parts) {
+      if (part.type !== 'tool' || part.tool !== 'task') continue;
+      const state = part.state;
+      if (!isRecord(state)) continue;
+      if (typeof state.output !== 'string') continue;
+
+      // Only running task results are volatile. Terminal results (completed,
+      // error, cancelled) are materialized exactly once and must stay intact.
+      const status = parseTaskStatusOutput(state.output);
+      const runningByStatus = status?.state === 'running';
+      const runningByField =
+        state.status === 'running' && (status === undefined || runningByStatus);
+      if (!runningByStatus && !runningByField) continue;
+
+      const taskID = status?.taskID;
+      if (!taskID) continue;
+
+      const placeholder = renderRunningTaskPlaceholder(taskID);
+      if (state.output === placeholder) continue;
+      state.output = placeholder;
+    }
+  }
+}
+
 export function updateFromInjectedCompletion(
   state: InjectionState,
   part: MessagePart,

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

@@ -13,6 +13,7 @@ import {
   injectBackgroundJobBoard,
   MAX_PROCESSED_INJECTED_COMPLETIONS,
   reconcileInjectedTerminalJobs,
+  stabilizeRunningTaskParts,
   updateFromInjectedCompletion,
 } from './board-injection';
 import { evaluateContinuation as evaluateContinuationFn } from './continuation-evaluator';
@@ -272,6 +273,11 @@ export function createTaskSessionManagerHook(
     ): Promise<void> => {
       const messages = Array.isArray(output.messages) ? output.messages : [];
 
+      // Keep still-running task tool results byte-stable so a live background
+      // lane never rewrites mid-history bytes and invalidates the prompt
+      // cache. Terminal results are left untouched (they materialize once).
+      stabilizeRunningTaskParts(messages);
+
       for (const [messageIndex, message] of messages.entries()) {
         if (!isUserMessageWithParts(message)) continue;
         if (message.info.agent && message.info.agent !== 'orchestrator') {

+ 305 - 0
src/hooks/task-session-manager/running-task-cache-safety.test.ts

@@ -0,0 +1,305 @@
+/**
+ * Regression coverage for running background-task tool-result byte stability.
+ *
+ * While a background `task` lane runs, the runtime may stream live child
+ * progress into the parent's task tool part (`state.output`). A still-running
+ * task result sits mid-history, so any per-request change to it invalidates
+ * the provider prompt cache from that byte onward, re-writing the entire tail
+ * every request (a write-never-read loop).
+ *
+ * The transform hook must:
+ *  (a) keep a running task part byte-identical across consecutive requests
+ *      while the child progresses,
+ *  (b) materialize the terminal result exactly once and keep it byte-stable
+ *      afterwards, and
+ *  (c) never produce duplicate completed results.
+ */
+import { describe, expect, mock, test } from 'bun:test';
+import { DEFAULT_MAX_RETAINED_SNAPSHOTS } from '../../config/constants';
+import { BackgroundJobBoard } from '../../utils';
+import { createTaskSessionManagerHook } from './index';
+
+const SESSION = 'ses_orchestrator_1114';
+const CHILD = 'ses_child_0771';
+
+function createHook(board: BackgroundJobBoard) {
+  return createTaskSessionManagerHook(
+    {
+      client: { session: { status: mock(async () => ({ data: {} })) } },
+      directory: '/tmp',
+      worktree: '/tmp',
+    } as never,
+    {
+      maxSessionsPerAgent: 4,
+      maxRetainedSnapshots: DEFAULT_MAX_RETAINED_SNAPSHOTS,
+      backgroundJobBoard: board,
+      shouldManageSession: () => true,
+    },
+  );
+}
+
+/** A task tool call on an assistant message, mirroring the SDK part shape. */
+function taskToolMessage(callID: string, output: string) {
+  return {
+    info: {
+      role: 'assistant',
+      agent: 'orchestrator',
+      sessionID: SESSION,
+      id: callID,
+    },
+    parts: [
+      { type: 'text', text: ' ' },
+      {
+        type: 'tool',
+        tool: 'task',
+        callID,
+        state: { status: 'running', input: { background: true }, output },
+      },
+    ],
+  };
+}
+
+function userMessage(id: string, text: string) {
+  return {
+    info: { role: 'user', agent: 'orchestrator', sessionID: SESSION, id },
+    parts: [{ type: 'text', text }],
+  };
+}
+
+/** Grab the task tool part's rendered output from a transformed history. */
+function taskOutput(messages: unknown[], callID: string): string | undefined {
+  for (const message of messages as any[]) {
+    for (const part of message?.parts ?? []) {
+      if (
+        part?.type === 'tool' &&
+        part?.tool === 'task' &&
+        part?.callID === callID
+      ) {
+        return part.state?.output as string | undefined;
+      }
+    }
+  }
+  return undefined;
+}
+
+async function transform(
+  hook: ReturnType<typeof createTaskSessionManagerHook>,
+  history: unknown[],
+): Promise<unknown[]> {
+  // prompt.ts rebuilds msgs from storage every request, so each transform
+  // starts from a fresh clone of the real history.
+  const request = { messages: structuredClone(history) };
+  await hook['experimental.chat.messages.transform']({}, request as never);
+  return request.messages;
+}
+
+// Core's running placeholder that grows with live child progress. In this
+// runtime it is static, but the transform must be robust to a runtime that
+// streams progress into it.
+function runningOutput(snapshot: string): string {
+  return [
+    `<task id="${CHILD}" state="running">`,
+    '<summary>Background task started</summary>',
+    '<task_result>',
+    snapshot,
+    '</task_result>',
+    '</task>',
+  ].join('\n');
+}
+
+describe('running task tool-result cache safety', () => {
+  test('(a) running task part is byte-identical across consecutive requests while the child progresses', async () => {
+    const board = new BackgroundJobBoard();
+    const hook = createHook(board);
+
+    // Two consecutive requests where the runtime streamed different live
+    // progress snapshots into the same running task part.
+    const history1 = [
+      userMessage('u1', 'Coordinate the work'),
+      taskToolMessage(
+        'call-1',
+        runningOutput('The task is working in the background... (711 bytes)'),
+      ),
+    ];
+    const history2 = [
+      userMessage('u1', 'Coordinate the work'),
+      taskToolMessage(
+        'call-1',
+        runningOutput(
+          'Progress snapshot: found 4 files, still working... (4132 bytes)',
+        ),
+      ),
+    ];
+
+    const out1 = await transform(hook, history1);
+    const out2 = await transform(hook, history2);
+
+    const o1 = taskOutput(out1, 'call-1');
+    const o2 = taskOutput(out2, 'call-1');
+
+    expect(o1).toBeDefined();
+    expect(o2).toBeDefined();
+    // Byte-identical despite different live snapshots — cache prefix preserved.
+    expect(o2).toBe(o1 as string);
+    // Deterministic placeholder keyed on the task ID, still parseable as running.
+    expect(o1).toContain(`<task id="${CHILD}" state="running">`);
+    expect(o1).not.toContain('4132 bytes');
+    expect(o1).not.toContain('711 bytes');
+  });
+
+  test('(b) terminal result materializes once and then stays byte-stable', async () => {
+    const board = new BackgroundJobBoard();
+    const hook = createHook(board);
+
+    const completedOutput = [
+      `<task id="${CHILD}" state="completed">`,
+      '<summary>Background task completed: research grok models</summary>',
+      '<task_result>',
+      'Full research findings: repo uses xai/grok-imagine-image, latest is quality mode.',
+      '</task_result>',
+      '</task>',
+    ].join('\n');
+
+    // The completed tool part carries a real terminal result.
+    const completedMessage = {
+      info: {
+        role: 'assistant',
+        agent: 'orchestrator',
+        sessionID: SESSION,
+        id: 'call-1',
+      },
+      parts: [
+        {
+          type: 'tool',
+          tool: 'task',
+          callID: 'call-1',
+          state: {
+            status: 'completed',
+            input: { background: true },
+            output: completedOutput,
+          },
+        },
+      ],
+    };
+    const history = [
+      userMessage('u1', 'Coordinate the work'),
+      completedMessage,
+    ];
+
+    const out1 = await transform(hook, history);
+    const out2 = await transform(hook, history);
+
+    const o1 = taskOutput(out1, 'call-1');
+    const o2 = taskOutput(out2, 'call-1');
+
+    // The terminal result must reach the orchestrator intact and unchanged.
+    expect(o1).toBe(completedOutput);
+    expect(o2).toBe(completedOutput);
+    expect(o1).toContain('Full research findings');
+  });
+
+  test('(c) running → terminal transition mutates the part exactly once, no duplicate completed results', async () => {
+    const board = new BackgroundJobBoard();
+    const hook = createHook(board);
+
+    const completedOutput = [
+      `<task id="${CHILD}" state="completed">`,
+      '<summary>Background task completed: research grok models</summary>',
+      '<task_result>',
+      'Final result body.',
+      '</task_result>',
+      '</task>',
+    ].join('\n');
+
+    // Turn 1 & 2: running (byte-stable). Turn 3: completed.
+    const runningHistory = [
+      userMessage('u1', 'Coordinate the work'),
+      taskToolMessage('call-1', runningOutput('snapshot A')),
+    ];
+    const runningHistory2 = [
+      userMessage('u1', 'Coordinate the work'),
+      taskToolMessage('call-1', runningOutput('snapshot B — bigger')),
+    ];
+    const terminalHistory = [
+      userMessage('u1', 'Coordinate the work'),
+      {
+        info: {
+          role: 'assistant',
+          agent: 'orchestrator',
+          sessionID: SESSION,
+          id: 'call-1',
+        },
+        parts: [
+          {
+            type: 'tool',
+            tool: 'task',
+            callID: 'call-1',
+            state: {
+              status: 'completed',
+              input: { background: true },
+              output: completedOutput,
+            },
+          },
+        ],
+      },
+    ];
+
+    const r1 = taskOutput(await transform(hook, runningHistory), 'call-1');
+    const r2 = taskOutput(await transform(hook, runningHistory2), 'call-1');
+    const outTerminal = await transform(hook, terminalHistory);
+    const t3 = taskOutput(outTerminal, 'call-1');
+    const t4 = taskOutput(await transform(hook, terminalHistory), 'call-1');
+
+    // Running requests are byte-identical; the single mutation is running→terminal.
+    expect(r2).toBe(r1 as string);
+    expect(r1).not.toBe(t3);
+    // Terminal stays stable afterwards (no further mutation).
+    expect(t4).toBe(t3 as string);
+    expect(t3).toBe(completedOutput);
+
+    // No duplicate completed results anywhere in the payload.
+    const completedCount = (outTerminal as any[])
+      .flatMap((m) => m?.parts ?? [])
+      .filter(
+        (p: any) =>
+          p?.type === 'tool' &&
+          p?.tool === 'task' &&
+          typeof p?.state?.output === 'string' &&
+          p.state.output.includes('state="completed"'),
+      ).length;
+    expect(completedCount).toBe(1);
+  });
+
+  test('foreground (non-background) running task parts are also stabilized deterministically', async () => {
+    // Defensive: a running task part with no background flag still normalizes
+    // to the deterministic placeholder (only terminal results are preserved).
+    const board = new BackgroundJobBoard();
+    const hook = createHook(board);
+
+    const message = {
+      info: {
+        role: 'assistant',
+        agent: 'orchestrator',
+        sessionID: SESSION,
+        id: 'call-1',
+      },
+      parts: [
+        {
+          type: 'tool',
+          tool: 'task',
+          callID: 'call-1',
+          state: {
+            status: 'running',
+            input: {},
+            output: runningOutput('live snapshot'),
+          },
+        },
+      ],
+    };
+
+    const out = await transform(hook, [userMessage('u1', 'go'), message]);
+    const o = taskOutput(out, 'call-1');
+    expect(o).toContain(`<task id="${CHILD}" state="running">`);
+    expect(o).not.toContain('live snapshot');
+  });
+});

+ 22 - 0
src/utils/task.test.ts

@@ -4,8 +4,30 @@ import {
   parseTaskLaunchOutput,
   parseTaskResultFromOutput,
   parseTaskStatusOutput,
+  renderRunningTaskPlaceholder,
 } from './task';
 
+describe('renderRunningTaskPlaceholder', () => {
+  test('is deterministic and keyed only on the task ID', () => {
+    const a = renderRunningTaskPlaceholder('ses_123');
+    const b = renderRunningTaskPlaceholder('ses_123');
+    expect(a).toBe(b);
+    expect(a).toContain('<task id="ses_123" state="running">');
+    // Parses back to a running status for the same task ID (round-trip safe).
+    expect(parseTaskStatusOutput(a)).toMatchObject({
+      taskID: 'ses_123',
+      state: 'running',
+    });
+  });
+
+  test('differs only by task ID', () => {
+    const a = renderRunningTaskPlaceholder('ses_a');
+    const b = renderRunningTaskPlaceholder('ses_b');
+    expect(a).not.toBe(b);
+    expect(a.replace('ses_a', 'ses_b')).toBe(b);
+  });
+});
+
 describe('parseTaskIdFromTaskOutput', () => {
   test('parses task_id line from successful task tool output', () => {
     const output = [

+ 19 - 0
src/utils/task.ts

@@ -17,6 +17,25 @@ export interface TaskStatusOutput {
   result?: string;
 }
 
+/**
+ * Static, deterministic placeholder for a still-running background task tool
+ * result. Keyed only on the task ID so re-rendering across consecutive
+ * requests produces byte-identical output regardless of any live progress the
+ * runtime may stream into the tool part's `state.output`. Keeping running
+ * results byte-stable prevents provider prompt-cache invalidation mid-history
+ * while a background lane is active.
+ */
+export function renderRunningTaskPlaceholder(taskID: string): string {
+  return [
+    `<task id="${taskID}" state="running">`,
+    '<summary>Background task running</summary>',
+    '<task_result>',
+    'The task is working in the background. You will be notified automatically when it finishes.',
+    '</task_result>',
+    '</task>',
+  ].join('\n');
+}
+
 export function parseTaskIdFromTaskOutput(output: string): string | undefined {
   const xmlMatch = /<task\s+[^>]*\bid=["']([^"']+)["'][^>]*>/i.exec(output);
   if (xmlMatch) return xmlMatch[1];