pi-long-task 0.3.17 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,9 +10,34 @@ import type {
10
10
  } from "./types.ts";
11
11
  import { commitAfterSession, gitDirtyPaths, shouldCommitOutcome, type CommitAfterSessionResult } from "./git.ts";
12
12
  import { formatCoordinatorResultMessage } from "./render.ts";
13
- import { extractResultSummary } from "./result_writer.ts";
13
+ import { extractResultSummary, hasCompleteTaskResult } from "./result_writer.ts";
14
14
  import { runGuardedSessionPrompt } from "./session_guard.ts";
15
+ import {
16
+ generatePlanRevision,
17
+ PlanRevisionGenerationError,
18
+ type GeneratedPlanRevision,
19
+ type PlanRevisionRequest,
20
+ type PlanRevisionRelevantResult,
21
+ } from "./plan_revision_generation.ts";
22
+ import { taskSemanticFingerprint, type PlanTaskState } from "./plan_revision.ts";
23
+ import {
24
+ PersistentTodoPlanStore,
25
+ planTaskReference,
26
+ resolvePlanTaskReference,
27
+ type PlanTaskReference,
28
+ } from "./plan_store.ts";
29
+ import type { SerializedSteeringQueue, SteeringMessage } from "./steering.ts";
15
30
  import { parseWorkerRuntimeConfig } from "./worker_config.ts";
31
+ import {
32
+ classifyWorkerSessionRetry,
33
+ createWorkerSessionCompatibilityFingerprint,
34
+ decideWorkerSessionReuse,
35
+ DEFAULT_WORKER_SESSION_REUSE_CONTEXT_THRESHOLD_PERCENT,
36
+ DEFAULT_WORKER_SESSION_REUSE_ENABLED,
37
+ resolveWorkerSessionReuseConfig,
38
+ type WorkerSessionCompatibilityFingerprint,
39
+ type WorkerSessionHealth,
40
+ } from "./worker_reuse_policy.ts";
16
41
  import { buildTaskProgressModel, type TaskProgressModel, type TaskProgressStatus } from "./task_progress.ts";
