taskplane 0.30.1 → 0.30.2

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.
@@ -243,6 +243,17 @@ let viewerMode = null; // "conversation" | "status-md" | null
243
243
  let viewerTarget = null; // session name (conversation) or taskId (status-md)
244
244
  let lastBatchId = null; // TP-178: track batchId for stale viewer detection (#487)
245
245
 
246
+ // #507: Debounce the no-batch transition. A single missed poll happens
247
+ // transiently during batch-state.json writes at batch startup, and was
248
+ // causing the dashboard to flash the previous batch's history view before
249
+ // switching to the new live batch. Require N consecutive no-batch polls
250
+ // before clearing the viewer / showing history. With the server's 2s
251
+ // POLL_INTERVAL, a threshold of 3 corresponds to ~6s of confirmed silence —
252
+ // well past the typical batch-state.json write window (sub-second) while
253
+ // still cleaning up promptly when a batch genuinely ends.
254
+ let consecutiveNoBatchPolls = 0;
255
+ const NO_BATCH_DEBOUNCE_THRESHOLD = 3;
256
+
246
257
  // ─── Repo Helpers ───────────────────────────────────────────────────────────
247
258
 
248
259
  /**
@@ -1818,6 +1829,16 @@ function render(data) {
1818
1829
  $lastUpdate.textContent = new Date().toLocaleTimeString();
1819
1830
 
1820
1831
  if (!batch) {
1832
+ // #507: A single missed poll during batch startup (batch-state.json being
1833
+ // written) is not a real "batch disappeared" signal. Only act on no-batch
1834
+ // after N consecutive polls confirm it, so we don't flash the history
1835
+ // view between two live batches.
1836
+ consecutiveNoBatchPolls += 1;
1837
+ if (consecutiveNoBatchPolls < NO_BATCH_DEBOUNCE_THRESHOLD) {
1838
+ // Hold the previous render in place. Still tick the timestamp so the
1839
+ // user knows the SSE stream is alive.
1840
+ return;
1841
+ }
1821
1842
  // TP-178: Clear viewer when batch disappears (#487)
1822
1843
  if (lastBatchId && viewerMode) closeViewer();
1823
1844
  lastBatchId = null;
@@ -1830,6 +1851,9 @@ function render(data) {
1830
1851
  return;
1831
1852
  }
1832
1853
 
1854
+ // Batch present — reset the no-batch debounce counter (#507).
1855
+ consecutiveNoBatchPolls = 0;
1856
+
1833
1857
  // TP-178: Detect batchId change — clear stale viewer state (#487)
1834
1858
  if (batch.batchId && lastBatchId && batch.batchId !== lastBatchId && viewerMode) {
1835
1859
  closeViewer();
@@ -467,12 +467,26 @@ function loadRuntimeLaneSnapshots(batchId) {
467
467
  /**
468
468
  * Load Runtime V2 merge agent snapshots for the current batch.
469
469
  *
470
- * Reads all `merge-N.json` files from `.pi/runtime/{batchId}/lanes/`.
471
- * Returns a map of mergeNumber (string) → snapshot data.
470
+ * Reads all `merge-*.json` files from `.pi/runtime/{batchId}/lanes/`.
471
+ * Returns a map of unique key → snapshot data, where the key is a composite
472
+ * of waveIndex and mergeNumber.
473
+ *
474
+ * The composite key is essential because lane numbers (and therefore
475
+ * `mergeNumber`) repeat across waves — keying solely by `mergeNumber` caused
476
+ * wave N+1's snapshots to silently overwrite wave N's in the intermediate
477
+ * map, which is the root cause of #509 ('merge agent telemetry missing for
478
+ * some waves').
472
479
  *
473
480
  * Follows the same pattern as {@link loadRuntimeLaneSnapshots}.
474
481
  *
475
- * @since TP-164
482
+ * Filename accepted patterns (back-compat-tolerant):
483
+ * merge-w{waveIndex}-{mergeNumber}.json (current, post-#509)
484
+ * merge-{mergeNumber}.json (legacy, pre-#509)
485
+ *
486
+ * Both patterns embed waveIndex inside the snapshot JSON itself, so the key
487
+ * derivation works for either filename.
488
+ *
489
+ * @since TP-164 (composite key added in #509 remediation)
476
490
  */
