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.
- package/CHANGELOG.md +9 -0
- package/README.md +20 -0
- package/package.json +1 -1
- package/src/coordinator.ts +419 -38
- package/src/index.ts +42 -13
- package/src/plan_revision.ts +471 -0
- package/src/plan_revision_generation.ts +376 -0
- package/src/plan_store.ts +281 -0
- package/src/render.ts +2 -0
- package/src/steering.ts +234 -0
- package/src/todo_parser.ts +46 -7
package/src/coordinator.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
346
|
-
?
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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:
|
|
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
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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 = [...
|
|
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(
|
|
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
|
|