Browse Source

fix(job-board): distinguish resumed sessions from new launches in age label

Add `lastLaunchedAt` field to BackgroundJobRecord. On first launch,
lastLaunchedAt equals launchedAt. On session reuse (same taskID relaunched),
lastLaunchedAt is updated to now while launchedAt is preserved.

formatJob uses lastLaunchedAt for the age calculation and compares it to
launchedAt to pick the right label:
  - new session  → `running [just launched, Xs ago]`
  - reused session → `running [resumed, Xs ago]`
  - older than 30s → no annotation (unchanged)

Fixes the Type 1 confusion where an orchestrator cannot tell whether a
running job in the board was just dispatched or is a pre-existing session
it decided to reuse.
Steve K 2 months ago
parent
commit
ad0200435d
2 changed files with 69 additions and 4 deletions
  1. 56 0
      src/utils/background-job-board.test.ts
  2. 13 4
      src/utils/background-job-board.ts

+ 56 - 0
src/utils/background-job-board.test.ts

@@ -165,6 +165,7 @@ describe('BackgroundJobBoard', () => {
       terminalUnreconciled: false,
       terminalUnreconciled: false,
       completedAt: undefined,
       completedAt: undefined,
       resultSummary: undefined,
       resultSummary: undefined,
+      lastLaunchedAt: 300,
       updatedAt: 300,
       updatedAt: 300,
     });
     });
   });
   });
@@ -267,6 +268,37 @@ describe('BackgroundJobBoard', () => {
     expect(board.formatForPrompt('parent-1')).toBeUndefined();
     expect(board.formatForPrompt('parent-1')).toBeUndefined();
   });
   });
 
 
+  test('annotates just-launched running jobs with age in the prompt', () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'implement feature',
+      now: 1_000,
+    });
+
+    // 4 seconds after launch — should show age annotation
+    const prompt = board.formatForPrompt('parent-1', 5_000);
+    expect(prompt).toContain('running [just launched, 4s ago]');
+  });
+
+  test('does not annotate running jobs older than 30s', () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'implement feature',
+      now: 1_000,
+    });
+
+    // 39 seconds after launch — age label should be absent
+    const prompt = board.formatForPrompt('parent-1', 40_000);
+    expect(prompt).not.toContain('just launched');
+    expect(prompt).toContain('/ running\n');
+  });
+
   test('registerLaunch can reset a reconciled job to running', () => {
   test('registerLaunch can reset a reconciled job to running', () => {
     const board = new BackgroundJobBoard();
     const board = new BackgroundJobBoard();
     board.registerLaunch({
     board.registerLaunch({
@@ -296,4 +328,28 @@ describe('BackgroundJobBoard', () => {
       updatedAt: 400,
       updatedAt: 400,
     });
     });
   });
   });
+
+  test('annotates resumed running jobs with resumed label in the prompt', () => {
+    const board = new BackgroundJobBoard();
+    // Initial launch at t=1000
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'implement feature',
+      now: 1_000,
+    });
+    // Reuse the same session ID at t=5000 (session reuse)
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'implement feature continued',
+      now: 5_000,
+    });
+
+    // 4 seconds after relaunch — should show [resumed, 4s ago]
+    const prompt = board.formatForPrompt('parent-1', 9_000);
+    expect(prompt).toContain('running [resumed, 4s ago]');
+  });
 });
 });

+ 13 - 4
src/utils/background-job-board.ts

@@ -12,6 +12,7 @@ export interface BackgroundJobRecord {
   timedOut: boolean;
   timedOut: boolean;
   terminalUnreconciled: boolean;
   terminalUnreconciled: boolean;
   launchedAt: number;
   launchedAt: number;
+  lastLaunchedAt: number;
   updatedAt: number;
   updatedAt: number;
   completedAt?: number;
   completedAt?: number;
   resultSummary?: string;
   resultSummary?: string;
@@ -70,6 +71,7 @@ export class BackgroundJobBoard {
         terminalUnreconciled: false,
         terminalUnreconciled: false,
         completedAt: undefined,
         completedAt: undefined,
         resultSummary: undefined,
         resultSummary: undefined,
+        lastLaunchedAt: now,
         updatedAt: now,
         updatedAt: now,
       } satisfies BackgroundJobRecord;
       } satisfies BackgroundJobRecord;
       this.jobs.set(input.taskID, updated);
       this.jobs.set(input.taskID, updated);
@@ -86,6 +88,7 @@ export class BackgroundJobBoard {
       timedOut: false,
       timedOut: false,
       terminalUnreconciled: false,
       terminalUnreconciled: false,
       launchedAt: now,
       launchedAt: now,
+      lastLaunchedAt: now,
       updatedAt: now,
       updatedAt: now,
       alias: this.nextAlias(input.parentSessionID, input.agent),
       alias: this.nextAlias(input.parentSessionID, input.agent),
     };
     };
@@ -180,7 +183,7 @@ export class BackgroundJobBoard {
     return this.list(parentSessionID).some((job) => job.terminalUnreconciled);
     return this.list(parentSessionID).some((job) => job.terminalUnreconciled);
   }
   }
 
 
-  formatForPrompt(parentSessionID: string): string | undefined {
+  formatForPrompt(parentSessionID: string, now = Date.now()): string | undefined {
     const jobs = this.list(parentSessionID).filter(
     const jobs = this.list(parentSessionID).filter(
       (job) => job.state === 'running' || job.terminalUnreconciled,
       (job) => job.state === 'running' || job.terminalUnreconciled,
     );
     );
@@ -191,7 +194,7 @@ export class BackgroundJobBoard {
       '### Background Job Board',
       '### Background Job Board',
       'Use task_status before consuming running jobs. Reconcile terminal jobs before final response.',
       'Use task_status before consuming running jobs. Reconcile terminal jobs before final response.',
       '',
       '',
-      ...jobs.map(formatJob),
+      ...jobs.map((job) => formatJob(job, now)),
     ].join('\n');
     ].join('\n');
   }
   }
 
 
@@ -215,12 +218,18 @@ export class BackgroundJobBoard {
   }
   }
 }
 }
 
 
-function formatJob(job: BackgroundJobRecord): string {
+function formatJob(job: BackgroundJobRecord, now = Date.now()): string {
+  const ageMs = now - job.lastLaunchedAt;
+  const isResume = job.lastLaunchedAt !== job.launchedAt;
+  const ageLabel =
+    job.state === 'running' && ageMs < 30_000
+      ? ` [${isResume ? 'resumed' : 'just launched'}, ${Math.round(ageMs / 1000)}s ago]`
+      : '';
   const status = job.terminalUnreconciled
   const status = job.terminalUnreconciled
     ? `${job.state}, unreconciled`
     ? `${job.state}, unreconciled`
     : job.timedOut
     : job.timedOut
       ? `${job.state}, timed out`
       ? `${job.state}, timed out`
-      : job.state;
+      : `${job.state}${ageLabel}`;
   const lines = [
   const lines = [
     `- ${job.alias} / ${job.taskID} / ${job.agent} / ${status}`,
     `- ${job.alias} / ${job.taskID} / ${job.agent} / ${status}`,
     `  Objective: ${job.objective || job.description}`,
     `  Objective: ${job.objective || job.description}`,