477
491
  function loadRuntimeMergeSnapshots(batchId) {
478
492
  if (!batchId) return {};
@@ -484,7 +498,14 @@ function loadRuntimeMergeSnapshots(batchId) {
484
498
  for (const file of files) {
485
499
  try {
486
500
  const data = JSON.parse(fs.readFileSync(path.join(lanesDir, file), "utf-8"));
487
- if (data.mergeNumber != null) snapshots[data.mergeNumber] = data;
501
+ if (data.mergeNumber == null) continue;
502
+ // Composite key keeps cross-wave snapshots from colliding in this map.
503
+ // Falls back to mergeNumber-only for legacy snapshots that pre-date
504
+ // the waveIndex-in-filename change.
505
+ const key = data.waveIndex != null
506
+ ? `w${data.waveIndex}-${data.mergeNumber}`
507
+ : String(data.mergeNumber);
508
+ snapshots[key] = data;
488
509
  } catch { continue; }
489
510
  }
490
511
  } catch { /* dir missing */ }
@@ -4225,14 +4225,23 @@ export async function executeOrchBatch(
4225
4225
  "info",
4226
4226
  );
4227
4227
 
4228
- // TP-040: Emit merge_success event
4228
+ // TP-040: Emit merge_success event.
4229
+ //
4230
+ // `waveIndex` is the segment-round index (0-based), and the supervisor
4231
+ // formatter renders the (N/M) counter using `waveIndex + 1` for N.
4232
+ // For unit consistency we therefore pair it with the segment-level
4233
+ // `batchState.totalWaves` (segment-expanded round count), not
4234
+ // `taskLevelWaveCount` (pre-expansion). Using the task-level count as
4235
+ // the denominator while the numerator counts segment rounds produced
4236
+ // `(4/3)`, `(5/3)`, `(6/3)` style overflow once segments expanded —
4237
+ // see issue #562.
4229
4238
  emitEvent(
4230
4239
  stateRoot,
4231
4240
  {
4232
4241
  ...buildEngineEventBase("merge_success", batchState.batchId, waveIdx, batchState.phase),
4233
4242
  laneCount: mergedCount,
4234
4243
  durationMs: mergeResult.totalDurationMs,
4235
- totalWaves: taskLevelWaveCount,
4244
+ totalWaves: batchState.totalWaves,
4236
4245
  },
4237
4246
  onEngineEvent,
4238
4247
  );
@@ -895,7 +895,7 @@ export async function spawnMergeAgentV2(
895
895
  agent: buildAgentSnap(tel, "running"),
896
896
  updatedAt: Date.now(),
897
897
  };
898
- writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, snap);
898
+ writeMergeSnapshot(mergeStateRoot, bid, waveIndex ?? 0, mergeNumber, snap);
899
899
  } catch {
900
900
  /* non-fatal */
901
901
  }
@@ -915,7 +915,7 @@ export async function spawnMergeAgentV2(
915
915
  agent: buildAgentSnap({}, "running"),
916
916
  updatedAt: Date.now(),
917
917
  };
918
- writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, initialSnap);
918
+ writeMergeSnapshot(mergeStateRoot, bid, waveIndex ?? 0, mergeNumber, initialSnap);
919
919
  } catch {
920
920
  /* non-fatal */
921
921
  }
@@ -964,7 +964,7 @@ export async function spawnMergeAgentV2(
964
964
  agent: buildAgentSnap(result, terminalStatus === "complete" ? "exited" : "crashed"),
965
965
  updatedAt: Date.now(),
966
966
  };
967
- writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, snap);
967
+ writeMergeSnapshot(mergeStateRoot, bid, waveIndex ?? 0, mergeNumber, snap);
968
968
  } catch {
969
969
  /* non-fatal */
970
970
  }
@@ -987,7 +987,7 @@ export async function spawnMergeAgentV2(
987
987
  agent: buildAgentSnap({}, "crashed"),
988
988
  updatedAt: Date.now(),
989
989
  };
990
- writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, snap);
990
+ writeMergeSnapshot(mergeStateRoot, bid, waveIndex ?? 0, mergeNumber, snap);
991
991
  } catch {
992
992
  /* non-fatal */
993
993
  }
@@ -112,32 +112,53 @@ const PI_PACKAGE_SCOPES = ["@earendil-works", "@mariozechner"] as const;
112
112
  * `dist/cli.js` so callers can spawn it with `node` directly, without a shell
113
113
  * intermediary.
114
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.)
115
+ * Resolution order:
116
+ *
117
+ * 0. **AUTHORITATIVE** `process.argv[1]` when it points at a Pi `cli.js`.
118
+ * When Taskplane is running as a Pi extension, the parent process IS
119
+ * Pi, and Node sets `process.argv[1]` to the path of the file used to
120
+ * start it. This is the single most reliable resolution path: it works
121
+ * for npm-global, mise, asdf, NVM (Windows + Unix), Nix, Bun-installed
122
+ * Pi, and any future install method we can't enumerate. Issues #519
123
+ * and #598 both stem from this signal being ignored in favor of a
124
+ * static-path search that misses non-canonical install layouts.
125
+ *
126
+ * If `process.argv[1]` isn't a Pi `cli.js` (e.g. running standalone in tests,
127
+ * or invoked through an indirect wrapper), the function falls through to a
128
+ * cross product of base directories × package scopes:
119
129
  *
