pi-long-task 0.3.17 → 0.4.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.
@@ -12,6 +12,21 @@ import { commitAfterSession, gitDirtyPaths, shouldCommitOutcome, type CommitAfte
12
12
  import { formatCoordinatorResultMessage } from "./render.ts";
13
13
  import { extractResultSummary } 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";
16
31
  import { buildTaskProgressModel, type TaskProgressModel, type TaskProgressStatus } from "./task_progress.ts";
17
32
  import {
@@ -23,7 +38,7 @@ import {
23
38
  todoMarkdownFromString,
24
39
  validateTodoMarkdown,
25
40
  } from "./todo_generator.ts";
26
- import { markTaskDone, parseTasks, todoGlobalInstructions, type Task } from "./todo_parser.ts";
41
+ import { parseTasks, todoGlobalInstructions, type Task } from "./todo_parser.ts";
27
42
  import {
28
43
  createIsolatedWorkerSession,
29
44
  runWorkerTask,
@@ -54,6 +69,7 @@ export type CoordinatorProgressPhase =
54
69
  | "task_done"
55
70
  | "task_blocked"
56
71
  | "task_failed"
72
+ | "task_obsolete"
57
73
  | "complete";
58
74
 
59
75
  export type PlannerDiagnosticKind = "timeout" | "abort" | "invalid_output" | "repair_attempt" | "failure";
@@ -132,6 +148,10 @@ export interface RunCoordinatorOptions extends PiLongTaskInput {
132
148
  todoThinking?: string;
133
149
  now?: () => Date;
134
150
  onProgress?: CoordinatorProgressHandler;
151
+ /** Run-scoped FIFO populated by the extension input handler during active execution. */
152
+ steeringQueue?: SerializedSteeringQueue;
153
+ /** Runs after rebase/validation and immediately before the revision is atomically persisted. */
154
+ onPlanRevisionAccepted?: (revision: GeneratedPlanRevision) => void | Promise<void>;
135
155
  }
136
156
 
137
157
  export interface TodoPlannerOptions {
@@ -146,11 +166,17 @@ export interface TodoPlannerOptions {
146
166
  sessionFactory?: WorkerSessionFactory;
147
167
  onDiagnostic?: PlannerDiagnosticHandler;
148
168
  goal?: string;
169
+ /** Exact prompt for revision planners; bypasses the initial TODO-creation wrapper. */
170
+ plannerPrompt?: string;
171
+ /** Structured revision context supplied alongside plannerPrompt. */
172
+ planRevision?: Readonly<PlanRevisionRequest>;
149
173
  }
150
174
 
151
175
  export interface TaskAttemptSummary {
152
176
  taskId: string;
153
177
  title: string;
178
+ taskStableId?: string;
179
+ taskFingerprint?: string;
154
180
  attempt: number;
155
181
  reportedStatus: string;
156
182
  done: boolean;
@@ -158,6 +184,10 @@ export interface TaskAttemptSummary {
158
184
  commitHash?: string;
159
185
  commitError?: string;
160
186
  commitSkipped?: string;
187
+ /** The worker finished after an accepted revision replaced or removed its task. */
188
+ obsolete?: boolean;
189
+ /** Retry continuity retained across task renumbering and accepted revisions. */
190
+ resultText?: string;
161
191
  }
162
192
 
163
193
  export interface CoordinatorResult {
@@ -220,6 +250,8 @@ interface RuntimeOptions {
220
250
  workerTextByWorker: Map<string, string>;
221
251
  workerTextPublishedLengthByWorker: Map<string, number>;
222
252
  plannerDiagnostics: PlannerDiagnostic[];
253
+ steeringQueue?: SerializedSteeringQueue;
254
+ onPlanRevisionAccepted?: (revision: GeneratedPlanRevision) => void | Promise<void>;
223
255
  }
224
256
 
225
257
  export async function runCoordinator(options: RunCoordinatorOptions): Promise<CoordinatorResult> {
@@ -235,6 +267,7 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
235
267
  let latestTodoMarkdown: string | undefined;
236
268
  let latestTasks: Task[] = [];
237
269
  let activeTask: Task | undefined;
270
+ let activeTaskReference: PlanTaskReference | undefined;
238
271
  let activeAttempt: number | undefined;
239
272
  const protectedDirtyPathsByTask = new Map<string, Set<string>>();
240
273
 
@@ -244,7 +277,7 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
244
277
  validateTodoMarkdown(todoMarkdown);
245
278
  planningComplete = true;
246
279
  latestTodoMarkdown = todoMarkdown;
247
- await writeFile(runtime.todoPath, todoMarkdown, "utf8");
280
+ const planStore = await PersistentTodoPlanStore.create(runtime.todoPath, todoMarkdown);
248
281
  const initialTasks = parseTasks(todoMarkdown);
249
282
  latestTasks = initialTasks;
250
283
  emitProgress(runtime, `Created TODO plan with ${initialTasks.length} task(s).`, {
@@ -253,10 +286,73 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
253
286
  taskProgress: buildTaskProgressModel({ tasks: initialTasks }),
254
287
  });
255
288
 
256
- const previousAttempts = new Map<string, string[]>();
257
289
  let failure: string | undefined;
290
+ runtime.steeringQueue?.setProcessor(async (message) => {
291
+ const baseAtRequest = planStore.snapshot();
292
+ const activeTaskAtRequest = activeTaskReference
293
+ ? resolvePlanTaskReference(
294
+ parseTasks(baseAtRequest.markdown),
295
+ activeTaskReference,
296
+ baseAtRequest.authorityToken,
297
+ )
298
+ : undefined;
299
+ try {
300
+ const revision = await generateSteeringPlanRevision({
301
+ message,
302
+ currentTodoMarkdown: baseAtRequest.markdown,
303
+ attempts,
304
+ outcomes,
305
+ commits,
306
+ activeTask: activeTaskAtRequest,
307
+ activeAttempt,
308
+ runtime,
309
+ });
310
+ const currentTasks = parseTasks(planStore.snapshot().markdown);
311
+ const appliedRevision = await planStore.applyRevision(revision, {
312
+ expectedAuthorityToken: baseAtRequest.authorityToken,
313
+ taskStates: coordinatorPlanTaskStates(currentTasks, attempts, activeTaskAtRequest),
314
+ runningTask: activeTaskReference,
315
+ // The callback remains part of acceptance: a rejection occurs before
316
+ // the atomic replacement and therefore leaves the prior plan active.
317
+ beforeCommit: runtime.onPlanRevisionAccepted,
318
+ });
319
+ // The store snapshot is the scheduler's authority at every task
320
+ // boundary, so this accepted revision continues the same run.
321
+ latestTodoMarkdown = appliedRevision.todoMarkdown;
322
+ latestTasks = appliedRevision.reconciliation.activeTasks.map((item) => item.task);
323
+ emitProgress(
324
+ runtime,
325
+ `Accepted steering revision ${message.sequence} with ${appliedRevision.reconciliation.activeTasks.length} task(s).`,
326
+ {
327
+ phase: "planned",
328
+ status: "revised",
329
+ totalTasks: appliedRevision.reconciliation.activeTasks.length,
330
+ taskProgress: revisionTaskProgress(appliedRevision),
331
+ },
332
+ );
333
+ } catch (error) {
334
+ const currentSnapshot = planStore.snapshot();
335
+ const messageText =
336
+ error instanceof PlanRevisionGenerationError
337
+ ? `${error.message} The prior plan remains active and this guidance can be retried.`
338
+ : `Plan revision failed. The prior plan remains active: ${errorMessage(error)}`;
339
+ emitProgress(runtime, messageText, {
340
+ phase: "planning",
341
+ status: "revision_failed",
342
+ isError: true,
343
+ totalTasks: parseTasks(currentSnapshot.markdown).length,
344
+ taskProgress: buildTaskProgressModel({ tasks: parseTasks(currentSnapshot.markdown) }),
345
+ });
346
+ throw error;
347
+ }
348
+ });
258
349
 
259
350
  while (!runtime.abortSignal?.aborted) {
351
+ // Guidance received before this boundary must settle before selecting
352
+ // more work. Failed revisions leave the prior snapshot usable.
353
+ await runtime.steeringQueue?.waitForIdle();
354
+ const schedulingSnapshot = planStore.snapshot();
355
+ todoMarkdown = schedulingSnapshot.markdown;
260
356
  const tasksBeforeAttempt = parseTasks(todoMarkdown);
261
357
  latestTasks = tasksBeforeAttempt;
262
358
  const nextTask = tasksBeforeAttempt.find((task) => !task.done);
@@ -264,7 +360,8 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
264
360
  break;
265
361
  }
266
362
 
267
- const attempt = (previousAttempts.get(nextTask.taskId)?.length ?? 0) + 1;
363
+ const priorTaskAttempts = attemptsForTask(tasksBeforeAttempt, attempts, nextTask);
364
+ const attempt = priorTaskAttempts.length + 1;
268
365
  const initialActivity =
269
366
  nextTask.statusItems.find((item) => !item.done)?.text ?? `Starting TODO ${nextTask.taskId}`;
270
367
  const worker = workerKey(nextTask.taskId, attempt);
@@ -288,22 +385,29 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
288
385
  }),
289
386
  },
290
387
  );
291
- let preExistingDirtyPaths = protectedDirtyPathsByTask.get(nextTask.taskId);
388
+ const executionIdentity = taskExecutionIdentity(nextTask);
389
+ let preExistingDirtyPaths = protectedDirtyPathsByTask.get(executionIdentity);
292
390
  if (!preExistingDirtyPaths) {
293
391
  preExistingDirtyPaths = options.commit
294
392
  ? await gitDirtyPaths(runtime.cwd, runtime.taskResultPath, runtime.todoPath, runtime.runDir)
295
393
  : new Set<string>();
296
- protectedDirtyPathsByTask.set(nextTask.taskId, preExistingDirtyPaths);
394
+ protectedDirtyPathsByTask.set(executionIdentity, preExistingDirtyPaths);
297
395
  }
298
396
  activeTask = nextTask;
299
397
  activeAttempt = attempt;
398
+ const taskPlanReference = planTaskReference(nextTask, schedulingSnapshot.authorityToken);
399
+ activeTaskReference = taskPlanReference;
300
400
  const outcome = await runtime.workerRunner({
301
401
  cwd: runtime.cwd,
302
402
  todoPath: runtime.todoPath,
303
403
  task: nextTask,
304
404
  attempt,
305
405
  commitRequested: options.commit,
306
- previousAttempts: previousAttempts.get(nextTask.taskId)?.join("\n\n---\n\n"),
406
+ previousAttempts:
407
+ priorTaskAttempts
408
+ .map((item) => item.resultText)
409
+ .filter((item): item is string => Boolean(item))
410
+ .join("\n\n---\n\n") || undefined,
307
411
  globalInstructions: todoGlobalInstructions(todoMarkdown),
308
412
  goal: runtime.goal,
309
413
  maxBashTimeoutSeconds: runtime.maxBashTimeoutSeconds,
@@ -316,47 +420,75 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
316
420
  now: runtime.now,
317
421
  onEvent: (event) => emitWorkerEventProgress(runtime, tasksBeforeAttempt, nextTask, attempts, attempt, event),
318
422
  });
319
- outcomes.push(outcome);
320
423
  finalizeWorkerCost(runtime.workerCostState, outcome);
321
424
 
425
+ // A revision may have been accepted while the worker was running. Let all
426
+ // already-received guidance settle, then resolve this exact task identity
427
+ // against the latest plan before applying any terminal state.
428
+ await runtime.steeringQueue?.waitForIdle();
429
+ const initialResolution = await planStore.resolveTask(taskPlanReference);
430
+ const initiallyObsolete = initialResolution.stale || !initialResolution.task;
431
+
432
+ // Durable attempt evidence must exist before any completion checkbox is
433
+ // persisted. If this write fails, normal failure handling leaves the task
434
+ // pending and retryable.
435
+ await appendTaskResult(runtime.taskResultPath, nextTask, outcome, initiallyObsolete);
436
+ const settlement = initiallyObsolete
437
+ ? initialResolution
438
+ : outcome.done
439
+ ? await planStore.completeTask(taskPlanReference)
440
+ : await planStore.resolveTask(taskPlanReference);
441
+ const obsolete = settlement.stale || !settlement.task;
442
+ if (obsolete && !initiallyObsolete) {
443
+ await appendObsoleteDisposition(runtime.taskResultPath);
444
+ }
445
+ const settledTask = settlement.task ?? initialResolution.task ?? nextTask;
446
+ todoMarkdown = settlement.snapshot.markdown;
447
+ latestTodoMarkdown = todoMarkdown;
448
+ latestTasks = parseTasks(todoMarkdown);
449
+
322
450
  const attemptDetails: TaskAttemptSummary = {
323
451
  taskId: nextTask.taskId,
324
452
  title: nextTask.title,
453
+ taskStableId: nextTask.stableId,
454
+ taskFingerprint: taskSemanticFingerprint(nextTask),
325
455
  attempt,
326
456
  reportedStatus: outcome.reportedStatus,
327
457
  done: outcome.done,
328
458
  error: outcome.error,
459
+ obsolete,
460
+ resultText: resultTextForPreviousAttempt(outcome),
329
461
  };
330
462
  attempts.push(attemptDetails);
331
- await appendTaskResult(runtime.taskResultPath, nextTask, outcome);
463
+ outcomes.push(outcome);
332
464
 
333
- if (outcome.done) {
334
- todoMarkdown = markTaskDone(todoMarkdown, nextTask.taskId);
335
- latestTodoMarkdown = todoMarkdown;
336
- await writeFile(runtime.todoPath, todoMarkdown, "utf8");
337
- }
338
465
  activeTask = undefined;
466
+ activeTaskReference = undefined;
339
467
  activeAttempt = undefined;
340
468
 
341
469
  let taskCommitHash: string | undefined;
342
470
  let taskCommitError: string | undefined;
343
471
  let taskCommitSkipped: string | undefined;
344
472
  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);
473
+ const commitResult = obsolete
474
+ ? ({
475
+ skipped: "task was replaced or removed by an accepted plan revision",
476
+ } satisfies CommitAfterSessionResult)
477
+ : shouldCommitOutcome(outcome)
478
+ ? await commitAfterSession({
479
+ cwd: runtime.cwd,
480
+ resultPath: runtime.taskResultPath,
481
+ todoPath: runtime.todoPath,
482
+ runDir: runtime.runDir,
483
+ outcome,
484
+ preExistingDirtyPaths,
485
+ })
486
+ : ({ skipped: "outcome is not eligible for commit" } satisfies CommitAfterSessionResult);
355
487
  attemptDetails.commitHash = commitResult.hash;
356
488
  attemptDetails.commitError = commitResult.error;
357
489
  attemptDetails.commitSkipped = commitResult.skipped;
358
490
  if (commitResult.hash || commitResult.error) {
359
- commits.push({ taskId: nextTask.taskId, hash: commitResult.hash, error: commitResult.error });
491
+ commits.push({ taskId: settledTask.taskId, hash: commitResult.hash, error: commitResult.error });
360
492
  }
361
493
  taskCommitHash = commitResult.hash;
362
494
  taskCommitError = commitResult.error;
@@ -364,10 +496,15 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
364
496
  await appendCommitNote(runtime.taskResultPath, commitResult);
365
497
  }
366
498
 
499
+ if (obsolete) {
500
+ emitObsoleteTaskOutcomeProgress(runtime, latestTasks, nextTask, attempts, outcome);
501
+ continue;
502
+ }
503
+
367
504
  emitTaskOutcomeProgress(
368
505
  runtime,
369
- parseTasks(todoMarkdown),
370
- nextTask,
506
+ latestTasks,
507
+ settledTask,
371
508
  attempts,
372
509
  outcome,
373
510
  taskCommitHash,
@@ -375,15 +512,33 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
375
512
  taskCommitSkipped,
376
513
  );
377
514
 
378
- const attemptSummary = resultTextForPreviousAttempt(outcome);
379
- previousAttempts.set(nextTask.taskId, [...(previousAttempts.get(nextTask.taskId) ?? []), attemptSummary]);
380
-
381
515
  if (outcome.done) {
382
516
  continue;
383
517
  }
384
518
 
385
519
  if (attempt >= runtime.maxAttemptsPerTask) {
386
- failure = `TODO ${nextTask.taskId} ${nextTask.title} did not report done after ${attempt} attempt(s).`;
520
+ // Give guidance received at the failure boundary the same chance to
521
+ // replace this work before terminal retry exhaustion is declared.
522
+ await runtime.steeringQueue?.waitForIdle();
523
+ const failureResolution = await planStore.resolveTask(taskPlanReference);
524
+ if (failureResolution.stale || !failureResolution.task) {
525
+ attemptDetails.obsolete = true;
526
+ await appendObsoleteDisposition(runtime.taskResultPath);
527
+ todoMarkdown = failureResolution.snapshot.markdown;
528
+ latestTodoMarkdown = todoMarkdown;
529
+ latestTasks = parseTasks(todoMarkdown);
530
+ emitObsoleteTaskOutcomeProgress(runtime, latestTasks, nextTask, attempts, outcome);
531
+ continue;
532
+ }
533
+ const currentAttemptCount = attemptsForTask(
534
+ parseTasks(failureResolution.snapshot.markdown),
535
+ attempts,
536
+ failureResolution.task,
537
+ ).length;
538
+ if (currentAttemptCount < runtime.maxAttemptsPerTask) {
539
+ continue;
540
+ }
541
+ failure = `TODO ${failureResolution.task.taskId} — ${failureResolution.task.title} did not report done after ${currentAttemptCount} attempt(s).`;
387
542
  break;
388
543
  }
389
544
  }
