pi-crew 0.9.38 → 0.9.40

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.9.38",
3
+ "version": "0.9.40",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -702,6 +702,58 @@ export function registerPiTeams(pi: ExtensionAPI): void {
702
702
  };
703
703
  });
704
704
 
705
+ /**
706
+ * Cleanup session resources WITHOUT aborting foreground team runs.
707
+ * Used during session switch (reason="resume"/"new"/"fork") to let foreground
708
+ * runs complete naturally when the session context is torn down.
709
+ */
710
+ const cleanupSessionResourcesOnly = (): void => {
711
+ if (cleanedUp) return;
712
+ cleanedUp = true;
713
+ if (preloadTimer) {
714
+ clearTimeout(preloadTimer);
715
+ preloadTimer = undefined;
716
+ }
717
+ crewRunWatchers?.closeAll();
718
+ crewRunWatchers = undefined;
719
+ userCrewWatchers?.closeAll();
720
+ userCrewWatchers = undefined;
721
+ stopSessionBoundSubagents();
722
+ // P0 fix: Do NOT abort foreground team runs on session switch.
723
+ // Foreground team runs run in the same process as the session; they naturally clean up
724
+ // when the session context is torn down. Only subagents need explicit abort on switch.
725
+ // Foreground runs will be aborted by cleanupRuntime() during actual session shutdown.
726
+ crewScheduler?.stop();
727
+ stopAsyncRunNotifier(notifierState);
728
+
729
+ // P0: Purge all stale active-run-index entries on session cleanup.
730
+ purgeStaleActiveRunIndexSyncIfLoaded();
731
+
732
+ stopCrewWidget(currentCtx, widgetState, currentCtx ? loadConfig(currentCtx.cwd).config.ui : undefined);
733
+ clearPiCrewPowerbar(pi.events);
734
+ disposePowerbarCoalescer();
735
+ disposeObservability(observabilityState, cleanedUp);
736
+ lifecycleState.deliveryCoordinator?.dispose();
737
+ clearHooksScoped();
738
+ uninstallCrewGlobalRegistry();
739
+ lifecycleState.overflowTracker?.dispose();
740
+ lifecycleState.deliveryCoordinator = undefined;
741
+ lifecycleState.overflowTracker = undefined;
742
+ manifestCache.dispose();
743
+ runSnapshotCache.dispose?.();
744
+ clearProjectRootCache();
745
+ renderScheduler?.dispose();
746
+ renderScheduler = undefined;
747
+ autoRecoveryLast.clear();
748
+ disposeNotifications(lifecycleState);
749
+ rpcHandle?.unsubscribe();
750
+ rpcHandle = undefined;
751
+ disposeI18n();
752
+ sessionGeneration += 1;
753
+ currentCtx = undefined;
754
+ if (globalStore[runtimeCleanupStoreKey] === cleanupSessionResourcesOnly) delete globalStore[runtimeCleanupStoreKey];
755
+ };
756
+
705
757
  const cleanupRuntime = (): void => {
706
758
  if (cleanedUp) return;
707
759
  cleanedUp = true;
@@ -774,6 +826,20 @@ export function registerPiTeams(pi: ExtensionAPI): void {
774
826
  };
775
827
  globalStore[runtimeCleanupStoreKey] = cleanupRuntime;
776
828
 
829
+ // P0 fix: Check session_shutdown reason to determine whether to abort foreground runs.
830
+ // - reason="quit" or "reload": Actual shutdown — abort foreground runs.
831
+ // - reason="resume"/"new"/"fork": Session switch — let foreground runs complete.
832
+ pi.on("session_shutdown", (event) => {
833
+ const reason = typeof event === "object" && event !== null && "reason" in event ? (event as { reason: string }).reason : undefined;
834
+ if (reason === "quit" || reason === "reload") {
835
+ // Actual shutdown — abort foreground runs and cleanup everything
836
+ cleanupRuntime();
837
+ } else {
838
+ // Session switch (resume/new/fork) — cleanup resources but preserve foreground runs
839
+ cleanupSessionResourcesOnly();
840
+ }
841
+ });
842
+
777
843
  pi.on("session_start", (_event, ctx) => {
778
844
  runArtifactCleanup(ctx.cwd);
779
845
 
@@ -1385,7 +1451,6 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1385
1451
  stopAsyncRunNotifier(notifierState);
1386
1452
  stopSessionBoundSubagents();
1387
1453
  });
1388
- pi.on("session_shutdown", () => cleanupRuntime());
1389
1454
 
1390
1455
  // Phase 11a: Dynamic resource discovery — inject pi-crew skill paths.
1391
1456
  try {
@@ -14,7 +14,7 @@ import path from "node:path";
14
14
  import type { AgentConfig } from "../agents/agent-config.ts";
15
15
  import { writeArtifact } from "../state/artifact-store.ts";
16
16
  import { appendEventAsync } from "../state/event-log.ts";
17
- import { saveRunTasks, updateRunStatus } from "../state/state-store.ts";
17
+ import { saveRunTasksAsync, updateRunStatus } from "../state/state-store.ts";
18
18
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
19
19
  import type { WorkflowStep } from "../workflows/workflow-config.ts";
20
20
  import { runChildPi } from "./child-pi.ts";
@@ -62,7 +62,7 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
62
62
  }
63
63
  return t;
64
64
  });