120
- * Base directories (outer loop):
121
- * 1. `npm root -g` result (dynamic — covers all setups: nvm, Homebrew, volta, etc.)
130
+ * 1. `npm root -g` result (dynamic — covers npm-global, Homebrew, volta, etc.)
122
131
  * 2. `%APPDATA%\npm\node_modules\...` (Windows, APPDATA env var)
123
132
  * 3. `%USERPROFILE%\AppData\Roaming\npm\node_modules\...` (Windows, HOME-relative)
124
133
  * 4. `~/.npm-global/lib/node_modules/...` (macOS/Linux custom global prefix)
125
- * 5. `/usr/local/lib/node_modules/...` (macOS system Node, Linux)
126
- * 6. `/opt/homebrew/lib/node_modules/...` (macOS Homebrew)
134
+ * 5. `$NVM_SYMLINK\node_modules` (NVM-for-Windows, when the env var is set)
135
+ * 6. `dirname($NVM_BIN)/../lib/node_modules` (NVM-for-Unix, when the env var is set)
136
+ * 7. `/usr/local/lib/node_modules/...` (macOS system Node, Linux)
137
+ * 8. `/opt/homebrew/lib/node_modules/...` (macOS Homebrew)
127
138
  *
128
139
  * Scopes per base (inner loop):
129
140
  * a. `@earendil-works/pi-coding-agent/dist/cli.js`
130
141
  * b. `@mariozechner/pi-coding-agent/dist/cli.js`
131
142
  *
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.
143
+ * @returns Absolute path to a Pi CLI `dist/cli.js`.
144
+ * @throws {Error} If the CLI entrypoint cannot be found by any strategy.
145
+ * The error message includes the `npm root -g` value AND lists
146
+ * both scopes searched, for operator diagnosis.
136
147
  */
