pi-crew 0.11.27 → 0.11.28

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.27",
3
+ "version": "0.11.28",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -49,6 +49,13 @@ export interface DwfCheckpointState {
49
49
  * Uses Record (not Map) for JSON serialization safety. Optional: legacy
50
50
  * checkpoints (and fresh states) omit it. */
51
51
  completedAgentCalls?: Record<string, AgentResult>;
52
+ /** R14-C3 (wave 7, 2026-10-20): dispatch-time run context fingerprint. On
53
+ * save the runner stamps {modelOverride, team}; on resume a mismatch with
54
+ * the CURRENT dispatch context emits dwf.resume_context_mismatch — replayed
55
+ * agent results were produced under a different model/team and the operator
56
+ * should know (fail-open with visibility; the completed-call cache is still
57
+ * intentional work-skip). Absent on legacy checkpoints → check skipped. */
58
+ dispatchContext?: { modelOverride?: string; team?: string };
52
59
  updatedAt: string;
53
60
  }
54
61
 
@@ -210,6 +210,36 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
210
210
  const dwfStore = new DwfStore(manifest.stateRoot);
211
211
  const resumedState = dwfStore.load();
212
212
  if (resumedState) {
213
+ // R14-C3 (wave 7): fingerprint check — a resume that changes the dispatch
214
+ // context (modelOverride/team) still replays the completed-call cache, but
215
+ // that replay now carries an operator-visible mismatch warning instead of
216
+ // silently passing off old-model results as current-model work.
217
+ const savedCtx2 = resumedState.dispatchContext;
218
+ const norm = (v: string | undefined): string | undefined => (v && v.length > 0 ? v : undefined);
219
+ if (savedCtx2 && norm(savedCtx2.modelOverride) !== norm(input.modelOverride)) {
220
+ await appendEventAsync(eventsPath, {
221
+ type: "dwf.resume_context_mismatch",
222
+ runId: manifest.runId,
223
+ data: {
224
+ axis: "modelOverride",
225
+ checkpoint: savedCtx2.modelOverride ?? null,
226
+ current: input.modelOverride ?? null,
227
+ },
228
+ message: `resume modelOverride differs from checkpoint (was ${savedCtx2.modelOverride ?? "default"}, now ${input.modelOverride ?? "default"}) — replayed agent results were produced under the checkpoint's model`,
229
+ });
230
+ }
231
+ if (savedCtx2 && norm(savedCtx2.team) !== norm(input.team?.name)) {
232
+ await appendEventAsync(eventsPath, {
233
+ type: "dwf.resume_context_mismatch",
234
+ runId: manifest.runId,
235
+ data: {
236
+ axis: "team",
237
+ checkpoint: savedCtx2.team ?? null,
238
+ current: input.team?.name ?? null,
239
+ },
240
+ message: `resume team differs from checkpoint (was ${savedCtx2.team ?? "default"}, now ${input.team?.name ?? "default"}) — replayed agent results were produced under the checkpoint's team`,
241
+ });
242
+ }
213
243
  await appendEventAsync(eventsPath, {
214
244
  type: "dwf.resumed",
215
245
  runId: manifest.runId,
@@ -228,6 +258,14 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
228
258
  const timeoutController = new AbortController();
229
259
  const combinedSignal = AbortSignal.any([signal, timeoutController.signal]);
230
260
 
261
+ // R14-C3 (wave 7): dispatch-time fingerprint stamped into every checkpoint
262
+ // save; the resume path above compares it against the CURRENT dispatch
263
+ // context and emits dwf.resume_context_mismatch on drift.
264
+ const dispatchContext: { modelOverride?: string; team?: string } = {
265
+ ...(input.modelOverride ? { modelOverride: input.modelOverride } : {}),
266
+ ...(input.team?.name ? { team: input.team.name } : {}),
267
+ };
268
+
231
269
  const ctx = makeWorkflowCtx(manifest, {
232
270
  concurrency: input.concurrency ?? workflow.maxConcurrency ?? 4,
233
271
  signal: combinedSignal,
@@ -241,9 +279,11 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
241
279
  resumedState,
242
280
  // round-18 P2-3: checkpoint after each ctx.agent() call so a crash between calls
243
281
  // leaves durable state. onCheckpoint captures the closure values at call time.
282
+ // R14-C3 (wave 7): dispatchContext (declared above the ctx) is stamped into
283
+ // every save so a later resume can compare against the current dispatch.
244
284
  onCheckpoint: (state) => {
245
285
  try {
246
- dwfStore.save(state);
286
+ dwfStore.save({ ...state, dispatchContext });
247
287
  } catch (error) {
248
288
  logInternalError("dynamic-workflow-runner.checkpoint-save", error, `runId=${manifest.runId}`);
249
289
  }
@@ -289,7 +289,15 @@ function validateExplicitBin(explicit: string): string | undefined {
289
289
  }
290
290
  } catch (e) {
291
291
  if (e instanceof Error && e.message.includes("allowed prefixes")) throw e;
292
- logInternalError("pi-spawn", e, "validateExplicitBin: unexpected realpathSync error", "error");
292
+ // R2-L-A2 (wave 7): the drop is now stated explicitly in the message — the
293
+ // operator sees that their explicit pin was IGNORED and which fallback ran,
294
+ // not just the raw realpath error.
295
+ logInternalError(
296
+ "pi-spawn",
297
+ e,
298
+ `validateExplicitBin: unexpected realpathSync error — explicit PI_TEAMS_PI_BIN='${resolved}' IGNORED, falling back to default pi resolution`,
299
+ "error",
300
+ );
293
301
  return undefined;
294
302
  }
295
303
  return resolved;
@@ -92,7 +92,12 @@ export class CrewScheduler {
92
92
  // update() (never add()), so a normal fire's stale nextRun cannot reach here.
93
93
  this.reportMissedCronOccurrences(job);
94
94
  if (job.enabled) this.arm(job);
95
- this.emit?.({ type: "added", job });
95
+ // R14-C4 (wave 7, 2026-10-20): arm() can mutate the stored entry BEFORE the
96
+ // added event fires (e.g. a past `once` job flips to enabled:false,
97
+ // lastStatus:"error" via update()). Emitting the ORIGINAL stale object
98
+ // confused event-order consumers (disk converged; the event stream didn't).
99
+ // Emit the post-arm truth from the map instead.
100
+ this.emit?.({ type: "added", job: this.jobs.get(job.id) ?? job });
96
101
  }
97
102
 
98
103
  remove(id: string): boolean {
@@ -189,6 +194,12 @@ export class CrewScheduler {
189
194
  private arm(job: ScheduledJob): void {
190
195
  if (this.timers.has(job.id)) return;
191
196
  if (job.scheduleType === "interval" && job.intervalMs) {
197
+ // R2-L-B10 (wave 7, documented design choice): interval jobs re-arm from
198
+ // FIRE time via the chained hop, but the fire() → update() → disarm/arm
199
+ // lifecycle only re-arms after the run COMPLETES — the effective period is
200
+ // intervalMs + run duration, NOT schedule-aligned wall-clock spacing.
201
+ // Deliberate (no overlap between occurrences of the same job); recorded
202
+ // here so the semantics are discoverable at the arm site.
192
203
  // 2026-09-22 spawn-storm root cause: this branch used
193
204
  // setInterval(fire, intervalMs). Node timers are 32-bit — a LEGAL long
194
205
  // interval (e.g. 90d = 7,776,000,000ms > 2^31-1) overflows and Node
@@ -234,6 +234,9 @@ export const TEAM_EVENT_TYPES = [
234
234
  "crew.task.retry_attempt",
235
235
  // Dynamic workflow resume
236
236
  "dwf.resumed",
237
+ // R14-C3 (wave 7): resume dispatch-context drift (modelOverride/team) —
238
+ // replayed agent results were produced under a different context.
239
+ "dwf.resume_context_mismatch",
237
240
  // Foreground interrupt
238
241
  "foreground.interrupt_requested",
239
242
  // Goal loop (P0/P1) additional outcomes
@@ -206,6 +206,16 @@ export function pruneExpiredArtifacts(descriptors: ArtifactDescriptor[], options
206
206
  if (!lstat.isDirectory()) throw new Error(`artifacts root is not a directory`);
207
207
  realAnchor = fs.realpathSync(options.artifactsRoot);
208
208
  } catch (error) {
209
+ // W7-C2 (2026-10-20): a MISSING anchor directory means the run's whole
210
+ // artifacts tree is already gone (age-based cleanup, an earlier prune, or
211
+ // operator removal) — there is nothing under it left to delete, so this is
212
+ // success-shaped silence, not a failure. Fail-closed behavior is unchanged
213
+ // (return 0, nothing deleted). Session start walks EVERY retained manifest,
214
+ // so warning here printed one line per stale run per boot (battery
215
+ // 2026-10-19 observation #4). Other anchor failures (symlink anchor,
216
+ // non-directory, permissions) stay visible warns.
217
+ const code = (error as NodeJS.ErrnoException).code;
218
+ if (code === "ENOENT") return 0;
209
219
  logInternalError(
210
220
  "artifact-store.prune-anchor",
211
221
  error instanceof Error ? error : new Error(String(error)),
@@ -1243,7 +1243,18 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
1243
1243
  let manifest: TeamRunManifest | undefined;
1244
1244
  let tasks: TeamTaskState[] | undefined;
1245
1245
  while (attempts < LOAD_MANIFEST_RETRY_LIMIT) {
1246
- const freshStat = fs.statSync(manifestPath);
1246
+ let freshStat: fs.Stats;
1247
+ try {
1248
+ freshStat = fs.statSync(manifestPath);
1249
+ } catch (error) {
1250
+ // R2-L-A1 (wave 7, 2026-10-20): the run dir can vanish between the
1251
+ // caller's listing and this load (crash-recovery sweeps, artifact
1252
+ // cleanup, operator rm). A thrown ENOENT aborted the WHOLE best-effort
1253
+ // sweep for the session; "cannot be loaded" is undefined — the loop's
1254
+ // own missing-file contract.
1255
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
1256
+ throw error;
1257
+ }
1247
1258
  // PERF (2026-08-24, Task 12 realized): after saveRunTasksCoalesced the
1248
1259
  // cache keeps the manifest half of the entry while zeroing only the
1249
1260
  // tasks stamps, so this slow path runs solely to refresh tasks. When the
@@ -1421,7 +1432,15 @@ export async function loadRunManifestByIdAsync(
1421
1432
  let tasks: TeamTaskState[] | undefined;
1422
1433
  let attempts = 0;
1423
1434
  while (attempts < LOAD_MANIFEST_RETRY_LIMIT) {
1424
- const freshStat = await fs.promises.stat(manifestPath);
1435
+ let freshStat: fs.Stats;
1436
+ try {
1437
+ freshStat = await fs.promises.stat(manifestPath);
1438
+ } catch (error) {
1439
+ // R2-L-A1 (wave 7): async twin — ENOENT mid-sweep returns undefined
1440
+ // instead of aborting the caller's best-effort sweep.
1441
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
1442
+ throw error;
1443
+ }
1425
1444
  // PERF (2026-08-24, Task 12 realized): async twin of the sync reuse —
1426
1445
  // after saveRunTasksCoalesced only the tasks stamps are zeroed, so when
1427
1446
  // the retained manifest stamps match the fresh stat (same verification
@@ -195,8 +195,20 @@ function startThemeSourceSubscription(theme: object, subscription: ThemeSourceSu
195
195
  subscription.unsubscribeSource = removeListener;
196
196
  return;
197
197
  }
198
+ // R2-L-B9 (wave 7, 2026-10-20): the poll closure previously held a STRONG
199
+ // ref to the theme — an abandoned subscription (consumer never calls the
200
+ // returned unsubscribe) kept the theme + subscription + callbacks alive
201
+ // forever with a permanent 1s poll. Hold only a WeakRef: once the theme is
202
+ // collected, the next tick self-terminates the interval (≤1s later), after
203
+ // which the closure chain is collectable.
204
+ const weakTheme = new WeakRef<object>(theme);
198
205
  subscription.pollTimer = setInterval(() => {
199
- const nextSignature = themeSignature(theme);
206
+ const live = weakTheme.deref();
207
+ if (!live) {
208
+ clearInterval(subscription.pollTimer);
209
+ return;
210
+ }
211
+ const nextSignature = themeSignature(live);
200
212
  if (nextSignature === subscription.lastSignature) return;
201
213
  subscription.lastSignature = nextSignature;
202
214
  emit();