pi-crew 0.11.22 → 0.11.23

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.11.22",
3
+ "version": "0.11.23",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -327,12 +327,14 @@ export function handleRespond(params: TeamToolParamsValue, ctx: TeamContext): Pi
327
327
  if (requeuedIds.length > 0) {
328
328
  try {
329
329
  const existingRuntimes = new Map(readCrewAgents(fresh.manifest).map((a) => [a.taskId, a.runtime]));
330
- saveCrewAgents(
331
- fresh.manifest,
332
- updatedTasks
333
- .filter((task) => requeuedIds.includes(task.id))
334
- .map((task) => recordFromTask(fresh.manifest, task, existingRuntimes.get(task.id) ?? "child-process")),
335
- );
330
+ // R4-A1 (fix): merge requeued records into the existing index — the
331
+ // subset-only save wiped sibling agent records (team-runner.ts:525 pattern).
332
+ const mergedById = new Map(readCrewAgents(fresh.manifest).map((record) => [record.id, record] as const));
333
+ for (const task of updatedTasks.filter((task) => requeuedIds.includes(task.id))) {
334
+ const record = recordFromTask(fresh.manifest, task, existingRuntimes.get(task.id) ?? "child-process");
335
+ mergedById.set(record.id, record);
336
+ }
337
+ saveCrewAgents(fresh.manifest, [...mergedById.values()]);
336
338
  } catch (error) {
337
339
  logInternalError("team-tool.handleRespond.crewAgents", error, `runId=${fresh.manifest.runId}`);
338
340
  }
@@ -137,7 +137,7 @@ function scheduleBackgroundEarlyExitGuard(cwd: string, runId: string, pid: numbe
137
137
  runId: failed.runId,
138
138
  message,
139
139
  data: { pid, detail: liveness.detail },
140
- });
140
+ }).catch((error) => logInternalError("team-tool.run.asyncFailedEvent", error, `runId=${failed.runId}`)); // R5-M-B3 (fix)
141
141
  });
142
142
  } catch (error) {
143
143
  // One-shot unref'd timer — a lock-contention error must not escape and
@@ -589,7 +589,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
589
589
  envTeams: getCrewEnv("PI_TEAMS_EXECUTE_WORKERS"),
590
590
  },
591
591
  },
592
- });
592
+ }).catch((error) => logInternalError("team-tool.run.blockedEvent", error, `runId=${blocked.runId}`)); // R5-M-B3 (fix)
593
593
  unregisterActiveRun(blocked.runId);
594
594
  return result(
595
595
  [
@@ -622,7 +622,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
622
622
  type: "async.spawned",
623
623
  runId: effectiveManifest.runId,
624
624
  data: { pid: spawned.pid, logPath: spawned.logPath },
625
- });
625
+ }).catch((error) => logInternalError("team-tool.run.asyncSpawnedEvent", error, `runId=${effectiveManifest.runId}`)); // R5-M-B3 (fix)
626
626
  ctx.onRunStarted?.(effectiveManifest.runId);
627
627
  scheduleBackgroundEarlyExitGuard(resolvedCtx.cwd, effectiveManifest.runId, spawned.pid, spawned.logPath);
628
628
  // CORE-8: unified deadline for waitForRun timeout (background process is detached).
@@ -684,7 +684,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
684
684
  envTeams: getCrewEnv("PI_TEAMS_EXECUTE_WORKERS"),
685
685
  },
686
686
  },
687
- });
687
+ }).catch((error) => logInternalError("team-tool.run.blockedEvent", error, `runId=${blocked.runId}`)); // R5-M-B3 (fix)
688
688
  unregisterActiveRun(blocked.runId);
689
689
  return result(
690
690
  [
@@ -142,11 +142,23 @@ export function parentAlive(pid: number, expectedStartTime: string | undefined,
142
142
  }
143
143
  });
144
144
  let stat: string | undefined;
145
+ let readerThrew = false;
145
146
  try {
146
147
  stat = reader(pid);
147
148
  } catch {
148
- stat = undefined;
149
+ // R7-P1 guard-gate fix (deep-review round 7/9): a THROWING/unreadable
150
+ // stat reader (macOS-shaped: /proc absent → read error) with NO
151
+ // recorded expectedStartTime cannot PROVE the parent died. The doc
152
+ // contract above ("Never kills on … an unreadable clock field — a
153
+ // false 'alive' falls back to the host watchdog") demands the
154
+ // alive-with-caveat tolerance here; the pre-fix code fell through to
155
+ // `return false` BEFORE the tolerance check and killed healthy
156
+ // workers at the first 5s tick.
157
+ readerThrew = true;
149
158
  }
