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/CHANGELOG.md +26 -0
- package/dist/build-meta.json +11 -11
- package/dist/index.mjs +56 -7
- package/dist/index.mjs.map +2 -2
- package/package.json +1 -1
- package/src/extension/register.ts +66 -1
- package/src/runtime/run-coalesced-task-group.ts +6 -6
- package/src/runtime/stale-reconciler.ts +1 -1
- package/src/state/locks.ts +23 -0
- package/src/state/state-store.ts +3 -0
package/package.json
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/src/state/locks.ts
CHANGED
|
@@ -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;
|
package/src/state/state-store.ts
CHANGED
|
@@ -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");
|