65
- saveRunTasks(manifest, updatedTasks);
65
+ await saveRunTasksAsync(manifest, updatedTasks);
66
66
  await appendEventAsync(manifest.eventsPath, {
67
67
  type: "task.coalesced_dispatch_start",
68
68
  runId: manifest.runId,
@@ -87,7 +87,7 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
87
87
  heartbeat: t.heartbeat ?? createWorkerHeartbeat(t.id),
88
88
  };
89
89
  });
90
- saveRunTasks(manifest, updatedTasks);
90
+ await saveRunTasksAsync(manifest, updatedTasks);
91
91
 
92
92
  let rawOutput = "";
93
93
  let success = false;
@@ -98,7 +98,7 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
98
98
  // Heartbeat refresher: touch every task's heartbeat every 15s while the
99
99
  // worker is alive. Set `alive: true` explicitly so post-completion
100
100
  // staleness checks immediately recognize liveness.
101
- const heartbeatTimer = setInterval(() => {
101
+ const heartbeatTimer = setInterval(async () => {
102
102
  const now = new Date().toISOString();
103
103
  updatedTasks = updatedTasks.map((t) => {
104
104
  if (!taskIds.includes(t.id)) return t;
@@ -108,7 +108,7 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
108
108
  };
109
109
  });
110
110
  try {
111
- saveRunTasks(manifest, updatedTasks);
111
+ await saveRunTasksAsync(manifest, updatedTasks);
112
112
  } catch {
113
113
  // Run may have been pruned mid-dispatch — best-effort only.
114
114
  }
@@ -168,7 +168,7 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
168
168
  resultArtifact,
169
169
  };
170
170
  });
171
- saveRunTasks(manifest, updatedTasks);
171
+ await saveRunTasksAsync(manifest, updatedTasks);
172
172
  let updatedManifest: TeamRunManifest = {
173
173
  ...manifest,
174
174
  artifacts: mergeArtifacts([...manifest.artifacts, ...newArtifacts]),
@@ -70,7 +70,7 @@ function checkResultFile(manifest: TeamRunManifest, tasks: TeamTaskState[]): { f
70
70
  // All tasks are terminal but manifest status was not updated — repair it.
71
71
  // Derive the run status from task outcomes instead of blindly marking
72
72
  // "completed": a run where every task failed/cancelled is NOT a success.
73
- const hasFailed = tasks.some((t) => t.status === "failed");
73
+ const hasFailed = tasks.some((t) => t.status === "failed" || t.status === "needs_attention");
74
74
  const onlyCancelledOrSkipped = tasks.every((t) => t.status === "cancelled" || t.status === "skipped");
75
75
  manifest.status = hasFailed ? "failed" : onlyCancelledOrSkipped ? "cancelled" : "completed";
76
76
  // Persist manifest status change immediately to make checkResultFile self-contained.
@@ -79,6 +79,29 @@ function isLockHolderAlive(filePath: string): boolean {
79
79
  * Returns `{ canSteal: true }` if the lock is stale OR the holder is dead
80
80
  * (safe to forcibly remove); `{ canSteal: false }` if it is fresh AND held by
81
81
  * a live process (must keep waiting).
82
+ *
83
+ * ## EPERM Handling (Accepted Risk)
84
+ *
85
+ * When `process.kill(pid, 0)` returns EPERM, it means the holder process
86
+ * EXISTS but we lack permission to signal it. We treat this as "not alive"
87
+ * (stealable) because:
88
+ *
89
+ * 1. **Blocking indefinitely is worse** — on shared systems, a holder running
90
+ * under a different user/permission context would block us forever.
91
+ * 2. **EPERM requires elevated privileges** — on single-user workstations
92
+ * (the typical pi-crew environment), EPERM is rare.
93
+ * 3. **Defense in depth** — the lock staleness check provides a secondary
94
+ * safeguard; we only steal if the lock is also stale.
95
+ *
96
+ * **Trade-off:** On multi-user systems, this could theoretically allow two
97
+ * processes in the critical section simultaneously if: (a) holder is alive
98
+ * under different permissions, AND (b) our steal attempt races with holder's
99
+ * release. The practical risk is low because:
100
+ * - Lock holders typically complete quickly
101
+ * - Stale timeout provides a safety window
102
+ * - Critical sections are designed to tolerate rare races
103
+ *
104
+ * See also: SECURITY-ISSUES.md SEC-008 for documented acceptance.
82
105
  */
83
106
  function readLockSnapshot(filePath: string, staleMs: number): { canSteal: boolean } {
84
107
  let stat: fs.Stats | undefined;
@@ -203,6 +203,9 @@ function validateRunManifestPaths(cwd: string, runId: string, manifest: TeamRunM
203
203
  }
204
204
 
205
205
  export function createRunPaths(cwd: string, runId = createRunId()): RunPaths {
206
+ if (!cwd || typeof cwd !== "string") {
207
+ throw new Error(`Invalid cwd: ${cwd}`);
208
+ }
206
209
  assertSafePathId("runId", runId);
207
210
  const baseRoot = scopeBaseRoot(cwd);
208
211
  const stateRoot = resolveContainedRelativePath(path.join(baseRoot, DEFAULT_PATHS.state.runsSubdir), runId, "runId");