17
42
  import {
18
43
  applyGoalInstructionsToTodoMarkdown,
@@ -23,14 +48,23 @@ import {
23
48
  todoMarkdownFromString,
24
49
  validateTodoMarkdown,
25
50
  } from "./todo_generator.ts";
26
- import { markTaskDone, parseTasks, todoGlobalInstructions, type Task } from "./todo_parser.ts";
51
+ import { parseTasks, todoGlobalInstructions, type Task } from "./todo_parser.ts";
27
52
  import {
53
+ buildWorkerSessionCreationFailureOutcome,
28
54
  createIsolatedWorkerSession,
55
+ createWorkerSessionResource,
56
+ DEFAULT_WORKER_TOOLS,
57
+ disposeWorkerSessionResource,
29
58
  runWorkerTask,
59
+ runWorkerTaskAssignment,
60
+ workerSessionContextUsagePercent,
30
61
  type RunWorkerTaskOptions,
31
62
  type SessionOutcome,
63
+ type WorkerSessionDiagnostic,
32
64
  type WorkerSessionFactory,
33
65
  type WorkerSessionLike,
66
+ type WorkerSessionResource,
67
+ type WorkerUsageTotals,
34
68
  } from "./worker_session.ts";
35
69
 
36
70
  export type { CoordinatorStatus } from "./types.ts";
@@ -43,6 +77,8 @@ export const DEFAULT_COORDINATOR_OPTIONS = {
43
77
  maxBashTimeoutMs: 300_000,
44
78
  taskThinking: "high",
45
79
  todoThinking: "xhigh",
80
+ workerSessionReuse: DEFAULT_WORKER_SESSION_REUSE_ENABLED,
81
+ workerSessionReuseContextThresholdPercent: DEFAULT_WORKER_SESSION_REUSE_CONTEXT_THRESHOLD_PERCENT,
46
82
  } as const;
47
83
 
48
84
  export type WorkerRunner = (options: RunWorkerTaskOptions) => Promise<SessionOutcome>;
@@ -50,10 +86,12 @@ export type CoordinatorProgressPhase =
50
86
  | "planning"
51
87
  | "planned"
52
88
  | "task_start"
89
+ | "worker_session"
53
90
  | "worker_tool"
54
91
  | "task_done"
55
92
  | "task_blocked"
56
93
  | "task_failed"
94
+ | "task_obsolete"
57
95
  | "complete";
58
96
 
59
97
  export type PlannerDiagnosticKind = "timeout" | "abort" | "invalid_output" | "repair_attempt" | "failure";
@@ -108,6 +146,10 @@ export interface CoordinatorProgressUpdate {
108
146
  plannerDiagnostics?: string[];
109
147
  plannerSessionFile?: string;
110
148
  plannerSessionId?: string;
149
+ workerSessionEvent?: WorkerSessionDiagnostic["event"];
150
+ workerSessionReason?: string;
151
+ workerSessionContextUsagePercent?: number;
152
+ workerSessionContextThresholdPercent?: number;
111
153
  }
112
154
 
113
155
  export type CoordinatorProgressHandler = (update: CoordinatorProgressUpdate) => void;
@@ -130,8 +172,16 @@ export interface RunCoordinatorOptions extends PiLongTaskInput {
130
172
  maxBashTimeoutMs?: number;
131
173
  taskThinking?: string;
132
174
  todoThinking?: string;
175
+ /** Set false to retain the legacy one-session-per-task lifecycle. */
176
+ workerSessionReuse?: boolean;
177
+ /** Rotate before another assignment when context usage reaches this percentage. */
178
+ workerSessionReuseContextThresholdPercent?: number;
133
179
  now?: () => Date;
134
180
  onProgress?: CoordinatorProgressHandler;
181
+ /** Run-scoped FIFO populated by the extension input handler during active execution. */
182
+ steeringQueue?: SerializedSteeringQueue;
183
+ /** Runs after rebase/validation and immediately before the revision is atomically persisted. */
184
+ onPlanRevisionAccepted?: (revision: GeneratedPlanRevision) => void | Promise<void>;
135
185
  }
136
186
 
137
187
  export interface TodoPlannerOptions {
@@ -146,11 +196,17 @@ export interface TodoPlannerOptions {
146
196
  sessionFactory?: WorkerSessionFactory;
147
197
  onDiagnostic?: PlannerDiagnosticHandler;
148
198
  goal?: string;
199
+ /** Exact prompt for revision planners; bypasses the initial TODO-creation wrapper. */
200
+ plannerPrompt?: string;
201
+ /** Structured revision context supplied alongside plannerPrompt. */
202
+ planRevision?: Readonly<PlanRevisionRequest>;
149
203
  }
150
204
 
151
205
  export interface TaskAttemptSummary {
152
206
  taskId: string;
153
207
  title: string;
208
+ taskStableId?: string;
209
+ taskFingerprint?: string;
154
210
  attempt: number;
155
211
  reportedStatus: string;
156
212
  done: boolean;
@@ -158,6 +214,18 @@ export interface TaskAttemptSummary {
158
214
  commitHash?: string;
159
215
  commitError?: string;
160
216
  commitSkipped?: string;
217
+ /** The worker finished after an accepted revision replaced or removed its task. */
218
+ obsolete?: boolean;
219
+ /** Retry continuity retained across task renumbering and accepted revisions. */
220
+ resultText?: string;
221
+ }
222
+
223
+ export interface WorkerSessionMetrics {
224
+ starts: number;
225
+ reuses: number;
226
+ rotations: number;
227
+ retained: number;
228
+ rotationReasons: Record<string, number>;
161
229
  }
162
230
 
163
231
  export interface CoordinatorResult {
@@ -180,6 +248,10 @@ export interface CoordinatorResult {
180
248
  attempts: TaskAttemptSummary[];
181
249
  taskProgress: TaskProgressModel;
182
250
  workerCostTotal: number;
251
+ /** Sum of task/attempt token deltas; omitted when statistics are unavailable. */
252
+ workerUsageTotal?: WorkerUsageTotals;
253
+ /** Additive lifecycle counters for adaptive worker-session reuse. */
254
+ workerSessionMetrics?: WorkerSessionMetrics;
183
255
  commit: boolean;
184
256
  goal?: string;
185
257
  error?: string;
@@ -206,9 +278,12 @@ interface RuntimeOptions {
206
278
  goal?: string;
207
279
  taskThinking: string;
208
280
  todoThinking: string;
281
+ workerSessionReuse: boolean;
282
+ workerSessionReuseContextThresholdPercent: number;
209
283
  todoTimeoutMs: number;
210
284
  todoGracefulShutdownMs: number;
211
285
  workerRunner: WorkerRunner;
286
+ useRetainedWorkerLifecycle: boolean;
212
287
  todoPlanner: TodoPlanner;
213
288
  abortSignal?: AbortSignal;
214
289
  workerSessionFactory?: WorkerSessionFactory;
@@ -220,10 +295,461 @@ interface RuntimeOptions {
220
295
  workerTextByWorker: Map<string, string>;
221
296
  workerTextPublishedLengthByWorker: Map<string, number>;
222
297
  plannerDiagnostics: PlannerDiagnostic[];
298
+ workerSessionMetrics: WorkerSessionMetrics;
299
+ steeringQueue?: SerializedSteeringQueue;
300
+ onPlanRevisionAccepted?: (revision: GeneratedPlanRevision) => void | Promise<void>;
301
+ }
302
+
303
+ type RetainedWorkerReuseScope = "sequential_task" | "partial_continuation";
304
+
305
+ export interface WorkerAssignmentIdentity {
306
+ /** Unique invocation identity, even when a replacement reuses a task ID and attempt number. */
307
+ assignmentId: string;
308
+ /** Stable task identity or semantic fingerprint from the plan that launched this assignment. */
309
+ taskIdentity: string;
310
+ /** Accepted-steering generation at the assignment boundary. */
311
+ steeringGeneration: number;
312
+ /** Structural plan generation from which the assignment was selected. */
313
+ planAuthorityToken: string;
314
+ }
315
+
316
+ interface RetainedWorkerState {
317
+ resource: WorkerSessionResource;
318
+ compatibility: WorkerSessionCompatibilityFingerprint;
319
+ health: WorkerSessionHealth;
320
+ contextUsagePercent?: number;
321
+ previousTask: Pick<Task, "taskId" | "title">;
322
+ previousAttempt: number;
323
+ previousAssignmentIdentity: WorkerAssignmentIdentity;
324
+ reportDiagnostic: (diagnostic: WorkerSessionDiagnostic) => void;
325
+ reuseScope: RetainedWorkerReuseScope;
326
+ }
327
+
328
+ interface ActiveWorkerSessionAssignment {
329
+ identity: WorkerAssignmentIdentity;
330
+ controller: AbortController;
331
+ tainted: boolean;
332
+ rotationReported: boolean;
333
+ resource?: WorkerSessionResource;
334
+ reportDiagnostic?: (diagnostic: WorkerSessionDiagnostic) => void;
335
+ resolveCompletion: () => void;
336
+ completion: Promise<void>;
337
+ }
338
+
339
+ export interface CoordinatorWorkerSessionOwnerOptions {
340
+ runId: string;
341
+ cwd: string;
342
+ workerSessionReuse: boolean;
343
+ workerSessionReuseContextThresholdPercent: number;
344
+ }
345
+
346
+ /** Run-scoped owner for the single retained worker session and its assignment lock. */
347
+ export class CoordinatorWorkerSessionOwner {
348
+ private retained: RetainedWorkerState | undefined;
349
+ private active: ActiveWorkerSessionAssignment | undefined;
350
+ private closed = false;
351
+ private disposePromise: Promise<void> | undefined;
352
+ private assignmentSequence = 0;
353
+ private readonly runtime: CoordinatorWorkerSessionOwnerOptions;
354
+
355
+ constructor(runtime: CoordinatorWorkerSessionOwnerOptions) {
356
+ this.runtime = runtime;
357
+ }
358
+
359
+ async run(options: RunWorkerTaskOptions, identity?: WorkerAssignmentIdentity): Promise<SessionOutcome> {
360
+ if (this.closed) {
361
+ throw new Error("retained worker session owner is closed");
362
+ }
363
+ const assignmentIdentity = identity ?? this.defaultIdentity(options);
364
+ if (this.active) {
365
+ throw new Error("retained worker session already has an active assignment");
366
+ }
367
+
368
+ let resolveCompletion: (() => void) | undefined;
369
+ const completion = new Promise<void>((resolve) => {
370
+ resolveCompletion = resolve;
371
+ });
372
+ const active: ActiveWorkerSessionAssignment = {
373
+ identity: assignmentIdentity,
374
+ controller: new AbortController(),
375
+ tainted: false,
376
+ rotationReported: false,
377
+ resolveCompletion: resolveCompletion as () => void,
378
+ completion,
379
+ };
380
+ this.active = active;
381
+ const assignmentOptions = {
382
+ ...options,
383
+ abortSignal: combineAbortSignals(options.abortSignal, active.controller.signal),
384
+ };
385
+ try {
386
+ return await this.runExclusive(assignmentOptions, assignmentIdentity, active);
387
+ } finally {
388
+ const activeResource = active.resource;
389
+ const retained = this.retained;
390
+ if (active.tainted && activeResource && retained?.resource === activeResource) {
391
+ retained.health = "cancelled";
392
+ await this.disposeRetainedResource(activeResource);
393
+ }
394
+ if (this.active === active) {
395
+ this.active = undefined;
396
+ }
397
+ active.resolveCompletion();
398
+ }
399
+ }
400
+
401
+ private async runExclusive(
402
+ options: RunWorkerTaskOptions,
403
+ identity: WorkerAssignmentIdentity,
404
+ active: ActiveWorkerSessionAssignment,
405
+ ): Promise<SessionOutcome> {
406
+ const compatibility = this.compatibilityFor(options);
407
+ const diagnostics: WorkerSessionDiagnostic[] = [];
408
+ const report = (diagnostic: WorkerSessionDiagnostic) => {
409
+ diagnostics.push(diagnostic);
410
+ options.onSessionDiagnostic?.(diagnostic);
411
+ };
412
+ active.reportDiagnostic = report;
413
+ const diagnosticContext = (retained: RetainedWorkerState) => ({
414
+ ...(retained.contextUsagePercent !== undefined ? { contextUsagePercent: retained.contextUsagePercent } : {}),
415
+ contextThresholdPercent: this.runtime.workerSessionReuseContextThresholdPercent,
416
+ previousTaskId: retained.previousTask.taskId,
417
+ });
418
+
419
+ let reusedFrom: Pick<Task, "taskId" | "title"> | undefined;
420
+ if (this.retained && !this.assignmentMatchesRetainedScope(options, this.retained)) {
421
+ // A partial-work session is continuity for exactly the next attempt of
422
+ // that task. It must never spill into unrelated work or a later retry.
423
+ report({
424
+ event: "session_rotated",
425
+ reasonCode: "partial_continuation_scope_mismatch",
426
+ ...diagnosticContext(this.retained),
427
+ });
428
+ await this.disposeRetained();
429
+ }
430
+ if (this.retained) {
431
+ const decision = decideWorkerSessionReuse({
432
+ config: {
433
+ enabled: this.runtime.workerSessionReuse,
434
+ contextThresholdPercent: this.runtime.workerSessionReuseContextThresholdPercent,
435
+ },
436
+ candidate: {
437
+ health: this.retained.health,
438
+ compatibility: this.retained.compatibility,
439
+ contextUsagePercent: this.retained.contextUsagePercent,
440
+ assignmentState: "idle",
441
+ disposed: this.retained.resource.disposed,
442
+ },
443
+ requestedCompatibility: compatibility,
444
+ });
445
+ if (decision.reusable) {
446
+ reusedFrom = this.retained.previousTask;
447
+ report({
448
+ event: "session_reused",
449
+ reasonCode: decision.reasonCode,
450
+ ...diagnosticContext(this.retained),
451
+ });
452
+ } else {
453
+ report({
454
+ event: "session_rotated",
455
+ reasonCode: decision.reasonCode,
456
+ ...diagnosticContext(this.retained),
457
+ });
458
+ await this.disposeRetained();
459
+ }
460
+ }
461
+
462
+ if (!this.retained) {
463
+ try {
464
+ this.retained = {
465
+ resource: await createWorkerSessionResource(options, options.sessionFactory ?? createIsolatedWorkerSession),
466
+ compatibility,
467
+ health: "healthy",
468
+ previousTask: options.task,
469
+ previousAttempt: options.attempt,
470
+ previousAssignmentIdentity: identity,
471
+ reportDiagnostic: report,
472
+ reuseScope: "sequential_task",
473
+ };
474
+ report({
475
+ event: "session_started",
476
+ reasonCode: diagnostics.some((item) => item.event === "session_rotated")
477
+ ? "rotation_completed"
478
+ : "fresh_session",
479
+ contextThresholdPercent: this.runtime.workerSessionReuseContextThresholdPercent,
480
+ });
481
+ } catch (error) {
482
+ const failed = buildWorkerSessionCreationFailureOutcome(options, error);
483
+ failed.sessionDiagnostics = diagnostics;
484
+ return failed;
485
+ }
486
+ }
487
+
488
+ active.resource = this.retained.resource;
489
+ let outcome: SessionOutcome;
490
+ try {
491
+ outcome = await runWorkerTaskAssignment(
492
+ options,
493
+ this.retained.resource,
494
+ reusedFrom ? { previousTask: reusedFrom } : undefined,
495
+ );
496
+ } catch (error) {
497
+ this.retained.health = active.tainted ? "cancelled" : "unrecoverable_error";
498
+ if (!active.rotationReported) {
499
+ active.rotationReported = true;
500
+ report({
501
+ event: "session_rotated",
502
+ reasonCode: "health_unrecoverable_error",
503
+ ...diagnosticContext(this.retained),
504
+ });
505
+ }
506
+ await this.disposeRetained();
507
+ throw error;
508
+ }
509
+
510
+ if (this.retained) {
511
+ const cancelled = Boolean(options.abortSignal?.aborted);
512
+ this.retained.health = workerSessionHealthForOutcome(outcome, cancelled);
513
+ this.retained.contextUsagePercent = await workerSessionContextUsagePercent(this.retained.resource.session);
514
+ this.retained.previousTask = options.task;
515
+ this.retained.previousAttempt = options.attempt;
516
+ this.retained.previousAssignmentIdentity = identity;
517
+ this.retained.reportDiagnostic = report;
518
+
519
+ if (active.tainted) {
520
+ if (!active.rotationReported) {
521
+ this.reportTaintedRotation(active, "assignment_cancelled");
522
+ }
523
+ await this.disposeRetainedResource(this.retained.resource);
524
+ outcome.sessionDiagnostics = [...(outcome.sessionDiagnostics ?? []), ...diagnostics];
525
+ return outcome;
526
+ }
527
+
528
+ const retry = classifyWorkerSessionRetry({
529
+ done: outcome.done,
530
+ reportedStatus: outcome.reportedStatus,
531
+ completeTaskResult: hasCompleteTaskResult(outcome.assistantText),
532
+ timedOut: outcome.timedOut,
533
+ aborted: outcome.aborted,
534
+ cancelled,
535
+ error: outcome.error,
536
+ });
537
+ this.retained.reuseScope = retry.mayContinueInSession ? "partial_continuation" : "sequential_task";
538
+
539
+ const postAssignmentDecision = decideWorkerSessionReuse({
540
+ config: {
541
+ enabled: this.runtime.workerSessionReuse,
542
+ contextThresholdPercent: this.runtime.workerSessionReuseContextThresholdPercent,
543
+ },
544
+ candidate: {
545
+ health: this.retained.health,
546
+ compatibility: this.retained.compatibility,
547
+ contextUsagePercent: this.retained.contextUsagePercent,
548
+ assignmentState: "idle",
549
+ disposed: this.retained.resource.disposed,
550
+ },
551
+ requestedCompatibility: compatibility,
552
+ });
553
+ // Completed tasks may flow into the next sequential TODO. A retry may
554
+ // remain only when it is an explicitly safe partial continuation and all
555
+ // normal health/compatibility/context checks still pass.
556
+ const rotateForRetry = !outcome.done && !retry.mayContinueInSession;
557
+ if (!postAssignmentDecision.reusable || rotateForRetry) {
558
+ if (!active.rotationReported) {
559
+ active.rotationReported = true;
560
+ report({
561
+ event: "session_rotated",
562
+ reasonCode: postAssignmentDecision.reusable ? retry.reasonCode : postAssignmentDecision.reasonCode,
563
+ ...diagnosticContext(this.retained),
564
+ });
565
+ }
566
+ await this.disposeRetained();
567
+ } else {
568
+ report({
569
+ event: "session_retained",
570
+ reasonCode: postAssignmentDecision.reasonCode,
571
+ ...diagnosticContext(this.retained),
572
+ });
573
+ }
574
+ }
575
+ outcome.sessionDiagnostics = [...(outcome.sessionDiagnostics ?? []), ...diagnostics];
576
+ return outcome;
577
+ }
578
+
579
+ /**
580
+ * Taint and abort only the matching obsolete assignment. Late cancellation
581
+ * from an older steering generation cannot affect a replacement assignment.
582
+ */
583
+ async invalidateAssignment(identity: WorkerAssignmentIdentity): Promise<boolean> {
584
+ const active = this.active;
585
+ if (active && sameWorkerAssignment(active.identity, identity)) {
586
+ this.taintActiveAssignment(active, "steering_revision_obsolete");
587
+ if (!active.controller.signal.aborted) {
588
+ active.controller.abort(new Error(`worker assignment ${identity.assignmentId} became obsolete`));
589
+ }
590
+ return true;
591
+ }
592
+
593
+ if (this.retained && sameWorkerAssignment(this.retained.previousAssignmentIdentity, identity)) {
594
+ const retained = this.retained;
595
+ retained.health = "cancelled";
596
+ retained.reportDiagnostic({
597
+ event: "session_rotated",
598
+ reasonCode: "steering_revision_obsolete",
599
+ ...this.diagnosticContext(retained),
600
+ });
601
+ await this.disposeRetained();
602
+ return true;
603
+ }
604
+ return false;
605
+ }
606
+
607
+ /** Abort active work, wait for its ownership path, then dispose retained state once. */
608
+ dispose(): Promise<void> {
609
+ if (!this.disposePromise) {
610
+ this.closed = true;
611
+ this.disposePromise = this.disposeAfterActiveAssignment();
612
+ }
613
+ return this.disposePromise;
614
+ }
615
+
616
+ private async disposeAfterActiveAssignment(): Promise<void> {
617
+ const active = this.active;
618
+ if (active) {
619
+ this.taintActiveAssignment(active, "coordinator_shutdown");
620
+ if (!active.controller.signal.aborted) {
621
+ active.controller.abort(new Error("worker session owner disposed"));
622
+ }
623
+ await active.completion;
624
+ }
625
+ await this.disposeRetained();
626
+ }
627
+
628
+ private assignmentMatchesRetainedScope(options: RunWorkerTaskOptions, retained: RetainedWorkerState): boolean {
629
+ if (retained.reuseScope === "sequential_task") {
630
+ return true;
631
+ }
632
+ return (
633
+ options.task.taskId === retained.previousTask.taskId &&
634
+ options.task.title === retained.previousTask.title &&
635
+ options.attempt === retained.previousAttempt + 1
636
+ );
637
+ }
638
+
639
+ private taintActiveAssignment(active: ActiveWorkerSessionAssignment, reasonCode: string): void {
640
+ active.tainted = true;
641
+ const retained = this.retained;
642
+ if (retained && retained.resource === active.resource) {
643
+ retained.health = "cancelled";
644
+ }
645
+ this.reportTaintedRotation(active, reasonCode);
646
+ }
647
+
648
+ private reportTaintedRotation(active: ActiveWorkerSessionAssignment, reasonCode: string): void {
649
+ if (active.rotationReported) return;
650
+ active.rotationReported = true;
651
+ const retained = this.retained;
652
+ active.reportDiagnostic?.({
653
+ event: "session_rotated",
654
+ reasonCode,
655
+ ...(retained ? this.diagnosticContext(retained) : {}),
656
+ });
657
+ }
658
+
659
+ private diagnosticContext(retained: RetainedWorkerState): {
660
+ contextUsagePercent?: number;
661
+ contextThresholdPercent: number;
662
+ previousTaskId: string;
663
+ } {
664
+ return {
665
+ ...(retained.contextUsagePercent !== undefined ? { contextUsagePercent: retained.contextUsagePercent } : {}),
666
+ contextThresholdPercent: this.runtime.workerSessionReuseContextThresholdPercent,
667
+ previousTaskId: retained.previousTask.taskId,
668
+ };
669
+ }
670
+
671
+ private defaultIdentity(options: RunWorkerTaskOptions): WorkerAssignmentIdentity {
672
+ const sequence = ++this.assignmentSequence;
673
+ return {
674
+ assignmentId: `${options.task.taskId}:${options.attempt}:${sequence}`,
675
+ taskIdentity: `${options.task.taskId}:${options.task.title}`,
676
+ steeringGeneration: 0,
677
+ planAuthorityToken: "direct-owner",
678
+ };
679
+ }
680
+
681
+ private compatibilityFor(options: RunWorkerTaskOptions): WorkerSessionCompatibilityFingerprint {
682
+ return createWorkerSessionCompatibilityFingerprint({
683
+ coordinatorRunId: this.runtime.runId,
684
+ repositoryRoot: this.runtime.cwd,
685
+ worktreeRoot: options.cwd,
686
+ modelName: options.modelName,
687
+ model: options.model,
688
+ tools: options.tools ?? DEFAULT_WORKER_TOOLS,
689
+ thinkingLevel: options.thinkingLevel,
690
+ agentDir: options.agentDir,
691
+ modelRuntime: options.modelRuntime,
692
+ authStorage: options.authStorage,
693
+ modelRegistry: options.modelRegistry,
694
+ settingsManager: options.settingsManager,
695
+ resourceLoader: options.resourceLoader,
696
+ sessionFactory: options.sessionFactory ?? createIsolatedWorkerSession,
697
+ });
698
+ }
699
+
700
+ private async disposeRetained(): Promise<void> {
701
+ const retained = this.retained;
702
+ if (!retained) {
703
+ return;
704
+ }
705
+ await this.disposeRetainedResource(retained.resource);
706
+ }
707
+
708
+ private async disposeRetainedResource(resource: WorkerSessionResource): Promise<void> {
709
+ if (this.retained?.resource === resource) {
710
+ this.retained = undefined;
711
+ }
712
+ try {
713
+ await disposeWorkerSessionResource(resource);
714
+ } catch {
715
+ // Session disposal is best effort; resource ownership is still closed exactly once.
716
+ }
717
+ }
718
+ }
719
+
720
+ function sameWorkerAssignment(left: WorkerAssignmentIdentity, right: WorkerAssignmentIdentity): boolean {
721
+ return (
722
+ left.assignmentId === right.assignmentId &&
723
+ left.taskIdentity === right.taskIdentity &&
724
+ left.steeringGeneration === right.steeringGeneration &&
725
+ left.planAuthorityToken === right.planAuthorityToken
726
+ );
727
+ }
728
+
729
+ function combineAbortSignals(...signals: Array<AbortSignal | undefined>): AbortSignal | undefined {
730
+ const available = signals.filter((signal): signal is AbortSignal => Boolean(signal));
731
+ if (available.length === 0) return undefined;
732
+ if (available.length === 1) return available[0];
733
+ return AbortSignal.any(available);
734
+ }
735
+
736
+ export function workerSessionHealthForOutcome(
737
+ outcome: Pick<SessionOutcome, "timedOut" | "aborted" | "error" | "assistantText">,
738
+ cancelled = false,
739
+ ): WorkerSessionHealth {
740
+ if (outcome.timedOut) return "timed_out";
741
+ if (cancelled) return "cancelled";
742
+ if (outcome.aborted) return "aborted";
743
+ if (outcome.error) return "unrecoverable_error";
744
+ if (!hasCompleteTaskResult(outcome.assistantText)) return "invalid_state";
745
+ return "healthy";
223
746
  }
224
747
 
225
748
  export async function runCoordinator(options: RunCoordinatorOptions): Promise<CoordinatorResult> {
226
749
  const runtime = buildRuntimeOptions(options);
750
+ const workerSessionOwner = runtime.useRetainedWorkerLifecycle
751
+ ? new CoordinatorWorkerSessionOwner(runtime)
752
+ : undefined;
227
753
  const inputText = coordinatorInputText(options);
228
754
  const attempts: TaskAttemptSummary[] = [];
229
755
  const outcomes: SessionOutcome[] = [];
@@ -235,7 +761,14 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
235
761
  let latestTodoMarkdown: string | undefined;
236
762
  let latestTasks: Task[] = [];
237
763
  let activeTask: Task | undefined;
764
+ let activeTaskReference: PlanTaskReference | undefined;
238
765
  let activeAttempt: number | undefined;
766
+ let activeWorkerAssignment:
767
+ | { identity: WorkerAssignmentIdentity; controller: AbortController; obsolete: boolean }
768
+ | undefined;
769
+ let steeringGeneration = 0;
770
+ let workerExecutionSequence = 0;
771
+ let removeSteeringProcessor: (() => void) | undefined;
239
772
  const protectedDirtyPathsByTask = new Map<string, Set<string>>();
240
773
 
241
774
  try {
@@ -244,7 +777,7 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
244
777
  validateTodoMarkdown(todoMarkdown);
245
778
  planningComplete = true;
246
779
  latestTodoMarkdown = todoMarkdown;
247
- await writeFile(runtime.todoPath, todoMarkdown, "utf8");
780
+ const planStore = await PersistentTodoPlanStore.create(runtime.todoPath, todoMarkdown);
248
781
  const initialTasks = parseTasks(todoMarkdown);
249
782
  latestTasks = initialTasks;
250
783
  emitProgress(runtime, `Created TODO plan with ${initialTasks.length} task(s).`, {
@@ -253,10 +786,92 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
253
786
  taskProgress: buildTaskProgressModel({ tasks: initialTasks }),
254
787
  });
255
788
 
256
- const previousAttempts = new Map<string, string[]>();
257
789
  let failure: string | undefined;
790
+ removeSteeringProcessor = runtime.steeringQueue?.setProcessor(async (message) => {
791
+ const baseAtRequest = planStore.snapshot();
792
+ const activeTaskAtRequest = activeTaskReference
793
+ ? resolvePlanTaskReference(
794
+ parseTasks(baseAtRequest.markdown),
795
+ activeTaskReference,
796
+ baseAtRequest.authorityToken,
797
+ )
798
+ : undefined;
799
+ try {
800
+ const revision = await generateSteeringPlanRevision({
801
+ message,
802
+ currentTodoMarkdown: baseAtRequest.markdown,
803
+ attempts,
804
+ outcomes,
805
+ commits,
806
+ activeTask: activeTaskAtRequest,
807
+ activeAttempt,
808
+ runtime,
809
+ });
810
+ const currentTasks = parseTasks(planStore.snapshot().markdown);
811
+ const appliedRevision = await planStore.applyRevision(revision, {
812
+ expectedAuthorityToken: baseAtRequest.authorityToken,
813
+ taskStates: coordinatorPlanTaskStates(currentTasks, attempts, activeTaskAtRequest),
814
+ runningTask: activeTaskReference,
815
+ // The callback remains part of acceptance: a rejection occurs before
816
+ // the atomic replacement and therefore leaves the prior plan active.
817
+ beforeCommit: runtime.onPlanRevisionAccepted,
818
+ });
819
+ // The store snapshot is the scheduler's authority at every task
820
+ // boundary, so this accepted revision continues the same run.
821
+ latestTodoMarkdown = appliedRevision.todoMarkdown;
822
+ latestTasks = appliedRevision.reconciliation.activeTasks.map((item) => item.task);
823
+ steeringGeneration += 1;
824
+
825
+ // Once replacement/removal is authoritative, make the exact old
826
+ // invocation obsolete before aborting it. Event callbacks consult this
827
+ // identity, so a late old result cannot repaint replacement progress.
828
+ const assignmentAtAcceptance = activeWorkerAssignment;
829
+ const activeStillValid = activeTaskReference
830
+ ? Boolean(resolvePlanTaskReference(latestTasks, activeTaskReference, planStore.snapshot().authorityToken))
831
+ : true;
832
+ if (assignmentAtAcceptance && !activeStillValid) {
833
+ assignmentAtAcceptance.obsolete = true;
834
+ if (!assignmentAtAcceptance.controller.signal.aborted) {
835
+ assignmentAtAcceptance.controller.abort(
836
+ new Error(`steering revision ${message.sequence} replaced the active assignment`),
837
+ );
838
+ }
839
+ await workerSessionOwner?.invalidateAssignment(assignmentAtAcceptance.identity);
840
+ }
841
+
842
+ emitProgress(
843
+ runtime,
844
+ `Accepted steering revision ${message.sequence} with ${appliedRevision.reconciliation.activeTasks.length} task(s).`,
845
+ {
846
+ phase: "planned",
847
+ status: "revised",
848
+ totalTasks: appliedRevision.reconciliation.activeTasks.length,
849
+ taskProgress: revisionTaskProgress(appliedRevision),
850
+ },
851
+ );
852
+ } catch (error) {
853
+ const currentSnapshot = planStore.snapshot();
854
+ const messageText =
855
+ error instanceof PlanRevisionGenerationError
856
+ ? `${error.message} The prior plan remains active and this guidance can be retried.`
857
+ : `Plan revision failed. The prior plan remains active: ${errorMessage(error)}`;
858
+ emitProgress(runtime, messageText, {
859
+ phase: "planning",
860
+ status: "revision_failed",
861
+ isError: true,
862
+ totalTasks: parseTasks(currentSnapshot.markdown).length,
863
+ taskProgress: buildTaskProgressModel({ tasks: parseTasks(currentSnapshot.markdown) }),
864
+ });
865
+ throw error;
866
+ }
867
+ });
258
868
 
259
869
  while (!runtime.abortSignal?.aborted) {
870
+ // Guidance received before this boundary must settle before selecting
871
+ // more work. Failed revisions leave the prior snapshot usable.
872
+ await runtime.steeringQueue?.waitForIdle();
873
+ const schedulingSnapshot = planStore.snapshot();
874
+ todoMarkdown = schedulingSnapshot.markdown;
260
875
  const tasksBeforeAttempt = parseTasks(todoMarkdown);
261
876
  latestTasks = tasksBeforeAttempt;
262
877
  const nextTask = tasksBeforeAttempt.find((task) => !task.done);
@@ -264,10 +879,28 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
264
879
  break;
265
880
  }
266
881
 
267
- const attempt = (previousAttempts.get(nextTask.taskId)?.length ?? 0) + 1;
882
+ const priorTaskAttempts = attemptsForTask(tasksBeforeAttempt, attempts, nextTask);
883
+ const attempt = priorTaskAttempts.length + 1;
268
884
  const initialActivity =
269
885
  nextTask.statusItems.find((item) => !item.done)?.text ?? `Starting TODO ${nextTask.taskId}`;
270
886
  const worker = workerKey(nextTask.taskId, attempt);
887
+ // Task IDs and attempt numbers may be reused after an in-flight task is
888
+ // replaced by steering. Accounting needs an invocation identity so the
889
+ // obsolete attempt's finalized spend cannot be overwritten.
890
+ const accountingWorker = `${worker}#${++workerExecutionSequence}`;
891
+ const assignmentIdentity: WorkerAssignmentIdentity = {
892
+ assignmentId: accountingWorker,
893
+ taskIdentity: nextTask.stableId ?? taskSemanticFingerprint(nextTask),
894
+ steeringGeneration,
895
+ planAuthorityToken: schedulingSnapshot.authorityToken,
896
+ };
897
+ const assignmentController = new AbortController();
898
+ const assignmentState = { identity: assignmentIdentity, controller: assignmentController, obsolete: false };
899
+ const taskPlanReference = planTaskReference(nextTask, schedulingSnapshot.authorityToken);
900
+ activeWorkerAssignment = assignmentState;
901
+ activeTask = nextTask;
902
+ activeAttempt = attempt;
903
+ activeTaskReference = taskPlanReference;
271
904
  runtime.workerActivityByWorker.set(worker, initialActivity);
272
905
  runtime.workerTextByWorker.delete(worker);
273
906
  runtime.workerTextPublishedLengthByWorker.delete(worker);
@@ -288,22 +921,25 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
288
921
  }),
289
922
  },
290
923
  );
291
- let preExistingDirtyPaths = protectedDirtyPathsByTask.get(nextTask.taskId);
924
+ const executionIdentity = taskExecutionIdentity(nextTask);
925
+ let preExistingDirtyPaths = protectedDirtyPathsByTask.get(executionIdentity);
292
926
  if (!preExistingDirtyPaths) {
293
927
  preExistingDirtyPaths = options.commit
294
928
  ? await gitDirtyPaths(runtime.cwd, runtime.taskResultPath, runtime.todoPath, runtime.runDir)
295
929
  : new Set<string>();
296
- protectedDirtyPathsByTask.set(nextTask.taskId, preExistingDirtyPaths);
930
+ protectedDirtyPathsByTask.set(executionIdentity, preExistingDirtyPaths);
297
931
  }
298
- activeTask = nextTask;
299
- activeAttempt = attempt;
300
- const outcome = await runtime.workerRunner({
932
+ const workerOptions: RunWorkerTaskOptions = {
301
933
  cwd: runtime.cwd,
302
934
  todoPath: runtime.todoPath,
303
935
  task: nextTask,
304
936
  attempt,
305
937
  commitRequested: options.commit,
306
- previousAttempts: previousAttempts.get(nextTask.taskId)?.join("\n\n---\n\n"),
938
+ previousAttempts:
939
+ priorTaskAttempts
940
+ .map((item) => item.resultText)
941
+ .filter((item): item is string => Boolean(item))
942
+ .join("\n\n---\n\n") || undefined,
307
943
  globalInstructions: todoGlobalInstructions(todoMarkdown),
308
944
  goal: runtime.goal,
309
945
  maxBashTimeoutSeconds: runtime.maxBashTimeoutSeconds,
@@ -311,52 +947,111 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
311
947
  model: runtime.workerModel,
312
948
  modelName: runtime.workerModelName,
313
949
  thinkingLevel: runtime.taskThinking,
314
- abortSignal: runtime.abortSignal,
950
+ abortSignal: combineAbortSignals(runtime.abortSignal, assignmentController.signal),
315
951
  sessionFactory: runtime.workerSessionFactory,
316
952
  now: runtime.now,
317
- onEvent: (event) => emitWorkerEventProgress(runtime, tasksBeforeAttempt, nextTask, attempts, attempt, event),
318
- });
319
- outcomes.push(outcome);
320
- finalizeWorkerCost(runtime.workerCostState, outcome);
953
+ onEvent: (event) => {
954
+ if (activeWorkerAssignment === assignmentState && !assignmentState.obsolete) {
955
+ emitWorkerEventProgress(runtime, tasksBeforeAttempt, nextTask, attempts, attempt, event, accountingWorker);
956
+ }
957
+ },
958
+ onSessionDiagnostic: (diagnostic) => {
959
+ if (activeWorkerAssignment === assignmentState && !assignmentState.obsolete) {
960
+ emitWorkerSessionProgress(runtime, tasksBeforeAttempt, nextTask, attempts, attempt, diagnostic);
961
+ } else {
962
+ // Lifecycle accounting remains accurate, but obsolete diagnostics
963
+ // must not mutate the replacement task's visible progress.
964
+ recordWorkerSessionMetric(runtime.workerSessionMetrics, diagnostic);
965
+ }
966
+ },
967
+ };
968
+ let outcome: SessionOutcome;
969
+ try {
970
+ outcome = workerSessionOwner
971
+ ? await workerSessionOwner.run(workerOptions, assignmentIdentity)
972
+ : await runtime.workerRunner(workerOptions);
973
+ } catch (error) {
974
+ if (!assignmentState.obsolete) {
975
+ throw error;
976
+ }
977
+ // A cancellation-aware custom runner may reject instead of returning
978
+ // an aborted outcome. Preserve historical evidence, but never let that
979
+ // obsolete rejection terminate or update the replacement assignment.
980
+ outcome = buildWorkerSessionCreationFailureOutcome(workerOptions, error);
981
+ outcome.aborted = true;
982
+ }
983
+ finalizeWorkerCost(runtime.workerCostState, accountingWorker, outcome);
984
+
985
+ // A revision may have been accepted while the worker was running. Let all
986
+ // already-received guidance settle, then resolve this exact task identity
987
+ // against the latest plan before applying any terminal state.
988
+ await runtime.steeringQueue?.waitForIdle();
989
+ const initialResolution = await planStore.resolveTask(taskPlanReference);
990
+ const initiallyObsolete = initialResolution.stale || !initialResolution.task;
991
+
992
+ // Durable attempt evidence must exist before any completion checkbox is
993
+ // persisted. If this write fails, normal failure handling leaves the task
994
+ // pending and retryable.
995
+ await appendTaskResult(runtime.taskResultPath, nextTask, outcome, initiallyObsolete);
996
+ const settlement = initiallyObsolete
997
+ ? initialResolution
998
+ : outcome.done
999
+ ? await planStore.completeTask(taskPlanReference)
1000
+ : await planStore.resolveTask(taskPlanReference);
1001
+ const obsolete = settlement.stale || !settlement.task;
1002
+ if (obsolete && !initiallyObsolete) {
1003
+ await appendObsoleteDisposition(runtime.taskResultPath);
1004
+ }
1005
+ const settledTask = settlement.task ?? initialResolution.task ?? nextTask;
1006
+ todoMarkdown = settlement.snapshot.markdown;
1007
+ latestTodoMarkdown = todoMarkdown;
1008
+ latestTasks = parseTasks(todoMarkdown);
321
1009
 
322
1010
  const attemptDetails: TaskAttemptSummary = {
323
1011
  taskId: nextTask.taskId,
324
1012
  title: nextTask.title,
1013
+ taskStableId: nextTask.stableId,
1014
+ taskFingerprint: taskSemanticFingerprint(nextTask),
325
1015
  attempt,
326
1016
  reportedStatus: outcome.reportedStatus,
327
1017
  done: outcome.done,
328
1018
  error: outcome.error,
1019
+ obsolete,
1020
+ resultText: resultTextForPreviousAttempt(outcome),
329
1021
  };
330
1022
  attempts.push(attemptDetails);
331
- await appendTaskResult(runtime.taskResultPath, nextTask, outcome);
1023
+ outcomes.push(outcome);
332
1024
 
333
- if (outcome.done) {
334
- todoMarkdown = markTaskDone(todoMarkdown, nextTask.taskId);
335
- latestTodoMarkdown = todoMarkdown;
336
- await writeFile(runtime.todoPath, todoMarkdown, "utf8");
337
- }
338
1025
  activeTask = undefined;
1026
+ activeTaskReference = undefined;
339
1027
  activeAttempt = undefined;
1028
+ if (activeWorkerAssignment === assignmentState) {
1029
+ activeWorkerAssignment = undefined;
1030
+ }
340
1031
 
341
1032
  let taskCommitHash: string | undefined;
342
1033
  let taskCommitError: string | undefined;
343
1034
  let taskCommitSkipped: string | undefined;
344
1035
  if (options.commit) {
345
- const commitResult = shouldCommitOutcome(outcome)
346
- ? await commitAfterSession({
347
- cwd: runtime.cwd,
348
- resultPath: runtime.taskResultPath,
349
- todoPath: runtime.todoPath,
350
- runDir: runtime.runDir,
351
- outcome,
352
- preExistingDirtyPaths,
353
- })
354
- : ({ skipped: "outcome is not eligible for commit" } satisfies CommitAfterSessionResult);
1036
+ const commitResult = obsolete
1037
+ ? ({
1038
+ skipped: "task was replaced or removed by an accepted plan revision",
1039
+ } satisfies CommitAfterSessionResult)
1040
+ : shouldCommitOutcome(outcome)
1041
+ ? await commitAfterSession({
1042
+ cwd: runtime.cwd,
1043
+ resultPath: runtime.taskResultPath,
1044
+ todoPath: runtime.todoPath,
1045
+ runDir: runtime.runDir,
1046
+ outcome,
1047
+ preExistingDirtyPaths,
1048
+ })
1049
+ : ({ skipped: "outcome is not eligible for commit" } satisfies CommitAfterSessionResult);
355
1050
  attemptDetails.commitHash = commitResult.hash;
356
1051
  attemptDetails.commitError = commitResult.error;
357
1052
  attemptDetails.commitSkipped = commitResult.skipped;
358
1053
  if (commitResult.hash || commitResult.error) {
359
- commits.push({ taskId: nextTask.taskId, hash: commitResult.hash, error: commitResult.error });
1054
+ commits.push({ taskId: settledTask.taskId, hash: commitResult.hash, error: commitResult.error });
360
1055
  }
361
1056
  taskCommitHash = commitResult.hash;
362
1057
  taskCommitError = commitResult.error;
@@ -364,10 +1059,15 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
364
1059
  await appendCommitNote(runtime.taskResultPath, commitResult);
365
1060
  }
366
1061
 
1062
+ if (obsolete) {
1063
+ emitObsoleteTaskOutcomeProgress(runtime, latestTasks, nextTask, attempts, outcome);
1064
+ continue;
1065
+ }
1066
+
367
1067
  emitTaskOutcomeProgress(
368
1068
  runtime,
369
- parseTasks(todoMarkdown),
370
- nextTask,
1069
+ latestTasks,
1070
+ settledTask,
371
1071
  attempts,
372
1072
  outcome,
373
1073
  taskCommitHash,
@@ -375,15 +1075,33 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
375
1075
  taskCommitSkipped,
376
1076
  );
377
1077
 
378
- const attemptSummary = resultTextForPreviousAttempt(outcome);
379
- previousAttempts.set(nextTask.taskId, [...(previousAttempts.get(nextTask.taskId) ?? []), attemptSummary]);
380
-
381
1078
  if (outcome.done) {
382
1079
  continue;
383
1080
  }
384
1081
 
385
1082
  if (attempt >= runtime.maxAttemptsPerTask) {
386
- failure = `TODO ${nextTask.taskId} ${nextTask.title} did not report done after ${attempt} attempt(s).`;
1083
+ // Give guidance received at the failure boundary the same chance to
1084
+ // replace this work before terminal retry exhaustion is declared.
1085
+ await runtime.steeringQueue?.waitForIdle();
1086
+ const failureResolution = await planStore.resolveTask(taskPlanReference);
1087
+ if (failureResolution.stale || !failureResolution.task) {
1088
+ attemptDetails.obsolete = true;
1089
+ await appendObsoleteDisposition(runtime.taskResultPath);
1090
+ todoMarkdown = failureResolution.snapshot.markdown;
1091
+ latestTodoMarkdown = todoMarkdown;
1092
+ latestTasks = parseTasks(todoMarkdown);
1093
+ emitObsoleteTaskOutcomeProgress(runtime, latestTasks, nextTask, attempts, outcome);
1094
+ continue;
1095
+ }
1096
+ const currentAttemptCount = attemptsForTask(
1097
+ parseTasks(failureResolution.snapshot.markdown),
1098
+ attempts,
1099
+ failureResolution.task,
1100
+ ).length;
1101
+ if (currentAttemptCount < runtime.maxAttemptsPerTask) {
1102
+ continue;
1103
+ }
1104
+ failure = `TODO ${failureResolution.task.taskId} — ${failureResolution.task.title} did not report done after ${currentAttemptCount} attempt(s).`;
387
1105
  break;
388
1106
  }
389
1107
  }
@@ -432,6 +1150,8 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
432
1150
  attempts,
433
1151
  taskProgress,
434
1152
  workerCostTotal: runtime.workerCostState.total,
1153
+ workerUsageTotal: aggregateWorkerUsage(outcomes),
1154
+ workerSessionMetrics: snapshotWorkerSessionMetrics(runtime.workerSessionMetrics),
435
1155
  commit: options.commit,
436
1156
  goal: runtime.goal,
437
1157
  error: failure,
@@ -470,6 +1190,8 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
470
1190
  attempts.push({
471
1191
  taskId: activeTask.taskId,
472
1192
  title: activeTask.title,
1193
+ taskStableId: activeTask.stableId,
1194
+ taskFingerprint: taskSemanticFingerprint(activeTask),
473
1195
  attempt: activeAttempt,
474
1196
  reportedStatus: "failed",
475
1197
  done: false,
@@ -511,6 +1233,8 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
511
1233
  attempts,
512
1234
  taskProgress,
513
1235
  workerCostTotal: runtime.workerCostState.total,
1236
+ workerUsageTotal: aggregateWorkerUsage(outcomes),
1237
+ workerSessionMetrics: snapshotWorkerSessionMetrics(runtime.workerSessionMetrics),
514
1238
  commit: options.commit,
515
1239
  goal: runtime.goal,
516
1240
  error: resultError,
@@ -523,6 +1247,9 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
523
1247
  taskProgress,
524
1248
  });
525
1249
  return result;
1250
+ } finally {
1251
+ removeSteeringProcessor?.();
1252
+ await workerSessionOwner?.dispose();
526
1253
  }
527
1254
  }
528
1255
 
@@ -607,6 +1334,152 @@ async function requestTodoPlan(inputText: string, runtime: RuntimeOptions): Prom
607
1334
  });
