pi-long-task 0.5.0 → 0.7.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/src/types.ts CHANGED
@@ -1,9 +1,41 @@
1
1
  import type { Static } from "typebox";
2
2
  import { Type } from "typebox";
3
3
 
4
+ import { MAX_PLANNER_DURATION_MS, type PlannerBudget } from "./planner_config.ts";
4
5
  import type { TaskProgressModel } from "./task_progress.ts";
6
+ import type { WorkerCapabilityWarning } from "./worker_capabilities.ts";
5
7
  import type { SessionOutcome } from "./worker_session.ts";
6
8
 
9
+ const NetworkRecoveryParams = Type.Object(
10
+ {
11
+ enabled: Type.Optional(
12
+ Type.Boolean({
13
+ description:
14
+ "Enable coordinator-level recovery after Pi's bounded provider retries are exhausted. Defaults to false for backward compatibility.",
15
+ }),
16
+ ),
17
+ baseDelayMs: Type.Optional(
18
+ Type.Integer({
19
+ minimum: 1,
20
+ description: "Initial network-recovery delay in milliseconds. Defaults to 1000.",
21
+ }),
22
+ ),
23
+ maxDelayMs: Type.Optional(
24
+ Type.Integer({
25
+ minimum: 1,
26
+ description: "Maximum network-recovery backoff delay in milliseconds. Defaults to 30000.",
27
+ }),
28
+ ),
29
+ maxOutageMs: Type.Optional(
30
+ Type.Union([Type.Integer({ minimum: 1 }), Type.Null()], {
31
+ description:
32
+ "Maximum continuous outage duration in milliseconds. Defaults to 300000; use null to wait indefinitely until cancelled.",
33
+ }),
34
+ ),
35
+ },
36
+ { additionalProperties: false },
37
+ );
38
+
7
39
  export const PiLongTaskParams = Type.Object(
8
40
  {
9
41
  inputText: Type.Optional(
@@ -21,6 +53,23 @@ export const PiLongTaskParams = Type.Object(
21
53
  description: "Optional high-level goal or desired outcome for the long-task run.",
22
54
  }),
23
55
  ),
56
+ todoTimeoutMs: Type.Optional(
57
+ Type.Integer({
58
+ minimum: 1,
59
+ maximum: MAX_PLANNER_DURATION_MS,
60
+ description:
61
+ "Explicit TODO-planner timeout in milliseconds. When omitted, the 5-minute default adapts deterministically for explicit item counts, enumerated deliverables, and separately planned tasks, up to 15 minutes.",
62
+ }),
63
+ ),
64
+ todoGracefulShutdownMs: Type.Optional(
65
+ Type.Integer({
66
+ minimum: 0,
67
+ maximum: MAX_PLANNER_DURATION_MS,
68
+ description:
69
+ "Grace period in milliseconds after the TODO-planner timeout. Defaults to 15000 (15 seconds); use 0 to disable the grace period.",
70
+ }),
71
+ ),
72
+ networkRecovery: Type.Optional(NetworkRecoveryParams),
24
73
  },
25
74
  { additionalProperties: false },
26
75
  );
@@ -82,6 +131,23 @@ export const PiGoalTaskParams = Type.Object(
82
131
  description: "Maximum bash command timeout in milliseconds allowed in worker sessions.",
83
132
  }),
84
133
  ),
134
+ todoTimeoutMs: Type.Optional(
135
+ Type.Integer({
136
+ minimum: 1,
137
+ maximum: MAX_PLANNER_DURATION_MS,
138
+ description:
139
+ "Explicit TODO-planner timeout in milliseconds for child long-task planning and plan revisions. When omitted, the 5-minute default adapts deterministically for explicit item counts, enumerated deliverables, and separately planned tasks, up to 15 minutes.",
140
+ }),
141
+ ),
142
+ todoGracefulShutdownMs: Type.Optional(
143
+ Type.Integer({
144
+ minimum: 0,
145
+ maximum: MAX_PLANNER_DURATION_MS,
146
+ description:
147
+ "Grace period in milliseconds after a child TODO-planner timeout. Defaults to 15000 (15 seconds); use 0 to disable the grace period.",
148
+ }),
149
+ ),
150
+ networkRecovery: Type.Optional(NetworkRecoveryParams),
85
151
  },
