pi-crew 0.9.29 → 0.9.31
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 +18 -18
- package/dist/index.mjs +28 -22
- package/dist/index.mjs.map +2 -2
- package/package.json +1 -1
- package/src/extension/register.ts +3 -0
- package/src/prompt/prompt-runtime.ts +148 -28
- package/src/runtime/async-runner.ts +3 -2
- package/src/runtime/child-pi.ts +1 -0
- package/src/runtime/crash-recovery.ts +4 -4
- package/src/runtime/skill-instructions.ts +2 -6
- package/src/runtime/task-runner/state-helpers.ts +11 -5
- package/src/runtime/team-runner.ts +37 -4
- package/src/state/atomic-write.ts +7 -7
- package/src/state/state-store.ts +9 -3
- package/src/ui/run-dashboard.ts +25 -10
- package/src/utils/internal-error.ts +5 -2
- package/src/workflows/cost-estimator.ts +34 -0
- package/src/workflows/validate-workflow.ts +10 -0
package/package.json
CHANGED
|
@@ -139,6 +139,9 @@ export { __test__subagentSpawnParams };
|
|
|
139
139
|
export function registerPiTeams(pi: ExtensionAPI): void {
|
|
140
140
|
const disposeI18n = initI18n(pi);
|
|
141
141
|
resetTimings();
|
|
142
|
+
// S06: Verbose/debug flags for pi-crew diagnostics
|
|
143
|
+
const verbose = process.env.PI_CREW_VERBOSE === "1" || process.argv.includes("--verbose");
|
|
144
|
+
const debug = process.env.PI_CREW_DEBUG === "1" || process.argv.includes("--debug");
|
|
142
145
|
time("register:start");
|
|
143
146
|
// Cold-start race fix (general): pre-warm the hot module graph NOW, during
|
|
144
147
|
// single-threaded registration, before any concurrent subagent can spawn.
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
2
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
|
@@ -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",
|
|
@@ -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"],
|
|
@@ -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
|
}
|
|
@@ -2,13 +2,14 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { AgentConfig } from "../agents/agent-config.ts";
|
|
4
4
|
import type { CrewLimitsConfig, CrewReliabilityConfig, CrewRuntimeConfig } from "../config/config.ts";
|
|
5
|
+
import { CrewError, ErrorCode } from "../errors.ts";
|
|
5
6
|
import { appendHookEvent, executeHook } from "../hooks/registry.ts";
|
|
6
7
|
import { childCorrelation, withCorrelation } from "../observability/correlation.ts";
|
|
7
8
|
import type { MetricRegistry } from "../observability/metric-registry.ts";
|
|
8
9
|
import { PluginRegistry } from "../plugins/plugin-registry.ts";
|
|
9
10
|
import { NextJsPlugin, VitePlugin, VitestPlugin } from "../plugins/plugins/index.ts";
|
|
10
11
|
import { writeArtifact } from "../state/artifact-store.ts";
|
|
11
|
-
import { appendEvent, appendEventAsync, appendEventBuffered, flushEventLogBuffer } from "../state/event-log.ts";
|
|
12
|
+
import { appendEvent, appendEventAsync, appendEventBuffered, appendEventFireAndForget, flushEventLogBuffer } from "../state/event-log.ts";
|
|
12
13
|
import { HealthStore } from "../state/health-store.ts";
|
|
13
14
|
import { withRunLock } from "../state/locks.ts";
|
|
14
15
|
import { loadRunManifestById, saveRunManifest, saveRunManifestAsync, saveRunTasksAsync, updateRunStatus } from "../state/state-store.ts";
|
|
@@ -190,13 +191,19 @@ export function checkPerTaskBudget(
|
|
|
190
191
|
|
|
191
192
|
function findStep(workflow: WorkflowConfig, task: TeamTaskState): WorkflowStep {
|
|
192
193
|
const step = workflow.steps.find((candidate) => candidate.id === task.stepId);
|
|
193
|
-
if (!step)
|
|
194
|
+
if (!step)
|
|
195
|
+
throw new CrewError(ErrorCode.ResourceNotFound, `Workflow step '${task.stepId}' not found for task '${task.id}'.`).withContext(
|
|
196
|
+
`workflow step lookup (task=${task.id})`,
|
|
197
|
+
);
|
|
194
198
|
return step;
|
|
195
199
|
}
|
|
196
200
|
|
|
197
201
|
function findAgent(agents: AgentConfig[], task: TeamTaskState): AgentConfig {
|
|
198
202
|
const agent = agents.find((candidate) => candidate.name === task.agent);
|
|
199
|
-
if (!agent)
|
|
203
|
+
if (!agent)
|
|
204
|
+
throw new CrewError(ErrorCode.ResourceNotFound, `Agent '${task.agent}' not found for task '${task.id}'.`).withContext(
|
|
205
|
+
`agent lookup (task=${task.id})`,
|
|
206
|
+
);
|
|
200
207
|
return agent;
|
|
201
208
|
}
|
|
202
209
|
|
|
@@ -704,6 +711,7 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
704
711
|
"team-runner.goalAchievement.falseGreen",
|
|
705
712
|
new Error(gaApplied.manifest.goalAchievementNote ?? "false-green detected"),
|
|
706
713
|
`runId=${manifest.runId}`,
|
|
714
|
+
"error",
|
|
707
715
|
);
|
|
708
716
|
stopTeamHeartbeat();
|
|
709
717
|
resolveRunPromise(manifest.runId, result);
|
|
@@ -1300,7 +1308,9 @@ async function executeTeamRunCore(
|
|
|
1300
1308
|
};
|
|
1301
1309
|
if (failed) {
|
|
1302
1310
|
lastFailed = enriched;
|
|
1303
|
-
throw new
|
|
1311
|
+
throw new CrewError(ErrorCode.TaskNotFound, failed.error ?? `Task ${task.id} failed.`).withContext(
|
|
1312
|
+
`retry evaluation (run=${manifest.runId})`,
|
|
1313
|
+
);
|
|
1304
1314
|
}
|
|
1305
1315
|
input.metricRegistry?.histogram("crew.task.retry_count", "Retries per task", [0, 1, 2, 3, 5, 10]).observe(
|
|
1306
1316
|
{
|
|
@@ -1651,6 +1661,29 @@ async function executeTeamRunCore(
|
|
|
1651
1661
|
const waiting = tasks.find((task) => task.status === "waiting");
|
|
1652
1662
|
const running = tasks.find((task) => task.status === "running");
|
|
1653
1663
|
manifest = applyPolicy(manifest, tasks, input.limits);
|
|
1664
|
+
|
|
1665
|
+
// S02: Verify workflow-declared output files exist before marking completed
|
|
1666
|
+
if (input.workflow?.steps) {
|
|
1667
|
+
const missingOutputs: string[] = [];
|
|
1668
|
+
for (const step of input.workflow.steps) {
|
|
1669
|
+
if (step.output && typeof step.output === "string") {
|
|
1670
|
+
const outputPath = path.join(manifest.artifactsRoot, step.output);
|
|
1671
|
+
if (!fs.existsSync(outputPath)) {
|
|
1672
|
+
missingOutputs.push(step.output);
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
if (missingOutputs.length > 0) {
|
|
1677
|
+
// Emit warning event — run still completes normally to avoid hanging
|
|
1678
|
+
appendEventFireAndForget(manifest.eventsPath, {
|
|
1679
|
+
type: "run.deliverable_warning",
|
|
1680
|
+
runId: manifest.runId,
|
|
1681
|
+
message: `Missing workflow output files: ${missingOutputs.join(", ")}`,
|
|
1682
|
+
data: { missingFiles: missingOutputs },
|
|
1683
|
+
});
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1654
1687
|
const effectiveness = evaluateRunEffectiveness({
|
|
1655
1688
|
manifest,
|
|
1656
1689
|
tasks,
|
|
@@ -166,12 +166,12 @@ export function isSymlinkSafePath(filePath: string): boolean {
|
|
|
166
166
|
// parent dirs of `.crew/state/runs/{runId}/` are stable within a run, so we
|
|
167
167
|
// cache the ancestor-walk verdict keyed by `dirname(filePath)`.
|
|
168
168
|
//
|
|
169
|
-
// SECURITY:
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
const SYMLINK_SAFE_TTL_MS =
|
|
169
|
+
// SECURITY: TTL-only invalidation. The `.crew/` state directory is trusted
|
|
170
|
+
// (single-user, non-shared). Symlink attacks require write access to the state
|
|
171
|
+
// dir, which would already compromise the system. The short TTL provides
|
|
172
|
+
// defense-in-depth. The target file itself is still checked every call (no
|
|
173
|
+
// cache) — when in doubt the next call's statSync re-verifies.
|
|
174
|
+
const SYMLINK_SAFE_TTL_MS = 10_000;
|
|
175
175
|
const SYMLINK_SAFE_MAX_ENTRIES = 128;
|
|
176
176
|
// NOTE: This cache is process-local and assumes single-threaded access.
|
|
177
177
|
// If worker-thread usage expands (e.g., worker-atomic-writer.ts), this cache
|
|
@@ -731,7 +731,7 @@ function flushOnePendingAtomicWrite(filePath: string): void {
|
|
|
731
731
|
pendingAtomicWrites.delete(filePath);
|
|
732
732
|
}
|
|
733
733
|
} catch (error) {
|
|
734
|
-
logInternalError("atomic-write.coalesced-flush", error, filePath);
|
|
734
|
+
logInternalError("atomic-write.coalesced-flush", error, filePath, "error");
|
|
735
735
|
// Issue 1 fix: set a fresh timer for failed entries before returning.
|
|
736
736
|
// This ensures failed entries are retried without waiting for another
|
|
737
737
|
// write to arrive. Only set timer if this entry is still current
|
package/src/state/state-store.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import * as fsp from "node:fs/promises";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import { DEFAULT_CACHE, DEFAULT_PATHS } from "../config/defaults.ts";
|
|
4
5
|
import { errors } from "../errors.ts";
|
|
@@ -83,7 +84,7 @@ function genOf(stateRoot: string): number {
|
|
|
83
84
|
return manifestCacheGeneration.get(stateRoot) ?? 0;
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
const MANIFEST_CACHE_TTL_MS =
|
|
87
|
+
const MANIFEST_CACHE_TTL_MS = 60 * 1000; // 60 seconds (FIX: increased from 15s for read-heavy workloads; render tick 160ms caused frequent misses)
|
|
87
88
|
const LOAD_MANIFEST_RETRY_LIMIT = 5; // Configurable retry limit for mtime/size stability checks under contention
|
|
88
89
|
const manifestCache = new Map<string, ManifestCacheEntry>();
|
|
89
90
|
|
|
@@ -475,7 +476,7 @@ export async function saveRunTasksAsync(manifest: TeamRunManifest, tasks: TeamTa
|
|
|
475
476
|
// FIX: Invalidate cache BEFORE atomic write to prevent stale cache serving.
|
|
476
477
|
invalidateRunCache(manifest.stateRoot);
|
|
477
478
|
try {
|
|
478
|
-
|
|
479
|
+
await fsp.access(manifest.stateRoot);
|
|
479
480
|
} catch {
|
|
480
481
|
return;
|
|
481
482
|
}
|
|
@@ -800,7 +801,12 @@ export async function loadRunManifestByIdAsync(
|
|
|
800
801
|
} else if (!validateRunManifestPaths(cwd, runId, cached.manifest, stateRoot, tasksPath)) {
|
|
801
802
|
manifestCache.delete(stateRoot);
|
|
802
803
|
return undefined;
|
|
803
|
-
} else if (
|
|
804
|
+
} else if (
|
|
805
|
+
!(await fsp.access(tasksPath).then(
|
|
806
|
+
() => true,
|
|
807
|
+
() => false,
|
|
808
|
+
))
|
|
809
|
+
) {
|
|
804
810
|
// Tasks file was deleted after cache was populated — do not serve stale cache.
|
|
805
811
|
manifestCache.delete(stateRoot);
|
|
806
812
|
return undefined;
|
package/src/ui/run-dashboard.ts
CHANGED
|
@@ -28,6 +28,17 @@ import { applyStatusColor, colorizeStatusGlyphs, iconForStatus, type RunStatus }
|
|
|
28
28
|
import type { CrewTheme } from "./theme-adapter.ts";
|
|
29
29
|
import { asCrewTheme, subscribeThemeChange } from "./theme-adapter.ts";
|
|
30
30
|
|
|
31
|
+
/** S05 — wrap a pane render in try/catch so a single pane crash does not bring down the whole dashboard. */
|
|
32
|
+
function safeRenderPane(name: string, fn: () => string[]): string[] {
|
|
33
|
+
try {
|
|
34
|
+
return fn();
|
|
35
|
+
} catch (error) {
|
|
36
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
37
|
+
logInternalError("run-dashboard", new Error(`Dashboard pane '${name}' render failed: ${message}`));
|
|
38
|
+
return [`<error: ${name}>`];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
interface DashboardComponent {
|
|
32
43
|
invalidate(): void;
|
|
33
44
|
render(width: number): string[];
|
|
@@ -615,20 +626,24 @@ export class RunDashboard implements DashboardComponent {
|
|
|
615
626
|
// Pane content (max 8 lines) — F-2: colorize embedded status glyphs.
|
|
616
627
|
const paneLines = snap
|
|
617
628
|
? this.activePane === "agents"
|
|
618
|
-
? renderAgentsPane(snap, this.options)
|
|
629
|
+
? safeRenderPane("agents", () => renderAgentsPane(snap, this.options))
|
|
619
630
|
: this.activePane === "progress"
|
|
620
|
-
? renderProgressPane(snap)
|
|
631
|
+
? safeRenderPane("progress", () => renderProgressPane(snap))
|
|
621
632
|
: this.activePane === "mailbox"
|
|
622
|
-
? renderMailboxPane(snap)
|
|
633
|
+
? safeRenderPane("mailbox", () => renderMailboxPane(snap))
|
|
623
634
|
: this.activePane === "health"
|
|
624
|
-
?
|
|
625
|
-
|
|
626
|
-
|
|
635
|
+
? safeRenderPane("health", () =>
|
|
636
|
+
renderHealthPane(snap, {
|
|
637
|
+
isForeground: !r.async,
|
|
638
|
+
}),
|
|
639
|
+
)
|
|
627
640
|
: this.activePane === "metrics"
|
|
628
|
-
?
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
641
|
+
? safeRenderPane("metrics", () =>
|
|
642
|
+
renderMetricsPane(snap, {
|
|
643
|
+
registry: this.options.registry,
|
|
644
|
+
}),
|
|
645
|
+
)
|
|
646
|
+
: safeRenderPane("transcript", () => renderTranscriptPane(snap))
|
|
632
647
|
: [...readAgentPreview(r, 4, this.options), ...readProgressPreview(r, 2)];
|
|
633
648
|
const filteredPane = paneLines.filter((l) => l && !l.includes("(none)") && l.trim() !== "");
|
|
634
649
|
if (filteredPane.length > 0) {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
export function logInternalError(scope: string, error: unknown, details?: string): void {
|
|
2
|
-
|
|
1
|
+
export function logInternalError(scope: string, error: unknown, details?: string, severity?: "error" | "warn" | "debug"): void {
|
|
2
|
+
// "error" and "warn" always emit; "debug" (default) is gated behind PI_TEAMS_DEBUG
|
|
3
|
+
if (!severity || severity === "debug") {
|
|
4
|
+
if (!process.env.PI_TEAMS_DEBUG) return;
|
|
5
|
+
}
|
|
3
6
|
const message =
|
|
4
7
|
error instanceof Error ? error.message : typeof error === "object" && error !== null ? JSON.stringify(error) : String(error);
|
|
5
8
|
const suffix = details ? `: ${details}` : "";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { WorkflowConfig } from "./workflow-config.ts";
|
|
2
|
+
|
|
3
|
+
// Rough token estimates per role (input + output combined)
|
|
4
|
+
const ROLE_TOKEN_ESTIMATES: Record<string, number> = {
|
|
5
|
+
explorer: 15000,
|
|
6
|
+
planner: 20000,
|
|
7
|
+
executor: 25000,
|
|
8
|
+
reviewer: 12000,
|
|
9
|
+
"security-reviewer": 12000,
|
|
10
|
+
"test-engineer": 15000,
|
|
11
|
+
analyst: 18000,
|
|
12
|
+
writer: 15000,
|
|
13
|
+
verifier: 10000,
|
|
14
|
+
critic: 12000,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Rough cost per 1K tokens (USD) — using a mid-range model price
|
|
18
|
+
const COST_PER_1K_TOKENS = 0.003;
|
|
19
|
+
|
|
20
|
+
export interface CostEstimate {
|
|
21
|
+
totalTokens: number;
|
|
22
|
+
estimatedCostUSD: number;
|
|
23
|
+
perStep: Array<{ stepId: string; role: string; tokens: number }>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function estimateWorkflowCost(workflow: WorkflowConfig): CostEstimate {
|
|
27
|
+
const perStep = workflow.steps.map((step) => {
|
|
28
|
+
const tokens = ROLE_TOKEN_ESTIMATES[step.role] ?? 15000; // default 15K
|
|
29
|
+
return { stepId: step.id, role: step.role, tokens };
|
|
30
|
+
});
|
|
31
|
+
const totalTokens = perStep.reduce((sum, s) => sum + s.tokens, 0);
|
|
32
|
+
const estimatedCostUSD = (totalTokens / 1000) * COST_PER_1K_TOKENS;
|
|
33
|
+
return { totalTokens, estimatedCostUSD, perStep };
|
|
34
|
+
}
|
|
@@ -18,6 +18,16 @@ export function validateWorkflowForTeam(workflow: WorkflowConfig, team: TeamConf
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// Validate output field types
|
|
22
|
+
for (const step of workflow.steps) {
|
|
23
|
+
if (step.output !== undefined && step.output !== false && typeof step.output !== "string") {
|
|
24
|
+
errors.push(`Step '${step.id}' has invalid 'output' field: expected string or false, got ${typeof step.output}.`);
|
|
25
|
+
}
|
|
26
|
+
if (typeof step.output === "string" && step.output.trim() === "") {
|
|
27
|
+
errors.push(`Step '${step.id}' has empty 'output' string.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
const visiting = new Set<string>();
|
|
22
32
|
const visited = new Set<string>();
|
|
23
33
|
const byId = new Map(workflow.steps.map((step) => [step.id, step]));
|