159
+ if (readerThrew) return expectedStartTime ? false : true;
160
+ // Reader returned "no entry" (default /proc reader ENOENT = dead on
161
+ // Linux) — dead-parent detection stays intact.
150
162
  if (stat === undefined) return false;
151
163
  if (!expectedStartTime) return true;
152
164
  const observed = procStartTimeTicks(stat);
@@ -73,6 +73,7 @@ import {
73
73
  BROKER_PROTOCOL,
74
74
  isHelloParams,
75
75
  isRequestObject,
76
+ parseDelegateRequestParams,
76
77
  parseMsgSendParams,
77
78
  parseWaitRequestParams,
78
79
  safeStringify,
@@ -1455,28 +1456,16 @@ export class CrewBroker {
1455
1456
  this.sendError(conn, id, "forbidden", "delegate requires a task-scoped token; re-dispatch with PI_CREW_BROKER_TASK_ID");
1456
1457
  return;
1457
1458
  }
1458
- const p = (params ?? {}) as {
1459
- description?: unknown;
1460
- prompt?: unknown;
1461
- role?: unknown;
1462
- model?: unknown;
1463
- maxTurns?: unknown;
1464
- budgetTokens?: unknown;
1465
- timeoutSec?: unknown;
1466
- };
1467
- if (typeof p.prompt !== "string" || p.prompt.trim().length === 0) {
1468
- this.sendError(conn, id, "bad-params", "delegate.request: 'prompt' (non-empty string) is required");
1459
+ // R7-H1 fix: parse params through the protocol-level parser (mirrors
1460
+ // parseWaitRequestParams discipline) — non-integer/non-finite/negative/
1461
+ // over-ceiling maxTurns/budgetTokens never reach admission or the
1462
+ // budget reservation (durable-negative tokensSpent escape).
1463
+ const parsed = parseDelegateRequestParams(params);
1464
+ if (!parsed) {
1465
+ this.sendError(conn, id, "bad-params", "delegate.request: invalid params");
1469
1466
  return;
1470
1467
  }
1471
- const requested = {
1472
- prompt: p.prompt,
1473
- ...(typeof p.description === "string" ? { description: p.description } : {}),
1474
- ...(typeof p.role === "string" ? { role: p.role } : {}),
1475
- ...(typeof p.model === "string" ? { model: p.model } : {}),
1476
- ...(typeof p.maxTurns === "number" ? { maxTurns: p.maxTurns } : {}),
1477
- ...(typeof p.budgetTokens === "number" ? { budgetTokens: p.budgetTokens } : {}),
1478
- ...(typeof p.timeoutSec === "number" ? { timeoutSec: p.timeoutSec } : {}),
1479
- };
1468
+ const requested = parsed;
1480
1469
  const cwd = this.options.cwd;
1481
1470
  if (!cwd) {
1482
1471
  this.sendError(conn, id, "no-manifest", "broker has no cwd configured");
@@ -176,6 +176,74 @@ export function parseWaitRequestParams(value: unknown): WaitRequestParams | unde
176
176
  };
177
177
  }
178
178
 
179
+ // ============================================================================
180
+ // R7-H1 fix (deep-review round 8): delegate.request parameter parser.
181
+ // Mirrors parseWaitRequestParams discipline — the handler previously spread
182
+ // `typeof x === "number"` values straight into `requested`, so non-integer,
183
+ // non-finite, negative and over-ceiling maxTurns/budgetTokens reached
184
+ // admission (e.g. raw-wire -1e9 budget → durable negative tokensSpent;
185
+ // 1e999 → Infinity maxTurns → dead child-pi turn caps).
186
+ // ============================================================================
187
+
188
+ /** Server-side ceiling for grandchild turn caps (client tool schema: 1..200). */
189
+ export const DELEGATE_MAX_TURNS_CEILING = 200;
190
+ /** Server-side ceiling for grandchild token budgets (client schema: <=100M). */
191
+ export const DELEGATE_BUDGET_TOKENS_CEILING = 100_000_000;
192
+ /** Bounded delegate prompt (defense-in-depth under the frame cap). */
193
+ export const DELEGATE_PROMPT_MAX_CHARS = 100_000;
194
+
195
+ export interface DelegateRequestParams {
196
+ prompt: string;
197
+ description?: string;
198
+ role?: string;
199
+ model?: string;
200
+ maxTurns?: number;
201
+ budgetTokens?: number;
202
+ timeoutSec?: number;
203
+ }
204
+
205
+ export function parseDelegateRequestParams(value: unknown): DelegateRequestParams | undefined {
206
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
207
+ const v = value as Record<string, unknown>;
208
+ if (typeof v.prompt !== "string" || v.prompt.trim().length === 0 || v.prompt.length > DELEGATE_PROMPT_MAX_CHARS) {
209
+ return undefined;
210
+ }
211
+ if (v.description !== undefined && typeof v.description !== "string") return undefined;
212
+ if (v.role !== undefined && !(typeof v.role === "string" && ["explorer", "analyst", "executor"].includes(v.role))) {
213
+ return undefined;
214
+ }
215
+ if (v.model !== undefined && typeof v.model !== "string") return undefined;
216
+ if (v.maxTurns !== undefined) {
217
+ if (typeof v.maxTurns !== "number" || !Number.isInteger(v.maxTurns) || v.maxTurns < 1 || v.maxTurns > DELEGATE_MAX_TURNS_CEILING) {
218
+ return undefined;
219
+ }
220
+ }
221
+ if (v.budgetTokens !== undefined) {
222
+ if (
223
+ typeof v.budgetTokens !== "number" ||
224
+ !Number.isInteger(v.budgetTokens) || // R8 lane-B correction: client Type.Integer parity (0.5/3.5 pass isFinite)
225
+ v.budgetTokens < 0 ||
226
+ v.budgetTokens > DELEGATE_BUDGET_TOKENS_CEILING
227
+ ) {
228
+ return undefined;
229
+ }
230
+ }
231
+ // timeoutSec stays finite-only here; the 1..MAX range enforcement remains
232
+ // in spawn-policy §7 (evaluateDelegateAdmission).
233
+ if (v.timeoutSec !== undefined && (typeof v.timeoutSec !== "number" || !Number.isFinite(v.timeoutSec))) {
234
+ return undefined;
235
+ }
236
+ return {
237
+ prompt: v.prompt,
238
+ ...(typeof v.description === "string" ? { description: v.description } : {}),
239
+ ...(typeof v.role === "string" ? { role: v.role } : {}),
240
+ ...(typeof v.model === "string" ? { model: v.model } : {}),
241
+ ...(v.maxTurns !== undefined ? { maxTurns: v.maxTurns as number } : {}),
242
+ ...(v.budgetTokens !== undefined ? { budgetTokens: v.budgetTokens as number } : {}),
243
+ ...(v.timeoutSec !== undefined ? { timeoutSec: v.timeoutSec as number } : {}),
244
+ };
245
+ }
246
+
179
247
  export interface WaitResolveParams {
180
248
  to: string;
181
249
  questionId: string;
@@ -698,6 +698,21 @@ function writeSeqToSidecar(filePath: string, seq: number): void {
698
698
  }
699
699
  }
700
700
 
701
+ function scanMaxAgentEventSeq(filePath: string): number {
702
+ let max = 0;
703
+ for (const line of fs.readFileSync(filePath, "utf-8").split(/\r?\n/)) {
704
+ if (!line.trim()) continue;
705
+ try {
706
+ const parsed = JSON.parse(line) as { seq?: unknown };
707
+ if (typeof parsed.seq === "number" && Number.isFinite(parsed.seq)) max = Math.max(max, parsed.seq);
708
+ else max += 1;
709
+ } catch {
710
+ max += 1;
711
+ }
712
+ }
713
+ return max;
714
+ }
715
+
701
716
  function nextAgentEventSeq(filePath: string): number {
702
717
  if (!fs.existsSync(filePath)) {
703
718
  // Clean up stale sidecar when main file is gone.
@@ -714,24 +729,21 @@ function nextAgentEventSeq(filePath: string): number {
714
729
  // FIX: Try sidecar file for O(1) lookup before falling back to O(n) scan.
715
730
  const sidecarSeq = readSeqFromSidecar(filePath);
716
731
  if (sidecarSeq !== undefined) {
732
+ // FS1 (fix, EL-1 port from event-log sequence-cache.ts): a REGRESSED
733
+ // sidecar (crash between append and sidecar advance, or lost best-effort
734
+ // durability) must never re-mint used seqs — trust it only as
735
+ // max(sidecar, tail-scan), else cursor consumers (seq > sinceSeq)
736
+ // permanently drop the duplicate-seq events.
737
+ const safeSeq = Math.max(sidecarSeq, scanMaxAgentEventSeq(filePath));
717
738
  setAgentEventSeqCache(filePath, {
718
739
  size: stat.size,
719
740
  mtimeMs: stat.mtimeMs,
720
- seq: sidecarSeq,
741
+ seq: safeSeq,
721
742
  });
722
- return sidecarSeq + 1;
723
- }
724
- let max = 0;
725
- for (const line of fs.readFileSync(filePath, "utf-8").split(/\r?\n/)) {
726
- if (!line.trim()) continue;
727
- try {
728
- const parsed = JSON.parse(line) as { seq?: unknown };
729
- if (typeof parsed.seq === "number" && Number.isFinite(parsed.seq)) max = Math.max(max, parsed.seq);
730
- else max += 1;
731
- } catch {
732
- max += 1;
733
- }
743
+ if (safeSeq !== sidecarSeq) writeSeqToSidecar(filePath, safeSeq);
744
+ return safeSeq + 1;
734
745
  }
746
+ const max = scanMaxAgentEventSeq(filePath);
735
747
  setAgentEventSeqCache(filePath, {
736
748
  size: stat.size,
737
749
  mtimeMs: stat.mtimeMs,
@@ -202,12 +202,15 @@ export async function sweepExpiredWaitingTasks(
202
202
  }
203
203
  try {
204
204
  const existingRuntimes = new Map(readCrewAgents(fresh.manifest).map((a) => [a.taskId, a.runtime]));
205
- saveCrewAgents(
206
- fresh.manifest,
207
- tasks
208
- .filter((t) => requeuedTaskIds.includes(t.id))
209
- .map((t) => recordFromTask(fresh.manifest, t, existingRuntimes.get(t.id) ?? "child-process")),
210
- );
205
+ // R4-A1 (fix): agents.json is a full-index overwrite — merge the requeued
206
+ // records into the existing set (team-runner.ts:525 pattern) instead of
207
+ // persisting the subset alone (siblings incl. RUNNING agents were wiped).
208
+ const mergedById = new Map(readCrewAgents(fresh.manifest).map((record) => [record.id, record] as const));
209
+ for (const task of tasks.filter((t) => requeuedTaskIds.includes(t.id))) {
210
+ const record = recordFromTask(fresh.manifest, task, existingRuntimes.get(task.id) ?? "child-process");
211
+ mergedById.set(record.id, record);
212
+ }
213
+ saveCrewAgents(fresh.manifest, [...mergedById.values()]);
211
214
  } catch (error) {
212
215
  logInternalError("dispatch-batch.ask-timeout.crewAgents", error, `runId=${runId}`);
213
216
  }
@@ -179,8 +179,12 @@ function isPidAlive(pid: number): boolean {
179
179
  // process.kill(pid, 0) throws if the pid is not alive (or not ours).
180
180
  process.kill(pid, 0);
181
181
  return true;
182
- } catch {
183
- return false;
182
+ } catch (error) {
183
+ // R7-A2 (2026-10-17): only ESRCH/ENOENT mean the process is gone.
184
+ // EPERM means the process is alive but protected (different security
185
+ // context); treat as alive — mirror of active-run-registry.ts fix.
186
+ const code = (error as NodeJS.ErrnoException).code;
187
+ return code !== "ESRCH" && code !== "ENOENT";
184
188
  }
185
189
  }
186
190
 
@@ -61,7 +61,9 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
61
61
  }
62
62
  return t;
63
63
  });
64
- await saveRunTasksAsync(manifest, updatedTasks);
64
+ // R3-H1 (fix): fresh-read group-scoped merge — never persist the dispatch-time
65
+ // full-array snapshot over concurrent sibling state changes.
66
+ await repairGroupTerminalWrite(manifest, taskIds, updatedTasks);
65
67
  await appendEventAsync(manifest.eventsPath, {
66
68
  type: "task.coalesced_dispatch_start",
67
69
  runId: manifest.runId,
@@ -86,7 +88,9 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
86
88
  heartbeat: t.heartbeat ?? createWorkerHeartbeat(t.id),
87
89
  };
88
90
  });
89
- await saveRunTasksAsync(manifest, updatedTasks);
91
+ // R3-H1 (fix): fresh-read group-scoped merge — never persist the dispatch-time
92
+ // full-array snapshot over concurrent sibling state changes.
93
+ await repairGroupTerminalWrite(manifest, taskIds, updatedTasks);
90
94
 
91
95
  let rawOutput = "";
92
96
  let success = false;
@@ -398,7 +402,9 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
398
402
  if (drainTimeout !== undefined) clearTimeout(drainTimeout);
399
403
  }
