pi-long-task 0.6.0 → 0.7.1

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.
@@ -32,6 +32,24 @@ import {
32
32
  type PlanRevisionRelevantResult,
33
33
  } from "./plan_revision_generation.ts";
34
34
  import { taskSemanticFingerprint, type PlanTaskState } from "./plan_revision.ts";
35
+ import {
36
+ DEFAULT_PLANNER_THINKING_LEVEL,
37
+ DEFAULT_PLANNER_TIMEOUT_MS,
38
+ resolvePlannerBudget,
39
+ resolvePlannerGracefulShutdownMs,
40
+ resolvePlannerTimeoutMs,
41
+ type PlannerBudget,
42
+ } from "./planner_config.ts";
43
+ import {
44
+ createPlannerActiveProgress,
45
+ createPlannerGraceProgress,
46
+ createPlannerStartedProgress,
47
+ formatFriendlyDuration,
48
+ plannerProgressCheckpoints,
49
+ type PlannerProgressEvent,
50
+ type PlannerProgressHandler,
51
+ type PlannerProgressState,
52
+ } from "./planner_progress.ts";
35
53
  import {
36
54
  PersistentTodoPlanStore,
37
55
  planTaskReference,
@@ -53,6 +71,7 @@ import {
53
71
  import { buildTaskProgressModel, type TaskProgressModel, type TaskProgressStatus } from "./task_progress.ts";
54
72
  import {
55
73
  applyGoalInstructionsToTodoMarkdown,
74
+ applyWorkerCapabilityConstraintsToTodoMarkdown,
56
75
  buildTodoCreationPrompt,
57
76
  buildTodoRepairPrompt,
58
77
  extractAndValidateTodoMarkdown,
@@ -61,6 +80,7 @@ import {
61
80
  validateTodoMarkdown,
62
81
  } from "./todo_generator.ts";
63
82
  import { parseTasks, todoGlobalInstructions, type Task } from "./todo_parser.ts";
83
+ import { detectUnavailableWorkerCapabilities, type WorkerCapabilityWarning } from "./worker_capabilities.ts";
64
84
  import {
65
85
  buildWorkerSessionCreationFailureOutcome,
66
86
  createIsolatedWorkerSession,
@@ -81,14 +101,19 @@ import {
81
101
 
82
102
  export type { CoordinatorStatus } from "./types.ts";
83
103
 
104
+ const WORKER_PROGRESS_MAX_BUFFER_CHARS = 2_048;
105
+ const WORKER_PROGRESS_MAX_STATUS_CHARS = 800;
106
+ const WORKER_PROGRESS_MIN_CHARACTER_DELTA = 256;
107
+ const WORKER_PROGRESS_MIN_INTERVAL_MS = 100;
108
+
84
109
  export const DEFAULT_COORDINATOR_OPTIONS = {
85
110
  maxAttemptsPerTask: 3,
86
111
  taskTimeoutMs: 900_000,
87
- todoTimeoutMs: 300_000,
112
+ todoTimeoutMs: DEFAULT_PLANNER_TIMEOUT_MS,
88
113
  todoGracefulShutdownMs: 15_000,
89
114
  maxBashTimeoutMs: 300_000,
90
115
  taskThinking: "high",
91
- todoThinking: "xhigh",
116
+ todoThinking: DEFAULT_PLANNER_THINKING_LEVEL,
92
117
  workerSessionReuse: DEFAULT_WORKER_SESSION_REUSE_ENABLED,
93
118
  workerSessionReuseContextThresholdPercent: DEFAULT_WORKER_SESSION_REUSE_CONTEXT_THRESHOLD_PERCENT,
94
119
  networkRecovery: DEFAULT_NETWORK_RECOVERY_CONFIG,
@@ -96,6 +121,7 @@ export const DEFAULT_COORDINATOR_OPTIONS = {
96
121
 
97
122
  export type WorkerRunner = (options: RunWorkerTaskOptions) => Promise<SessionOutcome>;
98
123
  export type CoordinatorProgressPhase =
124
+ | "capability_warning"
99
125
  | "planning"
100
126
  | "planned"
101
127
  | "task_start"
@@ -108,14 +134,30 @@ export type CoordinatorProgressPhase =
108
134
  | "task_obsolete"
109
135
  | "complete";
110
136
 
111
- export type PlannerDiagnosticKind = "timeout" | "abort" | "invalid_output" | "repair_attempt" | "failure";
137
+ export type PlannerDiagnosticKind =
138
+ | "timeout"
139
+ | "cancelled"
140
+ /** @deprecated Planner cancellation is now reported as `cancelled`. */
141
+ | "abort"
142
+ | "network_recovery"
143
+ | "network_failure"
144
+ | "invalid_output"
145
+ | "repair_attempt"
146
+ | "failure";
112
147
 
113
148
  export interface PlannerDiagnostic {
114
149
  kind: PlannerDiagnosticKind;
115
150
  message: string;
151
+ /** Present whenever output presence is known; partial content itself is deliberately omitted. */
152
+ partialOutputObserved?: boolean;
116
153
  diagnostics?: string[];
117
154
  sessionFile?: string;
118
155
  sessionId?: string;
156
+ /** Network lifecycle data is separate from timeout/cancellation classification. */
157
+ networkRecoveryEvent?: NetworkRecoveryEventType;
158
+ networkFailureReason?: string;
159
+ networkRetryCount?: number;
160
+ networkOutageElapsedMs?: number;
119
161
  }
120
162
 
121
163
  export type PlannerDiagnosticHandler = (diagnostic: PlannerDiagnostic) => void;
@@ -158,8 +200,18 @@ export interface CoordinatorProgressUpdate {
158
200
  taskProgress?: TaskProgressModel;
159
201
  plannerDiagnostic?: PlannerDiagnosticKind;
160
202
  plannerDiagnostics?: string[];
203
+ plannerPartialOutputObserved?: boolean;
161
204
  plannerSessionFile?: string;
162
205
  plannerSessionId?: string;
206
+ /** Deterministic deadline selection used by planner calls in this run. */
207
+ plannerBudget?: Readonly<PlannerBudget>;
208
+ /** Human-facing state with exact millisecond values retained for integrations. */
209
+ plannerProgressState?: PlannerProgressState;
210
+ plannerElapsedMs?: number;
211
+ plannerRemainingMs?: number;
212
+ plannerGracePeriodMs?: number;
213
+ plannerGraceRemainingMs?: number;
214
+ capabilityWarning?: Readonly<WorkerCapabilityWarning>;
163
215
  workerSessionEvent?: WorkerSessionDiagnostic["event"];
164
216
  workerSessionReason?: string;
165
217
  workerSessionContextUsagePercent?: number;
@@ -170,6 +222,10 @@ export interface CoordinatorProgressUpdate {
170
222
  networkNextRetryAtMs?: number;
171
223
  networkNextRetryInMs?: number;
172
224
  networkFailureReason?: string;
225
+ /** Identifies whether recovery belongs to planning or worker execution. */
226
+ networkOperation?: "planner" | "worker";
227
+ /** Planner recovery never mutates the configured per-attempt deadline. */
228
+ plannerDeadlinePolicy?: "per_attempt_excludes_network_wait";
173
229
  }
174
230
 
175
231
  export type CoordinatorProgressHandler = (update: CoordinatorProgressUpdate) => void;
@@ -210,20 +266,31 @@ export interface TodoPlannerOptions {
210
266
  inputText: string;
211
267
  cwd: string;
212
268
  runDir: string;
213
- thinkingLevel: string;
269
+ /** Defaults to the planner-only balanced level; explicit values are forwarded unchanged. */
270
+ thinkingLevel?: string;
214
271
  model?: unknown;
215
272
  abortSignal?: AbortSignal;
216
273
  timeoutMs?: number;
217
274
  gracefulShutdownMs?: number;
275
+ /** Structured record of explicit/default/adaptive deadline selection. */
276
+ plannerBudget?: Readonly<PlannerBudget>;
218
277
  sessionFactory?: WorkerSessionFactory;
219
278
  onDiagnostic?: PlannerDiagnosticHandler;
279
+ /** Shared human-readable timing events for CLI, TUI, and headless integrations. */
280
+ onProgress?: PlannerProgressHandler;
220
281
  goal?: string;
221
282
  /** Exact prompt for revision planners; bypasses the initial TODO-creation wrapper. */
222
283
  plannerPrompt?: string;
223
284
  /** Structured revision context supplied alongside plannerPrompt. */
224
285
  planRevision?: Readonly<PlanRevisionRequest>;
225
- /** Normalized coordinator recovery policy; network wait is excluded from operation timeouts. */
286
+ /**
287
+ * Normalized coordinator recovery policy. Recovery wait is accounted on its
288
+ * own outage clock; every replay receives the same configured per-attempt
289
+ * planner deadline, so recovery settings never replace that deadline.
290
+ */
226
291
  networkRecovery?: Readonly<NetworkRecoveryConfig>;
292
+ /** Run-level constraints derived from capabilities unavailable to isolated workers. */
293
+ capabilityConstraints?: readonly string[];
227
294
  }
228
295
 
229
296
  export interface TaskAttemptSummary {
@@ -276,6 +343,10 @@ export interface CoordinatorResult {
276
343
  workerUsageTotal?: WorkerUsageTotals;
277
344
  /** Additive lifecycle counters for adaptive worker-session reuse. */
278
345
  workerSessionMetrics?: WorkerSessionMetrics;
346
+ /** Deterministic deadline selection used by planner calls in this run. */
347
+ plannerBudget?: Readonly<PlannerBudget>;
348
+ /** Explicit, non-fatal warnings for requested capabilities unavailable to isolated workers. */
349
+ capabilityWarnings?: readonly WorkerCapabilityWarning[];
279
350
  commit: boolean;
280
351
  goal?: string;
281
352
  error?: string;
@@ -307,6 +378,7 @@ interface RuntimeOptions {
307
378
  networkRecovery: NetworkRecoveryConfig;
308
379
  todoTimeoutMs: number;
309
380
  todoGracefulShutdownMs: number;
381
+ plannerBudget: PlannerBudget;
310
382
  workerRunner: WorkerRunner;
311
383
  useRetainedWorkerLifecycle: boolean;
312
384
  todoPlanner: TodoPlanner;
@@ -318,8 +390,11 @@ interface RuntimeOptions {
318
390
  workerCostState: WorkerCostState;
319
391
  workerActivityByWorker: Map<string, string>;
320
392
  workerTextByWorker: Map<string, string>;
393
+ workerTextLengthByWorker: Map<string, number>;
321
394
  workerTextPublishedLengthByWorker: Map<string, number>;
395
+ workerTextPublishedAtByWorker: Map<string, number>;
322
396
  plannerDiagnostics: PlannerDiagnostic[];
397
+ capabilityWarnings: WorkerCapabilityWarning[];
323
398
  workerSessionMetrics: WorkerSessionMetrics;
324
399
  steeringQueue?: SerializedSteeringQueue;
325
400
  onPlanRevisionAccepted?: (revision: GeneratedPlanRevision) => void | Promise<void>;
@@ -327,7 +402,12 @@ interface RuntimeOptions {
327
402
  lastProgress?: CoordinatorProgressUpdate;
328
403
  progressClosed: boolean;
329
404
  networkRecoverySequence: number;
330
- activeNetworkRecoveries: Map<number, NetworkRecoveryEvent>;
405
+ activeNetworkRecoveries: Map<number, ActiveNetworkRecovery>;
406
+ }
407
+
408
+ interface ActiveNetworkRecovery {
409
+ event: NetworkRecoveryEvent;
410
+ operation: "planner" | "worker";
331
411
  }
332
412
 
333
413
  type RetainedWorkerReuseScope = "sequential_task" | "partial_continuation";
@@ -956,7 +1036,7 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
956
1036
  const commits: CoordinatorCommitSummary[] = [];
957
1037
 
958
1038
  await mkdir(runtime.runDir, { recursive: true });
959
- await writeFile(runtime.taskResultPath, initialTaskResultMarkdown(runtime.runId), "utf8");
1039
+ await writeFile(runtime.taskResultPath, initialTaskResultMarkdown(runtime.runId, runtime.capabilityWarnings), "utf8");
960
1040
  let planningComplete = false;
961
1041
  let latestTodoMarkdown: string | undefined;
962
1042
  let latestTasks: Task[] = [];
@@ -972,7 +1052,14 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
972
1052
  const protectedDirtyPathsByTask = new Map<string, Set<string>>();
973
1053
 
974
1054
  try {
975
- emitProgress(runtime, "Creating TODO plan...", { phase: "planning" });
1055
+ for (const warning of runtime.capabilityWarnings) {
1056
+ emitProgress(runtime, warning.message, {
1057
+ phase: "capability_warning",
1058
+ status: "warning",
1059
+ capabilityWarning: warning,
1060
+ });
1061
+ }
1062
+ emitPlannerProgress(runtime, createPlannerStartedProgress(runtime.plannerBudget, runtime.todoGracefulShutdownMs));
976
1063
  let todoMarkdown = await generateOrNormalizeTodoMarkdown(inputText, runtime);
977
1064
  validateTodoMarkdown(todoMarkdown);
978
1065
  planningComplete = true;
@@ -1103,7 +1190,9 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
1103
1190
  activeTaskReference = taskPlanReference;
1104
1191
  runtime.workerActivityByWorker.set(worker, initialActivity);
1105
1192
  runtime.workerTextByWorker.delete(worker);
1193
+ runtime.workerTextLengthByWorker.delete(worker);
1106
1194
  runtime.workerTextPublishedLengthByWorker.delete(worker);
1195
+ runtime.workerTextPublishedAtByWorker.delete(worker);
1107
1196
  emitProgress(
1108
1197
  runtime,
1109
1198
  `Running TODO ${nextTask.taskId} — ${nextTask.title}${attempt > 1 ? ` (attempt ${attempt})` : ""}...`,
@@ -1374,6 +1463,8 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
1374
1463
  workerCostTotal: runtime.workerCostState.total,
1375
1464
  workerUsageTotal: aggregateWorkerUsage(outcomes),
1376
1465
  workerSessionMetrics: snapshotWorkerSessionMetrics(runtime.workerSessionMetrics),
1466
+ plannerBudget: runtime.plannerBudget,
1467
+ capabilityWarnings: runtime.capabilityWarnings,
1377
1468
  commit: options.commit,
1378
1469
  goal: runtime.goal,
1379
1470
  error: failure,
@@ -1388,10 +1479,12 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
1388
1479
  return result;
1389
1480
  } catch (error) {
1390
1481
  const message = errorMessage(error);
1391
- if (!planningComplete) {
1482
+ if (!planningComplete && !hasTerminalPlannerDiagnostic(runtime.plannerDiagnostics)) {
1392
1483
  recordPlannerDiagnostic(runtime, {
1393
- kind: "failure",
1394
- message: `TODO planning failed: ${message}`,
1484
+ kind: runtime.abortSignal?.aborted ? "cancelled" : "failure",
1485
+ message: runtime.abortSignal?.aborted
1486
+ ? `TODO planning cancelled: ${abortSignalReason(runtime.abortSignal)}`
1487
+ : `TODO planning failed: ${message}`,
1395
1488
  });
1396
1489
  }
1397
1490
  const resultError = !planningComplete
@@ -1457,6 +1550,8 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
1457
1550
  workerCostTotal: runtime.workerCostState.total,
1458
1551
  workerUsageTotal: aggregateWorkerUsage(outcomes),
1459
1552
  workerSessionMetrics: snapshotWorkerSessionMetrics(runtime.workerSessionMetrics),
1553
+ plannerBudget: runtime.plannerBudget,
1554
+ capabilityWarnings: runtime.capabilityWarnings,
1460
1555
  commit: options.commit,
1461
1556
  goal: runtime.goal,
1462
1557
  error: resultError,
@@ -1478,9 +1573,10 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
1478
1573
  }
1479
1574
 
1480
1575
  async function generateOrNormalizeTodoMarkdown(inputText: string, runtime: RuntimeOptions): Promise<string> {
1576
+ const capabilityConstraints = runtime.capabilityWarnings.map((warning) => warning.planningConstraint);
1481
1577
  const local = todoMarkdownFromString(inputText, runtime.goal);
1482
1578
  if (local) {
1483
- return local;
1579
+ return applyWorkerCapabilityConstraintsToTodoMarkdown(local, capabilityConstraints);
1484
1580
  }
1485
1581
 
1486
1582
  const plannerText = await requestTodoPlan(inputText, runtime);
@@ -1489,6 +1585,7 @@ async function generateOrNormalizeTodoMarkdown(inputText: string, runtime: Runti
1489
1585
  plannerText,
1490
1586
  (repairPrompt) => requestTodoPlan(repairPrompt, runtime),
1491
1587
  runtime.goal,
1588
+ capabilityConstraints,
1492
1589
  {
1493
1590
  onInvalidOutput: (validationError) =>
1494
1591
  recordPlannerDiagnostic(runtime, {
@@ -1507,7 +1604,8 @@ async function generateOrNormalizeTodoMarkdown(inputText: string, runtime: Runti
1507
1604
  }),
1508
1605
  },
1509
1606
  );
1510
- return applyGoalInstructionsToTodoMarkdown(planned, runtime.goal);
1607
+ const withGoal = applyGoalInstructionsToTodoMarkdown(planned, runtime.goal);
1608
+ return applyWorkerCapabilityConstraintsToTodoMarkdown(withGoal, capabilityConstraints);
1511
1609
  }
1512
1610
 
1513
1611
  interface TodoExtractionRepairHooks {
@@ -1521,6 +1619,7 @@ async function extractTodoMarkdownWithOneRepair(
1521
1619
  plannerText: string,
1522
1620
  requestRepair: (repairPrompt: string) => Promise<string>,
1523
1621
  goal?: string,
1622
+ capabilityConstraints: readonly string[] = [],
1524
1623
  hooks: TodoExtractionRepairHooks = {},
1525
1624
  ): Promise<string> {
1526
1625
  try {
@@ -1529,7 +1628,9 @@ async function extractTodoMarkdownWithOneRepair(
1529
1628
  const validationError = errorMessage(error);
1530
1629
  hooks.onInvalidOutput?.(validationError);
1531
1630
  hooks.onRepairAttempt?.(validationError);
1532
- const repairText = await requestRepair(buildTodoRepairPrompt(inputText, plannerText, validationError, goal));
1631
+ const repairText = await requestRepair(
1632
+ buildTodoRepairPrompt(inputText, plannerText, validationError, goal, capabilityConstraints),
1633
+ );
1533
1634
  try {
1534
1635
  return extractAndValidateTodoMarkdown(repairText);
1535
1636
  } catch (repairError) {
@@ -1553,9 +1654,16 @@ async function requestTodoPlan(inputText: string, runtime: RuntimeOptions): Prom
1553
1654
  abortSignal: runtime.abortSignal,
1554
1655
  timeoutMs: runtime.todoTimeoutMs,
1555
1656
  gracefulShutdownMs: runtime.todoGracefulShutdownMs,
1657
+ plannerBudget: runtime.plannerBudget,
1556
1658
  sessionFactory: runtime.todoSessionFactory,
1557
1659
  networkRecovery: runtime.networkRecovery,
1660
+ capabilityConstraints: runtime.capabilityWarnings.map((warning) => warning.planningConstraint),
1558
1661
  onDiagnostic: (diagnostic) => recordPlannerDiagnostic(runtime, diagnostic),
1662
+ onProgress: (event) => {
1663
+ if (event.state !== "started") {
1664
+ emitPlannerProgress(runtime, event);
1665
+ }
1666
+ },
1559
1667
  goal: runtime.goal,
1560
1668
  },
1561
1669
  runtime,
@@ -1567,8 +1675,10 @@ async function requestTodoPlan(inputText: string, runtime: RuntimeOptions): Prom
1567
1675
  * failed. The default planner disables tools and disposes every session before
1568
1676
  * rejecting, so each retry rotates unsafe conversation state while replaying
1569
1677
  * only the complete immutable planning context. Recovery owns no planner
1570
- * repair/attempt counter, and each fresh call retains the planner timeout;
1571
- * backoff remains governed solely by the separate outage deadline.
1678
+ * repair/attempt counter, and each fresh call retains the exact configured
1679
+ * per-attempt planner timeout. Network wait is intentionally excluded and is
1680
+ * governed solely by the separate outage deadline; recovery cannot reset,
1681
+ * extend, or replace the timeout attached to any individual planner call.
1572
1682
  */
1573
1683
  async function runPlannerOperationWithNetworkRecovery(
1574
1684
  plannerOptions: TodoPlannerOptions,
@@ -1577,6 +1687,8 @@ async function runPlannerOperationWithNetworkRecovery(
1577
1687
  const run = (recoverySignal?: AbortSignal) =>
1578
1688
  runtime.todoPlanner({
1579
1689
  ...plannerOptions,
1690
+ // The recovery signal carries cancellation/outage expiry only. It does
1691
+ // not alter timeoutMs, which remains authoritative for every replay.
1580
1692
  abortSignal: combineAbortSignals(plannerOptions.abortSignal, recoverySignal),
1581
1693
  });
1582
1694
 
@@ -1584,17 +1696,73 @@ async function runPlannerOperationWithNetworkRecovery(
1584
1696
  return await run();
1585
1697
  } catch (initialFailure) {
1586
1698
  const classification = classifyNetworkFailure(initialFailure);
1699
+ if (plannerOptions.abortSignal?.aborted || classification.reason === "cancelled") {
1700
+ recordPlannerCancellation(runtime, plannerOptions.abortSignal, initialFailure);
1701
+ throw plannerCancellationError(plannerOptions.abortSignal, initialFailure);
1702
+ }
1587
1703
  if (!runtime.networkRecovery.enabled || !classification.recoverable) {
1588
1704
  throw initialFailure;
1589
1705
  }
1590
- const recovered = await recoverNetworkOperation({
1591
- initialFailure,
1592
- config: runtime.networkRecovery,
1593
- signal: plannerOptions.abortSignal,
1594
- onEvent: createNetworkRecoveryProgressHandler(runtime),
1595
- retry: ({ signal }) => run(signal),
1596
- });
1597
- return recovered.value;
1706
+
1707
+ const publishRecovery = createNetworkRecoveryProgressHandler(runtime, "planner");
1708
+ let recoveryStarted = false;
1709
+ let lastRecoveryEvent: NetworkRecoveryEvent | undefined;
1710
+ const onRecoveryEvent = (event: NetworkRecoveryEvent) => {
1711
+ if (event.type !== "cleanup") lastRecoveryEvent = event;
1712
+ if (event.type === "outage_started" && !recoveryStarted) {
1713
+ recoveryStarted = true;
1714
+ recordPlannerDiagnostic(
1715
+ runtime,
1716
+ plannerNetworkDiagnostic(
1717
+ event,
1718
+ plannerOptions.timeoutMs,
1719
+ latestPlannerPartialOutput(runtime.plannerDiagnostics),
1720
+ ),
1721
+ );
1722
+ }
1723
+ publishRecovery(event);
1724
+ if (event.type === "recovered") {
1725
+ recordPlannerDiagnostic(
1726
+ runtime,
1727
+ plannerNetworkDiagnostic(
1728
+ event,
1729
+ plannerOptions.timeoutMs,
1730
+ latestPlannerPartialOutput(runtime.plannerDiagnostics),
1731
+ ),
1732
+ );
1733
+ }
1734
+ };
1735
+
1736
+ try {
1737
+ const recovered = await recoverNetworkOperation({
1738
+ initialFailure,
1739
+ config: runtime.networkRecovery,
1740
+ signal: plannerOptions.abortSignal,
1741
+ onEvent: onRecoveryEvent,
1742
+ retry: ({ signal }) => run(signal),
1743
+ });
1744
+ return recovered.value;
1745
+ } catch (recoveryFailure) {
1746
+ if (plannerOptions.abortSignal?.aborted) {
1747
+ recordPlannerCancellation(runtime, plannerOptions.abortSignal, recoveryFailure);
1748
+ throw plannerCancellationError(plannerOptions.abortSignal, recoveryFailure);
1749
+ } else if (!hasTerminalPlannerDiagnosticAfterLatestRecovery(runtime.plannerDiagnostics)) {
1750
+ const finalClassification = classifyNetworkFailure(recoveryFailure);
1751
+ recordPlannerDiagnostic(runtime, {
1752
+ kind: "network_failure",
1753
+ message: `TODO planner network recovery ended before planning completed: ${errorMessage(recoveryFailure)}`,
1754
+ partialOutputObserved: latestPlannerPartialOutput(runtime.plannerDiagnostics),
1755
+ networkRecoveryEvent:
1756
+ lastRecoveryEvent?.type === "outage_expired" || lastRecoveryEvent?.type === "failed"
1757
+ ? lastRecoveryEvent.type
1758
+ : "failed",
1759
+ networkFailureReason: lastRecoveryEvent?.state.lastFailure.reason ?? finalClassification.reason,
1760
+ networkRetryCount: lastRecoveryEvent?.state.retryCount,
1761
+ networkOutageElapsedMs: lastRecoveryEvent?.state.elapsedMs,
1762
+ });
1763
+ }
1764
+ throw recoveryFailure;
1765
+ }
1598
1766
  }
1599
1767
  }
1600
1768
 
@@ -1640,9 +1808,12 @@ async function generateSteeringPlanRevision(options: {
1640
1808
  abortSignal: options.runtime.abortSignal,
1641
1809
  timeoutMs: options.runtime.todoTimeoutMs,
1642
1810
  gracefulShutdownMs: options.runtime.todoGracefulShutdownMs,
1811
+ plannerBudget: options.runtime.plannerBudget,
1643
1812
  sessionFactory: options.runtime.todoSessionFactory,
1644
1813
  networkRecovery: options.runtime.networkRecovery,
1814
+ capabilityConstraints: options.runtime.capabilityWarnings.map((warning) => warning.planningConstraint),
1645
1815
  onDiagnostic: (diagnostic) => recordPlannerDiagnostic(options.runtime, diagnostic),
1816
+ onProgress: (event) => emitPlannerProgress(options.runtime, event),
1646
1817
  goal: options.runtime.goal,
1647
1818
  },
1648
1819
  options.runtime,
@@ -1751,16 +1922,41 @@ function relevantPlanRevisionResults(
1751
1922
  // Planner/worker lifecycle differences are audited in docs/planner-worker-lifecycle-audit.md;
1752
1923
  // keep this function's public contract stable while moving shared prompt guarding into a helper.
1753
1924
  export async function runTodoPlanner(options: TodoPlannerOptions): Promise<string> {
1925
+ const capabilityConstraints =
1926
+ options.capabilityConstraints ??
1927
+ capabilityWarningsForRequest(options.inputText, options.goal).map((warning) => warning.planningConstraint);
1928
+ const plannerBudget =
1929
+ options.plannerBudget ??
1930
+ resolvePlannerBudget({
1931
+ inputText: options.inputText,
1932
+ explicitTimeoutMs: options.timeoutMs,
1933
+ defaultTimeoutMs: DEFAULT_COORDINATOR_OPTIONS.todoTimeoutMs,
1934
+ });
1935
+ const timeoutMs = resolvePlannerTimeoutMs(options.timeoutMs, plannerBudget.timeoutMs);
1936
+ const gracefulShutdownMs = resolvePlannerGracefulShutdownMs(
1937
+ options.gracefulShutdownMs,
1938
+ DEFAULT_COORDINATOR_OPTIONS.todoGracefulShutdownMs,
1939
+ );
1940
+ const effectivePlannerBudget: PlannerBudget =
1941
+ timeoutMs === plannerBudget.timeoutMs
1942
+ ? plannerBudget
1943
+ : {
1944
+ ...plannerBudget,
1945
+ timeoutMs,
1946
+ extensionApplied: false,
1947
+ extensionMs: 0,
1948
+ source: "explicit",
1949
+ trigger: undefined,
1950
+ };
1951
+ notifyPlannerProgress(options.onProgress, createPlannerStartedProgress(effectivePlannerBudget, gracefulShutdownMs));
1754
1952
  const sessionFactory = options.sessionFactory ?? createIsolatedWorkerSession;
1755
1953
  const result = await sessionFactory({
1756
1954
  cwd: options.cwd,
1757
1955
  tools: [],
1758
1956
  model: options.model,
1759
- thinkingLevel: options.thinkingLevel,
1957
+ thinkingLevel: options.thinkingLevel ?? DEFAULT_PLANNER_THINKING_LEVEL,
1760
1958
  });
1761
1959
  const session = result.session;
1762
- const timeoutMs = positiveMilliseconds(options.timeoutMs, DEFAULT_COORDINATOR_OPTIONS.todoTimeoutMs);
1763
- const gracefulShutdownMs = options.gracefulShutdownMs ?? DEFAULT_COORDINATOR_OPTIONS.todoGracefulShutdownMs;
1764
1960
 
1765
1961
  let plannerMarkdown: string | undefined;
1766
1962
  let plannerError: unknown;
@@ -1768,12 +1964,14 @@ export async function runTodoPlanner(options: TodoPlannerOptions): Promise<strin
1768
1964
  try {
1769
1965
  const plannerText = await runTodoPlannerPrompt({
1770
1966
  session,
1771
- prompt: options.plannerPrompt ?? buildTodoCreationPrompt(options.inputText, options.goal),
1967
+ prompt: options.plannerPrompt ?? buildTodoCreationPrompt(options.inputText, options.goal, capabilityConstraints),
1772
1968
  abortSignal: options.abortSignal,
1773
1969
  timeoutMs,
1774
1970
  gracefulShutdownMs,
1775
1971
  diagnostics: result.diagnostics,
1776
1972
  onDiagnostic: options.onDiagnostic,
1973
+ plannerBudget: effectivePlannerBudget,
1974
+ onProgress: options.onProgress,
1777
1975
  });
1778
1976
 
1779
1977
  plannerMarkdown = await extractTodoMarkdownWithOneRepair(
@@ -1788,8 +1986,11 @@ export async function runTodoPlanner(options: TodoPlannerOptions): Promise<strin
1788
1986
  gracefulShutdownMs,
1789
1987
  diagnostics: result.diagnostics,
1790
1988
  onDiagnostic: options.onDiagnostic,
1989
+ plannerBudget: effectivePlannerBudget,
1990
+ onProgress: options.onProgress,
1791
1991
  }),
1792
1992
  options.goal,
1993
+ capabilityConstraints,
1793
1994
  {
1794
1995
  onInvalidOutput: (validationError) =>
1795
1996
  options.onDiagnostic?.({
@@ -1833,7 +2034,8 @@ export async function runTodoPlanner(options: TodoPlannerOptions): Promise<strin
1833
2034
  if (!plannerMarkdown) {
1834
2035
  throw new TodoGenerationError("TODO planner did not return valid TODO markdown.");
1835
2036
  }
1836
- return applyGoalInstructionsToTodoMarkdown(plannerMarkdown, options.goal);
2037
+ const withGoal = applyGoalInstructionsToTodoMarkdown(plannerMarkdown, options.goal);
2038
+ return applyWorkerCapabilityConstraintsToTodoMarkdown(withGoal, capabilityConstraints);
1837
2039
  }
1838
2040
 
1839
2041
  async function runTodoPlannerPrompt(options: {
@@ -1844,6 +2046,8 @@ async function runTodoPlannerPrompt(options: {
1844
2046
  gracefulShutdownMs: number;
1845
2047
  diagnostics?: string[];
1846
2048
  onDiagnostic?: PlannerDiagnosticHandler;
2049
+ plannerBudget: Readonly<PlannerBudget>;
2050
+ onProgress?: PlannerProgressHandler;
1847
2051
  }): Promise<string> {
1848
2052
  const promptResult = await runGuardedSessionPrompt({
1849
2053
  session: options.session,
@@ -1853,17 +2057,47 @@ async function runTodoPlannerPrompt(options: {
1853
2057
  gracefulShutdownMs: options.gracefulShutdownMs,
1854
2058
  gracefulShutdownPrompt: buildTodoPlanningShutdownMessage(),
1855
2059
  diagnostics: options.diagnostics,
2060
+ progressCheckpointsMs: plannerProgressCheckpoints(options.timeoutMs),
2061
+ onProgressCheckpoint: (elapsedMs) =>
2062
+ notifyPlannerProgress(
2063
+ options.onProgress,
2064
+ createPlannerActiveProgress(options.plannerBudget, options.gracefulShutdownMs, elapsedMs),
2065
+ ),
2066
+ onGracePeriodStart: (gracePeriodMs) =>
2067
+ notifyPlannerProgress(options.onProgress, createPlannerGraceProgress(options.plannerBudget, gracePeriodMs)),
1856
2068
  dispose: false,
1857
2069
  });
1858
2070
 
2071
+ // Caller cancellation wins even when it arrives during grace. A hard abort
2072
+ // caused by grace expiry has cancelled=false and remains a timeout.
2073
+ if (promptResult.cancelled) {
2074
+ const outputState = promptResult.outputObserved
2075
+ ? "partial output observed; content omitted"
2076
+ : "no planner output observed";
2077
+ const message = `TODO planner cancelled (${outputState}): ${promptResult.error ?? "caller cancellation"}`;
2078
+ options.onDiagnostic?.(plannerPromptDiagnostic("cancelled", message, promptResult));
2079
+ throw new TodoGenerationError(message);
2080
+ }
1859
2081
  if (promptResult.timedOut) {
1860
- const message = `TODO planner timed out: ${promptResult.error ?? "time budget exceeded"}`;
2082
+ if (
2083
+ promptResult.completedDuringGrace &&
2084
+ promptResult.outputObserved &&
2085
+ !promptResult.error &&
2086
+ isSafeCompletedTodoPlannerOutput(promptResult.assistantText)
2087
+ ) {
2088
+ return promptResult.assistantText;
2089
+ }
2090
+
2091
+ const outputState = promptResult.outputObserved
2092
+ ? "partial output observed; content omitted"
2093
+ : "no planner output observed";
2094
+ const message = `TODO planner timed out (${outputState}): ${promptResult.error ?? "time budget exceeded"}`;
1861
2095
  options.onDiagnostic?.(plannerPromptDiagnostic("timeout", message, promptResult));
1862
2096
  throw new TodoGenerationError(message);
1863
2097
  }
1864
2098
  if (promptResult.aborted) {
1865
- const message = `TODO planner aborted: ${promptResult.error ?? "outer abort signal"}`;
1866
- options.onDiagnostic?.(plannerPromptDiagnostic("abort", message, promptResult));
2099
+ const message = `TODO planner stopped: ${promptResult.error ?? "session abort"}`;
2100
+ options.onDiagnostic?.(plannerPromptDiagnostic("failure", message, promptResult));
1867
2101
  throw new TodoGenerationError(message);
1868
2102
  }
1869
2103
  if (promptResult.error) {
@@ -1883,9 +2117,10 @@ async function runTodoPlannerPrompt(options: {
1883
2117
  }
1884
2118
 
1885
2119
  function plannerPromptDiagnostic(
1886
- kind: Extract<PlannerDiagnosticKind, "timeout" | "abort" | "failure">,
2120
+ kind: Extract<PlannerDiagnosticKind, "timeout" | "cancelled" | "abort" | "failure">,
1887
2121
  message: string,
1888
2122
  promptResult: {
2123
+ outputObserved: boolean;
1889
2124
  diagnostics: string[];
1890
2125
  sessionFile?: string;
1891
2126
  sessionId?: string;
@@ -1894,12 +2129,25 @@ function plannerPromptDiagnostic(
1894
2129
  return {
1895
2130
  kind,
1896
2131
  message,
2132
+ partialOutputObserved: promptResult.outputObserved,
1897
2133
  diagnostics: promptResult.diagnostics,
1898
2134
  sessionFile: promptResult.sessionFile,
1899
2135
  sessionId: promptResult.sessionId,
1900
2136
  };
1901
2137
  }
1902
2138
 
2139
+ function isSafeCompletedTodoPlannerOutput(text: string): boolean {
2140
+ if (!text.trim()) {
2141
+ return false;
2142
+ }
2143
+ try {
2144
+ extractAndValidateTodoMarkdown(text);
2145
+ return true;
2146
+ } catch {
2147
+ return false;
2148
+ }
2149
+ }
2150
+
1903
2151
  function buildTodoPlanningShutdownMessage(): string {
1904
2152
  return `Pi Long Task notice: TODO planning has reached its time budget.
1905
2153
  Return the best valid Pi Long Task TODO markdown you can produce now, or stop if that is not possible.`;
@@ -1912,8 +2160,13 @@ function buildRuntimeOptions(options: RunCoordinatorOptions): RuntimeOptions {
1912
2160
  const parsedWorkerConfig = parseWorkerRuntimeConfig(options.inputText ?? "");
1913
2161
  const configuredAttempts = options.maxAttemptsPerTask ?? parsedWorkerConfig.maxAttemptsPerTask;
1914
2162
  const configuredTaskTimeoutMs = options.taskTimeoutMs ?? parsedWorkerConfig.taskTimeoutMs;
1915
- const configuredTodoTimeoutMs = options.todoTimeoutMs;
1916
- const configuredTodoGracefulShutdownMs = options.todoGracefulShutdownMs;
2163
+ const configuredTodoTimeoutMs = options.todoTimeoutMs ?? parsedWorkerConfig.todoTimeoutMs;
2164
+ const configuredTodoGracefulShutdownMs = options.todoGracefulShutdownMs ?? parsedWorkerConfig.todoGracefulShutdownMs;
2165
+ const plannerBudget = resolvePlannerBudget({
2166
+ inputText: coordinatorInputText(options),
2167
+ explicitTimeoutMs: configuredTodoTimeoutMs,
2168
+ defaultTimeoutMs: DEFAULT_COORDINATOR_OPTIONS.todoTimeoutMs,
2169
+ });
1917
2170
  const configuredMaxBashTimeoutMs = options.maxBashTimeoutMs ?? parsedWorkerConfig.maxBashTimeoutMs;
1918
2171
  const workerModelName = options.workerModelName ?? parsedWorkerConfig.modelName;
1919
2172
  const workerModel = workerModelName ? undefined : options.workerModel;
@@ -1927,6 +2180,7 @@ function buildRuntimeOptions(options: RunCoordinatorOptions): RuntimeOptions {
1927
2180
  ...parsedWorkerConfig.networkRecovery,
1928
2181
  ...options.networkRecovery,
1929
2182
  });
2183
+ const capabilityWarnings = capabilityWarningsForRequest(options.inputText, options.goal);
1930
2184
 
1931
2185
  return {
1932
2186
  cwd,
@@ -1936,11 +2190,12 @@ function buildRuntimeOptions(options: RunCoordinatorOptions): RuntimeOptions {
1936
2190
  taskResultPath: path.join(runDir, "TASK_RESULT.md"),
1937
2191
  maxAttemptsPerTask: positiveInteger(configuredAttempts, DEFAULT_COORDINATOR_OPTIONS.maxAttemptsPerTask),
1938
2192
  taskTimeoutSeconds: positiveMilliseconds(configuredTaskTimeoutMs, DEFAULT_COORDINATOR_OPTIONS.taskTimeoutMs) / 1000,
1939
- todoTimeoutMs: positiveMilliseconds(configuredTodoTimeoutMs, DEFAULT_COORDINATOR_OPTIONS.todoTimeoutMs),
1940
- todoGracefulShutdownMs: positiveMilliseconds(
2193
+ todoTimeoutMs: plannerBudget.timeoutMs,
2194
+ todoGracefulShutdownMs: resolvePlannerGracefulShutdownMs(
1941
2195
  configuredTodoGracefulShutdownMs,
1942
2196
  DEFAULT_COORDINATOR_OPTIONS.todoGracefulShutdownMs,
1943
2197
  ),
2198
+ plannerBudget,
1944
2199
  maxBashTimeoutSeconds:
1945
2200
  positiveMilliseconds(configuredMaxBashTimeoutMs, DEFAULT_COORDINATOR_OPTIONS.maxBashTimeoutMs) / 1000,
1946
2201
  workerModel,
@@ -1962,8 +2217,11 @@ function buildRuntimeOptions(options: RunCoordinatorOptions): RuntimeOptions {
1962
2217
  workerCostState: createWorkerCostState(),
1963
2218
  workerActivityByWorker: new Map(),
1964
2219
  workerTextByWorker: new Map(),
2220
+ workerTextLengthByWorker: new Map(),
1965
2221
  workerTextPublishedLengthByWorker: new Map(),
2222
+ workerTextPublishedAtByWorker: new Map(),
1966
2223
  plannerDiagnostics: [],
2224
+ capabilityWarnings,
1967
2225
  workerSessionMetrics: createWorkerSessionMetrics(),
1968
2226
  steeringQueue: options.steeringQueue,
1969
2227
  onPlanRevisionAccepted: options.onPlanRevisionAccepted,
@@ -1974,6 +2232,23 @@ function buildRuntimeOptions(options: RunCoordinatorOptions): RuntimeOptions {
1974
2232
  };
1975
2233
  }
1976
2234
 
2235
+ function emitPlannerProgress(runtime: RuntimeOptions, event: Readonly<PlannerProgressEvent>): void {
2236
+ emitProgress(runtime, event.message, {
2237
+ phase: "planning",
2238
+ activeStatus: event.message,
2239
+ plannerBudget: event.budget,
2240
+ plannerProgressState: event.state,
2241
+ plannerElapsedMs: event.elapsedMs,
2242
+ plannerRemainingMs: event.remainingMs,
2243
+ plannerGracePeriodMs: event.gracePeriodMs,
2244
+ ...(event.graceRemainingMs === undefined ? {} : { plannerGraceRemainingMs: event.graceRemainingMs }),
2245
+ });
2246
+ }
2247
+
2248
+ function notifyPlannerProgress(handler: PlannerProgressHandler | undefined, event: PlannerProgressEvent): void {
2249
+ handler?.(event);
2250
+ }
2251
+
1977
2252
  function emitProgress(
1978
2253
  runtime: RuntimeOptions,
1979
2254
  message: string,
@@ -1992,7 +2267,7 @@ function emitProgress(
1992
2267
  runtime.lastProgress = progress;
1993
2268
  const activeRecovery = latestNetworkRecovery(runtime.activeNetworkRecoveries);
1994
2269
  if (activeRecovery) {
1995
- publishNetworkRecoveryProgress(runtime, activeRecovery);
2270
+ publishNetworkRecoveryProgress(runtime, activeRecovery.event, activeRecovery.operation);
1996
2271
  } else {
1997
2272
  runtime.onProgress?.(progress);
1998
2273
  }
@@ -2005,7 +2280,10 @@ function emitProgress(
2005
2280
  * status path. Operation IDs prevent an older concurrent recovery from
2006
2281
  * repainting a newer outage or completion.
2007
2282
  */
2008
- function createNetworkRecoveryProgressHandler(runtime: RuntimeOptions): (event: NetworkRecoveryEvent) => void {
2283
+ function createNetworkRecoveryProgressHandler(
2284
+ runtime: RuntimeOptions,
2285
+ operation: "planner" | "worker" = "worker",
2286
+ ): (event: NetworkRecoveryEvent) => void {
2009
2287
  const operationId = ++runtime.networkRecoverySequence;
2010
2288
  let cleaned = false;
2011
2289
 
@@ -2024,7 +2302,7 @@ function createNetworkRecoveryProgressHandler(runtime: RuntimeOptions): (event:
2024
2302
  if (event.type === "recovered") {
2025
2303
  const active = latestNetworkRecovery(runtime.activeNetworkRecoveries);
2026
2304
  if (active) {
2027
- publishNetworkRecoveryProgress(runtime, active);
2305
+ publishNetworkRecoveryProgress(runtime, active.event, active.operation);
2028
2306
  } else if (runtime.lastProgress) {
2029
2307
  runtime.onProgress?.({ ...runtime.lastProgress, workerCostTotal: runtime.workerCostState.total });
2030
2308
  }
@@ -2032,18 +2310,26 @@ function createNetworkRecoveryProgressHandler(runtime: RuntimeOptions): (event:
2032
2310
  return;
2033
2311
  }
2034
2312
 
2035
- runtime.activeNetworkRecoveries.set(operationId, event);
2313
+ runtime.activeNetworkRecoveries.set(operationId, { event, operation });
2036
2314
  if (operationId === latestNetworkRecoveryId(runtime.activeNetworkRecoveries)) {
2037
- publishNetworkRecoveryProgress(runtime, event);
2315
+ publishNetworkRecoveryProgress(runtime, event, operation);
2038
2316
  }
2039
2317
  };
2040
2318
  }
2041
2319
 
2042
- function publishNetworkRecoveryProgress(runtime: RuntimeOptions, event: NetworkRecoveryEvent): void {
2320
+ function publishNetworkRecoveryProgress(
2321
+ runtime: RuntimeOptions,
2322
+ event: NetworkRecoveryEvent,
2323
+ operation: "planner" | "worker" = "worker",
2324
+ ): void {
2043
2325
  if (runtime.progressClosed) return;
2044
2326
  const stable = runtime.lastProgress;
2045
2327
  const nowMs = event.state.outageStartedAtMs + event.state.elapsedMs;
2046
- const message = formatNetworkRecoveryStatus(event);
2328
+ const recoveryStatus = formatNetworkRecoveryStatus(event);
2329
+ const message =
2330
+ operation === "planner"
2331
+ ? `TODO planner network recovery: ${recoveryStatus} The ${formatFriendlyDuration(runtime.todoTimeoutMs)} planning deadline remains unchanged for each provider attempt.`
2332
+ : recoveryStatus;
2047
2333
  runtime.onProgress?.({
2048
2334
  message,
2049
2335
  phase: "network_wait",
@@ -2067,17 +2353,19 @@ function publishNetworkRecoveryProgress(runtime: RuntimeOptions, event: NetworkR
2067
2353
  networkNextRetryInMs:
2068
2354
  event.state.nextRetryAtMs === undefined ? undefined : Math.max(0, event.state.nextRetryAtMs - nowMs),
2069
2355
  networkFailureReason: event.state.lastFailure.reason,
2356
+ networkOperation: operation,
2357
+ ...(operation === "planner" ? { plannerDeadlinePolicy: "per_attempt_excludes_network_wait" as const } : {}),
2070
2358
  });
2071
2359
  }
2072
2360
 
2073
2361
  function latestNetworkRecovery(
2074
- recoveries: ReadonlyMap<number, NetworkRecoveryEvent>,
2075
- ): NetworkRecoveryEvent | undefined {
2362
+ recoveries: ReadonlyMap<number, ActiveNetworkRecovery>,
2363
+ ): ActiveNetworkRecovery | undefined {
2076
2364
  const id = latestNetworkRecoveryId(recoveries);
2077
2365
  return id === undefined ? undefined : recoveries.get(id);
2078
2366
  }
2079
2367
 
2080
- function latestNetworkRecoveryId(recoveries: ReadonlyMap<number, NetworkRecoveryEvent>): number | undefined {
2368
+ function latestNetworkRecoveryId(recoveries: ReadonlyMap<number, ActiveNetworkRecovery>): number | undefined {
2081
2369
  let latest: number | undefined;
2082
2370
  for (const id of recoveries.keys()) {
2083
2371
  if (latest === undefined || id > latest) latest = id;
@@ -2089,13 +2377,79 @@ function isTerminalNetworkRecoveryEvent(type: NetworkRecoveryEventType): boolean
2089
2377
  return type === "recovered" || type === "failed" || type === "cancelled" || type === "outage_expired";
2090
2378
  }
2091
2379
 
2380
+ function plannerNetworkDiagnostic(
2381
+ event: NetworkRecoveryEvent,
2382
+ timeoutMs: number | undefined,
2383
+ partialOutputObserved: boolean | undefined,
2384
+ ): PlannerDiagnostic {
2385
+ const recovered = event.type === "recovered";
2386
+ const budget = formatFriendlyDuration(timeoutMs ?? DEFAULT_COORDINATOR_OPTIONS.todoTimeoutMs);
2387
+ return {
2388
+ kind: "network_recovery",
2389
+ message: recovered
2390
+ ? `TODO planner network recovery succeeded after ${formatFriendlyDuration(event.state.elapsedMs)}; planning continues with its unchanged ${budget} per-attempt deadline.`
2391
+ : `TODO planner network recovery started (${event.state.lastFailure.reason}); its outage clock is separate and the ${budget} per-attempt planning deadline remains unchanged.`,
2392
+ partialOutputObserved,
2393
+ networkRecoveryEvent: event.type,
2394
+ networkFailureReason: event.state.lastFailure.reason,
2395
+ networkRetryCount: event.state.retryCount,
2396
+ networkOutageElapsedMs: event.state.elapsedMs,
2397
+ };
2398
+ }
2399
+
2400
+ function latestPlannerPartialOutput(diagnostics: readonly PlannerDiagnostic[]): boolean | undefined {
2401
+ return [...diagnostics].reverse().find((diagnostic) => diagnostic.partialOutputObserved !== undefined)
2402
+ ?.partialOutputObserved;
2403
+ }
2404
+
2405
+ function hasTerminalPlannerDiagnostic(diagnostics: readonly PlannerDiagnostic[]): boolean {
2406
+ const kind = diagnostics.at(-1)?.kind;
2407
+ return kind !== undefined && ["timeout", "cancelled", "abort", "network_failure", "failure"].includes(kind);
2408
+ }
2409
+
2410
+ function hasTerminalPlannerDiagnosticAfterLatestRecovery(diagnostics: readonly PlannerDiagnostic[]): boolean {
2411
+ let recoveryIndex = -1;
2412
+ for (let index = diagnostics.length - 1; index >= 0; index -= 1) {
2413
+ if (diagnostics[index]?.kind === "network_recovery") {
2414
+ recoveryIndex = index;
2415
+ break;
2416
+ }
2417
+ }
2418
+ return diagnostics
2419
+ .slice(recoveryIndex + 1)
2420
+ .some((diagnostic) => ["timeout", "cancelled", "abort", "network_failure"].includes(diagnostic.kind));
2421
+ }
2422
+
2423
+ function recordPlannerCancellation(runtime: RuntimeOptions, signal: AbortSignal | undefined, cause: unknown): void {
2424
+ if (runtime.plannerDiagnostics.some((diagnostic) => diagnostic.kind === "cancelled")) return;
2425
+ recordPlannerDiagnostic(runtime, {
2426
+ kind: "cancelled",
2427
+ message: `TODO planning cancelled: ${signal?.aborted ? abortSignalReason(signal) : errorMessage(cause)}`,
2428
+ partialOutputObserved: latestPlannerPartialOutput(runtime.plannerDiagnostics),
2429
+ });
2430
+ }
2431
+
2432
+ function abortSignalReason(signal: AbortSignal): string {
2433
+ return signal.reason === undefined ? "cancelled by caller" : errorMessage(signal.reason);
2434
+ }
2435
+
2436
+ function plannerCancellationError(signal: AbortSignal | undefined, cause: unknown): TodoGenerationError {
2437
+ const reason = signal?.aborted ? abortSignalReason(signal) : errorMessage(cause);
2438
+ return new TodoGenerationError(`TODO planning cancelled: ${reason}`, { cause });
2439
+ }
2440
+
2092
2441
  function recordPlannerDiagnostic(runtime: RuntimeOptions, diagnostic: PlannerDiagnostic): void {
2093
2442
  const normalized: PlannerDiagnostic = {
2094
2443
  kind: diagnostic.kind,
2095
2444
  message: diagnostic.message,
2445
+ partialOutputObserved: diagnostic.partialOutputObserved,
2096
2446
  diagnostics: diagnostic.diagnostics?.filter(Boolean),
2097
2447
  sessionFile: diagnostic.sessionFile,
2098
2448
  sessionId: diagnostic.sessionId,
2449
+ networkRecoveryEvent: diagnostic.networkRecoveryEvent,
2450
+ networkFailureReason: diagnostic.networkFailureReason,
2451
+ networkRetryCount: diagnostic.networkRetryCount,
2452
+ networkOutageElapsedMs: diagnostic.networkOutageElapsedMs,
2099
2453
  };
2100
2454
  const last = runtime.plannerDiagnostics.at(-1);
2101
2455
  if (last?.kind === normalized.kind && last.message === normalized.message) {
@@ -2105,11 +2459,22 @@ function recordPlannerDiagnostic(runtime: RuntimeOptions, diagnostic: PlannerDia
2105
2459
  emitProgress(runtime, normalized.message, {
2106
2460
  phase: "planning",
2107
2461
  status: normalized.kind,
2108
- isError: normalized.kind !== "repair_attempt",
2462
+ isError: !["repair_attempt", "network_recovery"].includes(normalized.kind),
2109
2463
  plannerDiagnostic: normalized.kind,
2110
2464
  plannerDiagnostics: normalized.diagnostics,
2465
+ plannerPartialOutputObserved: normalized.partialOutputObserved,
2111
2466
  plannerSessionFile: normalized.sessionFile,
2112
2467
  plannerSessionId: normalized.sessionId,
2468
+ networkRecoveryEvent: normalized.networkRecoveryEvent,
2469
+ networkRetryCount: normalized.networkRetryCount,
2470
+ networkOutageElapsedMs: normalized.networkOutageElapsedMs,
2471
+ networkFailureReason: normalized.networkFailureReason,
2472
+ networkOperation:
2473
+ normalized.kind === "network_recovery" || normalized.kind === "network_failure" ? "planner" : undefined,
2474
+ plannerDeadlinePolicy:
2475
+ normalized.kind === "network_recovery" || normalized.kind === "network_failure"
2476
+ ? "per_attempt_excludes_network_wait"
2477
+ : undefined,
2113
2478
  taskProgress: buildTaskProgressModel({ tasks: [] }),
2114
2479
  });
2115
2480
  }
@@ -2314,15 +2679,23 @@ function emitWorkerEventProgress(
2314
2679
  let activeStatus = runtime.workerActivityByWorker.get(worker);
2315
2680
 
2316
2681
  if (event.type === "message_update" && event.textDelta) {
2317
- const workerText = `${runtime.workerTextByWorker.get(worker) ?? ""}${event.textDelta}`;
2682
+ const previousText = runtime.workerTextByWorker.get(worker) ?? "";
2683
+ const workerText = `${previousText}${event.textDelta}`.slice(-WORKER_PROGRESS_MAX_BUFFER_CHARS);
2684
+ const receivedLength = (runtime.workerTextLengthByWorker.get(worker) ?? 0) + event.textDelta.length;
2318
2685
  runtime.workerTextByWorker.set(worker, workerText);
2686
+ runtime.workerTextLengthByWorker.set(worker, receivedLength);
2319
2687
  const streamedStatus = activeStatusFromWorkerText(workerText);
2320
2688
  const publishedLength = runtime.workerTextPublishedLengthByWorker.get(worker) ?? 0;
2321
- const publishBoundary = /[\n.!?:]\s*$/.test(event.textDelta) || streamedStatus.length - publishedLength >= 48;
2322
- if (streamedStatus && publishBoundary) {
2689
+ const publishedAt = runtime.workerTextPublishedAtByWorker.get(worker);
2690
+ const nowMs = runtime.now().getTime();
2691
+ const sentenceBoundary = /[\n.!?:]\s*$/.test(event.textDelta);
2692
+ const enoughTimeElapsed = publishedAt === undefined || nowMs - publishedAt >= WORKER_PROGRESS_MIN_INTERVAL_MS;
2693
+ const enoughNewText = receivedLength - publishedLength >= WORKER_PROGRESS_MIN_CHARACTER_DELTA;
2694
+ if (streamedStatus && ((sentenceBoundary && enoughTimeElapsed) || enoughNewText)) {
2323
2695
  activeStatus = streamedStatus;
2324
2696
  runtime.workerActivityByWorker.set(worker, activeStatus);
2325
- runtime.workerTextPublishedLengthByWorker.set(worker, streamedStatus.length);
2697
+ runtime.workerTextPublishedLengthByWorker.set(worker, receivedLength);
2698
+ runtime.workerTextPublishedAtByWorker.set(worker, nowMs);
2326
2699
  emitProgress(runtime, activeStatus, {
2327
2700
  phase: "worker_tool",
2328
2701
  taskId: task.taskId,
@@ -2340,7 +2713,9 @@ function emitWorkerEventProgress(
2340
2713
 
2341
2714
  if (event.type === "message_end") {
2342
2715
  runtime.workerTextByWorker.delete(worker);
2716
+ runtime.workerTextLengthByWorker.delete(worker);
2343
2717
  runtime.workerTextPublishedLengthByWorker.delete(worker);
2718
+ runtime.workerTextPublishedAtByWorker.delete(worker);
2344
2719
  }
2345
2720
 
2346
2721
  if (event.activity) {
@@ -2426,7 +2801,9 @@ function stripToolOutcomePrefix(activity: string): string {
2426
2801
 
2427
2802
  function activeStatusFromWorkerText(text: string): string {
2428
2803
  const taskResultIndex = text.indexOf("TASK_RESULT:");
2429
- return (taskResultIndex >= 0 ? text.slice(0, taskResultIndex) : text).replace(/\s+/g, " ").trim();
2804
+ const normalized = (taskResultIndex >= 0 ? text.slice(0, taskResultIndex) : text).replace(/\s+/g, " ").trim();
2805
+ if (normalized.length <= WORKER_PROGRESS_MAX_STATUS_CHARS) return normalized;
2806
+ return `… ${normalized.slice(-WORKER_PROGRESS_MAX_STATUS_CHARS + 2)}`;
2430
2807
  }
2431
2808
 
2432
2809
  function emitObsoleteTaskOutcomeProgress(
@@ -2582,8 +2959,11 @@ function outcomeProgressItemStatus(
2582
2959
  return "failed";
2583
2960
  }
2584
2961
 
2585
- function initialTaskResultMarkdown(runId: string): string {
2586
- return `# Pi Long Task TASK_RESULT\n\nRun: ${runId}\n`;
2962
+ function initialTaskResultMarkdown(runId: string, capabilityWarnings: readonly WorkerCapabilityWarning[] = []): string {
2963
+ const warningBlock = capabilityWarnings.length
2964
+ ? `\n\n## Worker capability warnings\n\n${capabilityWarnings.map((warning) => `- ${warning.message}`).join("\n")}`
2965
+ : "";
2966
+ return `# Pi Long Task TASK_RESULT\n\nRun: ${runId}${warningBlock}\n`;
2587
2967
  }
2588
2968
 
2589
2969
  async function appendFailureNote(
@@ -2596,6 +2976,21 @@ async function appendFailureNote(
2596
2976
  lines.push("", "### Planner diagnostics");
2597
2977
  for (const diagnostic of plannerDiagnostics) {
2598
2978
  lines.push("", `- ${diagnostic.kind}: ${diagnostic.message}`);
2979
+ if (diagnostic.partialOutputObserved !== undefined) {
2980
+ lines.push(` - Partial output observed: ${diagnostic.partialOutputObserved ? "yes" : "no"}`);
2981
+ }
2982
+ if (diagnostic.networkRecoveryEvent) {
2983
+ lines.push(` - Network recovery event: ${diagnostic.networkRecoveryEvent}`);
2984
+ }
2985
+ if (diagnostic.networkFailureReason) {
2986
+ lines.push(` - Network failure reason: ${diagnostic.networkFailureReason}`);
2987
+ }
2988
+ if (diagnostic.networkRetryCount !== undefined) {
2989
+ lines.push(` - Network retry count: ${diagnostic.networkRetryCount}`);
2990
+ }
2991
+ if (diagnostic.networkOutageElapsedMs !== undefined) {
2992
+ lines.push(` - Network outage elapsed: ${formatFriendlyDuration(diagnostic.networkOutageElapsedMs)}`);
2993
+ }
2599
2994
  if (diagnostic.sessionId) {
2600
2995
  lines.push(` - Session ID: ${diagnostic.sessionId}`);
2601
2996
  }
@@ -2794,6 +3189,17 @@ function coordinatorInputText(options: RunCoordinatorOptions): string {
2794
3189
  return normalizeOptionalText(options.inputText) ?? normalizeOptionalText(options.goal) ?? "";
2795
3190
  }
2796
3191
 
3192
+ function capabilityWarningsForRequest(inputText?: string, goal?: string): WorkerCapabilityWarning[] {
3193
+ const requestText = [normalizeOptionalText(inputText), normalizeOptionalText(goal)]
3194
+ .filter((item): item is string => Boolean(item))
3195
+ .filter((item, index, all) => all.indexOf(item) === index)
3196
+ .join("\n");
3197
+ return detectUnavailableWorkerCapabilities(requestText, {
3198
+ tools: DEFAULT_WORKER_TOOLS,
3199
+ extensionsEnabled: false,
3200
+ });
3201
+ }
3202
+
2797
3203
  function positiveInteger(value: number | undefined, fallback: number): number {
2798
3204
  if (typeof value === "number" && Number.isFinite(value) && value > 0) {
2799
3205
  return Math.floor(value);