608
1335
  }
609
1336
 
1337
+ async function generateSteeringPlanRevision(options: {
1338
+ message: Readonly<SteeringMessage>;
1339
+ currentTodoMarkdown: string;
1340
+ attempts: readonly TaskAttemptSummary[];
1341
+ outcomes: readonly SessionOutcome[];
1342
+ commits: readonly CoordinatorCommitSummary[];
1343
+ activeTask: Task | undefined;
1344
+ activeAttempt: number | undefined;
1345
+ runtime: RuntimeOptions;
1346
+ }): Promise<GeneratedPlanRevision> {
1347
+ const currentTasks = parseTasks(options.currentTodoMarkdown);
1348
+ const taskStates = coordinatorPlanTaskStates(currentTasks, options.attempts, options.activeTask);
1349
+
1350
+ return generatePlanRevision({
1351
+ currentTodoMarkdown: options.currentTodoMarkdown,
1352
+ guidance: options.message.text,
1353
+ revisionId: options.message.id,
1354
+ taskStates,
1355
+ relevantResults: relevantPlanRevisionResults(currentTasks, options.attempts, options.outcomes, options.commits),
1356
+ activeTask: options.activeTask
1357
+ ? {
1358
+ taskId: options.activeTask.taskId,
1359
+ title: options.activeTask.title,
1360
+ attempt: options.activeAttempt,
1361
+ activity: options.runtime.workerActivityByWorker.get(
1362
+ workerKey(options.activeTask.taskId, options.activeAttempt ?? 1),
1363
+ ),
1364
+ }
1365
+ : undefined,
1366
+ planner: ({ prompt, request }) =>
1367
+ options.runtime.todoPlanner({
1368
+ inputText: prompt,
1369
+ plannerPrompt: prompt,
1370
+ planRevision: request,
1371
+ cwd: options.runtime.cwd,
1372
+ runDir: options.runtime.runDir,
1373
+ thinkingLevel: options.runtime.todoThinking,
1374
+ model: options.runtime.workerModel,
1375
+ abortSignal: options.runtime.abortSignal,
1376
+ timeoutMs: options.runtime.todoTimeoutMs,
1377
+ gracefulShutdownMs: options.runtime.todoGracefulShutdownMs,
1378
+ sessionFactory: options.runtime.todoSessionFactory,
1379
+ onDiagnostic: (diagnostic) => recordPlannerDiagnostic(options.runtime, diagnostic),
1380
+ goal: options.runtime.goal,
1381
+ }),
1382
+ });
1383
+ }
1384
+
1385
+ function coordinatorPlanTaskStates(
1386
+ tasks: readonly Task[],
1387
+ attempts: readonly TaskAttemptSummary[],
1388
+ activeTask: Task | undefined,
1389
+ ): Record<string, PlanTaskState> {
1390
+ const lastAttemptByTask = new Map<string, TaskAttemptSummary>();
1391
+ for (const attempt of taskProgressAttempts(tasks, attempts)) {
1392
+ lastAttemptByTask.set(attempt.taskId, attempt);
1393
+ }
1394
+
1395
+ return Object.fromEntries(
1396
+ tasks.map((task) => {
1397
+ const attempt = lastAttemptByTask.get(task.taskId);
1398
+ const state: PlanTaskState = task.done
1399
+ ? "completed"
1400
+ : activeTask?.stableId && task.stableId === activeTask.stableId
1401
+ ? "running"
1402
+ : activeTask?.taskId === task.taskId && task.title === activeTask.title
1403
+ ? "running"
1404
+ : attempt?.reportedStatus === "blocked"
1405
+ ? "blocked"
1406
+ : attempt
1407
+ ? "failed"
1408
+ : "pending";
1409
+ return [task.taskId, state];
1410
+ }),
1411
+ );
1412
+ }
1413
+
1414
+ function revisionTaskProgress(revision: GeneratedPlanRevision): TaskProgressModel {
1415
+ const tasks = revision.reconciliation.activeTasks.map((item) => item.task);
1416
+ const running = revision.reconciliation.activeTasks.find((item) => item.state === "running");
1417
+ const stateAttempts = revision.reconciliation.activeTasks.flatMap((item) => {
1418
+ if (item.state !== "failed" && item.state !== "blocked") {
1419
+ return [];
1420
+ }
1421
+ return [
1422
+ {
1423
+ taskId: item.task.taskId,
1424
+ reportedStatus: item.state,
1425
+ done: false,
1426
+ },
1427
+ ];
1428
+ });
1429
+ return buildTaskProgressModel({
1430
+ tasks,
1431
+ attempts: stateAttempts,
1432
+ currentTaskId: running?.task.taskId,
1433
+ });
1434
+ }
1435
+
1436
+ function relevantPlanRevisionResults(
1437
+ currentTasks: readonly Task[],
1438
+ attempts: readonly TaskAttemptSummary[],
1439
+ outcomes: readonly SessionOutcome[],
1440
+ commits: readonly CoordinatorCommitSummary[],
1441
+ ): PlanRevisionRelevantResult[] {
1442
+ const commitByTask = new Map(
1443
+ commits.filter((commit) => commit.hash).map((commit) => [commit.taskId, commit.hash as string]),
1444
+ );
1445
+
1446
+ return currentTasks.flatMap((task) => {
1447
+ if (!task.done) {
1448
+ return [];
1449
+ }
1450
+ const completedAttempt = attemptsForTask(currentTasks, attempts, task)
1451
+ .filter((attempt) => attempt.done)
1452
+ .at(-1);
1453
+ if (!completedAttempt) {
1454
+ return [];
1455
+ }
1456
+ const outcome = [...outcomes]
1457
+ .reverse()
1458
+ .find(
1459
+ (item) =>
1460
+ item.attempt === completedAttempt.attempt &&
1461
+ item.task.title === completedAttempt.title &&
1462
+ (!completedAttempt.taskFingerprint ||
1463
+ taskSemanticFingerprint(item.task as Task) === completedAttempt.taskFingerprint),
1464
+ );
1465
+ const commitHash = completedAttempt.commitHash ?? commitByTask.get(completedAttempt.taskId);
1466
+ const outputReferences = [
1467
+ outcome?.sessionFile ? `session:${outcome.sessionFile}` : undefined,
1468
+ outcome?.sessionId ? `session-id:${outcome.sessionId}` : undefined,
1469
+ commitHash ? `commit:${commitHash}` : undefined,
1470
+ ].filter((item): item is string => Boolean(item));
1471
+ return [
1472
+ {
1473
+ taskId: task.taskId,
1474
+ status: completedAttempt.reportedStatus,
1475
+ summary:
1476
+ (outcome ? extractResultSummary(outcome.assistantText).trim() : "") || `Completed TODO ${task.taskId}.`,
1477
+ outputReferences,
1478
+ },
1479
+ ];
1480
+ });
1481
+ }
1482
+
610
1483
  // Planner/worker lifecycle differences are audited in docs/planner-worker-lifecycle-audit.md;