@@ -470,6 +625,8 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
470
625
  attempts.push({
471
626
  taskId: activeTask.taskId,
472
627
  title: activeTask.title,
628
+ taskStableId: activeTask.stableId,
629
+ taskFingerprint: taskSemanticFingerprint(activeTask),
473
630
  attempt: activeAttempt,
474
631
  reportedStatus: "failed",
475
632
  done: false,
@@ -607,6 +764,152 @@ async function requestTodoPlan(inputText: string, runtime: RuntimeOptions): Prom
607
764
  });
608
765
  }
609
766
 
767
+ async function generateSteeringPlanRevision(options: {
768
+ message: Readonly<SteeringMessage>;
769
+ currentTodoMarkdown: string;
770
+ attempts: readonly TaskAttemptSummary[];
771
+ outcomes: readonly SessionOutcome[];
772
+ commits: readonly CoordinatorCommitSummary[];
773
+ activeTask: Task | undefined;
774
+ activeAttempt: number | undefined;
775
+ runtime: RuntimeOptions;
776
+ }): Promise<GeneratedPlanRevision> {
777
+ const currentTasks = parseTasks(options.currentTodoMarkdown);
778
+ const taskStates = coordinatorPlanTaskStates(currentTasks, options.attempts, options.activeTask);
779
+
780
+ return generatePlanRevision({
781
+ currentTodoMarkdown: options.currentTodoMarkdown,
782
+ guidance: options.message.text,
783
+ revisionId: options.message.id,
784
+ taskStates,
785
+ relevantResults: relevantPlanRevisionResults(currentTasks, options.attempts, options.outcomes, options.commits),
786
+ activeTask: options.activeTask
787
+ ? {
788
+ taskId: options.activeTask.taskId,
789
+ title: options.activeTask.title,
790
+ attempt: options.activeAttempt,
791
+ activity: options.runtime.workerActivityByWorker.get(
792
+ workerKey(options.activeTask.taskId, options.activeAttempt ?? 1),
793
+ ),
794
+ }
795
+ : undefined,
796
+ planner: ({ prompt, request }) =>
797
+ options.runtime.todoPlanner({
798
+ inputText: prompt,
799
+ plannerPrompt: prompt,
800
+ planRevision: request,
801
+ cwd: options.runtime.cwd,
802
+ runDir: options.runtime.runDir,
803
+ thinkingLevel: options.runtime.todoThinking,
804
+ model: options.runtime.workerModel,
805
+ abortSignal: options.runtime.abortSignal,
806
+ timeoutMs: options.runtime.todoTimeoutMs,
807
+ gracefulShutdownMs: options.runtime.todoGracefulShutdownMs,
808
+ sessionFactory: options.runtime.todoSessionFactory,
809
+ onDiagnostic: (diagnostic) => recordPlannerDiagnostic(options.runtime, diagnostic),
810
+ goal: options.runtime.goal,
811
+ }),
812
+ });
813
+ }
814
+
815
+ function coordinatorPlanTaskStates(
816
+ tasks: readonly Task[],
817
+ attempts: readonly TaskAttemptSummary[],
818
+ activeTask: Task | undefined,
819
+ ): Record<string, PlanTaskState> {
820
+ const lastAttemptByTask = new Map<string, TaskAttemptSummary>();
821
+ for (const attempt of taskProgressAttempts(tasks, attempts)) {
822
+ lastAttemptByTask.set(attempt.taskId, attempt);
823
+ }
824
+
825
+ return Object.fromEntries(
826
+ tasks.map((task) => {
827
+ const attempt = lastAttemptByTask.get(task.taskId);
828
+ const state: PlanTaskState = task.done
829
+ ? "completed"
830
+ : activeTask?.stableId && task.stableId === activeTask.stableId
831
+ ? "running"
832
+ : activeTask?.taskId === task.taskId && task.title === activeTask.title
833
+ ? "running"
834
+ : attempt?.reportedStatus === "blocked"
835
+ ? "blocked"
836
+ : attempt
837
+ ? "failed"
838
+ : "pending";
839
+ return [task.taskId, state];
840
+ }),
841
+ );
842
+ }
843
+
844
+ function revisionTaskProgress(revision: GeneratedPlanRevision): TaskProgressModel {
845
+ const tasks = revision.reconciliation.activeTasks.map((item) => item.task);
846
+ const running = revision.reconciliation.activeTasks.find((item) => item.state === "running");
847
+ const stateAttempts = revision.reconciliation.activeTasks.flatMap((item) => {
848
+ if (item.state !== "failed" && item.state !== "blocked") {
849
+ return [];
850
+ }
851
+ return [
852
+ {
853
+ taskId: item.task.taskId,
854
+ reportedStatus: item.state,
855
+ done: false,
856
+ },
857
+ ];
858
+ });
859
+ return buildTaskProgressModel({
860
+ tasks,
861
+ attempts: stateAttempts,
862
+ currentTaskId: running?.task.taskId,
863
+ });
864
+ }
865
+
866
+ function relevantPlanRevisionResults(
867
+ currentTasks: readonly Task[],
868
+ attempts: readonly TaskAttemptSummary[],
869
+ outcomes: readonly SessionOutcome[],
870
+ commits: readonly CoordinatorCommitSummary[],
871
+ ): PlanRevisionRelevantResult[] {
872
+ const commitByTask = new Map(
873
+ commits.filter((commit) => commit.hash).map((commit) => [commit.taskId, commit.hash as string]),
874
+ );
875
+
876
+ return currentTasks.flatMap((task) => {
877
+ if (!task.done) {
878
+ return [];
879
+ }
880
+ const completedAttempt = attemptsForTask(currentTasks, attempts, task)
881
+ .filter((attempt) => attempt.done)
882
+ .at(-1);
883
+ if (!completedAttempt) {
884
+ return [];
885
+ }
886
+ const outcome = [...outcomes]
887
+ .reverse()
888
+ .find(
889
+ (item) =>
890
+ item.attempt === completedAttempt.attempt &&
891
+ item.task.title === completedAttempt.title &&
892
+ (!completedAttempt.taskFingerprint ||
893
+ taskSemanticFingerprint(item.task as Task) === completedAttempt.taskFingerprint),
894
+ );
895
+ const commitHash = completedAttempt.commitHash ?? commitByTask.get(completedAttempt.taskId);
896
+ const outputReferences = [
897
+ outcome?.sessionFile ? `session:${outcome.sessionFile}` : undefined,
898
+ outcome?.sessionId ? `session-id:${outcome.sessionId}` : undefined,
899
+ commitHash ? `commit:${commitHash}` : undefined,
900
+ ].filter((item): item is string => Boolean(item));
901
+ return [
902
+ {
903
+ taskId: task.taskId,
904
+ status: completedAttempt.reportedStatus,
905
+ summary:
906
+ (outcome ? extractResultSummary(outcome.assistantText).trim() : "") || `Completed TODO ${task.taskId}.`,
907
+ outputReferences,
908
+ },
909
+ ];
910
+ });
911
+ }
912
+
610
913
  // Planner/worker lifecycle differences are audited in docs/planner-worker-lifecycle-audit.md;
