taskplane 0.28.8 → 0.29.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.
@@ -53,6 +53,7 @@ import {
53
53
  sessionInboxDir,
54
54
  ackOutboxMessage,
55
55
  appendMailboxAuditEvent,
56
+ drainAgentOutbox,
56
57
  } from "./mailbox.ts";
57
58
 
58
59
  import {
@@ -245,6 +246,14 @@ export interface LaneRunnerConfig {
245
246
  killPercent: number;
246
247
  /** Optional callback for surfacing runtime mailbox replies/escalations to supervisor */
247
248
  onSupervisorAlert?: SupervisorAlertCallback;
249
+ /**
250
+ * Optional callback fired when the lane reaches a terminal state (no-progress
251
+ * kill or hard-fail). The supervisor process uses this to suppress any
252
+ * subsequent zombie alerts queued for the now-dead lane.
253
+ *
254
+ * @since TP-187 (#538)
255
+ */
256
+ onLaneTerminated?: (info: import("./types.ts").LaneTerminatedInfo) => void;
248
257
  }
249
258
 
250
259
  /**
@@ -656,8 +665,41 @@ export async function executeTaskV2(
656
665
  }
657
666
  } catch { /* If we can't read STATUS.md, proceed with escalation */ }
658
667
 
659
- // No visible progress — compose escalation message
660
- const truncatedMsg = assistantMessage.slice(0, 500);
668
+ // No visible progress — compose escalation message.
669
+ // TP-187 (#540): when the worker exits silently, fall back to the most
670
+ // recent `assistant_message` event in events.jsonl so the supervisor
671
+ // has SOMETHING to act on instead of `Worker said: ""`.
672
+ let workerSaid = (assistantMessage ?? "").trim();
673
+ let workerSaidSource: "current-turn" | "events-jsonl-fallback" | "empty-sentinel" = "current-turn";
674
+ if (!workerSaid) {
675
+ workerSaidSource = "empty-sentinel";
676
+ try {
677
+ const raw = readFileSync(eventsPath, "utf-8");
678
+ const lines = raw.split("\n");
679
+ // Walk backward to find the most recent assistant_message with non-empty text.
680
+ for (let i = lines.length - 1; i >= 0; i--) {
681
+ const line = lines[i].trim();
682
+ if (!line) continue;
683
+ try {
684
+ const evt = JSON.parse(line) as Record<string, unknown>;
685
+ if (evt.type === "assistant_message") {
686
+ const payload = evt.payload as Record<string, unknown> | undefined;
687
+ const text = typeof payload?.text === "string" ? payload.text.trim() : "";
688
+ if (text) {
689
+ workerSaid = text;
690
+ workerSaidSource = "events-jsonl-fallback";
691
+ break;
692
+ }
693
+ }
694
+ } catch { /* skip malformed line */ }
695
+ }
696
+ } catch { /* events.jsonl unreadable; sentinel will be used */ }
697
+ }
698
+ if (!workerSaid) {
699
+ workerSaid = "(no assistant message captured — worker exited without producing visible output)";
700
+ workerSaidSource = "empty-sentinel";
701
+ }
702
+ const truncatedMsg = workerSaid.slice(0, 500);
661
703
  const uncheckedItems: string[] = [];