400
404
  }
401
- await saveRunTasksAsync(manifest, updatedTasks);
405
+ // R3-H1 (fix): fresh-read group-scoped merge — never persist the dispatch-time
406
+ // full-array snapshot over concurrent sibling state changes.
407
+ await repairGroupTerminalWrite(manifest, taskIds, updatedTasks);
402
408
  let updatedManifest: TeamRunManifest = {
403
409
  ...manifest,
404
410
  artifacts: mergeArtifacts([...manifest.artifacts, ...newArtifacts]),
@@ -713,7 +713,10 @@ export function reconcileOrphanedTempWorkspaces(
713
713
  const result = reconcileStaleRun(manifest, tasks, now);
714
714
  if (result.repaired && result.repairedTasks) {
715
715
  // Persist repaired tasks (atomic — temp+rename to survive mid-write crash)
716
- atomicWriteJson(tasksPath, result.repairedTasks);
716
+ // R2-H1 (fix): persist the FULL array (NEW-1 persistTasks), not the filtered
717
+ // stale subset — the subset write destroyed every sibling record (mirror
718
+ // crash-recovery.ts:1066).
719
+ atomicWriteJson(tasksPath, result.persistTasks ?? result.repairedTasks);
717
720
  // Update manifest status
718
721
  const updated = {
719
722
  ...manifest,
@@ -522,7 +522,15 @@ function cursorResultFromEvents(
522
522
  // streaming consumers. No-rotation case: no archives exist → behavior is
523
523
  // byte-identical to before (mergeArchiveTailEvents returns liveEvents).
524
524
  const merged = mergeArchiveTailEvents(readArchiveTailEvents(eventsPath, sinceSeq), filtered);
525
- const events = limit !== undefined ? merged.slice(0, limit) : merged;
525
+ // FS3 (fix): seq-sort before the limit slice. File-order slicing on a
526
+ // documented non-monotonic log can deliver an early high seq inside the
527
+ // first `limit` events; nextSeq=max-delivered then permanently skips every
528
+ // undelivered lower-seq event for paging consumers resuming from nextSeq.
529
+ // Sort ONLY under a limit: the no-limit path delivers every event (nextSeq
530
+ // = true max ⇒ nothing skippable) and keeps its historical FILE-order
531
+ // contract (pinned by event-log-torn-poison.test.ts AC7, U22 Phase 1).
532
+ const events =
533
+ limit !== undefined ? [...merged].sort((a, b) => (a.metadata?.seq ?? 0) - (b.metadata?.seq ?? 0)).slice(0, limit) : merged;
526
534
  const returnedMaxSeq = events.reduce((max, event) => Math.max(max, event.metadata?.seq ?? 0), sinceSeq);
527
535
  return {
528
536
  events,
@@ -782,7 +790,11 @@ export function readEventsCursor(eventsPath: string, options: EventCursorOptions
782
790
  const filtered = safeItems.filter((event) => (event.metadata?.seq ?? 0) > sinceSeq);
783
791
  const merged = mergeArchiveTailEvents(drainedArchiveEvents, filtered);
784
792
  const limit = positiveInteger(options.limit);
785
- const events = limit !== undefined ? merged.slice(0, limit) : merged;
793
+ // FS3 (fix): seq-sort before the limit slice — see cursorResultFromEvents.
794
+ // Sort ONLY under a limit (no-limit keeps the historical file-order
795
+ // contract; nothing is skippable when everything is delivered).
796
+ const events =
797
+ limit !== undefined ? [...merged].sort((a, b) => (a.metadata?.seq ?? 0) - (b.metadata?.seq ?? 0)).slice(0, limit) : merged;
786
798
  const returnedMaxSeq = events.reduce((max, event) => Math.max(max, event.metadata?.seq ?? 0), sinceSeq);
787
799
  // BR-08: `read.state.byteOffset` sits past the ENTIRE delta read above —
788
800
  // including the events the `limit` slice just dropped. Handing that back