137
148
  export function resolvePiCliPath(): string {
149
+ // 0. AUTHORITATIVE: trust process.argv[1] when it points at a Pi cli.js.
150
+ // Pi's package.json declares `"bin": { "pi": "dist/cli.js" }`, so the
151
+ // `endsWith("cli.js")` guard is a tight sanity check that rejects e.g.
152
+ // test runners or wrapper scripts that happen to leave argv[1] pointing
153
+ // somewhere else. existsSync() guards against stale argv state in mocks.
154
+ const piEntry = process.argv[1] || "";
155
+ if (piEntry.endsWith("cli.js") && existsSync(piEntry)) {
156
+ return piEntry;
157
+ }
158
+
138
159
  const bases: string[] = [];
139
160
 
140
- // 1. Dynamic: npm root -g (covers nvm, Homebrew, volta, custom npm prefix, etc.)
161
+ // 1. Dynamic: npm root -g (covers npm-global, Homebrew, volta, custom npm prefix, etc.)
141
162
  const npmRoot = getNpmGlobalRoot();
142
163
  if (npmRoot) bases.push(npmRoot);
143
164
 
@@ -151,9 +172,25 @@ export function resolvePiCliPath(): string {
151
172
  // 4. macOS/Linux custom global prefix
152
173
  bases.push(join(home, ".npm-global", "lib", "node_modules"));
153
174
  }
154
- // 5. macOS system Node / Linux
175
+
176
+ // 5. NVM-for-Windows defense in depth: NVM_SYMLINK points at the active
177
+ // Node install (typically C:\Program Files\nodejs as a junction), and the
178
+ // global packages live under <symlink>\node_modules. Child processes
179
+ // inherit this env var even when PATH is stripped of npm.
180
+ if (process.env.NVM_SYMLINK) {
181
+ bases.push(join(process.env.NVM_SYMLINK, "node_modules"));
182
+ }
183
+
184
+ // 6. NVM-for-Unix defense in depth: NVM_BIN points at the active version's
185
+ // bin directory, and the corresponding node_modules sit alongside it at
186
+ // `../lib/node_modules`. Same inheritance properties as NVM_SYMLINK.
187
+ if (process.env.NVM_BIN) {
188
+ bases.push(join(process.env.NVM_BIN, "..", "lib", "node_modules"));
189
+ }
190
+
191
+ // 7. macOS system Node / Linux
155
192
  bases.push(join("/usr", "local", "lib", "node_modules"));
156
- // 6. macOS Homebrew
193
+ // 8. macOS Homebrew
157
194
  bases.push(join("/opt", "homebrew", "lib", "node_modules"));
158
195
 
159
196
  // Cross product: scope is the inner loop so a single base directory is
@@ -400,20 +400,25 @@ export function readLaneSnapshot(
400
400
  * Stored in the `lanes/` directory alongside lane snapshots so the dashboard
401
401
  * server picks it up with the same scan that reads lane-N.json files.
402
402
  *
403
+ * Filename includes BOTH waveIndex and mergeNumber so wave-N+1's merges
404
+ * cannot overwrite wave-N's snapshots before the dashboard polls them (#509).
405
+ *
403
406
  * @param stateRoot - Repository root (where `.pi/` lives)
404
407
  * @param batchId - Current batch identifier
408
+ * @param waveIndex - 0-based wave index for the merge
405
409
  * @param mergeNumber - 1-indexed merge agent number
406
410
  * @param snapshot - Snapshot data to persist
407
411
  *
408
- * @since TP-164
412
+ * @since TP-164 (waveIndex parameter added in #509 remediation)
409
413
  */
410
414
  export function writeMergeSnapshot(
411
415
  stateRoot: string,
412
416
  batchId: string,
417
+ waveIndex: number,
413
418
  mergeNumber: number,
414
419
  snapshot: RuntimeMergeSnapshot,
415
420
  ): void {
416
- const path = runtimeMergeSnapshotPath(stateRoot, batchId, mergeNumber);
421
+ const path = runtimeMergeSnapshotPath(stateRoot, batchId, waveIndex, mergeNumber);
417
422
  mkdirSync(dirname(path), { recursive: true });
418
423
  const tmpPath = path + ".tmp";
419
424
  writeFileSync(tmpPath, JSON.stringify(snapshot, null, 2) + "\n", "utf-8");
@@ -426,17 +431,19 @@ export function writeMergeSnapshot(
426
431
  *
427
432
  * @param stateRoot - Repository root (where `.pi/` lives)
428
433
  * @param batchId - Current batch identifier
434
+ * @param waveIndex - 0-based wave index for the merge
429
435
  * @param mergeNumber - 1-indexed merge agent number
430
436
  *
431
- * @since TP-164
437
+ * @since TP-164 (waveIndex parameter added in #509 remediation)
432
438
  */
433
439
  export function readMergeSnapshot(
434
440
  stateRoot: string,
435
441
  batchId: string,
442
+ waveIndex: number,
436
443
  mergeNumber: number,
437
444
  ): RuntimeMergeSnapshot | null {
438
445
  try {
439
- const p = runtimeMergeSnapshotPath(stateRoot, batchId, mergeNumber);
446
+ const p = runtimeMergeSnapshotPath(stateRoot, batchId, waveIndex, mergeNumber);
440
447
  if (!existsSync(p)) return null;
441
448
  return JSON.parse(readFileSync(p, "utf-8")) as RuntimeMergeSnapshot;
442
449
  } catch {
@@ -4323,12 +4323,26 @@ export interface RuntimeMergeSnapshot {
4323
4323
  *
4324
4324
  * @since TP-164
4325
4325
  */
4326
+ /**
4327
+ * Path to a merge agent snapshot file.
4328
+ *
4329
+ * The filename includes BOTH `waveIndex` and `mergeNumber` because lane
4330
+ * numbers (and therefore the legacy `mergeNumber`-only filename) repeat
4331
+ * across waves — a wave-2 lane-1 merge would overwrite the wave-1 lane-1
4332
+ * snapshot before the dashboard's next poll could read it. Per-wave
4333
+ * namespacing keeps each merge's snapshot durable until the runtime
4334
+ * directory itself is cleaned up at end-of-batch. See #509.
4335
+ *
4336
+ * @param waveIndex 0-based wave index for the merge
4337
+ * @param mergeNumber 1-based merge agent number (derived from lane number)
4338
+ */
4326
4339
  export function runtimeMergeSnapshotPath(
4327
4340
  stateRoot: string,
4328
4341
  batchId: string,
4342
+ waveIndex: number,
4329
4343
  mergeNumber: number,
4330
4344
  ): string {
4331
- return `${stateRoot}/.pi/runtime/${batchId}/lanes/merge-${mergeNumber}.json`;
4345
+ return `${stateRoot}/.pi/runtime/${batchId}/lanes/merge-w${waveIndex}-${mergeNumber}.json`;
4332
4346
  }
4333
4347
 
4334
4348
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.30.1",
3
+ "version": "0.30.2",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",