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.
- package/CHANGELOG.md +25 -0
- package/README.md +74 -8
- package/package.json +2 -2
- package/src/coordinator.ts +1102 -50
- package/src/index.ts +60 -18
- 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/worker_config.ts +63 -14
- package/src/worker_reuse_policy.ts +389 -0
- package/src/worker_session.ts +261 -33
package/src/coordinator.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
930
|
+
protectedDirtyPathsByTask.set(executionIdentity, preExistingDirtyPaths);
|
|
297
931
|
}
|
|
298
|
-
|
|
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:
|
|
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) =>
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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 =
|
|
346
|
-
?
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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:
|
|
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
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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 = [...
|
|
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(
|
|
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
|
|