pi-long-task 0.4.0 → 0.6.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.
@@ -3,9 +3,18 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
5
  import { runCoordinator, type CoordinatorResult, type RunCoordinatorOptions } from "./coordinator.ts";
6
- import { type GoalIterationState, type GoalLoopState, recordGeneratedTodo, startGoalIteration } from "./goal_loop.ts";
6
+ import {
7
+ excludeNetworkOutageFromGoalDeadlines,
8
+ type GoalIterationState,
9
+ type GoalLoopState,
10
+ recordGeneratedTodo,
11
+ startGoalIteration,
12
+ } from "./goal_loop.ts";
7
13
  import { GoalStateStore } from "./goal_state.ts";
8
14
  import type { GoalSpecification } from "./goal_spec.ts";
15
+ import { classifyNetworkFailure } from "./network_failure.ts";
16
+ import { recoverNetworkOperation, type NetworkRecoveryEvent } from "./network_recovery.ts";
17
+ import type { NetworkRecoveryConfig } from "./network_recovery_config.ts";
9
18
  import { parseTasks } from "./todo_parser.ts";
10
19
  import {
11
20
  applyGoalInstructionsToTodoMarkdown,
@@ -29,10 +38,12 @@ export interface GoalTodoGenerationOptions {
29
38
  modelName?: string;
30
39
  thinkingLevel?: string;
31
40
  maxBashTimeoutMs?: number;
41
+ networkRecovery?: Readonly<NetworkRecoveryConfig>;
32
42
  now?: () => Date;
33
43
  additionalContext?: string;
34
44
  outputPath?: string;
35
45
  goalSpecification?: GoalSpecification;
46
+ onNetworkRecovery?: (event: NetworkRecoveryEvent) => void;
36
47
  }
37
48
 
38
49
  export interface GoalTodoGenerationResult {
@@ -92,7 +103,14 @@ export async function runGoalTodoGenerationLongTask(
92
103
  );
93
104
  }
94
105
 
95
- const childResult = await (options.longTaskRunner ?? runCoordinator)({
106
+ let excludedOutageMs = 0;
107
+ const captureOutage = (event: NetworkRecoveryEvent) => {
108
+ if (event.type === "cleanup") {
109
+ excludedOutageMs += event.state.elapsedMs;
110
+ }
111
+ options.onNetworkRecovery?.(event);
112
+ };
113
+ const childOptions: RunCoordinatorOptions = {
96
114
  inputText: payload,
97
115
  commit: false,
98
116
  goal: state.goal,
@@ -104,7 +122,36 @@ export async function runGoalTodoGenerationLongTask(
104
122
  taskThinking: options.thinkingLevel,
105
123
  taskTimeoutMs: childTimeoutMs,
106
124
  maxBashTimeoutMs: options.maxBashTimeoutMs,
107
- });
125
+ networkRecovery: options.networkRecovery,
126
+ onNetworkRecovery: captureOutage,
127
+ };
128
+
129
+ let childResult: CoordinatorResult | undefined;
130
+ let childFailure: unknown;
131
+ try {
132
+ childResult = await runGoalPlannerWithNetworkRecovery(
133
+ options.longTaskRunner ?? runCoordinator,
134
+ childOptions,
135
+ options.networkRecovery,
136
+ options.abortSignal,
137
+ now,
138
+ captureOutage,
139
+ );
140
+ } catch (error) {
141
+ childFailure = error;
142
+ }
143
+
144
+ if (excludedOutageMs > 0) {
145
+ state = excludeNetworkOutageFromGoalDeadlines(state, excludedOutageMs, "planner", { now: now() });
146
+ await persistStateChange(store, previousTraceLength, state);
147
+ previousTraceLength = state.trace.length;
148
+ }
149
+ if (childFailure !== undefined) {
150
+ throw childFailure;
151
+ }
152
+ if (!childResult) {
153
+ throw new GoalTodoGenerationError("TODO-generation planner ended without a coordinator result.");
154
+ }
108
155
 
109
156
  throwIfAborted(options.abortSignal);
110
157
  const rawOutput = await readGeneratedTodo(rawTodoPath, childResult);
@@ -259,6 +306,45 @@ async function persistStateChange(
259
306
  await store.appendNewTraceEvents(previousTraceLength, state);
260
307
  }
261
308
 
309
+ /**
310
+ * Resume the TODO-generation child under its stable run ID. This child is
311
+ * constrained to one replaceable generated-plan artifact (never implementation
312
+ * work), while the default coordinator durably resumes its TODO evidence and
313
+ * rotates provider-failed sessions. Network probes therefore neither create a
314
+ * goal iteration nor consume a normal child task attempt.
315
+ */
316
+ async function runGoalPlannerWithNetworkRecovery(
317
+ runner: GoalTodoGenerationLongTaskRunner,
318
+ childOptions: RunCoordinatorOptions,
319
+ networkRecovery: Readonly<NetworkRecoveryConfig> | undefined,
320
+ abortSignal: AbortSignal | undefined,
321
+ now: () => Date,
322
+ onRecoveryEvent: (event: NetworkRecoveryEvent) => void,
323
+ ): Promise<CoordinatorResult> {
324
+ const run = (recoverySignal?: AbortSignal) =>
325
+ runner({
326
+ ...childOptions,
327
+ abortSignal: combineAbortSignals(abortSignal, recoverySignal),
328
+ });
329
+
330
+ try {
331
+ return await run();
332
+ } catch (initialFailure) {
333
+ if (!networkRecovery?.enabled || !classifyNetworkFailure(initialFailure).recoverable) {
334
+ throw initialFailure;
335
+ }
336
+ const recovered = await recoverNetworkOperation({
337
+ initialFailure,
338
+ config: networkRecovery,
339
+ signal: abortSignal,
340
+ now: () => now().getTime(),
341
+ onEvent: onRecoveryEvent,
342
+ retry: ({ signal }) => run(signal),
343
+ });
344
+ return recovered.value;
345
+ }
346
+ }
347
+
262
348
  async function readGeneratedTodo(rawTodoPath: string, childResult: CoordinatorResult): Promise<string> {
263
349
  try {
264
350
  return await readFile(rawTodoPath, "utf8");
@@ -538,6 +624,13 @@ function sha256(value: string): string {
538
624
  return createHash("sha256").update(value).digest("hex");
539
625
  }
540
626
 
627
+ function combineAbortSignals(...signals: Array<AbortSignal | undefined>): AbortSignal | undefined {
628
+ const available = signals.filter((signal): signal is AbortSignal => Boolean(signal));
629
+ if (available.length === 0) return undefined;
630
+ if (available.length === 1) return available[0];
631
+ return AbortSignal.any(available);
632
+ }
633
+
541
634
  function throwIfAborted(signal: AbortSignal | undefined): void {
542
635
  if (signal?.aborted) {
543
636
  throw new GoalTodoGenerationError("TODO generation was aborted before producing a generated TODO.");
package/src/index.ts CHANGED
@@ -276,10 +276,8 @@ function renderSidebarWidgetLines(update: CoordinatorProgressUpdate): string[] {
276
276
  const progress = update.taskProgress;
277
277
  const summary = progress?.summary;
278
278
  const statusDetails = sidebarUpdateStateDetails(update);
279
- const lines = [
280
- "Pi Long Task",
281
- `${statusDetails.icon} ${statusDetails.label} · ${update.activeStatus ?? update.message}`,
282
- ];
279
+ const statusText = normalizeActiveStatus(update.activeStatus ?? update.message);
280
+ const lines = ["Pi Long Task", `${statusDetails.icon} ${statusDetails.label} · ${statusText}`];
283
281
  if (summary) {
284
282
  lines.push(
285
283
  `Tasks: ${summary.completedTasks}/${summary.totalTasks} · ${summary.completedPercent}%` +
@@ -396,7 +394,8 @@ function renderSidebarRows(update: CoordinatorProgressUpdate | undefined, theme:
396
394
  rows.push(theme.fg("success", "No active task"));
397
395
  }
398
396
 
399
- const activeStatus = update.activeStatus ?? normalizeMessageForSidebar(update.message, update);
397
+ const rawActiveStatus = update.activeStatus ?? normalizeMessageForSidebar(update.message, update);
398
+ const activeStatus = rawActiveStatus ? normalizeActiveStatus(rawActiveStatus) : undefined;
400
399
  if (currentTask && activeStatus) {
401
400
  rows.push("", sidebarHeading("Active status", theme));
402
401
  rows.push(...wrapPlainText(activeStatus, width, 6).map((line) => theme.fg("accent", line)));
@@ -573,8 +572,12 @@ function sidebarUpdateStateDetails(update: CoordinatorProgressUpdate): {
573
572
  return { icon: "✓", label: "Plan ready", color: "success" };
574
573
  case "task_start":
575
574
  return { icon: "▢", label: "Running task", color: "accent" };
575
+ case "worker_session":
576
+ return { icon: "↻", label: "Worker session", color: "accent" };
576
577
  case "worker_tool":
577
578
  return { icon: "+", label: "Worker tool", color: "warning" };
579
+ case "network_wait":
580
+ return { icon: "↻", label: "Waiting for connection", color: "warning" };
578
581
  case "task_done":
579
582
  return { icon: "✓", label: "Task complete", color: "success" };
580
583
  case "task_blocked":
@@ -681,6 +684,18 @@ function normalizeMessageForSidebar(updateMessage: string, update: CoordinatorPr
681
684
  return title && message.includes(title) && message.length <= title.length + 16 ? undefined : message;
682
685
  }
683
686
 
687
+ function normalizeActiveStatus(status: string): string {
688
+ const normalized = status.trim();
689
+ const firstOutcome = /^(Finished|Failed):\s*/i.exec(normalized)?.[1];
690
+ if (!firstOutcome) {
691
+ return normalized;
692
+ }
693
+
694
+ const activity = normalized.replace(/^(?:(?:Finished|Failed):\s*)+/i, "");
695
+ const outcome = firstOutcome.toLowerCase() === "failed" ? "Failed" : "Finished";
696
+ return activity ? `${outcome}: ${activity}` : `${outcome}:`;
697
+ }
698
+
684
699
  function wrapPlainText(text: string, width: number, limit?: number): string[] {
685
700
  const safeWidth = Math.max(8, width);
686
701
  const words = text.trim().split(/\s+/).filter(Boolean);