pi-crew 0.9.28 → 0.9.30
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 +48 -0
- package/dist/build-meta.json +168 -101
- package/dist/index.mjs +921 -529
- package/dist/index.mjs.map +4 -4
- package/docs/REVIEW-FINDINGS-2026-07-CORE.md +139 -0
- package/docs/REVIEW-FINDINGS-2026-07.md +125 -0
- package/docs/bugs/bug-quota-display-truncation.md +223 -0
- package/docs/stories/README.md +3 -1
- package/docs/stories/US-DEPS-major-upgrade.md +62 -0
- package/package.json +4 -4
- package/src/config/config.ts +4 -0
- package/src/extension/crew-vibes/config.ts +1 -1
- package/src/extension/crew-vibes/footer.ts +292 -0
- package/src/extension/crew-vibes/index.ts +67 -52
- package/src/prompt/prompt-runtime.ts +149 -29
- package/src/runtime/async-runner.ts +3 -2
- package/src/runtime/child-pi.ts +53 -43
- package/src/runtime/crash-recovery.ts +4 -4
- package/src/runtime/cross-extension-rpc.ts +1 -1
- package/src/runtime/post-exit-stdio-guard.ts +22 -4
- package/src/runtime/skill-instructions.ts +2 -6
- package/src/runtime/stale-reconciler.ts +9 -4
- package/src/runtime/task-runner/state-helpers.ts +11 -5
- package/src/runtime/team-runner.ts +12 -16
- package/src/state/atomic-write.ts +53 -26
- package/src/state/event-log.ts +77 -24
- package/src/state/mailbox.ts +6 -3
- package/src/ui/pi-ui-compat.ts +11 -0
- package/src/utils/conflict-detect.ts +9 -3
- package/src/utils/internal-error.ts +5 -2
- package/src/utils/paths.ts +7 -1
- package/src/worktree/cleanup.ts +19 -0
- package/src/worktree/worktree-manager.ts +145 -34
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
1
|
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
5
|
+
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
3
6
|
|
|
4
7
|
export const PI_TEAMS_INHERIT_PROJECT_CONTEXT_ENV = "PI_TEAMS_INHERIT_PROJECT_CONTEXT";
|
|
5
8
|
export const PI_TEAMS_INHERIT_SKILLS_ENV = "PI_TEAMS_INHERIT_SKILLS";
|
|
@@ -12,6 +15,99 @@ const PROJECT_CONTEXT_HEADER = "\n\n# Project Context\n\nProject-specific instru
|
|
|
12
15
|
const SKILLS_HEADER = "\n\nThe following skills provide specialized instructions for specific tasks.";
|
|
13
16
|
const DATE_HEADER = "\nCurrent date:";
|
|
14
17
|
|
|
18
|
+
// ── FIX-02: Steering content sanitization limits ──────────────────────────
|
|
19
|
+
// Bounded to keep a malformed/malicious steer entry from blowing up the
|
|
20
|
+
// worker's prompt budget or smuggling control sequences into the agent.
|
|
21
|
+
const MAX_STEER_MESSAGE_LENGTH = 4096;
|
|
22
|
+
const MAX_STEER_MESSAGE_NEWLINES = 50;
|
|
23
|
+
// C0 control characters minus the printable whitespace (\t \n \r). These
|
|
24
|
+
// are the bytes most useful for ANSI escapes, terminal-control tricks, and
|
|
25
|
+
// NUL-injection attacks when steer content reaches the worker's UI.
|
|
26
|
+
const STEER_CONTROL_CHAR_PATTERN = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/;
|
|
27
|
+
|
|
28
|
+
export interface SteerSanitizeResult {
|
|
29
|
+
valid: boolean;
|
|
30
|
+
reason?: string;
|
|
31
|
+
message?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SteerEntry {
|
|
35
|
+
type?: string;
|
|
36
|
+
message?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Validate a single steering-file entry before forwarding it to
|
|
41
|
+
* `pi.sendMessage`. FIX-02: reject oversized payloads, excessive newlines,
|
|
42
|
+
* or control characters that could be used to confuse the worker UI.
|
|
43
|
+
*/
|
|
44
|
+
export function sanitizeSteerMessage(entry: SteerEntry): SteerSanitizeResult {
|
|
45
|
+
const message = entry.message;
|
|
46
|
+
if (typeof message !== "string" || message.length === 0) {
|
|
47
|
+
return { valid: false, reason: "missing-or-empty-message" };
|
|
48
|
+
}
|
|
49
|
+
if (message.length > MAX_STEER_MESSAGE_LENGTH) {
|
|
50
|
+
return { valid: false, reason: `message-too-long:${message.length}` };
|
|
51
|
+
}
|
|
52
|
+
const newlineCount = (message.match(/\n/g) ?? []).length;
|
|
53
|
+
if (newlineCount > MAX_STEER_MESSAGE_NEWLINES) {
|
|
54
|
+
return { valid: false, reason: `too-many-newlines:${newlineCount}` };
|
|
55
|
+
}
|
|
56
|
+
if (STEER_CONTROL_CHAR_PATTERN.test(message)) {
|
|
57
|
+
return { valid: false, reason: "contains-control-characters" };
|
|
58
|
+
}
|
|
59
|
+
return { valid: true, message };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── FIX-03: Steering file path containment validation ─────────────────────
|
|
63
|
+
// The steering file path is inherited from the parent via env, so we
|
|
64
|
+
// defensively re-validate it before the first read to catch symlink
|
|
65
|
+
// redirection or paths that escape the session's artifacts root.
|
|
66
|
+
export interface SteeringFileValidation {
|
|
67
|
+
valid: boolean;
|
|
68
|
+
reason?: string;
|
|
69
|
+
resolvedPath?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Validate `PI_CREW_STEERING_FILE` before first read. FIX-03:
|
|
74
|
+
* 1. `lstatSync` rejects a symlink at the steering file itself.
|
|
75
|
+
* 2. `resolveRealContainedPath` walks the ancestor chain with O_NOFOLLOW
|
|
76
|
+
* to reject any symlinked parent (e.g. a redirected `artifactsRoot`)
|
|
77
|
+
* and to verify the resolved path stays inside the derived artifacts
|
|
78
|
+
* root (`<artifactsRoot>/steering/<taskId>.jsonl` → `<artifactsRoot>`).
|
|
79
|
+
*
|
|
80
|
+
* Returns `{ valid: false }` with a reason on any violation. Callers must
|
|
81
|
+
* log + skip steering on failure rather than abort the worker.
|
|
82
|
+
*/
|
|
83
|
+
export function validateSteeringFile(steeringFile: string): SteeringFileValidation {
|
|
84
|
+
try {
|
|
85
|
+
const lst = fs.lstatSync(steeringFile);
|
|
86
|
+
if (lst.isSymbolicLink()) {
|
|
87
|
+
return { valid: false, reason: "steering-file-is-symlink" };
|
|
88
|
+
}
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const errCode = (error as NodeJS.ErrnoException).code;
|
|
91
|
+
if (errCode && errCode !== "ENOENT") {
|
|
92
|
+
return { valid: false, reason: `lstat-failed:${errCode}` };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Layout invariant from task-runner.ts: `steeringFile` is built as
|
|
96
|
+
// `<artifactsRoot>/steering/<taskId>.jsonl`. We don't trust the caller
|
|
97
|
+
// to pass `artifactsRoot`, so derive it as 2 levels up. `resolveRealContainedPath`
|
|
98
|
+
// then enforces both containment AND ancestor-symlink safety in one shot.
|
|
99
|
+
const artifactsRoot = path.resolve(steeringFile, "..", "..");
|
|
100
|
+
try {
|
|
101
|
+
const resolved = resolveRealContainedPath(artifactsRoot, steeringFile);
|
|
102
|
+
return { valid: true, resolvedPath: resolved };
|
|
103
|
+
} catch (error) {
|
|
104
|
+
return {
|
|
105
|
+
valid: false,
|
|
106
|
+
reason: `path-validation-failed:${error instanceof Error ? error.message : String(error)}`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
15
111
|
function readBooleanEnv(name: string): boolean | undefined {
|
|
16
112
|
const value = process.env[name];
|
|
17
113
|
if (value === undefined) return undefined;
|
|
@@ -83,43 +179,67 @@ export default function registerPiTeamsPromptRuntime(pi: ExtensionAPI): void {
|
|
|
83
179
|
// the active session via pi.sendMessage with deliverAs:"steer".
|
|
84
180
|
const steeringFile = process.env[PI_CREW_STEERING_FILE_ENV];
|
|
85
181
|
if (steeringFile) {
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
182
|
+
// FIX-03: validate the steering file path once before first read.
|
|
183
|
+
const validation = validateSteeringFile(steeringFile);
|
|
184
|
+
if (!validation.valid) {
|
|
185
|
+
logInternalError(
|
|
186
|
+
"prompt-runtime.steering-file-rejected",
|
|
187
|
+
new Error(validation.reason ?? "steering-file-validation-failed"),
|
|
188
|
+
`path=${steeringFile}`,
|
|
189
|
+
"warn",
|
|
190
|
+
);
|
|
191
|
+
} else {
|
|
192
|
+
const safeSteeringFile = validation.resolvedPath ?? steeringFile;
|
|
193
|
+
let lastOffset = 0;
|
|
194
|
+
const pollSteering = (): void => {
|
|
92
195
|
try {
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
196
|
+
const stat = fs.statSync(safeSteeringFile, { throwIfNoEntry: false });
|
|
197
|
+
if (!stat || stat.size <= lastOffset) return;
|
|
198
|
+
const fd = fs.openSync(safeSteeringFile, "r");
|
|
199
|
+
try {
|
|
200
|
+
const buf = Buffer.alloc(stat.size - lastOffset);
|
|
201
|
+
fs.readSync(fd, buf, 0, buf.length, lastOffset);
|
|
202
|
+
lastOffset = stat.size;
|
|
203
|
+
const lines = buf.toString("utf8").split("\n").filter(Boolean);
|
|
204
|
+
for (const line of lines) {
|
|
205
|
+
try {
|
|
206
|
+
const entry = JSON.parse(line) as SteerEntry;
|
|
207
|
+
if (entry.type !== "steer") continue;
|
|
208
|
+
// FIX-02: sanitize each steer entry before forwarding
|
|
209
|
+
// to pi.sendMessage. Reject oversized payloads,
|
|
210
|
+
// excessive newlines, and control characters.
|
|
211
|
+
const sanitized = sanitizeSteerMessage(entry);
|
|
212
|
+
if (!sanitized.valid || sanitized.message === undefined) {
|
|
213
|
+
logInternalError(
|
|
214
|
+
"prompt-runtime.steer-rejected",
|
|
215
|
+
new Error(sanitized.reason ?? "steer-sanitization-failed"),
|
|
216
|
+
`line-preview=${line.slice(0, 64)}`,
|
|
217
|
+
"warn",
|
|
218
|
+
);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
101
221
|
pi.sendMessage(
|
|
102
|
-
{ customType: "crew-steer", content:
|
|
222
|
+
{ customType: "crew-steer", content: sanitized.message, display: false },
|
|
103
223
|
{ deliverAs: "steer" },
|
|
104
224
|
);
|
|
225
|
+
} catch {
|
|
226
|
+
// Malformed line — skip
|
|
105
227
|
}
|
|
228
|
+
}
|
|
229
|
+
} finally {
|
|
230
|
+
try {
|
|
231
|
+
fs.closeSync(fd);
|
|
106
232
|
} catch {
|
|
107
|
-
|
|
233
|
+
/* already closed */
|
|
108
234
|
}
|
|
109
235
|
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
fs.closeSync(fd);
|
|
113
|
-
} catch {
|
|
114
|
-
/* already closed */
|
|
115
|
-
}
|
|
236
|
+
} catch {
|
|
237
|
+
// File doesn't exist yet or read error — will retry next tick
|
|
116
238
|
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
const timer = setInterval(pollSteering, 500);
|
|
122
|
-
timer.unref?.();
|
|
239
|
+
};
|
|
240
|
+
const timer = setInterval(pollSteering, 500);
|
|
241
|
+
timer.unref?.();
|
|
242
|
+
}
|
|
123
243
|
}
|
|
124
244
|
|
|
125
245
|
// ── Prompt rewriting (existing) ────────────────────────────────────────
|
|
@@ -3,7 +3,7 @@ import * as fs from "node:fs";
|
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
-
import { appendEvent } from "../state/event-log.ts";
|
|
6
|
+
import { appendEvent, appendEventAsync } from "../state/event-log.ts";
|
|
7
7
|
import type { TeamRunManifest } from "../state/types.ts";
|
|
8
8
|
import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
|
|
9
9
|
import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
|
|
@@ -257,7 +257,8 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
|
|
|
257
257
|
const loader = resolveTypeScriptLoader();
|
|
258
258
|
if (!loader) {
|
|
259
259
|
const message = buildLoaderUnavailableMessage(packageRootFromRuntime());
|
|
260
|
-
|
|
260
|
+
// FIX-08: use async event append to avoid sleepSync event-loop blocking.
|
|
261
|
+
await appendEventAsync(manifest.eventsPath, {
|
|
261
262
|
type: "async.failed",
|
|
262
263
|
runId: manifest.runId,
|
|
263
264
|
message,
|
package/src/runtime/child-pi.ts
CHANGED
|
@@ -80,6 +80,22 @@ function clearHardKillTimer(pid: number | undefined): void {
|
|
|
80
80
|
childHardKillTimers.delete(pid);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* B6: spawn taskkill and attach an 'error' listener. spawn() emits ENOENT/EACCES
|
|
85
|
+
* asynchronously via the 'error' event (not as a throw), so an unlistened spawn
|
|
86
|
+
* can crash the parent as an uncaught exception. taskkill is a standard Windows
|
|
87
|
+
* binary so this is defensive, but the listener keeps failures bounded.
|
|
88
|
+
*/
|
|
89
|
+
function spawnTaskkillSafe(pid: number): void {
|
|
90
|
+
const taskkillChild = spawn("taskkill", ["/pid", String(pid), "/t", "/f"], {
|
|
91
|
+
stdio: "ignore",
|
|
92
|
+
windowsHide: true,
|
|
93
|
+
});
|
|
94
|
+
taskkillChild.on("error", (err) => {
|
|
95
|
+
logInternalError("child-pi.taskkill-spawn-error", err instanceof Error ? err : new Error(String(err)), `pid=${pid}`);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
83
99
|
export function killProcessPid(pid: number): void {
|
|
84
100
|
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
85
101
|
try {
|
|
@@ -87,10 +103,7 @@ export function killProcessPid(pid: number): void {
|
|
|
87
103
|
// 3.8: Windows path uses taskkill /T /F (force kill the entire tree).
|
|
88
104
|
// taskkill itself can silently fail (PID gone, permission denied, etc.)
|
|
89
105
|
// so verify after 2s and log a warning if the process is still alive.
|
|
90
|
-
|
|
91
|
-
stdio: "ignore",
|
|
92
|
-
windowsHide: true,
|
|
93
|
-
});
|
|
106
|
+
spawnTaskkillSafe(pid);
|
|
94
107
|
const verifyTimer = setTimeout(() => {
|
|
95
108
|
try {
|
|
96
109
|
process.kill(pid, 0); // throws ESRCH when dead
|
|
@@ -99,12 +112,10 @@ export function killProcessPid(pid: number): void {
|
|
|
99
112
|
"child-pi.taskkill-stuck",
|
|
100
113
|
new Error(`process ${pid} still alive 2s after taskkill /T /F; retrying`),
|
|
101
114
|
`pid=${pid}`,
|
|
115
|
+
"error",
|
|
102
116
|
);
|
|
103
117
|
try {
|
|
104
|
-
|
|
105
|
-
stdio: "ignore",
|
|
106
|
-
windowsHide: true,
|
|
107
|
-
});
|
|
118
|
+
spawnTaskkillSafe(pid);
|
|
108
119
|
} catch {
|
|
109
120
|
/* best-effort */
|
|
110
121
|
}
|
|
@@ -894,6 +905,20 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
894
905
|
});
|
|
895
906
|
// Pass steering file path to child for real-time steer injection
|
|
896
907
|
if (input.steeringFile) built.env.PI_CREW_STEERING_FILE = input.steeringFile;
|
|
908
|
+
// B5: if the parent already aborted before we spawn, do not start the child
|
|
909
|
+
// at all. Spawning a doomed process wastes resources, and the abort listener
|
|
910
|
+
// registered below will not re-fire for an already-aborted signal (so the
|
|
911
|
+
// child would only be killed later by the response-timeout path). Return a
|
|
912
|
+
// cancelled-style result immediately.
|
|
913
|
+
if (input.signal?.aborted) {
|
|
914
|
+
return {
|
|
915
|
+
exitCode: null,
|
|
916
|
+
stdout: "",
|
|
917
|
+
stderr: "",
|
|
918
|
+
error: "Aborted before spawn (parent AbortSignal already aborted)",
|
|
919
|
+
aborted: true,
|
|
920
|
+
};
|
|
921
|
+
}
|
|
897
922
|
const spawnSpec = getPiSpawnCommand(built.args);
|
|
898
923
|
try {
|
|
899
924
|
return await new Promise<ChildPiRunResult>((resolve) => {
|
|
@@ -1129,45 +1154,30 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
1129
1154
|
turnCount += 1;
|
|
1130
1155
|
if (maxTurns !== undefined && !softLimitReached && turnCount >= maxTurns) {
|
|
1131
1156
|
softLimitReached = true;
|
|
1132
|
-
//
|
|
1133
|
-
//
|
|
1134
|
-
//
|
|
1135
|
-
//
|
|
1136
|
-
//
|
|
1137
|
-
//
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
// advisory; if the worker ignores it and runs past maxTurns +
|
|
1151
|
-
// graceTurns, the hard-abort below terminates it.
|
|
1157
|
+
// C8: deliver the "wrap up" advisory by appending to the steering JSONL
|
|
1158
|
+
// file the child polls (PI_CREW_STEERING_FILE). The child is spawned with
|
|
1159
|
+
// stdio:["ignore",...], so child.stdin is null and the old stdin branch was
|
|
1160
|
+
// dead code that only spammed logs on every soft-limit hit. Advisory only —
|
|
1161
|
+
// the hard-abort below at maxTurns + graceTurns is the real enforcement, so
|
|
1162
|
+
// a failed write must NOT kill the worker.
|
|
1163
|
+
if (input.steeringFile) {
|
|
1164
|
+
try {
|
|
1165
|
+
fs.appendFileSync(
|
|
1166
|
+
input.steeringFile,
|
|
1167
|
+
JSON.stringify({
|
|
1168
|
+
type: "steer",
|
|
1169
|
+
message:
|
|
1170
|
+
"You have reached your turn limit. Wrap up immediately — provide your final answer now.",
|
|
1171
|
+
}) + "\n",
|
|
1172
|
+
"utf-8",
|
|
1173
|
+
);
|
|
1174
|
+
} catch (err) {
|
|
1152
1175
|
logInternalError(
|
|
1153
|
-
"child-pi.steer-
|
|
1154
|
-
new Error(
|
|
1155
|
-
"stdin write returned false (normal backpressure); steer buffered, worker NOT killed",
|
|
1156
|
-
),
|
|
1176
|
+
"child-pi.steer-write-failed",
|
|
1177
|
+
err instanceof Error ? err : new Error(String(err)),
|
|
1157
1178
|
`pid=${child.pid}`,
|
|
1158
1179
|
);
|
|
1159
1180
|
}
|
|
1160
|
-
} else {
|
|
1161
|
-
// stdin closed (worker already finished) or otherwise unwritable.
|
|
1162
|
-
// Also advisory — the worker is done or nearly done; let it exit
|
|
1163
|
-
// naturally. Hard-abort remains the safety net for true runaways.
|
|
1164
|
-
logInternalError(
|
|
1165
|
-
"child-pi.steer-not-writable",
|
|
1166
|
-
new Error(
|
|
1167
|
-
"stdin not writable when attempting steer injection (worker may be done); worker NOT killed",
|
|
1168
|
-
),
|
|
1169
|
-
`pid=${child.pid}`,
|
|
1170
|
-
);
|
|
1171
1181
|
}
|
|
1172
1182
|
} else if (maxTurns !== undefined && softLimitReached && turnCount >= maxTurns + (graceTurns ?? 5)) {
|
|
1173
1183
|
// Hard abort — terminate after grace turns
|
|
@@ -244,7 +244,7 @@ export function cancelOrphanedRuns(
|
|
|
244
244
|
});
|
|
245
245
|
if (cancelledRun)
|
|
246
246
|
void terminateLiveAgentsForRun(manifest.runId, "cancelled", appendEvent, loaded.manifest.eventsPath).catch((error) =>
|
|
247
|
-
logInternalError("crash-recovery.orphan.terminate", error, `runId=${manifest.runId}
|
|
247
|
+
logInternalError("crash-recovery.orphan.terminate", error, `runId=${manifest.runId}`, "warn"),
|
|
248
248
|
);
|
|
249
249
|
}
|
|
250
250
|
|
|
@@ -428,7 +428,7 @@ export function purgeStaleActiveRunIndex(staleThresholdMs = 300_000, now = Date.
|
|
|
428
428
|
appendEvent,
|
|
429
429
|
fullLoaded.manifest.eventsPath,
|
|
430
430
|
).catch((error) =>
|
|
431
|
-
logInternalError("crash-recovery.pid-dead.terminate", error, `runId=${fullLoaded.manifest.runId}
|
|
431
|
+
logInternalError("crash-recovery.pid-dead.terminate", error, `runId=${fullLoaded.manifest.runId}`, "warn"),
|
|
432
432
|
);
|
|
433
433
|
}
|
|
434
434
|
} catch {
|
|
@@ -482,7 +482,7 @@ export function purgeStaleActiveRunIndex(staleThresholdMs = 300_000, now = Date.
|
|
|
482
482
|
appendEvent,
|
|
483
483
|
fullLoaded.manifest.eventsPath,
|
|
484
484
|
).catch((error) =>
|
|
485
|
-
logInternalError("crash-recovery.pid-dead.terminate", error, `runId=${fullLoaded.manifest.runId}
|
|
485
|
+
logInternalError("crash-recovery.pid-dead.terminate", error, `runId=${fullLoaded.manifest.runId}`, "warn"),
|
|
486
486
|
);
|
|
487
487
|
}
|
|
488
488
|
} catch {
|
|
@@ -543,7 +543,7 @@ export function reconcileAllStaleRuns(cwd: string, manifestCache: ManifestCache,
|
|
|
543
543
|
}
|
|
544
544
|
updateRunStatus(fresh.manifest, "failed", `Stale run reconciled: ${result.detail}`);
|
|
545
545
|
void terminateLiveAgentsForRun(fresh.manifest.runId, "failed", appendEvent, fresh.manifest.eventsPath).catch((error) =>
|
|
546
|
-
logInternalError("crash-recovery.reconcile.terminate", error, `runId=${fresh.manifest.runId}
|
|
546
|
+
logInternalError("crash-recovery.reconcile.terminate", error, `runId=${fresh.manifest.runId}`, "warn"),
|
|
547
547
|
);
|
|
548
548
|
appendEvent(fresh.manifest.eventsPath, {
|
|
549
549
|
type: "crew.run.reconciled_stale",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as crypto from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { extractSignaturePayload, isHmacEnabled, verifyRpcSignature } from "../extension/rpc-hmac.ts";
|
|
3
3
|
|
|
4
4
|
export interface EventBus {
|
|
5
5
|
on(event: string, handler: (data: unknown) => void): () => void;
|
|
@@ -9,6 +9,9 @@ export interface ChildWithPipedStdio {
|
|
|
9
9
|
stdout: ChildProcess["stdout"];
|
|
10
10
|
stderr: ChildProcess["stderr"];
|
|
11
11
|
on: ChildProcess["on"];
|
|
12
|
+
/** Set by Node after the child exits. Used to detect a post-exit attach. */
|
|
13
|
+
exitCode?: number | null;
|
|
14
|
+
signalCode?: NodeJS.Signals | null;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
export interface ChildWithKill {
|
|
@@ -72,13 +75,28 @@ export function attachPostExitStdioGuard(child: ChildWithPipedStdio, options: Po
|
|
|
72
75
|
stderrEnded = true;
|
|
73
76
|
if (stdoutEnded && stderrEnded) clearTimers();
|
|
74
77
|
});
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
armIdleTimer();
|
|
78
|
+
|
|
79
|
+
const armHardTimer = (): void => {
|
|
78
80
|
if (hardTimer) return;
|
|
79
81
|
hardTimer = setTimeout(destroyUnendedStdio, hardMs);
|
|
80
82
|
hardTimer.unref();
|
|
81
|
-
}
|
|
83
|
+
};
|
|
84
|
+
const onExit = (): void => {
|
|
85
|
+
exited = true;
|
|
86
|
+
armIdleTimer();
|
|
87
|
+
armHardTimer();
|
|
88
|
+
};
|
|
89
|
+
// The guard is normally attached from INSIDE the child's 'exit' handler
|
|
90
|
+
// (child-pi.ts), i.e. AFTER the child has already exited. A listener
|
|
91
|
+
// registered with child.on('exit') during the in-flight 'exit' dispatch
|
|
92
|
+
// will NOT fire (Node clones the listener array before iterating), so
|
|
93
|
+
// relying on it alone makes this guard a no-op and can hang the run if a
|
|
94
|
+
// descendant process holds the stdio pipes open. When a prior exit is
|
|
95
|
+
// detectable (exitCode/signalCode already set by Node), arm immediately.
|
|
96
|
+
if (child.exitCode != null || child.signalCode != null) {
|
|
97
|
+
onExit();
|
|
98
|
+
}
|
|
99
|
+
child.on("exit", onExit);
|
|
82
100
|
child.on("close", clearTimers);
|
|
83
101
|
child.on("error", clearTimers);
|
|
84
102
|
|
|
@@ -29,13 +29,9 @@ const DEFAULT_ROLE_SKILLS: Record<string, string[]> = {
|
|
|
29
29
|
critic: ["read-only-explorer", "multi-perspective-review"],
|
|
30
30
|
executor: ["state-mutation-locking", "safe-bash", "verification-before-done"],
|
|
31
31
|
reviewer: ["read-only-explorer", "multi-perspective-review"],
|
|
32
|
-
// SECURITY NOTE: The following skill names are trusted package-level skills.
|
|
33
|
-
// If a project has a skills/ directory containing subdirectories with these names,
|
|
34
|
-
// those project-level SKILL.md files will be FOUND FIRST (readSkillMarkdown checks
|
|
35
|
-
// project dir before package dir) and their content injected verbatim into prompts.
|
|
36
|
-
// The "Applicable Skills" block will add an untrusted-content warning for project skills,
|
|
37
|
-
// but be aware this is a potential supply-chain risk in multi-contributor projects.
|
|
38
32
|
"security-reviewer": ["secure-agent-orchestration-review", "ownership-session-security"],
|
|
33
|
+
// SECURITY NOTE: Package skills are checked FIRST (SEC-003). Project-level
|
|
34
|
+
// skills with the same name will NOT override the trusted package version.
|
|
39
35
|
"test-engineer": ["verification-before-done", "safe-bash"],
|
|
40
36
|
verifier: ["verification-before-done", "runtime-state-reader"],
|
|
41
37
|
writer: ["context-artifact-hygiene", "verification-before-done"],
|
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { errors } from "../errors.ts";
|
|
5
|
+
import { atomicWriteJson } from "../state/atomic-write.ts";
|
|
5
6
|
import { saveRunManifest } from "../state/state-store.ts";
|
|
6
7
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
7
8
|
import { recordFromTask, upsertCrewAgent } from "./crew-agent-records.ts";
|
|
@@ -67,8 +68,12 @@ function checkResultFile(manifest: TeamRunManifest, tasks: TeamTaskState[]): { f
|
|
|
67
68
|
);
|
|
68
69
|
if (allTerminal) {
|
|
69
70
|
// All tasks are terminal but manifest status was not updated — repair it.
|
|
71
|
+
// Derive the run status from task outcomes instead of blindly marking
|
|
72
|
+
// "completed": a run where every task failed/cancelled is NOT a success.
|
|
73
|
+
const hasFailed = tasks.some((t) => t.status === "failed");
|
|
74
|
+
const onlyCancelledOrSkipped = tasks.every((t) => t.status === "cancelled" || t.status === "skipped");
|
|
75
|
+
manifest.status = hasFailed ? "failed" : onlyCancelledOrSkipped ? "cancelled" : "completed";
|
|
70
76
|
// Persist manifest status change immediately to make checkResultFile self-contained.
|
|
71
|
-
manifest.status = "completed";
|
|
72
77
|
saveRunManifest(manifest);
|
|
73
78
|
// Sync agent records even when tasks are already terminal
|
|
74
79
|
// (e.g., a previous reconcile fixed tasks but crashed before updating agents)
|
|
@@ -492,8 +497,8 @@ export function reconcileOrphanedTempWorkspaces(
|
|
|
492
497
|
const tasks: TeamTaskState[] = JSON.parse(fs.readFileSync(tasksPath, "utf-8"));
|
|
493
498
|
const result = reconcileStaleRun(manifest, tasks, now);
|
|
494
499
|
if (result.repaired && result.repairedTasks) {
|
|
495
|
-
// Persist repaired tasks
|
|
496
|
-
|
|
500
|
+
// Persist repaired tasks (atomic — temp+rename to survive mid-write crash)
|
|
501
|
+
atomicWriteJson(tasksPath, result.repairedTasks);
|
|
497
502
|
// Update manifest status
|
|
498
503
|
const updated = {
|
|
499
504
|
...manifest,
|
|
@@ -501,7 +506,7 @@ export function reconcileOrphanedTempWorkspaces(
|
|
|
501
506
|
updatedAt: new Date(now).toISOString(),
|
|
502
507
|
summary: `Stale run reconciled: ${result.detail}`,
|
|
503
508
|
};
|
|
504
|
-
|
|
509
|
+
atomicWriteJson(manifestPath, updated);
|
|
505
510
|
// Update agent records
|
|
506
511
|
for (const task of result.repairedTasks) {
|
|
507
512
|
try {
|
|
@@ -32,6 +32,7 @@ export function persistSingleTaskUpdate(
|
|
|
32
32
|
updated: TeamTaskState,
|
|
33
33
|
checkpointPhase?: TaskCheckpointState["phase"],
|
|
34
34
|
): TeamTaskState[] {
|
|
35
|
+
const MAX_CAS_ATTEMPTS = 100;
|
|
35
36
|
let baseMtime = 0;
|
|
36
37
|
try {
|
|
37
38
|
baseMtime = fs.statSync(manifest.tasksPath).mtimeMs;
|
|
@@ -55,7 +56,7 @@ export function persistSingleTaskUpdate(
|
|
|
55
56
|
|
|
56
57
|
try {
|
|
57
58
|
return withRunLockSync(manifest, () => {
|
|
58
|
-
for (let attempt = 0; attempt <
|
|
59
|
+
for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt++) {
|
|
59
60
|
// F4: persistSingleTaskUpdate now uses saveRunTasksCoalesced below
|
|
60
61
|
// (50ms debounce window). Read-modify-write loops are unsafe under
|
|
61
62
|
// coalescing — a parallel writer's buffered write is invisible to
|
|
@@ -93,8 +94,13 @@ export function persistSingleTaskUpdate(
|
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
if (merged === undefined) {
|
|
96
|
-
logInternalError(
|
|
97
|
-
|
|
97
|
+
logInternalError(
|
|
98
|
+
"persistSingleTaskUpdate",
|
|
99
|
+
new Error(`failed to converge after ${MAX_CAS_ATTEMPTS} attempts`),
|
|
100
|
+
undefined,
|
|
101
|
+
"error",
|
|
102
|
+
);
|
|
103
|
+
throw new Error(`persistSingleTaskUpdate: failed to converge after ${MAX_CAS_ATTEMPTS} attempts`);
|
|
98
104
|
}
|
|
99
105
|
|
|
100
106
|
try {
|
|
@@ -105,14 +111,14 @@ export function persistSingleTaskUpdate(
|
|
|
105
111
|
// writer's flushed-before-this-call state.
|
|
106
112
|
saveRunTasksCoalesced(manifest, merged);
|
|
107
113
|
} catch (err) {
|
|
108
|
-
logInternalError("persistSingleTaskUpdate", err);
|
|
114
|
+
logInternalError("persistSingleTaskUpdate", err, undefined, "error");
|
|
109
115
|
throw err;
|
|
110
116
|
}
|
|
111
117
|
return merged;
|
|
112
118
|
});
|
|
113
119
|
} catch (err) {
|
|
114
120
|
if (merged === undefined) {
|
|
115
|
-
logInternalError("persistSingleTaskUpdate", err);
|
|
121
|
+
logInternalError("persistSingleTaskUpdate", err, undefined, "error");
|
|
116
122
|
}
|
|
117
123
|
throw err;
|
|
118
124
|
}
|
|
@@ -704,6 +704,7 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
704
704
|
"team-runner.goalAchievement.falseGreen",
|
|
705
705
|
new Error(gaApplied.manifest.goalAchievementNote ?? "false-green detected"),
|
|
706
706
|
`runId=${manifest.runId}`,
|
|
707
|
+
"error",
|
|
707
708
|
);
|
|
708
709
|
stopTeamHeartbeat();
|
|
709
710
|
resolveRunPromise(manifest.runId, result);
|
|
@@ -757,22 +758,17 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
757
758
|
stopTeamHeartbeat();
|
|
758
759
|
// P1: Catch unhandled errors — ensure manifest/tasks/agents are terminal so they don't stay "running" forever.
|
|
759
760
|
const message = error instanceof Error ? error.message : String(error);
|
|
760
|
-
//
|
|
761
|
-
//
|
|
762
|
-
//
|
|
763
|
-
//
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
//
|
|
767
|
-
//
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
// The stale-data concern is mitigated by the dispatcher having re-read disk
|
|
772
|
-
// state under lock at line ~1364 for each iteration; the manifest+task
|
|
773
|
-
// objects in the calling scope are already post-merge.
|
|
774
|
-
const freshManifest = manifest;
|
|
775
|
-
const freshTasks = refreshTaskGraphQueues(input.tasks);
|
|
761
|
+
// Re-read the latest persisted state from disk instead of trusting
|
|
762
|
+
// input.tasks (the ORIGINAL start snapshot, still all "queued" — it is never
|
|
763
|
+
// mutated by executeTeamRunCore). A late failure during closeout would
|
|
764
|
+
// otherwise map every task to "failed", overwriting tasks that already
|
|
765
|
+
// completed during the run. loadRunManifestById is the established
|
|
766
|
+
// fresh-read pattern in this file (see ~line 1269); it is best-effort with
|
|
767
|
+
// no lock, consistent with the lock-drop decision below. If the disk read
|
|
768
|
+
// fails, fall back to input.tasks so the run is still marked terminal.
|
|
769
|
+
const fresh = loadRunManifestById(manifest.cwd, manifest.runId);
|
|
770
|
+
const freshManifest = fresh?.manifest ?? manifest;
|
|
771
|
+
const freshTasks = refreshTaskGraphQueues(fresh?.tasks ?? input.tasks);
|
|
776
772
|
const failedAt = new Date().toISOString();
|
|
777
773
|
const tasks = freshTasks.map((task) =>
|
|
778
774
|
task.status === "running" || task.status === "queued" || task.status === "waiting"
|