611
914
  // keep this function's public contract stable while moving shared prompt guarding into a helper.
612
915
  export async function runTodoPlanner(options: TodoPlannerOptions): Promise<string> {
@@ -627,7 +930,7 @@ export async function runTodoPlanner(options: TodoPlannerOptions): Promise<strin
627
930
  try {
628
931
  const plannerText = await runTodoPlannerPrompt({
629
932
  session,
630
- prompt: buildTodoCreationPrompt(options.inputText, options.goal),
933
+ prompt: options.plannerPrompt ?? buildTodoCreationPrompt(options.inputText, options.goal),
631
934
  abortSignal: options.abortSignal,
632
935
  timeoutMs,
633
936
  gracefulShutdownMs,
@@ -807,6 +1110,8 @@ function buildRuntimeOptions(options: RunCoordinatorOptions): RuntimeOptions {
807
1110
  workerTextByWorker: new Map(),
808
1111
  workerTextPublishedLengthByWorker: new Map(),
809
1112
  plannerDiagnostics: [],
1113
+ steeringQueue: options.steeringQueue,
1114
+ onPlanRevisionAccepted: options.onPlanRevisionAccepted,
810
1115
  };
811
1116
  }
812
1117
 
@@ -1093,6 +1398,27 @@ function activeStatusFromWorkerText(text: string): string {
1093
1398
  return (taskResultIndex >= 0 ? text.slice(0, taskResultIndex) : text).replace(/\s+/g, " ").trim();
1094
1399
  }
1095
1400
 
1401
+ function emitObsoleteTaskOutcomeProgress(
1402
+ runtime: RuntimeOptions,
1403
+ tasks: readonly Task[],
1404
+ task: Pick<Task, "taskId" | "title" | "statusItems">,
1405
+ attempts: readonly TaskAttemptSummary[],
1406
+ outcome: SessionOutcome,
1407
+ ): void {
1408
+ emitProgress(
1409
+ runtime,
1410
+ `TODO ${task.taskId} result was retained as obsolete because an accepted revision replaced or removed the in-flight task.`,
1411
+ {
1412
+ phase: "task_obsolete",
1413
+ taskId: task.taskId,
1414
+ title: task.title,
1415
+ attempt: outcome.attempt,
1416
+ status: "obsolete",
1417
+ taskProgress: buildTaskProgressModel({ tasks, attempts }),
1418
+ },
1419
+ );
1420
+ }
1421
+
1096
1422
  function emitTaskOutcomeProgress(
1097
1423
  runtime: RuntimeOptions,
1098
1424
  tasks: readonly Task[],
@@ -1142,23 +1468,62 @@ function emitTaskOutcomeProgress(
1142
1468
  emitProgress(runtime, `TODO ${task.taskId} ${statusText}${commitText}.`, update);
1143
1469
  }
1144
1470
 
1471
+ function taskProgressAttempts(tasks: readonly Task[], attempts: readonly TaskAttemptSummary[]): TaskAttemptSummary[] {
1472
+ return attempts.flatMap((attempt) => {
1473
+ if (attempt.obsolete) {
1474
+ return [];
1475
+ }
1476
+ const fingerprintMatches = attempt.taskFingerprint
1477
+ ? tasks.filter((task) => taskSemanticFingerprint(task) === attempt.taskFingerprint)
1478
+ : [];
1479
+ const stableMatches =
1480
+ !attempt.taskFingerprint && attempt.taskStableId
1481
+ ? tasks.filter((task) => task.stableId === attempt.taskStableId)
1482
+ : [];
1483
+ const matched =
1484
+ fingerprintMatches.length === 1
1485
+ ? fingerprintMatches[0]
1486
+ : stableMatches.length === 1
1487
+ ? stableMatches[0]
1488
+ : !attempt.taskFingerprint
1489
+ ? tasks.find((task) => task.taskId === attempt.taskId && task.title === attempt.title)
1490
+ : undefined;
1491
+ return matched ? [{ ...attempt, taskId: matched.taskId, title: matched.title }] : [];
1492
+ });
1493
+ }
1494
+
1495
+ function attemptsForTask(
1496
+ tasks: readonly Task[],
1497
+ attempts: readonly TaskAttemptSummary[],
1498
+ task: Pick<Task, "taskId">,
1499
+ ): TaskAttemptSummary[] {
1500
+ return taskProgressAttempts(tasks, attempts).filter((attempt) => attempt.taskId === task.taskId);
1501
+ }
1502
+
1503
+ function taskExecutionIdentity(task: Task): string {
1504
+ return task.stableId
1505
+ ? `stable:${task.stableId}:${taskSemanticFingerprint(task)}`
1506
+ : `semantic:${taskSemanticFingerprint(task)}`;
1507
+ }
1508
+
1145
1509
  function buildCompletionTaskProgressModel(
1146
1510
  tasks: readonly Task[],
1147
1511
  attempts: readonly TaskAttemptSummary[],
1148
1512
  status: CoordinatorStatus,
1149
1513
  ): TaskProgressModel {
1514
+ const currentAttempts = taskProgressAttempts(tasks, attempts);
1150
1515
  if (status === "done") {
1151
- return buildTaskProgressModel({ tasks, attempts });
1516
+ return buildTaskProgressModel({ tasks, attempts: currentAttempts });
1152
1517
  }
1153
1518
 
1154
- const lastIncompleteAttempt = [...attempts].reverse().find((attempt) => !attempt.done);
1519
+ const lastIncompleteAttempt = [...currentAttempts].reverse().find((attempt) => !attempt.done);
1155
1520
  if (!lastIncompleteAttempt) {
1156
- return buildTaskProgressModel({ tasks, attempts });
1521
+ return buildTaskProgressModel({ tasks, attempts: currentAttempts });
1157
1522
  }
1158
1523
 
1159
1524
  return buildTaskProgressModel({
1160
1525
  tasks,
1161
- attempts,
1526
+ attempts: currentAttempts,
1162
1527
  currentTaskId: lastIncompleteAttempt.taskId,
1163
1528
  currentTaskStatus: outcomeTaskProgressStatus(lastIncompleteAttempt),
1164
1529
  });
@@ -1226,7 +1591,12 @@ async function appendCommitNote(pathname: string, result: CommitAfterSessionResu
1226
1591
  await appendFile(pathname, `${lines.join("\n")}\n`, "utf8");
1227
1592
  }
1228
1593
 
1229
- async function appendTaskResult(pathname: string, task: Task, outcome: SessionOutcome): Promise<void> {
1594
+ async function appendTaskResult(
1595
+ pathname: string,
1596
+ task: Task,
1597
+ outcome: SessionOutcome,
1598
+ obsolete = false,
1599
+ ): Promise<void> {
1230
1600
  const summary = extractResultSummary(outcome.assistantText || "").trim() || "TASK_RESULT:\nstatus: unknown";
1231
1601
  const lines = [
1232
1602
  "",
@@ -1237,6 +1607,9 @@ async function appendTaskResult(pathname: string, task: Task, outcome: SessionOu
1237
1607
  `Reported status: ${outcome.reportedStatus}`,
1238
1608
  `Done: ${outcome.done ? "yes" : "no"}`,
1239
1609
  ];
1610
+ if (obsolete) {
1611
+ lines.push(obsoleteDispositionText());
1612
+ }
1240
1613
 
1241
1614
  if (outcome.sessionId) {
1242
1615
  lines.push(`Session ID: ${outcome.sessionId}`);
@@ -1264,9 +1637,17 @@ async function appendTaskResult(pathname: string, task: Task, outcome: SessionOu
1264
1637
  await appendFile(pathname, `${lines.join("\n")}\n`, "utf8");
1265
1638
  }
1266
1639
 
1640
+ async function appendObsoleteDisposition(pathname: string): Promise<void> {
1641
+ await appendFile(pathname, `\n${obsoleteDispositionText()}\n`, "utf8");
1642
+ }
1643
+
1644
+ function obsoleteDispositionText(): string {
1645
+ return "Plan disposition: obsolete — an accepted revision replaced or removed this in-flight task; its result did not update plan status.";
1646
+ }
1647
+
1267
1648
  function remainingTaskSummaries(tasks: Task[], attempts: TaskAttemptSummary[]): CoordinatorRemainingTask[] {
1268
1649
  const lastAttemptByTask = new Map<string, TaskAttemptSummary>();
1269
- for (const attempt of attempts) {
1650
+ for (const attempt of taskProgressAttempts(tasks, attempts)) {
1270
1651
  lastAttemptByTask.set(attempt.taskId, attempt);
1271
1652
  }
1272
1653