pi-crew 0.9.61 → 0.9.64
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 +99 -0
- package/README.md +1 -0
- package/agents/critic.md +1 -1
- package/agents/explorer.md +1 -1
- package/agents/planner.md +1 -1
- package/agents/reviewer.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/test-engineer.md +1 -1
- package/agents/writer.md +1 -1
- package/dist/index.mjs +256 -81
- package/package.json +5 -2
- package/src/agents/agent-config.ts +4 -0
- package/src/agents/agent-serializer.ts +1 -0
- package/src/agents/discover-agents.ts +8 -0
- package/src/config/role-tools.ts +49 -1
- package/src/extension/notification-router.ts +25 -0
- package/src/extension/registration/lifecycle-handlers.ts +47 -6
- package/src/extension/registration/lifecycle.ts +13 -7
- package/src/prompt/prompt-runtime.ts +6 -0
- package/src/prompt/scratchpad-lifecycle.ts +605 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +42 -1
- package/src/runtime/child-pi/child-pi.ts +5 -0
- package/src/runtime/live-session/live-session-runtime.ts +12 -1
- package/src/runtime/model/pi-args.ts +1 -1
- package/src/runtime/model/session-model.ts +76 -1
- package/src/runtime/recovery/crash-recovery.ts +1 -1
- package/src/runtime/scratchpad/README.md +184 -0
- package/src/runtime/scratchpad/engine.ts +610 -0
- package/src/runtime/scratchpad/guest.ts +360 -0
- package/src/runtime/scratchpad/index.ts +22 -0
- package/src/runtime/scratchpad/protocol.ts +88 -0
- package/src/runtime/scratchpad/snapshot-lookup.ts +74 -0
- package/src/runtime/scratchpad/transform.ts +363 -0
- package/src/runtime/task-runner/child-executor.ts +48 -31
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
import { readEnabledModelsPatterns } from "../model/model-scope.ts";
|
|
27
27
|
import { isLiveSessionRuntimeAvailable } from "../model/runtime-resolver.ts";
|
|
28
28
|
import { awaitRuntimeWarmup } from "../model/runtime-warmup.ts";
|
|
29
|
+
import { liveAgentContext, registerLiveAgentModel, unregisterLiveAgentModel } from "../model/session-model.ts";
|
|
29
30
|
import { eventToSidechainType, sidechainOutputPath, writeSidechainEntry } from "../output/sidechain-output.ts";
|
|
30
31
|
// NOTE: buildMemoryBlock is intentionally NOT imported here. The agent memory
|
|
31
32
|
// block is injected via renderTaskPrompt().full (the USER prompt), which is
|
|
@@ -705,6 +706,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
705
706
|
});
|
|
706
707
|
const resolvedModel =
|
|
707
708
|
modelFromRegistry(input.modelRegistry, modelRouting.candidates[0] ?? modelRouting.requested) ?? input.parentModel;
|
|
709
|
+
const resolvedModelRef = modelRefToString(resolvedModel) ?? modelRouting.candidates[0];
|
|
708
710
|
// Surface a warning when the caller's requested model was silently replaced.
|
|
709
711
|
if (modelRouting.droppedRequested) {
|
|
710
712
|
appendEventFireAndForget(input.manifest.eventsPath, {
|
|
@@ -839,6 +841,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
839
841
|
appendEvent,
|
|
840
842
|
input.manifest.eventsPath,
|
|
841
843
|
);
|
|
844
|
+
registerLiveAgentModel(agentId, resolvedModelRef ?? "");
|
|
842
845
|
streamOut = createStreamingOutput(input.manifest, input.task.id);
|
|
843
846
|
let controlCursor: LiveAgentControlCursor = { offset: 0 };
|
|
844
847
|
const seenControlRequestIds = new Set<string>();
|
|
@@ -988,7 +991,9 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
988
991
|
// Phase 3: Wrap session.prompt with timeout for graceful cancellation
|
|
989
992
|
const sessionTimeoutMs = DEFAULT_LIVE_SESSION.responseTimeoutMs;
|
|
990
993
|
try {
|
|
991
|
-
await
|
|
994
|
+
await liveAgentContext.run({ agentId, modelRef: resolvedModelRef ?? "" }, () =>
|
|
995
|
+
promptWithTimeout(session!, effectivePrompt, sessionTimeoutMs, "Live-session"),
|
|
996
|
+
);
|
|
992
997
|
} catch (promptError) {
|
|
993
998
|
const msg = promptError instanceof Error ? promptError.message : String(promptError);
|
|
994
999
|
// P7: fire-and-forget — return value not needed.
|
|
@@ -1181,6 +1186,12 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
1181
1186
|
error: message,
|
|
1182
1187
|
};
|
|
1183
1188
|
} finally {
|
|
1189
|
+
// Unregister the live-agent model FIRST: a synchronous Map.delete that must run on
|
|
1190
|
+
// every exit path. If skipped (e.g. terminateLiveAgent throws on session.abort()),
|
|
1191
|
+
// hasActiveLiveAgents() stays true for the process lifetime and permanently disables
|
|
1192
|
+
// quota attribution (priority-2 skip) — including the main session's own tracking.
|
|
1193
|
+
// (Review finding H1/M1.)
|
|
1194
|
+
unregisterLiveAgentModel(agentId);
|
|
1184
1195
|
// H6: Unsubscribe listeners FIRST before clearing timer to prevent race
|
|
1185
1196
|
unsubscribe?.();
|
|
1186
1197
|
unsubscribeControlRealtime?.();
|
|
@@ -31,7 +31,7 @@ const createdTempDirs = new Set<string>();
|
|
|
31
31
|
* /tmp directory. Uses `userPiRoot()` so the path stays consistent with
|
|
32
32
|
* the rest of pi-crew (respects PI_TEAMS_HOME / PI_CODING_AGENT_DIR).
|
|
33
33
|
*/
|
|
34
|
-
function getPiTempBase(): string {
|
|
34
|
+
export function getPiTempBase(): string {
|
|
35
35
|
return path.join(userPiRoot(), "tmp");
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -17,8 +17,10 @@
|
|
|
17
17
|
* the pi events; the spawn paths read it through {@link resolveParentModel}.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
20
21
|
import type { RunModelContext } from "../../state/types.ts";
|
|
21
|
-
import {
|
|
22
|
+
import { logInternalError } from "../../utils/internal-error.ts";
|
|
23
|
+
import { availableModelInfosFromRegistry, modelRefToString, providerOfModelRef } from "./model-fallback.ts";
|
|
22
24
|
|
|
23
25
|
export type SessionModelSource = "model_select" | "session_start" | "none";
|
|
24
26
|
|
|
@@ -31,6 +33,78 @@ interface SessionModelState {
|
|
|
31
33
|
|
|
32
34
|
const state: SessionModelState = { source: "none" };
|
|
33
35
|
|
|
36
|
+
// --- Live-session per-agent quota attribution ---
|
|
37
|
+
//
|
|
38
|
+
// In the opt-in `live-session` runtime, multiple in-process subagents share
|
|
39
|
+
// this ONE module-scoped tracker. The `after_provider_response` event carries
|
|
40
|
+
// no sessionId/model field, so the global `currentSessionModel()` returns the
|
|
41
|
+
// MAIN session's model regardless of which in-process agent actually produced
|
|
42
|
+
// the response. That mis-attributes quota (e.g. a provider-B 429 written under
|
|
43
|
+
// provider-A's key).
|
|
44
|
+
//
|
|
45
|
+
// AsyncLocalStorage propagates each live agent's known model through the
|
|
46
|
+
// async call chain. `resolveProviderForResponse()` checks it first, then falls
|
|
47
|
+
// back to a guard (skip attribution when live agents are active but context
|
|
48
|
+
// is absent — prevents contamination), then the original global tracker (the
|
|
49
|
+
// default child-process path, unchanged).
|
|
50
|
+
|
|
51
|
+
/** Per-agent async context for live-session quota attribution. */
|
|
52
|
+
export const liveAgentContext = new AsyncLocalStorage<{ agentId: string; modelRef: string }>();
|
|
53
|
+
|
|
54
|
+
/** Registered live-session agent models (agentId → "provider/id"). */
|
|
55
|
+
const liveAgentModels = new Map<string, string>();
|
|
56
|
+
|
|
57
|
+
// Cap the tracker to prevent unbounded growth if a caller registers an agent
|
|
58
|
+
// but fails to unregister it (e.g. a crashed/disposed live agent). Matches the
|
|
59
|
+
// precedent in live-agent-manager.ts (MAX_LIVE_AGENTS). When at cap, evict the
|
|
60
|
+
// oldest insertion (Map preserves insertion order); a leaked entry also pins
|
|
61
|
+
// hasActiveLiveAgents()=true, so bounding it matters beyond raw memory.
|
|
62
|
+
const MAX_LIVE_AGENT_MODELS = 5_000;
|
|
63
|
+
|
|
64
|
+
/** Record a live-session agent's resolved model for quota attribution. */
|
|
65
|
+
export function registerLiveAgentModel(agentId: string, model: string): void {
|
|
66
|
+
if (liveAgentModels.size >= MAX_LIVE_AGENT_MODELS && !liveAgentModels.has(agentId)) {
|
|
67
|
+
const oldestKey = liveAgentModels.keys().next().value;
|
|
68
|
+
if (oldestKey !== undefined) {
|
|
69
|
+
logInternalError(
|
|
70
|
+
"session-model.liveAgentModels.cap",
|
|
71
|
+
new Error(`liveAgentModels at cap ${MAX_LIVE_AGENT_MODELS}; evicting oldest ${oldestKey}`),
|
|
72
|
+
);
|
|
73
|
+
liveAgentModels.delete(oldestKey);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
liveAgentModels.set(agentId, model);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Remove a live-session agent's model (called in the finally block). */
|
|
80
|
+
export function unregisterLiveAgentModel(agentId: string): void {
|
|
81
|
+
liveAgentModels.delete(agentId);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Whether any live-session agents are currently registered. */
|
|
85
|
+
export function hasActiveLiveAgents(): boolean {
|
|
86
|
+
return liveAgentModels.size > 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve the provider for an `after_provider_response` event.
|
|
91
|
+
*
|
|
92
|
+
* Priority:
|
|
93
|
+
* 1. Async context from the live-session agent that issued the request
|
|
94
|
+
* (correct per-agent attribution — pi-crew knows each agent's model).
|
|
95
|
+
* 2. Live agents are active but the context didn't propagate → skip
|
|
96
|
+
* attribution entirely (return undefined) to PREVENT cross-agent
|
|
97
|
+
* contamination.
|
|
98
|
+
* 3. No live agents (default child-process runtime) → original behavior:
|
|
99
|
+
* attribute to the global session model's provider.
|
|
100
|
+
*/
|
|
101
|
+
export function resolveProviderForResponse(): string | undefined {
|
|
102
|
+
const ctx = liveAgentContext.getStore();
|
|
103
|
+
if (ctx) return providerOfModelRef(ctx.modelRef);
|
|
104
|
+
if (hasActiveLiveAgents()) return undefined;
|
|
105
|
+
return providerOfModelRef(currentSessionModel());
|
|
106
|
+
}
|
|
107
|
+
|
|
34
108
|
/**
|
|
35
109
|
* Record the model the main session is running. Accepts pi's `Model` object
|
|
36
110
|
* (`{ provider, id }`) or a `"provider/id"` string; anything unrecognized is
|
|
@@ -132,4 +206,5 @@ export function __test_resetSessionModel(): void {
|
|
|
132
206
|
state.thinking = undefined;
|
|
133
207
|
state.source = "none";
|
|
134
208
|
state.updatedAt = undefined;
|
|
209
|
+
liveAgentModels.clear();
|
|
135
210
|
}
|
|
@@ -98,7 +98,7 @@ export function readManifestWithTransientRetry(manifestPath: string, maxRetries
|
|
|
98
98
|
throw new Error(`unreachable: readManifestWithTransientRetry exhausted for ${manifestPath}`);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
function shouldRecoverTask(task: TeamTaskState, deadMs: number): boolean {
|
|
101
|
+
export function shouldRecoverTask(task: TeamTaskState, deadMs: number): boolean {
|
|
102
102
|
if (task.status !== "running") return false;
|
|
103
103
|
if (!task.heartbeat) return true;
|
|
104
104
|
return task.heartbeat.alive === false || isWorkerHeartbeatStale(task.heartbeat, deadMs);
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# Spike go/no-go — pi-rlm → Node port (pattern 01+04+05+08+09)
|
|
2
|
+
|
|
3
|
+
> **Kết quả: ✅ GO** — 17/17 test GREEN, spawn subprocess Node thật (không mock, không Bun).
|
|
4
|
+
> Ngày: 2026-08-08. Spec: `../../rlm-apply-pi-crew.md` mục 5 + 7.1.
|
|
5
|
+
|
|
6
|
+
## Mục đích
|
|
7
|
+
|
|
8
|
+
Chứng minh 8 pattern FLAGSHIP của pi-rlm port sang Node thuần chạy được TRƯỚC khi đầu tư full FLAGSHIP. 2 invariant quyết định:
|
|
9
|
+
|
|
10
|
+
- **(a) Bindings survive mid-cell failure** (pattern 05): cell 1 `throw` giữa chừng → cell sau vẫn thấy biến đã gán trước throw.
|
|
11
|
+
- **(b) Namespace revives across process boundary** (pattern 08+09): engine 1 snapshot → kill → engine 2 (process mới) restore → cell mới đọc được biến.
|
|
12
|
+
|
|
13
|
+
## Cấu trúc
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
src/runtime/scratchpad/
|
|
17
|
+
├── protocol.ts (88 dòng) — fd3 + nonce + envelope, port 1:1 từ pi-rlm
|
|
18
|
+
├── transform.ts (363 dòng) — esbuild transformSync (strip) + acorn parse, decl→assignment, trailing-expr→setResult
|
|
19
|
+
├── guest.ts (330 dòng) — namespace proxy + with(SCOPE) + v8.serialize + AsyncLocalStorage, KHÔNG Bun
|
|
20
|
+
├── engine.ts (564 dòng) — EngineManager host, spawn(process.execPath, [--experimental-strip-types, guest.ts])
|
|
21
|
+
└── index.ts (22 dòng) — barrel
|
|
22
|
+
|
|
23
|
+
test/runtime/scratchpad/
|
|
24
|
+
├── protocol.test.ts (72 dòng)
|
|
25
|
+
├── transform.test.ts (59 dòng)
|
|
26
|
+
└── engine.spike.test.ts (126 dòng) — 2 invariant + phụ trợ, spawn subprocess thật
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Cách chạy
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
cd /home/bom/source/my_pi/pi-crew
|
|
33
|
+
node scripts/test-runner.mjs --test-force-exit 'test/runtime/scratchpad/**/*.test.ts'
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Kết quả
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
# tests 17
|
|
40
|
+
# pass 17
|
|
41
|
+
# fail 0
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Port Node quan trọng (không Bun)
|
|
45
|
+
|
|
46
|
+
| pi-rlm (Bun) | port Node | xác nhận |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `bun:jsc serialize/deserialize` | `node:v8` serialize/deserialize | ✅ guest.ts:32, snapshot/restore pass invariant (b) |
|
|
49
|
+
| `Bun.Transpiler` (DCE off) | `esbuild` transformSync + acorn | ✅ transform.ts, trailing-expr không bị drop |
|
|
50
|
+
| `Bun.inspect` | `node:util` inspect | ✅ guest.ts:31 |
|
|
51
|
+
| `spawn("bun", ["run", guest])` | `spawn(process.execPath, ["--experimental-strip-types", guest])` | ✅ engine.ts:173 |
|
|
52
|
+
| `Bun.$` guard / host bridge | **bỏ** (không cần cho spike) | — |
|
|
53
|
+
|
|
54
|
+
## Kết luận
|
|
55
|
+
|
|
56
|
+
**GO** — FLAGSHIP được xanh-light. Bước tiếp: FLAGSHIP Phase 1 (tool `execute` opt-in per role + snapshot vào artifact-store), rồi Phase 2 (crash-resume trong retry loop).
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
# Phase 2 — Crash-Resume (cross-attempt restore)
|
|
61
|
+
|
|
62
|
+
Phase 2 closes the crash-resume loop: a worker attempt N+1 (retry / crash-recovery
|
|
63
|
+
re-queue / manual re-run) automatically revives the namespace from the previous
|
|
64
|
+
attempt's snapshot, **without the model knowing** (besides a one-line notice).
|
|
65
|
+
|
|
66
|
+
## Flow
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
attempt N (scratchpad worker)
|
|
70
|
+
└─ execute → EngineManager → guest namespace
|
|
71
|
+
└─ flush (debounce / shutdown-quit / post-ok) → writeArtifact REDACTED
|
|
72
|
+
→ artifactsRoot/scratchpad/<taskId>.attempt-<i>.snapshot.json
|
|
73
|
+
│ worker dies / fails / cancelled
|
|
74
|
+
▼
|
|
75
|
+
attempt N+1 spawn (prepareSpawnContext — the single choke point)
|
|
76
|
+
└─ findLatestScratchpadSnapshot(artifactsRoot, taskId) ← snapshot-lookup.ts
|
|
77
|
+
└─ latest MTIME wins (model-fallback `i` resets each retry round → number is
|
|
78
|
+
NOT write-order; tie-break: lowest attempt = newest round)
|
|
79
|
+
└─ env PI_CREW_SCRATCHPAD_RESTORE (+ RESTORE_MTIME hint)
|
|
80
|
+
▼
|
|
81
|
+
attempt N+1 worker (scratchpad-lifecycle.ts)
|
|
82
|
+
└─ FIRST execute call → re-validate at READ time (D10) → restoreState →
|
|
83
|
+
notice "[scratchpad] restored N vars from attempt-K; restored:[...]; failed:[...]"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Env keys (parent → worker, set in `prepareSpawnContext` scratchpad gate)
|
|
87
|
+
|
|
88
|
+
| Key | Direction | Purpose |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `PI_CREW_SCRATCHPAD` | parent→worker | "1" arms the execute tool (dormant gate) |
|
|
91
|
+
| `PI_CREW_TASK_ID` | parent→worker | snapshot relativePath provenance |
|
|
92
|
+
| `PI_CREW_ATTEMPT` | parent→worker | model-fallback index (per-attempt suffix) |
|
|
93
|
+
| `PI_CREW_ARTIFACTS_ROOT` | parent→worker | writeArtifact root |
|
|
94
|
+
| `PI_CREW_SCRATCHPAD_SNAPSHOT` | parent→worker | WRITE target (raw temp, never in artifacts) |
|
|
95
|
+
| `PI_CREW_SCRATCHPAD_RESTORE` | parent→worker | **Phase 2**: READ source (redacted artifact) |
|
|
96
|
+
| `PI_CREW_SCRATCHPAD_RESTORE_MTIME` | parent→worker | **Phase 2**: swap-detection HINT (forgeable, not authn) |
|
|
97
|
+
| `PI_CREW_KIND` / `PI_CREW_PARENT_PID` / `PI_CREW_GUEST` | engine→guest | **Phase 2 (D5)**: guest reports the WORKER pid (not the leader's) so an orphaned guest is flagged by the zombie scanner |
|
|
98
|
+
|
|
99
|
+
## Guards (D1–D13)
|
|
100
|
+
|
|
101
|
+
- **D1/D1b'** lookup at spawn, latest mtime, tie-break lowest attempt.
|
|
102
|
+
- **D3** restore once per session, lazy on first execute (D7 invariant kept).
|
|
103
|
+
- **D4** redacted secret → literal `"***"` placeholder (guest special-case; base64
|
|
104
|
+
of a real value is never `"***"`).
|
|
105
|
+
- **D5/MAJOR-S1** production wiring: `getScratchpadEngine` overrides
|
|
106
|
+
`PI_CREW_PARENT_PID=worker pid` (pure inheritance leaves guests LIVE forever).
|
|
107
|
+
- **D6** cap 4 MiB two-sided: write-side raw byteLength (trim failed→50 only when
|
|
108
|
+
over cap); read-side file size + guest per-var 256 KiB.
|
|
109
|
+
- **D10** restore path re-validated at READ time (TOCTOU): containment +
|
|
110
|
+
filename pattern + lstat regular + size + mtime pin.
|
|
111
|
+
- **D11** restore fail-open: any failure logs + continues on an empty namespace.
|
|
112
|
+
- **D12** scan strict (lstat/pattern/regular) — cross-agent poisoning is NOT a new
|
|
113
|
+
trust boundary (same-uid team worker already writes artifacts); notice lists
|
|
114
|
+
var names so the model re-verifies.
|
|
115
|
+
- **D13** base64 round-trip check before deserialize (flat redaction can inject
|
|
116
|
+
`"***"` into a valid payload → silent corruption → failed[]).
|
|
117
|
+
|
|
118
|
+
## Threat model (Phase 2 additions — defense-in-depth within the same-uid boundary)
|
|
119
|
+
|
|
120
|
+
- **v8.deserialize of restore content is unauthenticated** (no HMAC). Bounded by
|
|
121
|
+
the 4 MiB file cap + 256 KiB per-var cap + same-uid artifact dir. A planted
|
|
122
|
+
snapshot with a crafted v8 blob can run deserialize gadgets in the guest — but
|
|
123
|
+
the guest already runs at full worker trust (it holds provider keys + broker
|
|
124
|
+
token), so this does not cross the existing boundary. An HMAC over the payload
|
|
125
|
+
is a Phase 2.5/3 hardening if artifacts ever land in a shared location.
|
|
126
|
+
- **Secret-at-rest under a benign key name** persists as base64 for the run's
|
|
127
|
+
retention (structural redaction is key-name based, best-effort). A sanitized-
|
|
128
|
+
namespace policy is a Phase 2.5 concern.
|
|
129
|
+
- **Restore-source trust = same as any artifact**: a same-uid team worker can
|
|
130
|
+
plant a matching-named snapshot; restore removes the "model chooses to read"
|
|
131
|
+
step, so the notice deliberately lists the revived var names.
|
|
132
|
+
|
|
133
|
+
## CI note
|
|
134
|
+
|
|
135
|
+
The `test/runtime/scratchpad/*` spike tests (incl. the D1/D4/D6/D13 restore pins)
|
|
136
|
+
are NOT wired into `npm run test:unit` (which globs `test/unit/**`). Run them
|
|
137
|
+
directly per the Phase runbook: `node scripts/test-runner.mjs --test-force-exit
|
|
138
|
+
test/runtime/scratchpad/restore-e2e.spike.test.ts`.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
# Phase 3 — Cancellation: kill-and-restore (verified) + atomic snapshot
|
|
143
|
+
|
|
144
|
+
## Kill-and-restore ALREADY WORKS (no new handler needed)
|
|
145
|
+
|
|
146
|
+
A worker killed via SIGTERM is flushed by the **existing** F3 quit-path — no
|
|
147
|
+
Phase 3 worker-side handler is required. The chain (verified against installed
|
|
148
|
+
pi 0.80.3/0.84.1):
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
parent abort() / timeout / drain
|
|
152
|
+
└─ child-pi killProcessTree → group SIGTERM
|
|
153
|
+
└─ pi print-mode signal handler (modes/print-mode.js:31-44 registerSignalHandlers)
|
|
154
|
+
└─ disposeRuntime() → runtimeHost.dispose()
|
|
155
|
+
└─ emitSessionShutdownEvent({ reason: "quit" }) [awaited]
|
|
156
|
+
└─ scratchpad-lifecycle F3 handler (reason === "quit" gate passes)
|
|
157
|
+
└─ performShutdownFlush: snapshot → writeArtifact (redact) → engine.kill
|
|
158
|
+
└─ process.exit(143)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The next attempt then restores from this F3 flush (Phase 2), closing the ≤1.5s
|
|
162
|
+
debounce gap. (pi-crew's own `extension/crew-cleanup.ts:98` also installs a
|
|
163
|
+
worker SIGTERM handler for child-process cleanup; both coexist.)
|
|
164
|
+
|
|
165
|
+
## Phase 3 hardening
|
|
166
|
+
|
|
167
|
+
- **Atomic `snapshotState` (D2')**: the raw snapshot temp is now written via
|
|
168
|
+
`temp + rename` (same dir → atomic). Eliminates the theoretical torn-write
|
|
169
|
+
race between the debounce timer and the F3 quit flush.
|
|
170
|
+
- **EngineBusyError: SKIPPED** (confirmed). The spike dropped it (engine.ts:15);
|
|
171
|
+
the engine serializes concurrent `execute` calls via a FIFO queue, and
|
|
172
|
+
ping-before-execute (`scratchpad-lifecycle.ts`) already detects a wedged guest.
|
|
173
|
+
Re-adding a busy-reject would break the queue contract for no new coverage.
|
|
174
|
+
- **Eviction is a non-scenario**: live agents are in-process SDK sessions
|
|
175
|
+
(`live-session-runtime.ts`), `abort()` is in-process (no SIGTERM, no worker
|
|
176
|
+
process). Scratchpad is never armed in the host process (gate requires
|
|
177
|
+
`PI_CREW_KIND=subagent`), so there is nothing to flush on eviction.
|
|
178
|
+
|
|
179
|
+
## Pin test
|
|
180
|
+
|
|
181
|
+
`test/runtime/scratchpad/sigterm-kill-restore.spike.test.ts` (gated
|
|
182
|
+
`PI_CREW_TEST_REAL_MODEL=1`) spawns a real `pi --mode json -p` worker, runs one
|
|
183
|
+
`execute` cell, sends SIGTERM inside the debounce window, and asserts an F3
|
|
184
|
+
artifact appears with a SIGTERM-time mtime. Skipped by default (CI-safe).
|