611
1484
  // keep this function's public contract stable while moving shared prompt guarding into a helper.
612
1485
  export async function runTodoPlanner(options: TodoPlannerOptions): Promise<string> {
@@ -627,7 +1500,7 @@ export async function runTodoPlanner(options: TodoPlannerOptions): Promise<strin
627
1500
  try {
628
1501
  const plannerText = await runTodoPlannerPrompt({
629
1502
  session,
630
- prompt: buildTodoCreationPrompt(options.inputText, options.goal),
1503
+ prompt: options.plannerPrompt ?? buildTodoCreationPrompt(options.inputText, options.goal),
631
1504
  abortSignal: options.abortSignal,
632
1505
  timeoutMs,
633
1506
  gracefulShutdownMs,
@@ -774,6 +1647,11 @@ function buildRuntimeOptions(options: RunCoordinatorOptions): RuntimeOptions {
774
1647
  const workerModelName = options.workerModelName ?? parsedWorkerConfig.modelName;
775
1648
  const workerModel = workerModelName ? undefined : options.workerModel;
776
1649
  const goal = normalizeOptionalText(options.goal);
1650
+ const workerSessionReuseConfig = resolveWorkerSessionReuseConfig({
1651
+ enabled: options.workerSessionReuse ?? parsedWorkerConfig.workerSessionReuseEnabled,
1652
+ contextThresholdPercent:
1653
+ options.workerSessionReuseContextThresholdPercent ?? parsedWorkerConfig.workerSessionReuseContextThresholdPercent,
1654
+ });
777
1655
 
778
1656
  return {
779
1657
  cwd,
@@ -795,7 +1673,10 @@ function buildRuntimeOptions(options: RunCoordinatorOptions): RuntimeOptions {
795
1673
  goal,
796
1674
  taskThinking: options.taskThinking ?? DEFAULT_COORDINATOR_OPTIONS.taskThinking,
797
1675
  todoThinking: options.todoThinking ?? DEFAULT_COORDINATOR_OPTIONS.todoThinking,
1676
+ workerSessionReuse: workerSessionReuseConfig.enabled,
1677
+ workerSessionReuseContextThresholdPercent: workerSessionReuseConfig.contextThresholdPercent,
798
1678
  workerRunner: options.workerRunner ?? runWorkerTask,
1679
+ useRetainedWorkerLifecycle: options.workerRunner === undefined,
799
1680
  todoPlanner: options.todoPlanner ?? runTodoPlanner,
800
1681
  abortSignal: options.abortSignal,
801
1682
  workerSessionFactory: options.workerSessionFactory,
@@ -807,6 +1688,9 @@ function buildRuntimeOptions(options: RunCoordinatorOptions): RuntimeOptions {
807
1688
  workerTextByWorker: new Map(),
808
1689
  workerTextPublishedLengthByWorker: new Map(),
809
1690
  plannerDiagnostics: [],
1691
+ workerSessionMetrics: createWorkerSessionMetrics(),
1692
+ steeringQueue: options.steeringQueue,
1693
+ onPlanRevisionAccepted: options.onPlanRevisionAccepted,
810
1694
  };
811
1695
  }
812
1696
 
@@ -851,6 +1735,41 @@ function recordPlannerDiagnostic(runtime: RuntimeOptions, diagnostic: PlannerDia
851
1735
  });
852
1736
  }
853
1737
 
1738
+ function aggregateWorkerUsage(outcomes: readonly SessionOutcome[]): WorkerUsageTotals | undefined {
1739
+ const usage = outcomes.flatMap((outcome) => (outcome.workerUsage ? [outcome.workerUsage] : []));
1740
+ if (usage.length === 0) {
1741
+ return undefined;
1742
+ }
1743
+ return usage.reduce<WorkerUsageTotals>(
1744
+ (total, item) => ({
1745
+ input: total.input + item.input,
1746
+ output: total.output + item.output,
1747
+ cacheRead: total.cacheRead + item.cacheRead,
1748
+ cacheWrite: total.cacheWrite + item.cacheWrite,
1749
+ total: total.total + item.total,
1750
+ }),
1751
+ { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
1752
+ );
1753
+ }
1754
+
1755
+ function createWorkerSessionMetrics(): WorkerSessionMetrics {
1756
+ return { starts: 0, reuses: 0, rotations: 0, retained: 0, rotationReasons: {} };
1757
+ }
1758
+
1759
+ function snapshotWorkerSessionMetrics(metrics: WorkerSessionMetrics): WorkerSessionMetrics {
1760
+ return { ...metrics, rotationReasons: { ...metrics.rotationReasons } };
1761
+ }
1762
+
1763
+ function recordWorkerSessionMetric(metrics: WorkerSessionMetrics, diagnostic: WorkerSessionDiagnostic): void {
1764
+ if (diagnostic.event === "session_started") metrics.starts += 1;
1765
+ if (diagnostic.event === "session_reused") metrics.reuses += 1;
1766
+ if (diagnostic.event === "session_retained") metrics.retained += 1;
1767
+ if (diagnostic.event === "session_rotated") {
1768
+ metrics.rotations += 1;
1769
+ metrics.rotationReasons[diagnostic.reasonCode] = (metrics.rotationReasons[diagnostic.reasonCode] ?? 0) + 1;
1770
+ }
1771
+ }
1772
+
854
1773
  function createWorkerCostState(): WorkerCostState {
855
1774
  return {
856
1775
  total: 0,
@@ -887,9 +1806,9 @@ function recordLiveWorkerCost(
887
1806
 
888
1807
  function finalizeWorkerCost(
889
1808
  state: WorkerCostState,
890
- outcome: Pick<SessionOutcome, "task" | "attempt" | "workerCostTotal">,
1809
+ worker: string,
1810
+ outcome: Pick<SessionOutcome, "workerCostTotal">,
891
1811
  ): void {
892
- const worker = workerKey(outcome.task.taskId, outcome.attempt);
893
1812
  state.finalizedByWorker.set(worker, finiteNonNegativeNumber(outcome.workerCostTotal) ?? 0);
894
1813
  state.liveByWorker.delete(worker);
895
1814
  for (const messageKey of state.liveByMessage.keys()) {
@@ -958,6 +1877,43 @@ function subtaskProgress(
958
1877
  });
959
1878
  }
960
1879
 
1880
+ function emitWorkerSessionProgress(
1881
+ runtime: RuntimeOptions,
1882
+ tasks: readonly Task[],
1883
+ task: Pick<Task, "taskId" | "title" | "statusItems">,
1884
+ attempts: readonly TaskAttemptSummary[],
1885
+ attempt: number,
1886
+ diagnostic: WorkerSessionDiagnostic,
1887
+ ): void {
1888
+ recordWorkerSessionMetric(runtime.workerSessionMetrics, diagnostic);
1889
+ const contextText =
1890
+ diagnostic.contextUsagePercent === undefined
1891
+ ? ""
1892
+ : ` at ${diagnostic.contextUsagePercent.toFixed(1)}% context usage`;
1893
+ const action =
1894
+ diagnostic.event === "session_started"
1895
+ ? "started"
1896
+ : diagnostic.event === "session_reused"
1897
+ ? "reused"
1898
+ : diagnostic.event === "session_rotated"
1899
+ ? "rotated"
1900
+ : "retained";
1901
+ emitProgress(runtime, `Worker session ${action}${contextText} (${diagnostic.reasonCode}).`, {
1902
+ phase: "worker_session",
1903
+ taskId: task.taskId,
1904
+ title: task.title,
1905
+ attempt,
1906
+ status: "in_progress",
1907
+ activeStatus: `Worker session ${action}`,
1908
+ workerSessionEvent: diagnostic.event,
1909
+ workerSessionReason: diagnostic.reasonCode,
1910
+ workerSessionContextUsagePercent: diagnostic.contextUsagePercent,
1911
+ workerSessionContextThresholdPercent: diagnostic.contextThresholdPercent,
1912
+ ...currentTaskProgress(task, "in_progress"),
1913
+ taskProgress: buildTaskProgressModel({ tasks, attempts, currentTaskId: task.taskId }),
1914
+ });
1915
+ }
1916
+
961
1917
  function emitWorkerEventProgress(
962
1918
  runtime: RuntimeOptions,
963
1919
  tasks: readonly Task[],
@@ -973,6 +1929,7 @@ function emitWorkerEventProgress(
973
1929
  usageCostTotal?: number;
974
1930
  usageCostKey?: string;
975
1931
  },
1932
+ accountingWorker = workerKey(task.taskId, attempt),
976
1933
  ): void {
977
1934
  const worker = workerKey(task.taskId, attempt);
978
1935
  let activeStatus = runtime.workerActivityByWorker.get(worker);
@@ -1013,7 +1970,7 @@ function emitWorkerEventProgress(
1013
1970
  }
1014
1971
 
1015
1972
  const costChanged =
1016
- event.usageCostTotal !== undefined && recordLiveWorkerCost(runtime.workerCostState, worker, event);
1973
+ event.usageCostTotal !== undefined && recordLiveWorkerCost(runtime.workerCostState, accountingWorker, event);
1017
1974
 
1018
1975
  if (event.type === "message_end" && event.activity) {
1019
1976
  emitProgress(runtime, event.activity, {
@@ -1085,7 +2042,7 @@ function emitWorkerEventProgress(
1085
2042
  }
1086
2043
 
1087
2044
  function stripToolOutcomePrefix(activity: string): string {
1088
- return activity.replace(/^(?:(?:Finished|Failed):\s*)+/i, "");
2045
+ return activity.trim().replace(/^(?:(?:Finished|Failed):\s*)+/i, "");
1089
2046
  }
1090
2047
 
1091
2048
  function activeStatusFromWorkerText(text: string): string {
@@ -1093,6 +2050,27 @@ function activeStatusFromWorkerText(text: string): string {
1093
2050
  return (taskResultIndex >= 0 ? text.slice(0, taskResultIndex) : text).replace(/\s+/g, " ").trim();
1094
2051
  }
1095
2052
 
2053
+ function emitObsoleteTaskOutcomeProgress(
2054
+ runtime: RuntimeOptions,
2055
+ tasks: readonly Task[],
2056
+ task: Pick<Task, "taskId" | "title" | "statusItems">,
2057
+ attempts: readonly TaskAttemptSummary[],
2058
+ outcome: SessionOutcome,
2059
+ ): void {
2060
+ emitProgress(
2061
+ runtime,
2062
+ `TODO ${task.taskId} result was retained as obsolete because an accepted revision replaced or removed the in-flight task.`,
2063
+ {
2064
+ phase: "task_obsolete",
2065
+ taskId: task.taskId,
2066
+ title: task.title,
2067
+ attempt: outcome.attempt,
2068
+ status: "obsolete",
2069
+ taskProgress: buildTaskProgressModel({ tasks, attempts }),
2070
+ },
2071
+ );
2072
+ }
2073
+
1096
2074
  function emitTaskOutcomeProgress(
1097
2075
  runtime: RuntimeOptions,
1098
2076
  tasks: readonly Task[],
@@ -1142,23 +2120,62 @@ function emitTaskOutcomeProgress(
1142
2120
  emitProgress(runtime, `TODO ${task.taskId} ${statusText}${commitText}.`, update);
1143
2121
  }
1144
2122
 
2123
+ function taskProgressAttempts(tasks: readonly Task[], attempts: readonly TaskAttemptSummary[]): TaskAttemptSummary[] {
2124
+ return attempts.flatMap((attempt) => {
2125
+ if (attempt.obsolete) {
2126
+ return [];
2127
+ }
2128
+ const fingerprintMatches = attempt.taskFingerprint
2129
+ ? tasks.filter((task) => taskSemanticFingerprint(task) === attempt.taskFingerprint)
2130
+ : [];
2131
+ const stableMatches =
2132
+ !attempt.taskFingerprint && attempt.taskStableId
2133
+ ? tasks.filter((task) => task.stableId === attempt.taskStableId)
2134
+ : [];
2135
+ const matched =
2136
+ fingerprintMatches.length === 1
2137
+ ? fingerprintMatches[0]
2138
+ : stableMatches.length === 1
2139
+ ? stableMatches[0]
2140
+ : !attempt.taskFingerprint
2141
+ ? tasks.find((task) => task.taskId === attempt.taskId && task.title === attempt.title)
2142
+ : undefined;
2143
+ return matched ? [{ ...attempt, taskId: matched.taskId, title: matched.title }] : [];
2144
+ });
2145
+ }
2146
+
2147
+ function attemptsForTask(
2148
+ tasks: readonly Task[],
2149
+ attempts: readonly TaskAttemptSummary[],
2150
+ task: Pick<Task, "taskId">,
2151
+ ): TaskAttemptSummary[] {
2152
+ return taskProgressAttempts(tasks, attempts).filter((attempt) => attempt.taskId === task.taskId);
2153
+ }
2154
+
2155
+ function taskExecutionIdentity(task: Task): string {
2156
+ return task.stableId
2157
+ ? `stable:${task.stableId}:${taskSemanticFingerprint(task)}`
2158
+ : `semantic:${taskSemanticFingerprint(task)}`;
2159
+ }
2160
+
1145
2161
  function buildCompletionTaskProgressModel(
1146
2162
  tasks: readonly Task[],
1147
2163
  attempts: readonly TaskAttemptSummary[],
1148
2164
  status: CoordinatorStatus,
1149
2165
  ): TaskProgressModel {
2166
+ const currentAttempts = taskProgressAttempts(tasks, attempts);
1150
2167
  if (status === "done") {
1151
- return buildTaskProgressModel({ tasks, attempts });
2168
+ return buildTaskProgressModel({ tasks, attempts: currentAttempts });
1152
2169
  }
1153
2170
 
1154
- const lastIncompleteAttempt = [...attempts].reverse().find((attempt) => !attempt.done);
2171
+ const lastIncompleteAttempt = [...currentAttempts].reverse().find((attempt) => !attempt.done);
1155
2172
  if (!lastIncompleteAttempt) {
1156
- return buildTaskProgressModel({ tasks, attempts });
2173
+ return buildTaskProgressModel({ tasks, attempts: currentAttempts });
1157
2174
  }
1158
2175
 
1159
2176
  return buildTaskProgressModel({
1160
2177
  tasks,
1161
- attempts,
2178
+ attempts: currentAttempts,
1162
2179
  currentTaskId: lastIncompleteAttempt.taskId,
1163
2180
  currentTaskStatus: outcomeTaskProgressStatus(lastIncompleteAttempt),
1164
2181
  });
@@ -1226,7 +2243,12 @@ async function appendCommitNote(pathname: string, result: CommitAfterSessionResu
1226
2243
  await appendFile(pathname, `${lines.join("\n")}\n`, "utf8");
1227
2244
  }
1228
2245
 
1229
- async function appendTaskResult(pathname: string, task: Task, outcome: SessionOutcome): Promise<void> {
2246
+ async function appendTaskResult(
2247
+ pathname: string,
2248
+ task: Task,
2249
+ outcome: SessionOutcome,
2250
+ obsolete = false,
2251
+ ): Promise<void> {
1230
2252
  const summary = extractResultSummary(outcome.assistantText || "").trim() || "TASK_RESULT:\nstatus: unknown";
1231
2253
  const lines = [
1232
2254
  "",
@@ -1237,6 +2259,9 @@ async function appendTaskResult(pathname: string, task: Task, outcome: SessionOu
1237
2259
  `Reported status: ${outcome.reportedStatus}`,
1238
2260
  `Done: ${outcome.done ? "yes" : "no"}`,
1239
2261
  ];
2262
+ if (obsolete) {
2263
+ lines.push(obsoleteDispositionText());
2264
+ }
1240
2265
 
1241
2266
  if (outcome.sessionId) {
1242
2267
  lines.push(`Session ID: ${outcome.sessionId}`);
@@ -1256,6 +2281,25 @@ async function appendTaskResult(pathname: string, task: Task, outcome: SessionOu
1256
2281
  if (outcome.contextObservations.length > 0) {
1257
2282
  lines.push("", "Context observations:", ...outcome.contextObservations.map((item) => `- ${item}`));
1258
2283
  }
2284
+ if (outcome.workerCostSource || outcome.workerCostTotal > 0) {
2285
+ lines.push(`Worker cost: ${outcome.workerCostTotal} (${outcome.workerCostSource ?? "unavailable"})`);
2286
+ }
2287
+ if (outcome.workerUsage) {
2288
+ lines.push(
2289
+ `Worker token usage: input=${outcome.workerUsage.input}, output=${outcome.workerUsage.output}, cacheRead=${outcome.workerUsage.cacheRead}, cacheWrite=${outcome.workerUsage.cacheWrite}, total=${outcome.workerUsage.total}`,
2290
+ );
2291
+ }
2292
+ if (outcome.sessionDiagnostics?.length) {
2293
+ lines.push(
2294
+ "",
2295
+ "Worker session diagnostics:",
2296
+ ...outcome.sessionDiagnostics.map((item) => {
2297
+ const context =
2298
+ item.contextUsagePercent === undefined ? "" : ` context=${item.contextUsagePercent.toFixed(1)}%`;
2299
+ return `- event=${item.event} reason=${item.reasonCode}${context}`;
2300
+ }),
2301
+ );
2302
+ }
1259
2303
  if (outcome.compactionEvents.length > 0) {
1260
2304
  lines.push("", "Compaction events:", ...outcome.compactionEvents.map((item) => `- ${item}`));
1261
2305
  }
@@ -1264,9 +2308,17 @@ async function appendTaskResult(pathname: string, task: Task, outcome: SessionOu
1264
2308
  await appendFile(pathname, `${lines.join("\n")}\n`, "utf8");
1265
2309
  }
1266
2310
 
2311
+ async function appendObsoleteDisposition(pathname: string): Promise<void> {
2312
+ await appendFile(pathname, `\n${obsoleteDispositionText()}\n`, "utf8");
2313
+ }
2314
+
2315
+ function obsoleteDispositionText(): string {
2316
+ return "Plan disposition: obsolete — an accepted revision replaced or removed this in-flight task; its result did not update plan status.";
2317
+ }
2318
+
1267
2319
  function remainingTaskSummaries(tasks: Task[], attempts: TaskAttemptSummary[]): CoordinatorRemainingTask[] {
1268
2320
  const lastAttemptByTask = new Map<string, TaskAttemptSummary>();
1269
- for (const attempt of attempts) {
2321
+ for (const attempt of taskProgressAttempts(tasks, attempts)) {
1270
2322
  lastAttemptByTask.set(attempt.taskId, attempt);
1271
2323
  }
1272
2324