86
152
  { additionalProperties: false },
87
153
  );
@@ -133,6 +199,8 @@ export interface PiLongTaskResult {
133
199
  }>;
134
200
  taskProgress: TaskProgressModel;
135
201
  workerCostTotal: number;
202
+ plannerBudget?: Readonly<PlannerBudget>;
203
+ capabilityWarnings?: readonly WorkerCapabilityWarning[];
136
204
  commit: boolean;
137
205
  goal?: string;
138
206
  error?: string;
@@ -0,0 +1,103 @@
1
+ export interface IsolatedWorkerCapabilities {
2
+ /** Tool names exposed directly to the worker session. */
3
+ tools: readonly string[];
4
+ /** Pi Long Task isolated sessions deliberately disable extension runtimes. */
5
+ extensionsEnabled: boolean;
6
+ }
7
+
8
+ export type WorkerCapabilityWarningCode = "unavailable_browser_capability";
9
+
10
+ export interface WorkerCapabilityWarning {
11
+ code: WorkerCapabilityWarningCode;
12
+ requestedCapabilities: readonly string[];
13
+ availableTools: readonly string[];
14
+ message: string;
15
+ planningConstraint: string;
16
+ }
17
+
18
+ const BROWSER_EXTENSION_REQUIREMENT_RE =
19
+ /\b(?:must(?:\s+use)?|need(?:s|ed)?(?:\s+to\s+use)?|require(?:s|d)?|rely(?:ing)?\s+on|use|using|via|through|with)\s+(?:the\s+|an?\s+)?(?:google\s+)?(?:chrome|chromium|firefox|edge|browser)(?:\s+(?:browser|devtools?))?\s+(?:extension|mcp|connector)\b/i;
20
+ const REQUIRED_NAMED_BROWSER_EXTENSION_RE =
21
+ /\b(?:chrome\s+devtools\s+(?:mcp|extension|tool)|(?:chrome|browser)\s+(?:mcp|extension(?:\s+tool)?|tool\s+extension))\s+(?:is\s+(?:required|needed)|must\s+be\s+used)\b/i;
22
+ const IMPERATIVE_CHROME_RE =
23
+ /\b(?:open|launch|control|drive|browse\s+with|inspect\s+(?:with|using)|scrape\s+(?:with|using)|fetch\s+(?:with|using)|test\s+(?:with|using))\s+(?:google\s+)?chrome\b/i;
24
+ const EXPLICIT_BROWSER_TOOL_RE =
25
+ /\b(?:must(?:\s+use)?|need(?:s|ed)?(?:\s+to\s+use)?|require(?:s|d)?|use|using|via|through|with)\s+(?:the\s+)?(browser|chrome|web[_-]?fetch|playwright|puppeteer)\s+tool\b/i;
26
+ const EXPLICIT_CHROME_RUNTIME_RE =
27
+ /(?:\b(?:must\s+use|need(?:s|ed)?\s+to\s+use|use|using)\s+(?:google\s+)?chrome(?:\s+devtools?)?(?=\s+(?:to|for)\b|\s*[.,;:]|\s*$)|\b(?:via|through)\s+(?:google\s+)?chrome\b|\b(?:google\s+)?chrome(?:\s+devtools?)?\s+(?:is\s+required|must\s+be\s+used)\b)/i;
28
+ const NEGATED_CAPABILITY_RE =
29
+ /\b(?:do\s+not|don't|dont|never|avoid|without)\s+(?:use|using|rely(?:ing)?\s+on|requiring?)?\s*(?:the\s+|an?\s+)?(?:google\s+)?(?:chrome(?:\s+(?:browser|devtools?))?|chromium|firefox|edge|browser|web[_-]?fetch|playwright|puppeteer)(?:\s+(?:extension|mcp|tool(?:ing)?|connector))?/gi;
30
+
31
+ /**
32
+ * Detect only explicit requests to invoke a browser capability. Merely asking
33
+ * workers to build or support a browser extension is implementation work and
34
+ * does not imply that the extension must be loaded during the run.
35
+ */
36
+ export function detectUnavailableWorkerCapabilities(
37
+ requestText: string,
38
+ capabilities: Readonly<IsolatedWorkerCapabilities>,
39
+ ): WorkerCapabilityWarning[] {
40
+ const text = requestText.replace(NEGATED_CAPABILITY_RE, " ");
41
+ const extensionRequested =
42
+ BROWSER_EXTENSION_REQUIREMENT_RE.test(text) ||
43
+ REQUIRED_NAMED_BROWSER_EXTENSION_RE.test(text) ||
44
+ IMPERATIVE_CHROME_RE.test(text);
45
+ const browserToolMatch = EXPLICIT_BROWSER_TOOL_RE.exec(text);
46
+ const requestedDirectTools = [
47
+ browserToolMatch?.[1],
48
+ EXPLICIT_CHROME_RUNTIME_RE.test(text) ? "chrome" : undefined,
49
+ ].filter((item, index, all): item is string => Boolean(item) && all.indexOf(item) === index);
50
+ const unavailableDirectTools = requestedDirectTools.filter(
51
+ (requested) =>
52
+ !capabilities.tools.some((available) => canonicalToolName(available) === canonicalToolName(requested)),
53
+ );
54
+
55
+ if ((!extensionRequested || capabilities.extensionsEnabled) && unavailableDirectTools.length === 0) {
56
+ return [];
57
+ }
58
+
59
+ const requestedCapabilities = [
60
+ extensionRequested && !capabilities.extensionsEnabled ? "Chrome/browser extension runtime" : undefined,
61
+ unavailableDirectTools.length > 0 ? `${unavailableDirectTools.join("/")} direct tool` : undefined,
62
+ ].filter((item): item is string => Boolean(item));
63
+ const availableTools = [...capabilities.tools];
64
+ const toolList = availableTools.length > 0 ? availableTools.join(", ") : "none";
65
+ const alternatives = availableWorkerAlternatives(capabilities);
66
+ const alternativeText = alternatives.join(", or ");
67
+
68
+ return [
69
+ {
70
+ code: "unavailable_browser_capability",
71
+ requestedCapabilities,
72
+ availableTools,
73
+ message:
74
+ `Worker capability warning: isolated Pi Long Task workers disable extensions and expose only these direct tools: ${toolList}. ` +
75
+ `They cannot silently use the requested ${requestedCapabilities.join(" or ")}. The run will continue, but it must use an available safe alternative when that satisfies the request: ${alternativeText}. ` +
76
+ "If the exact extension or browser tool is mandatory, the affected task must report blocked instead of claiming it used that capability.",
77
+ planningConstraint:
78
+ `Isolated-worker capability constraint: extensions are disabled and workers have only these direct tools: ${toolList}. ` +
79
+ `Do not create or execute tasks that assume the requested ${requestedCapabilities.join(" or ")} is available, and never claim it was used. ` +
80
+ `When equivalent, ${alternativeText}. If the exact unavailable capability is mandatory, make the affected task report blocked with the required user action.`,
81
+ },
82
+ ];
83
+ }
84
+
85
+ function canonicalToolName(tool: string): string {
86
+ return tool.toLowerCase().replace(/[-_]/g, "");
87
+ }
88
+
89
+ function availableWorkerAlternatives(capabilities: Readonly<IsolatedWorkerCapabilities>): string[] {
90
+ const alternatives: string[] = [];
91
+ if (capabilities.tools.includes("bash")) {
92
+ alternatives.push(
93
+ "fetch public content through a supported command-line mechanism via bash or run project-provided browser automation via bash when available",
94
+ );
95
+ }
96
+ if (capabilities.tools.includes("read")) {
97
+ alternatives.push("supply the page or source content to the run so workers can read it locally");
98
+ }
99
+ if (alternatives.length === 0) {
100
+ alternatives.push("supply the needed source content to the run");
101
+ }
102
+ return alternatives;
103
+ }
@@ -1,10 +1,20 @@
1
+ import {
2
+ NetworkRecoveryConfigError,
3
+ resolveNetworkRecoveryConfig,
4
+ type NetworkRecoveryConfigInput,
5
+ } from "./network_recovery_config.ts";
6
+ import { MAX_PLANNER_DURATION_MS, PlannerDurationConfigError } from "./planner_config.ts";
7
+
1
8
  export interface ParsedWorkerRuntimeConfig {
2
9
  modelName?: string;
3
10
  maxAttemptsPerTask?: number;
4
11
  taskTimeoutMs?: number;
12
+ todoTimeoutMs?: number;
13
+ todoGracefulShutdownMs?: number;
5
14
  maxBashTimeoutMs?: number;
6
15
  workerSessionReuseEnabled?: boolean;
7
16
  workerSessionReuseContextThresholdPercent?: number;
17
+ networkRecovery?: NetworkRecoveryConfigInput;
8
18
  }
9
19
 
10
20
  type MutableWorkerRuntimeConfig = ParsedWorkerRuntimeConfig & { provider?: string; model?: string };
@@ -36,12 +46,17 @@ export function parseWorkerRuntimeConfig(text: string): ParsedWorkerRuntimeConfi
36
46
 
37
47
  parseLineDirectives(text, state);
38
48
  parseNaturalLanguageDirectives(text, state);
49
+ if (state.networkRecovery) {
50
+ resolveNetworkRecoveryConfig(state.networkRecovery);
51
+ }
39
52
 
40
53
  const modelName = combineProviderAndModel(state.provider, state.model);
41
54
  return {
42
55
  ...(modelName ? { modelName } : {}),
43
56
  ...(state.maxAttemptsPerTask !== undefined ? { maxAttemptsPerTask: state.maxAttemptsPerTask } : {}),
44
57
  ...(state.taskTimeoutMs !== undefined ? { taskTimeoutMs: state.taskTimeoutMs } : {}),
58
+ ...(state.todoTimeoutMs !== undefined ? { todoTimeoutMs: state.todoTimeoutMs } : {}),
59
+ ...(state.todoGracefulShutdownMs !== undefined ? { todoGracefulShutdownMs: state.todoGracefulShutdownMs } : {}),
45
60
  ...(state.maxBashTimeoutMs !== undefined ? { maxBashTimeoutMs: state.maxBashTimeoutMs } : {}),
46
61
  ...(state.workerSessionReuseEnabled !== undefined
47
62
  ? { workerSessionReuseEnabled: state.workerSessionReuseEnabled }
@@ -49,6 +64,7 @@ export function parseWorkerRuntimeConfig(text: string): ParsedWorkerRuntimeConfi
49
64
  ...(state.workerSessionReuseContextThresholdPercent !== undefined
50
65
  ? { workerSessionReuseContextThresholdPercent: state.workerSessionReuseContextThresholdPercent }
51
66
  : {}),
67
+ ...(state.networkRecovery ? { networkRecovery: { ...state.networkRecovery } } : {}),
52
68
  };
53
69
  }
54
70
 
@@ -124,14 +140,36 @@ function parseNaturalLanguageDirectives(text: string, state: MutableWorkerRuntim
124
140
 
125
141
  captureDurations(
126
142
  text,
127
- /\b(?<!bash\s)(?<!max\s)(?:worker\s+|task\s+)?timeout\s*(?:is|=|:|to|of)?\s*(\d+(?:\.\d+)?\s*(?:milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)?)/gi,
143
+ /\b(?:todo\s+)?(?:planner|planning)\s+timeout\s*(?:is|=|:|to|of)?\s*(\d+(?:\.\d+)?\s*(?:milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)?)/gi,
144
+ (value) => {
145
+ state.todoTimeoutMs = value;
146
+ },
147
+ );
148
+ captureDurations(
149
+ text,
150
+ /\b(\d+(?:\.\d+)?\s*(?:milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h))\s+(?:todo\s+)?(?:planner|planning)\s+timeout\b/gi,
151
+ (value) => {
152
+ state.todoTimeoutMs = value;
153
+ },
154
+ );
155
+ captureDurations(
156
+ text,
157
+ /\b(?:todo\s+)?(?:planner|planning)\s+(?:grace(?:ful)?(?:\s+shutdown|\s+period)?|shutdown\s+grace(?:\s+period)?)\s*(?:duration\s*)?(?:is|=|:|to|of)?\s*(\d+(?:\.\d+)?\s*(?:milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)?)/gi,
158
+ (value) => {
159
+ state.todoGracefulShutdownMs = value;
160
+ },
161
+ { allowZero: true },
162
+ );
163
+ captureDurations(
164
+ text,
165
+ /\b(?<!bash\s)(?<!max\s)(?<!planner\s)(?<!planning\s)(?:worker\s+|task\s+)?timeout\s*(?:is|=|:|to|of)?\s*(\d+(?:\.\d+)?\s*(?:milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)?)/gi,
128
166
  (value) => {
129
167
  state.taskTimeoutMs = value;
130
168
  },
131
169
  );
132
170
  captureDurations(
133
171
  text,
134
- /\b(\d+(?:\.\d+)?\s*(?:milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h))\s+(?:worker\s+|task\s+)?timeout\b/gi,
172
+ /\b(\d+(?:\.\d+)?\s*(?:milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h))[ \t]+(?:worker[ \t]+|task[ \t]+)?timeout\b/gi,
135
173
  (value) => {
136
174
  state.taskTimeoutMs = value;
137
175
  },
@@ -159,6 +197,11 @@ function parseNaturalLanguageDirectives(text: string, state: MutableWorkerRuntim
159
197
  }
160
198
 
161
199
  function applyDirective(key: string, value: string, state: MutableWorkerRuntimeConfig): void {
200
+ if (/\bnetwork\b/.test(key) && /\brecover(?:y|ies)?\b/.test(key)) {
201
+ applyNetworkRecoveryDirective(key, value, state);
202
+ return;
203
+ }
204
+
162
205
  if (/\breuse\b/.test(key) && /\b(?:threshold|context)\b/.test(key)) {
163
206
  const threshold = percentageFromText(value);
164
207
  if (threshold !== undefined) {
@@ -199,6 +242,16 @@ function applyDirective(key: string, value: string, state: MutableWorkerRuntimeC
199
242
  return;
200
243
  }
201
244
 
245
+ if (/\b(?:planner|planning)\b/.test(key) && /\b(?:grace|graceful|shutdown)\b/.test(key)) {
246
+ state.todoGracefulShutdownMs = requiredPlannerDuration("graceful-shutdown duration", value, true);
247
+ return;
248
+ }
249
+
250
+ if (/\b(?:planner|planning)\b/.test(key) && /\btimeout\b/.test(key)) {
251
+ state.todoTimeoutMs = requiredPlannerDuration("timeout", value, false);
252
+ return;
253
+ }
254
+
202
255
  if (/\bbash\b/.test(key) && /\btimeout\b/.test(key)) {
203
256
  const timeout = durationMsFromText(value, { allowBareSeconds: true });
204
257
  if (timeout !== undefined) {
@@ -215,6 +268,81 @@ function applyDirective(key: string, value: string, state: MutableWorkerRuntimeC
215
268
  }
216
269
  }
217
270
 
271
+ function applyNetworkRecoveryDirective(key: string, value: string, state: MutableWorkerRuntimeConfig): void {
272
+ const recovery = (state.networkRecovery ??= {});
273
+
274
+ if (/\bbase\b/.test(key) && /\bdelay\b/.test(key)) {
275
+ recovery.baseDelayMs = requiredNetworkRecoveryDuration("base delay", value);
276
+ return;
277
+ }
278
+ if (/\bmax(?:imum)?\b/.test(key) && /\bdelay\b/.test(key)) {
279
+ recovery.maxDelayMs = requiredNetworkRecoveryDuration("maximum delay", value);
280
+ return;
281
+ }
282
+ if (/\b(?:outage|duration|wait)\b/.test(key)) {
283
+ const normalized = trimDirectiveValue(value)
284
+ .toLowerCase()
285
+ .replace(/[.!]+$/g, "")
286
+ .trim();
287
+ if (/^(?:unlimited|indefinite|indefinitely|until cancelled|until canceled)$/.test(normalized)) {
288
+ recovery.maxOutageMs = null;
289
+ return;
290
+ }
291
+ recovery.maxOutageMs = requiredNetworkRecoveryDuration("maximum outage", value);
292
+ return;
293
+ }
294
+ if (/\b(?:enabled?|enablement)\b/.test(key) || /\bnetwork recovery\b/.test(key)) {
295
+ const enabled = booleanSetting(value);
296
+ if (enabled === undefined) {
297
+ throw new NetworkRecoveryConfigError(
298
+ "Network recovery must be configured as enabled/disabled, on/off, true/false, or yes/no.",
299
+ );
300
+ }
301
+ recovery.enabled = enabled;
302
+ return;
303
+ }
304
+
305
+ throw new NetworkRecoveryConfigError(`Unknown network recovery configuration directive: ${key}.`);
306
+ }
307
+
308
+ function requiredPlannerDuration(label: string, value: string, allowZero: boolean): number {
309
+ const trimmed = trimDirectiveValue(value)
310
+ .replace(/[.!]+$/g, "")
311
+ .trim();
312
+ const match = /^(\d+(?:\.\d+)?)\s*(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)?$/i.exec(
313
+ trimmed,
314
+ );
315
+ const milliseconds = match ? durationMsFromText(trimmed, { allowBareSeconds: true, allowZero }) : undefined;
316
+ if (milliseconds === undefined || milliseconds > MAX_PLANNER_DURATION_MS) {
317
+ const minimum = allowZero ? "non-negative" : "positive";
318
+ throw new PlannerDurationConfigError(
319
+ `TODO planner ${label} must be a ${minimum} finite duration no greater than about 24.9 days (${MAX_PLANNER_DURATION_MS} milliseconds), for example 30s or 5m.`,
320
+ );
321
+ }
322
+ return milliseconds;
323
+ }
324
+
325
+ function requiredNetworkRecoveryDuration(label: string, value: string): number {
326
+ const trimmed = trimDirectiveValue(value)
327
+ .replace(/[.!]+$/g, "")
328
+ .trim();
329
+ const match = /^(\d+(?:\.\d+)?)\s*(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)?$/i.exec(
330
+ trimmed,
331
+ );
332
+ if (!match) {
333
+ throw new NetworkRecoveryConfigError(
334
+ `Network recovery ${label} must be a positive finite duration (for example 1000ms, 30s, or 5m).`,
335
+ );
336
+ }
337
+ const milliseconds = durationMsFromText(trimmed, { allowBareSeconds: true });
338
+ if (milliseconds === undefined) {
339
+ throw new NetworkRecoveryConfigError(
340
+ `Network recovery ${label} must be a positive finite duration (for example 1000ms, 30s, or 5m).`,
341
+ );
342
+ }
343
+ return milliseconds;
344
+ }
345
+
218
346
  function captureTokens(text: string, pattern: RegExp, apply: (token: string) => void): void {
219
347
  for (const match of text.matchAll(pattern)) {
220
348
  const token = modelToken(match[1] ?? "");
@@ -233,9 +361,17 @@ function captureNumbers(text: string, pattern: RegExp, apply: (value: number) =>
233
361
  }
234
362
  }
235
363
 
236
- function captureDurations(text: string, pattern: RegExp, apply: (value: number) => void): void {
364
+ function captureDurations(
365
+ text: string,
366
+ pattern: RegExp,
367
+ apply: (value: number) => void,
368
+ options: { allowZero?: boolean } = {},
369
+ ): void {
237
370
  for (const match of text.matchAll(pattern)) {
238
- const value = durationMsFromText(match[1] ?? "", { allowBareSeconds: true });
371
+ const value = durationMsFromText(match[1] ?? "", {
372
+ allowBareSeconds: true,
373
+ allowZero: options.allowZero,
374
+ });
239
375
  if (value !== undefined) {
240
376
  apply(value);
241
377
  }
@@ -299,7 +435,10 @@ function booleanSetting(value: string): boolean | undefined {
299
435
  return undefined;
300
436
  }
301
437
 
302
- function durationMsFromText(value: string, options: { allowBareSeconds: boolean }): number | undefined {
438
+ function durationMsFromText(
439
+ value: string,
440
+ options: { allowBareSeconds: boolean; allowZero?: boolean },
441
+ ): number | undefined {
303
442
  const match = /(\d+(?:\.\d+)?)\s*(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)?\b/i.exec(
304
443
  value,
305
444
  );
@@ -308,7 +447,7 @@ function durationMsFromText(value: string, options: { allowBareSeconds: boolean
308
447
  }
309
448
 
310
449
  const amount = Number.parseFloat(match[1] ?? "");
311
- if (!Number.isFinite(amount) || amount <= 0) {
450
+ if (!Number.isFinite(amount) || amount < 0 || (!options.allowZero && amount === 0)) {
312
451
  return undefined;
313
452
  }
314
453
 
@@ -319,7 +458,9 @@ function durationMsFromText(value: string, options: { allowBareSeconds: boolean
319
458
 
320
459
  const multiplier = durationMultiplier(unit || "seconds");
321
460
  const milliseconds = Math.round(amount * multiplier);
322
- return Number.isSafeInteger(milliseconds) && milliseconds > 0 ? milliseconds : undefined;
461
+ return Number.isSafeInteger(milliseconds) && (options.allowZero ? milliseconds >= 0 : milliseconds > 0)
462
+ ? milliseconds
463
+ : undefined;
323
464
  }
324
465
 
325
466
  function durationMultiplier(unit: string): number {
@@ -8,14 +8,25 @@ import {
8
8
  parseCompleteTaskResult,
9
9
  parseReportedStatus,
10
10
  } from "./result_writer.ts";
11
+ import type { NetworkRecoveryConfig } from "./network_recovery_config.ts";
11
12
  import type { Task } from "./todo_parser.ts";
12
13
 
14
+ export interface WorkerNetworkRecoveryContext {
15
+ /** One-based coordinator network retry count; this is not a task attempt. */
16
+ retryCount: number;
17
+ durableEvidencePath: string;
18
+ priorSessionId?: string;
19
+ failure: string;
20
+ }
21
+
13
22
  export interface WorkerTaskPromptOptions {
14
23
  todoPath: string;
15
24
  task: Pick<Task, "taskId" | "title" | "section">;
16
25
  attempt: number;
17
26
  commitRequested: boolean;
18
27
  previousAttempts?: string;
28
+ /** Continuity supplied only when a failed transport session is replaced. */
29
+ networkRecoveryContext?: WorkerNetworkRecoveryContext;
19
30
  globalInstructions?: string;
20
31
  goal?: string;
21
32
  maxBashTimeoutSeconds: number;
@@ -43,6 +54,18 @@ Previous attempts for this same assigned task are below. Use them only as contin
43
54
  \`\`\`text
44
55
  ${previousAttempts}
45
56
  \`\`\`
57
+ `
58
+ : "";
59
+
60
+ const recovery = options.networkRecoveryContext;
61
+ const recoveryText = recovery
62
+ ? `
63
+ Network recovery continuation (network retry ${recovery.retryCount}, still ordinary task attempt ${options.attempt}):
64
+ - The prior worker session ended only after Pi's bounded provider-request retries were exhausted: ${recovery.failure}
65
+ - Durable interruption evidence was recorded in \`${recovery.durableEvidencePath}\`${recovery.priorSessionId ? ` for session \`${recovery.priorSessionId}\`` : ""}.
66
+ - The prior session may already have completed tool calls and changed the working tree. Inspect the durable evidence and current files before acting.
67
+ - Continue this same TODO from its current state. Never blindly replay prior edits, commands, commits, external writes, or other side effects.
68
+ - Report one final TASK_RESULT for the assignment only after verifying what remains.
46
69
  `
47
70
  : "";
48
71
 
@@ -93,7 +116,7 @@ Rules:
93
116
  - Do not run bash commands with timeout greater than ${options.maxBashTimeoutSeconds.toFixed(0)} seconds. For long full-suite checks, run once with a bounded timeout and report any timeout/failure in TASK_RESULT instead of continuing indefinitely.
94
117
  - If TODO-file global instructions restrict scope, obey them strictly. If the task appears to require out-of-scope code changes, stop and report \`status: blocked\` instead of changing those files.
95
118
 
96
- ${globalText}Assigned task content only:
119
+ ${globalText}${recoveryText}Assigned task content only:
97
120
 
98
121
  \`\`\`markdown
99
122
  ${options.task.section.trimEnd()}
@@ -289,6 +312,8 @@ export type WorkerSessionFactory = (options: CreateWorkerSessionOptions) => Prom
289
312
  export interface RunWorkerTaskOptions extends WorkerTaskPromptOptions, CreateWorkerSessionOptions {
290
313
  taskTimeoutSeconds?: number;
291
314
  gracefulShutdownSeconds?: number;
315
+ /** Normalized coordinator policy for resuming this operation after transient transport failure. */
316
+ networkRecovery?: Readonly<NetworkRecoveryConfig>;
292
317
  abortSignal?: AbortSignal;
293
318
  sessionFactory?: WorkerSessionFactory;
294
319
  onEvent?: (event: CapturedWorkerEvent) => void;
@@ -353,6 +378,8 @@ export interface SessionOutcome {
353
378
  timedOut: boolean;
354
379
  aborted: boolean;
355
380
  error?: string;
381
+ /** Original provider/transport failure retained for coordinator classification. */
382
+ failure?: unknown;
356
383
  }
357
384
 
358
385
  export function buildMissingTaskResultMessage(): string {
@@ -512,6 +539,7 @@ export async function runWorkerTaskAssignment(
512
539
  let timedOut = false;
513
540
  let aborted = false;
514
541
  let error: string | undefined;
542
+ let failure: unknown;
515
543
  let finished = false;
516
544
  let turnCount = 0;
517
545
  let messageUsageCostTotal = 0;
@@ -637,6 +665,7 @@ export async function runWorkerTaskAssignment(
637
665
  },
638
666
  (exc: unknown) => {
639
667
  settled = true;
668
+ failure ??= exc;
640
669
  error = error ?? errorMessage(exc);
641
670
  resolvePromptWait?.();
642
671
  resolvePromptWait = undefined;
@@ -773,6 +802,7 @@ export async function runWorkerTaskAssignment(
773
802
  assistantText = latestInvocationAssistantText(session, assistantText, invocationMessageStart, !reusedAssignment);
774
803
  }
775
804
  } catch (exc) {
805
+ failure ??= exc;
776
806
  error = error ?? errorMessage(exc);
777
807
  } finally {
778
808
  finished = true;
@@ -823,6 +853,7 @@ export async function runWorkerTaskAssignment(
823
853
  timedOut,
824
854
  aborted: aborted || cancelled,
825
855
  error,
856
+ ...(failure === undefined ? {} : { failure }),
826
857
  };
827
858
  }
828
859
 
@@ -872,6 +903,7 @@ export function buildWorkerSessionCreationFailureOutcome(
872
903
  timedOut: false,
873
904
  aborted: Boolean(options.abortSignal?.aborted),
874
905
  error: message,
906
+ failure: error,
875
907
  };
876
908
  }
877
909