662
704
  try {
663
705
  const statusContent = readFileSync(statusPath, "utf-8");
@@ -693,7 +735,12 @@ export async function executeTaskV2(
693
735
  ` Current step: ${currentStepInfo}\n` +
694
736
  ` Iteration: ${totalIterations}, No-progress count: ${noProgressCount + 1}\n` +
695
737
  ` Unchecked items: ${uncheckedItems.length > 0 ? uncheckedItems.join("; ") : "(none found)"}\n` +
696
- ` Worker said: "${truncatedMsg}"\n` +
738
+ ` Worker said: "${truncatedMsg}"` +
739
+ (workerSaidSource === "events-jsonl-fallback"
740
+ ? ` (fallback: most-recent assistant_message from events.jsonl)\n`
741
+ : workerSaidSource === "empty-sentinel"
742
+ ? ` (no assistant message captured this iteration)\n`
743
+ : "\n") +
697
744
  `\nSend a steering message to ${workerAgentId} with targeted instructions,` +
698
745
  ` or reply "skip" / "let it fail" to close the session.`,
699
746
  context: {
@@ -978,6 +1025,30 @@ export async function executeTaskV2(
978
1025
  `Iteration ${totalIterations}: 0 new checkboxes (${noProgressCount}/${config.noProgressLimit} stall limit)`);
979
1026
  if (noProgressCount >= config.noProgressLimit) {
980
1027
  logExecution(statusPath, "Task blocked", `No progress after ${noProgressCount} iterations`);
1028
+ // TP-187 (#538): synchronous outbox drain at lane-termination decision
1029
+ // point. Purges any pending escalations/replies/segment-expansions the
1030
+ // worker emitted just before termination so they are not later re-
1031
+ // discovered and re-forwarded as zombie supervisor alerts.
1032
+ try {
1033
+ const drained = drainAgentOutbox(config.stateRoot, config.batchId, workerAgentId);
1034
+ if (drained > 0) {
1035
+ logExecution(statusPath, "Outbox drained",
1036
+ `No-progress kill: drained ${drained} pending outbox entr${drained === 1 ? "y" : "ies"} for ${workerAgentId}`);
1037
+ }
1038
+ } catch { /* best effort — do not block termination */ }
1039
+ // TP-187 (#538): notify the supervisor process so it can suppress any
1040
+ // further alerts queued for this lane (zombie-alert filter).
1041
+ if (config.onLaneTerminated) {
1042
+ try {
1043
+ config.onLaneTerminated({
1044
+ laneNumber: config.laneNumber,
1045
+ agentId: workerAgentId,
1046
+ batchId: config.batchId,
1047
+ terminatedAt: Date.now(),
1048
+ reason: "no-progress-kill",
1049
+ });
1050
+ } catch { /* best effort */ }
1051
+ }
981
1052
  return makeResult(taskId, segmentId, workerAgentId, "failed", startTime,
982
1053
  `No progress after ${noProgressCount} iterations`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx);
983
1054
  }
@@ -538,6 +538,89 @@ export function ackOutboxMessage(
538
538
  }
539
539
  }
540
540
 
541
+ /**
542
+ * Drain (purge to processed/) all pending outbox messages for an agent.
543
+ *
544
+ * Used at lane-termination decision points to ensure stale escalations or
545
+ * replies that the worker emitted just before termination don't get later
546
+ * re-discovered and re-forwarded as zombie supervisor alerts. The drain
547
+ * mirrors {@link ackOutboxMessage} — each pending `*.msg.json` file is
548
+ * moved to `outbox/processed/` so it remains in the durable history (for
549
+ * `read_agent_replies`) but is no longer pending.
550
+ *
551
+ * Best-effort: any per-file failure is logged but does not abort the drain.
552
+ * Returns the number of messages successfully drained.
553
+ *
554
+ * Also drains any non-message pending files in the outbox (e.g.,
555
+ * `segment-expansion-*.json` requests) by renaming them to a `.drained`
556
+ * sibling so the engine's discovery scans don't re-pick them up.
557
+ *
558
+ * @since TP-187 (#538)
559
+ */
560
+ export function drainAgentOutbox(
561
+ stateRoot: string,
562
+ batchId: string,
563
+ agentId: string,
564
+ ): number {
565
+ const outboxDir = sessionOutboxDir(stateRoot, batchId, agentId);
566
+ if (!existsSync(outboxDir)) return 0;
567
+
568
+ let entries: string[] = [];
569
+ try {
570
+ entries = readdirSync(outboxDir);
571
+ } catch (err) {
572
+ process.stderr.write(
573
+ `[mailbox] WARNING: drainAgentOutbox failed to read ${outboxDir}: ${err instanceof Error ? err.message : String(err)}\n`,
574
+ );
575
+ return 0;
576
+ }
577
+
578
+ let drained = 0;
579
+ const processedDir = join(outboxDir, "processed");
580
+ let processedDirEnsured = false;
581
+
582
+ for (const entry of entries) {
583
+ // Skip the processed/ subdirectory itself and any in-flight temp writes.
584
+ if (entry === "processed" || entry.endsWith(".tmp")) continue;
585
+
586
+ const srcPath = join(outboxDir, entry);
587
+
588
+ if (entry.endsWith(".msg.json")) {
589
+ if (!processedDirEnsured) {
590
+ try { mkdirSync(processedDir, { recursive: true }); } catch { /* fall through to rename error handling */ }
591
+ processedDirEnsured = true;
592
+ }
593
+ const dstPath = join(processedDir, entry);
594
+ try {
595
+ renameSync(srcPath, dstPath);
596
+ drained++;
597
+ } catch (err: unknown) {
598
+ const code = (err as NodeJS.ErrnoException).code;
599
+ if (code === "ENOENT") continue; // already gone — race-safe
600
+ process.stderr.write(
601
+ `[mailbox] WARNING: drainAgentOutbox failed to rename ${entry}: ${err instanceof Error ? err.message : String(err)}\n`,
602
+ );
603
+ }
604
+ continue;
605
+ }
606
+
607
+ // Non-message pending files (e.g., segment-expansion-*.json). Rename in
608
+ // place to a `.drained` suffix so engine.ts discovery scans skip them.
609
+ try {
610
+ renameSync(srcPath, `${srcPath}.drained`);
611
+ drained++;
612
+ } catch (err: unknown) {
613
+ const code = (err as NodeJS.ErrnoException).code;
614
+ if (code === "ENOENT") continue;
615
+ process.stderr.write(
616
+ `[mailbox] WARNING: drainAgentOutbox failed to mark ${entry} drained: ${err instanceof Error ? err.message : String(err)}\n`,
617
+ );
618
+ }
619
+ }
620
+
621
+ return drained;
622
+ }
623
+
541
624
  /**
542
625
  * Discover all agent IDs that have mailbox directories for a batch.
543
626
  * Returns directory names under .pi/mailbox/{batchId}/ excluding _broadcast.
@@ -104,6 +104,25 @@ export const ORCH_MESSAGES = {
104
104
  resumeNoState: () =>
105
105
  `❌ No batch to resume. No batch-state.json file found.\n` +
106
106
  ` Use /orch <areas|all> to start a new batch.`,
107
+
108
+ /**
109
+ * TP-187 (#539): Successful reconstruction from .pi/runtime/<batchId>/
110
+ * runtime artifacts during force-resume after `orch_abort()`.
111
+ */
112
+ resumeReconstructed: (batchId: string, selectionNote: string) =>
113
+ `🔨 Reconstructed batch ${batchId} from .pi/runtime/ artifacts (${selectionNote}).\n` +
114
+ ` Force-resume will proceed with a fresh wave-zero pass; the existing\n` +
115
+ ` reconciliation logic will re-detect succeeded tasks via .DONE markers.`,
116
+
117
+ /**
118
+ * TP-187 (#539): Fail-loud message when force-resume can't reconstruct
119
+ * after `orch_abort()` because required runtime artifacts are missing.
120
+ */
121
+ resumeNoStateAfterAbort: (missingArtifact: string, batchId: string | null) =>
122
+ `❌ Cannot resume after abort: ${missingArtifact}.\n` +
123
+ (batchId ? ` Last known batch: ${batchId}.\n` : "") +
124
+ ` To start fresh from the preserved worktree state, run\n` +
125
+ ` \`orch_start <PROMPT.md>\` (or \`/orch <areas|all>\`).`,
107
126
  resumeInvalidState: (error: string) =>
108
127
  `❌ Cannot resume: batch state file is invalid.\n` +
109
128
  ` Error: ${error}\n` +
@@ -86,15 +86,38 @@ export function getNpmGlobalRoot(): string {
86
86
  return _npmGlobalRoot;
87
87
  }
88
88
 
89
+ /**
90
+ * Pi CLI npm package scopes that taskplane resolves at runtime, ordered with
91
+ * the canonical (current) scope FIRST and legacy scopes after for backward
92
+ * compatibility. Issue #560: the Pi coding agent was renamed from
93
+ * `@mariozechner/pi-coding-agent` to `@earendil-works/pi-coding-agent` in
94
+ * Pi v0.74.0. Pi's own extension loader bundles BOTH scope aliases at runtime
95
+ * for in-process module imports, but spawn-side path resolution (this file)
96
+ * has to look on disk under whichever scope was actually installed.
97
+ *
98
+ * Order matters: the new scope is preferred so a system that has BOTH
99
+ * installed (e.g., during a transition window) picks up the current Pi.
100
+ */
101
+ const PI_PACKAGE_SCOPES = ["@earendil-works", "@mariozechner"] as const;
102
+
89
103
  /**
90
104
  * Resolve the absolute path to the Pi coding agent CLI entrypoint (`cli.js`).
91
105
  *
92
- * The Pi CLI is installed as `@mariozechner/pi-coding-agent`. On Windows, invoking
93
- * `pi` directly executes a `.CMD` shim that cannot be spawned with `shell: false`.
94
- * This function locates the underlying `dist/cli.js` so callers can spawn it with
95
- * `node` directly, without a shell intermediary.
106
+ * The Pi CLI is installed under one of two npm scopes:
107
+ * - `@earendil-works/pi-coding-agent` (current, as of Pi v0.74.0)
108
+ * - `@mariozechner/pi-coding-agent` (legacy)
96
109
  *
97
- * Resolution order:
110
+ * On Windows, invoking `pi` directly executes a `.CMD` shim that cannot be
111
+ * spawned with `shell: false`. This function locates the underlying
112
+ * `dist/cli.js` so callers can spawn it with `node` directly, without a shell
113
+ * intermediary.
114
+ *
115
+ * Resolution order: the cross product of base directories × package scopes,
116
+ * with each base directory tried for the new scope before any base directory
117
+ * is tried for the legacy scope. (Equivalently: scope is the inner loop, base
118
+ * is the outer loop.)
119
+ *
120
+ * Base directories (outer loop):
98
121
  * 1. `npm root -g` result (dynamic — covers all setups: nvm, Homebrew, volta, etc.)
99
122
  * 2. `%APPDATA%\npm\node_modules\...` (Windows, APPDATA env var)
100
123
  * 3. `%USERPROFILE%\AppData\Roaming\npm\node_modules\...` (Windows, HOME-relative)
@@ -102,40 +125,53 @@ export function getNpmGlobalRoot(): string {
102
125
  * 5. `/usr/local/lib/node_modules/...` (macOS system Node, Linux)
103
126
  * 6. `/opt/homebrew/lib/node_modules/...` (macOS Homebrew)
104
127
  *
105
- * @returns Absolute path to `@mariozechner/pi-coding-agent/dist/cli.js`
106
- * @throws {Error} If the CLI entrypoint cannot be found in any known location.
107
- * The error message includes the `npm root -g` value for diagnosis.
128
+ * Scopes per base (inner loop):
129
+ * a. `@earendil-works/pi-coding-agent/dist/cli.js`
130
+ * b. `@mariozechner/pi-coding-agent/dist/cli.js`
131
+ *
132
+ * @returns Absolute path to a Pi CLI `dist/cli.js` (under whichever scope was found).
133
+ * @throws {Error} If the CLI entrypoint cannot be found under any base × scope
134
+ * combination. The error message includes the `npm root -g` value
135
+ * AND lists both scopes searched, for operator diagnosis.
108
136
  */
109
137
  export function resolvePiCliPath(): string {
110
- const relPath = join("@mariozechner", "pi-coding-agent", "dist", "cli.js");
111
- const candidates: string[] = [];
138
+ const bases: string[] = [];
112
139
 
113
140
  // 1. Dynamic: npm root -g (covers nvm, Homebrew, volta, custom npm prefix, etc.)
114
141
  const npmRoot = getNpmGlobalRoot();
115
- if (npmRoot) candidates.push(join(npmRoot, relPath));
142
+ if (npmRoot) bases.push(npmRoot);
116
143
 
117
144
  // 2-3. Static Windows fallbacks
118
145
  const home = process.env.HOME || process.env.USERPROFILE || "";
119
146
  if (process.env.APPDATA) {
120
- candidates.push(join(process.env.APPDATA, "npm", "node_modules", relPath));
147
+ bases.push(join(process.env.APPDATA, "npm", "node_modules"));
121
148
  }
122
149
  if (home) {
123
- candidates.push(join(home, "AppData", "Roaming", "npm", "node_modules", relPath));
150
+ bases.push(join(home, "AppData", "Roaming", "npm", "node_modules"));
124
151
  // 4. macOS/Linux custom global prefix
125
- candidates.push(join(home, ".npm-global", "lib", "node_modules", relPath));
152
+ bases.push(join(home, ".npm-global", "lib", "node_modules"));
126
153
  }
127
154
  // 5. macOS system Node / Linux
128
- candidates.push(join("/usr", "local", "lib", "node_modules", relPath));
155
+ bases.push(join("/usr", "local", "lib", "node_modules"));
129
156
  // 6. macOS Homebrew
130
- candidates.push(join("/opt", "homebrew", "lib", "node_modules", relPath));
157
+ bases.push(join("/opt", "homebrew", "lib", "node_modules"));
131
158
 
132
- for (const candidate of candidates) {
133
- if (existsSync(candidate)) return candidate;
159
+ // Cross product: scope is the inner loop so a single base directory is
160
+ // fully exhausted (new scope, then legacy scope) before falling back to
161
+ // the next base. This matches operator intuition ("check the most likely
162
+ // install location for either scope first").
163
+ for (const base of bases) {
164
+ for (const scope of PI_PACKAGE_SCOPES) {
165
+ const candidate = join(base, scope, "pi-coding-agent", "dist", "cli.js");
166
+ if (existsSync(candidate)) return candidate;
167
+ }
134
168
  }
135
169
 
136
170
  throw new Error(
137
- "Cannot find Pi CLI entrypoint (@mariozechner/pi-coding-agent/dist/cli.js). " +
138
- "Ensure the pi coding agent is installed globally via 'npm install -g @mariozechner/pi-coding-agent'. " +
171
+ "Cannot find Pi CLI entrypoint (pi-coding-agent/dist/cli.js) under any known npm scope " +
172
+ `(${PI_PACKAGE_SCOPES.join(" or ")}). ` +
173
+ "Install via 'npm install -g @earendil-works/pi-coding-agent' " +
174
+ "(or, for legacy installs, 'npm install -g @mariozechner/pi-coding-agent'). " +
139
175
  `npm root -g returned: ${npmRoot || "(empty — npm may not be on PATH)"}`,
140
176
  );
141
177
  }
@@ -189,12 +225,15 @@ export function resolveTaskplanePackageFile(repoRoot: string, relPath: string):
189
225
  candidates.push(join("/opt", "homebrew", "lib", "node_modules", "taskplane", relPath));
190
226
 
191
227
  // 8. Peer of pi's package (look adjacent to pi's CLI entrypoint).
192
- // pi is at: <npmRoot>/@mariozechner/pi-coding-agent/dist/cli.js
193
- // so piPkgDir = <npmRoot>/@mariozechner/pi-coding-agent (resolve up 2 levels from cli.js)
194
- // then go up TWO more levels to reach <npmRoot>, then into taskplane/
228
+ // pi is at: <npmRoot>/<scope>/pi-coding-agent/dist/cli.js (where <scope> is
229
+ // @earendil-works (current) or @mariozechner (legacy)).
230
+ // so piPkgDir = <npmRoot>/<scope>/pi-coding-agent (resolve up 2 levels from cli.js).
231
+ // Then go up TWO more levels to reach <npmRoot>, then into taskplane/.
232
+ // This works regardless of which scope Pi is installed under because we
233
+ // only walk up the directory tree — we never name the scope explicitly.
195
234
  try {
196
235
  const piPath = process.argv[1] || "";
197
- const piPkgDir = resolve(piPath, "..", ".."); // <npmRoot>/@mariozechner/pi-coding-agent
236
+ const piPkgDir = resolve(piPath, "..", ".."); // <npmRoot>/<scope>/pi-coding-agent
198
237
  const npmRootFromPi = resolve(piPkgDir, "..", ".."); // <npmRoot>
199
238
  candidates.push(join(npmRootFromPi, "taskplane", relPath));
200
239
  } catch { /* ignore — process.argv[1] may be undefined in test contexts */ }