pi-crew 0.8.14 → 0.9.1
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 +366 -0
- package/README.md +112 -2
- package/docs/FEATURE_INTAKE.md +1 -1
- package/docs/HARNESS.md +20 -19
- package/docs/PROJECT_REVIEW.md +132 -133
- package/docs/PROJECT_REVIEW_FIXES.md +130 -131
- package/docs/actions-reference.md +127 -121
- package/docs/architecture.md +1 -1
- package/docs/code-review-2026-05-11.md +134 -134
- package/docs/commands-reference.md +108 -106
- package/docs/comparison-pi-subagents-vs-pi-crew.md +105 -105
- package/docs/deep-review-report.md +1 -1
- package/docs/dynamic-workflows.md +90 -0
- package/docs/fixes/BATCH_A_H1_H2.md +17 -17
- package/docs/fixes/bug-007-async-notifier-stale-ctx.md +23 -23
- package/docs/followup-plan-2026-05-12.md +135 -135
- package/docs/followup-review-2026-05-12.md +86 -86
- package/docs/followup-review-round3-2026-05-12.md +123 -123
- package/docs/goals.md +59 -0
- package/docs/implementation-plan-top3.md +4 -4
- package/docs/issue-29-analysis.md +2 -2
- package/docs/oh-my-pi-research.md +154 -154
- package/docs/optimization-plan.md +2 -0
- package/docs/perf/baseline-2026-05.md +9 -9
- package/docs/perf/final-report-2026-05.md +2 -2
- package/docs/perf/sprint-1-report.md +2 -2
- package/docs/perf/sprint-2-report.md +1 -1
- package/docs/perf/upgrade-plan-2026-05.md +72 -72
- package/docs/pi-crew-bugs.md +230 -230
- package/docs/pi-crew-investigation-report.md +102 -102
- package/docs/pi-crew-test-round5.md +4 -4
- package/docs/runtime-analysis-child-vs-live.md +57 -57
- package/docs/runtime-migration-in-process-analysis.md +97 -97
- package/package.json +2 -4
- package/skills/orchestration/SKILL.md +11 -11
- package/src/agents/agent-config.ts +4 -0
- package/src/config/config.ts +39 -0
- package/src/config/types.ts +11 -0
- package/src/extension/action-suggestions.ts +2 -1
- package/src/extension/async-notifier.ts +10 -0
- package/src/extension/help.ts +14 -0
- package/src/extension/registration/commands.ts +27 -0
- package/src/extension/team-tool/destructive-gate.ts +1 -1
- package/src/extension/team-tool/goal-wrap.ts +288 -0
- package/src/extension/team-tool/goal.ts +405 -0
- package/src/extension/team-tool/run.ts +103 -4
- package/src/extension/team-tool/workflow-manage.ts +194 -0
- package/src/extension/team-tool.ts +20 -0
- package/src/hooks/types.ts +3 -1
- package/src/runtime/async-runner.ts +27 -2
- package/src/runtime/background-runner.ts +68 -19
- package/src/runtime/child-pi.ts +9 -1
- package/src/runtime/completion-guard.ts +1 -1
- package/src/runtime/dynamic-workflow-context.ts +450 -0
- package/src/runtime/dynamic-workflow-runner.ts +180 -0
- package/src/runtime/global-worker-cap.ts +96 -0
- package/src/runtime/goal-evaluator.ts +294 -0
- package/src/runtime/goal-loop-runner.ts +612 -0
- package/src/runtime/goal-state-store.ts +209 -0
- package/src/runtime/iteration-hooks.ts +2 -1
- package/src/runtime/pi-args.ts +10 -2
- package/src/runtime/post-checks.ts +2 -1
- package/src/runtime/result-extractor.ts +32 -0
- package/src/runtime/team-runner.ts +11 -1
- package/src/runtime/verification-gates.ts +88 -5
- package/src/runtime/verification-integrity.ts +110 -0
- package/src/runtime/verification-worktree.ts +136 -0
- package/src/runtime/workspace-lock.ts +448 -0
- package/src/schema/config-schema.ts +26 -0
- package/src/schema/team-tool-schema.ts +39 -4
- package/src/state/atomic-write.ts +9 -0
- package/src/state/contracts.ts +14 -0
- package/src/state/crew-init.ts +18 -5
- package/src/state/event-log.ts +7 -1
- package/src/state/state-store.ts +2 -0
- package/src/state/types.ts +82 -0
- package/src/state/worker-atomic-writer.ts +190 -0
- package/src/utils/env-allowlist.ts +30 -0
- package/src/utils/redaction.ts +104 -24
- package/src/utils/safe-paths.ts +55 -14
- package/src/workflows/discover-workflows.ts +25 -1
- package/src/workflows/workflow-config.ts +13 -0
- package/src/worktree/cleanup.ts +2 -1
- package/src/worktree/worktree-manager.ts +4 -3
- package/teams/parallel-research.team.md +1 -1
- package/workflows/examples/hello.dwf.ts +24 -0
|
@@ -72,6 +72,12 @@ export const TeamToolParams = Type.Object({
|
|
|
72
72
|
Type.Literal("anchor"),
|
|
73
73
|
Type.Literal("auto-summarize"),
|
|
74
74
|
Type.Literal("auto_boomerang"),
|
|
75
|
+
Type.Literal("goal"),
|
|
76
|
+
Type.Literal("workflow-create"),
|
|
77
|
+
Type.Literal("workflow-get"),
|
|
78
|
+
Type.Literal("workflow-list"),
|
|
79
|
+
Type.Literal("workflow-save"),
|
|
80
|
+
Type.Literal("workflow-delete"),
|
|
75
81
|
],
|
|
76
82
|
{ description: "Team action. Defaults to 'list' when omitted." },
|
|
77
83
|
),
|
|
@@ -244,8 +250,14 @@ export const TeamToolParams = Type.Object({
|
|
|
244
250
|
budgetTotal: Type.Optional(
|
|
245
251
|
Type.Number({
|
|
246
252
|
description:
|
|
247
|
-
"Total token budget for the run. When set, enables budget tracking with default 80% warning and 95% abort thresholds.",
|
|
248
|
-
minimum:
|
|
253
|
+
"Total token budget for the run. When set, enables budget tracking with default 80% warning and 95% abort thresholds. Minimum 1000 — this is a MISCONFIGURATION GUARD (catches typos / silent-abort configs like budgetTotal:1, which would abort on turn 1), NOT a usefulness guarantee; a productive multi-turn goal needs far more than 1000 tokens.",
|
|
254
|
+
minimum: 1000,
|
|
255
|
+
}),
|
|
256
|
+
),
|
|
257
|
+
budgetUnlimited: Type.Optional(
|
|
258
|
+
Type.Boolean({
|
|
259
|
+
description:
|
|
260
|
+
"When true, skip budget enforcement entirely (explicit opt-out). Goal-start validation requires budgetTotal>=1000 OR budgetUnlimited:true; audit-logged when set. The validation itself is enforced in a later integration task.",
|
|
249
261
|
}),
|
|
250
262
|
),
|
|
251
263
|
budgetWarning: Type.Optional(
|
|
@@ -264,6 +276,19 @@ export const TeamToolParams = Type.Object({
|
|
|
264
276
|
maximum: 1,
|
|
265
277
|
}),
|
|
266
278
|
),
|
|
279
|
+
runKind: Type.Optional(
|
|
280
|
+
Type.Union(
|
|
281
|
+
[
|
|
282
|
+
Type.Literal("team-run"),
|
|
283
|
+
Type.Literal("goal-loop"),
|
|
284
|
+
Type.Literal("dynamic-workflow"),
|
|
285
|
+
],
|
|
286
|
+
{
|
|
287
|
+
description:
|
|
288
|
+
"Background dispatch discriminator. Default \"team-run\" runs the normal executeTeamRun workflow; \"goal-loop\" (P0/P1) and \"dynamic-workflow\" (P2/P3) dispatch to their respective background runners. Absent = \"team-run\" for backward compatibility.",
|
|
289
|
+
},
|
|
290
|
+
),
|
|
291
|
+
),
|
|
267
292
|
});
|
|
268
293
|
|
|
269
294
|
export interface TeamToolParamsValue {
|
|
@@ -312,7 +337,13 @@ export interface TeamToolParamsValue {
|
|
|
312
337
|
| "search"
|
|
313
338
|
| "orchestrate"
|
|
314
339
|
| "schedule"
|
|
315
|
-
| "scheduled"
|
|
340
|
+
| "scheduled"
|
|
341
|
+
| "goal"
|
|
342
|
+
| "workflow-create"
|
|
343
|
+
| "workflow-get"
|
|
344
|
+
| "workflow-list"
|
|
345
|
+
| "workflow-save"
|
|
346
|
+
| "workflow-delete";
|
|
316
347
|
resource?: "agent" | "team" | "workflow";
|
|
317
348
|
team?: string;
|
|
318
349
|
workflow?: string;
|
|
@@ -352,10 +383,14 @@ export interface TeamToolParamsValue {
|
|
|
352
383
|
once?: string | number;
|
|
353
384
|
/** Mark certain bash commands as excludeFromContext to reduce context tokens (default: false). */
|
|
354
385
|
excludeContextBash?: boolean;
|
|
355
|
-
/** Total token budget for the run. When set, enables budget tracking. */
|
|
386
|
+
/** Total token budget for the run. When set, enables budget tracking (minimum 1000). */
|
|
356
387
|
budgetTotal?: number;
|
|
388
|
+
/** When true, skip budget enforcement entirely (explicit opt-out). */
|
|
389
|
+
budgetUnlimited?: boolean;
|
|
357
390
|
/** Budget warning threshold as a fraction (0-1). Default: 0.8. */
|
|
358
391
|
budgetWarning?: number;
|
|
359
392
|
/** Budget abort threshold as a fraction (0-1). Default: 0.95. */
|
|
360
393
|
budgetAbort?: number;
|
|
394
|
+
/** Background dispatch discriminator. Default "team-run". "goal-loop"/"dynamic-workflow" dispatch to their runners (P0/P2). */
|
|
395
|
+
runKind?: "team-run" | "goal-loop" | "dynamic-workflow";
|
|
361
396
|
}
|
|
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
|
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
6
|
+
import { isWorkerAtomicWriterEnabled, atomicWriteFileViaWorker } from "./worker-atomic-writer.ts";
|
|
6
7
|
import { sleepSync } from "../utils/sleep.ts";
|
|
7
8
|
|
|
8
9
|
function hashContent(content: string): string {
|
|
@@ -380,6 +381,14 @@ export function atomicWriteFile(filePath: string, content: string, expectedHash?
|
|
|
380
381
|
|
|
381
382
|
|
|
382
383
|
export async function atomicWriteFileAsync(filePath: string, content: string): Promise<void> {
|
|
384
|
+
// Phase 1.5 (RFC 15): when the worker-thread atomic writer is enabled
|
|
385
|
+
// (PI_CREW_WORKER_ATOMIC_WRITER=1), dispatch to a dedicated worker thread
|
|
386
|
+
// that performs SYNC fs ops with no internal yields. Mitigates the
|
|
387
|
+
// non-deterministic V8/libuv crash during event-loop yields in multi-step
|
|
388
|
+
// goal-wrapped workflows.
|
|
389
|
+
if (isWorkerAtomicWriterEnabled()) {
|
|
390
|
+
return atomicWriteFileViaWorker(filePath, content);
|
|
391
|
+
}
|
|
383
392
|
if (!isSymlinkSafePath(filePath)) throw new Error(`Refusing to write: target is a symlink or inside untrusted directory: ${filePath}`);
|
|
384
393
|
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
|
385
394
|
const tempPath = `${filePath}.${crypto.randomUUID()}.tmp`;
|
package/src/state/contracts.ts
CHANGED
|
@@ -77,6 +77,20 @@ const TEAM_EVENT_TYPES = [
|
|
|
77
77
|
"phase.completed",
|
|
78
78
|
"phase.skipped",
|
|
79
79
|
"phase.failed",
|
|
80
|
+
// Goal loop events (P0/P1) — autonomous goal-loop coordinator.
|
|
81
|
+
"goal.loop_start",
|
|
82
|
+
"goal.turn_start",
|
|
83
|
+
"goal.turn_evaluated",
|
|
84
|
+
"goal.budget_warning",
|
|
85
|
+
"goal.loop_end",
|
|
86
|
+
"goal.feedback_steered",
|
|
87
|
+
"goal.state_changed",
|
|
88
|
+
// Dynamic workflow events (P2) — script-driven orchestration.
|
|
89
|
+
"dwf.started",
|
|
90
|
+
"dwf.phase_started",
|
|
91
|
+
"dwf.phase_completed",
|
|
92
|
+
"dwf.completed",
|
|
93
|
+
"dwf.failed",
|
|
80
94
|
] as const;
|
|
81
95
|
export type TeamEventType = typeof TEAM_EVENT_TYPES[number];
|
|
82
96
|
|
package/src/state/crew-init.ts
CHANGED
|
@@ -22,8 +22,19 @@ import { updateGitignore } from "./gitignore-manager.ts";
|
|
|
22
22
|
// Re-export updateGitignore for backwards compatibility with tests.
|
|
23
23
|
export { updateGitignore };
|
|
24
24
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
25
|
+
/**
|
|
26
|
+
* README content for the .crew directory.
|
|
27
|
+
*
|
|
28
|
+
* Defined as a function (not a `const`) to avoid the Temporal Dead Zone race
|
|
29
|
+
* documented in issue #28 + RFC 17. When this module is loaded via
|
|
30
|
+
* `jiti.import()` (pi's extension loader) wrapped in an async function, a
|
|
31
|
+
* `const` initializer can be hit in TDZ by functions hoisted above it (the
|
|
32
|
+
* same pattern that bit `crewInitPromise` in team-tool/run.ts — see commit
|
|
33
|
+
* fixing it). A function declaration is hoisted with its body available
|
|
34
|
+
* immediately, so callers always get the fully-built string.
|
|
35
|
+
*/
|
|
36
|
+
function buildCrewReadme(): string {
|
|
37
|
+
return `# .crew — pi-crew Runtime Directory
|
|
27
38
|
|
|
28
39
|
This directory contains pi-crew runtime state and artifacts.
|
|
29
40
|
|
|
@@ -50,7 +61,7 @@ To clear cache:
|
|
|
50
61
|
team action='cache' action='clear'
|
|
51
62
|
\`\`\`
|
|
52
63
|
`;
|
|
53
|
-
|
|
64
|
+
}
|
|
54
65
|
/**
|
|
55
66
|
* Find the project root by walking up from start directory.
|
|
56
67
|
* Inline implementation to avoid module dependency on paths.ts.
|
|
@@ -249,13 +260,15 @@ export async function ensureCrewDirectory(cwd: string): Promise<void> {
|
|
|
249
260
|
}
|
|
250
261
|
|
|
251
262
|
// 3. Write README.md (always overwrite to keep it current)
|
|
252
|
-
fs.writeFileSync(safeJoin(crewRoot, "README.md"),
|
|
263
|
+
fs.writeFileSync(safeJoin(crewRoot, "README.md"), buildCrewReadme(), "utf-8");
|
|
253
264
|
|
|
254
265
|
// 4. Update .gitignore at project root
|
|
255
266
|
const repoRoot = findProjectRoot(cwd);
|
|
256
267
|
if (repoRoot) {
|
|
257
268
|
const gitignorePath = safeJoin(repoRoot, ".gitignore");
|
|
258
|
-
|
|
269
|
+
// LAZY: dodge the jiti ESM/CJS interop TDZ race on the static `import { updateGitignore }` above (issue #28, RFC 17). At this point the module body has fully evaluated, so the dynamic import resolves to a live binding.
|
|
270
|
+
const { updateGitignore: updateGitignoreFn } = await import("./gitignore-manager.ts");
|
|
271
|
+
await updateGitignoreFn(gitignorePath);
|
|
259
272
|
}
|
|
260
273
|
}
|
|
261
274
|
|
package/src/state/event-log.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import { isWorkerAtomicWriterEnabled, appendFileViaWorker } from "./worker-atomic-writer.ts";
|
|
4
5
|
import { DEFAULT_EVENT_LOG } from "../config/defaults.ts";
|
|
5
6
|
import { atomicWriteFile } from "./atomic-write.ts";
|
|
6
7
|
import { errors } from "../errors.ts";
|
|
@@ -443,7 +444,12 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
|
|
|
443
444
|
|
|
444
445
|
if (!skippedDueToSize) {
|
|
445
446
|
const line = JSON.stringify(redactSecrets(fullEvent)) + "\n";
|
|
446
|
-
|
|
447
|
+
// Phase 1.5: when worker atomic writer is enabled, append via worker.
|
|
448
|
+
if (isWorkerAtomicWriterEnabled()) {
|
|
449
|
+
await appendFileViaWorker(eventsPath, line);
|
|
450
|
+
} else {
|
|
451
|
+
await fs.promises.appendFile(eventsPath, line, { encoding: "utf-8", flag: "a" });
|
|
452
|
+
}
|
|
447
453
|
// FIX: fsync to ensure event content is flushed to disk before persisting
|
|
448
454
|
// the sequence number. This closes the crash window between appendFile and
|
|
449
455
|
// persistSequence where sequence reuse could occur on restart.
|
package/src/state/state-store.ts
CHANGED
|
@@ -227,6 +227,7 @@ export function createRunManifest(params: {
|
|
|
227
227
|
goal: string;
|
|
228
228
|
workspaceMode?: "single" | "worktree";
|
|
229
229
|
ownerSessionId?: string;
|
|
230
|
+
runKind?: "team-run" | "goal-loop" | "dynamic-workflow";
|
|
230
231
|
}): { manifest: TeamRunManifest; tasks: TeamTaskState[]; paths: RunPaths } {
|
|
231
232
|
const paths = createRunPaths(params.cwd);
|
|
232
233
|
const now = new Date().toISOString();
|
|
@@ -249,6 +250,7 @@ export function createRunManifest(params: {
|
|
|
249
250
|
eventsPath: paths.eventsPath,
|
|
250
251
|
artifacts: [],
|
|
251
252
|
...(params.ownerSessionId ? { ownerSessionId: params.ownerSessionId } : {}),
|
|
253
|
+
runKind: params.runKind ?? "team-run",
|
|
252
254
|
};
|
|
253
255
|
fs.mkdirSync(paths.stateRoot, { recursive: true });
|
|
254
256
|
fs.mkdirSync(paths.artifactsRoot, { recursive: true });
|
package/src/state/types.ts
CHANGED
|
@@ -183,6 +183,8 @@ export interface TeamRunManifest {
|
|
|
183
183
|
runtimeResolution?: RuntimeResolutionState;
|
|
184
184
|
/** Effective run config snapshot used by async background workers. Optional for backward compatibility. */
|
|
185
185
|
runConfig?: unknown;
|
|
186
|
+
/** Background dispatch discriminator. Default "team-run" runs executeTeamRun; "goal-loop" / "dynamic-workflow" dispatch to their respective runners. Absent = "team-run" for backward compatibility. */
|
|
187
|
+
runKind?: "team-run" | "goal-loop" | "dynamic-workflow";
|
|
186
188
|
summary?: string;
|
|
187
189
|
policyDecisions?: PolicyDecision[];
|
|
188
190
|
}
|
|
@@ -196,6 +198,86 @@ export interface UsageState {
|
|
|
196
198
|
turns?: number;
|
|
197
199
|
}
|
|
198
200
|
|
|
201
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
202
|
+
// Goal loop types (P0/P1 — autonomous goal loop, Claude-Code-style /goal).
|
|
203
|
+
// Spec: research-findings/goal-workflow/00-SPEC.md §2.3; plan 07-PLAN.md v3 §0b G2 + §0c.
|
|
204
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Outer-state lifecycle of a goal loop. Inner per-turn state lives on each turn's TeamRunManifest.
|
|
208
|
+
*
|
|
209
|
+
* P1b (RFC v0.5 §P1b): `"stuck"` is NON-TERMINAL and RE-HINTABLE. Legal transitions:
|
|
210
|
+
* running → stuck (only by the background loop, after the oscillation detector fires)
|
|
211
|
+
* stuck → running (only by `goal resume`, atomically via GoalStore.compareAndSetStatus)
|
|
212
|
+
* stuck → cancelled (by the idle-timeout sweeper OR `goal stop`)
|
|
213
|
+
*/
|
|
214
|
+
export type GoalLoopStatus =
|
|
215
|
+
| "running"
|
|
216
|
+
| "paused"
|
|
217
|
+
| "stuck"
|
|
218
|
+
| "achieved"
|
|
219
|
+
| "max_turns"
|
|
220
|
+
| "budget_exceeded"
|
|
221
|
+
| "blocked"
|
|
222
|
+
| "cancelled";
|
|
223
|
+
|
|
224
|
+
/** One evaluation by the goal-judge model after a turn. */
|
|
225
|
+
export interface GoalVerdict {
|
|
226
|
+
turn: number;
|
|
227
|
+
achieved: boolean;
|
|
228
|
+
/** "achieved: all tests pass" | "not-achieved: 2/8 tests failing" | "BLOCKED: <reason>" (BLOCKED: prefix → status='blocked'). */
|
|
229
|
+
reason: string;
|
|
230
|
+
evidenceRefs?: string[];
|
|
231
|
+
evaluatorModel: string;
|
|
232
|
+
evaluatedAt: string;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Persisted at <crewRoot>/state/goals/<goalId>.json by GoalStore. Survives session restart. */
|
|
236
|
+
export interface GoalLoopState {
|
|
237
|
+
goalId: string;
|
|
238
|
+
ownerSessionId: string;
|
|
239
|
+
objective: string;
|
|
240
|
+
scope?: string;
|
|
241
|
+
/** Acceptance conditions as shell commands (exit 0 = pass). Reuses VerificationContract semantics. */
|
|
242
|
+
verification?: { commands: string[]; allowManualEvidence?: boolean };
|
|
243
|
+
state: GoalLoopStatus;
|
|
244
|
+
maxTurns: number;
|
|
245
|
+
turnsUsed: number;
|
|
246
|
+
budgetTotal?: number;
|
|
247
|
+
/** P1d (RFC v0.5 §P1d): when true, budget enforcement is skipped (explicit opt-out; audit-logged at start). */
|
|
248
|
+
budgetUnlimited?: boolean;
|
|
249
|
+
budgetWarning?: number;
|
|
250
|
+
budgetAbort?: number;
|
|
251
|
+
budgetUsed: number;
|
|
252
|
+
/**
|
|
253
|
+
* P1a (RFC v0.5 §P1a): bookend integrity snapshot of project-manifest files
|
|
254
|
+
* taken at goal start (only when verification.commands is declared). The
|
|
255
|
+
* goal-loop-runner re-hashes before (T_snap) and after (T_verify_done) each
|
|
256
|
+
* verification command to detect persistent manifest tampering. The literal
|
|
257
|
+
* `"none-text-only"` marks goals started in text-only verification mode
|
|
258
|
+
* (no objective oracle → no snapshot taken).
|
|
259
|
+
*/
|
|
260
|
+
verificationIntegrity?:
|
|
261
|
+
| { snapshot: Record<string, string>; takenAt: string }
|
|
262
|
+
| "none-text-only";
|
|
263
|
+
evaluatorModel: string;
|
|
264
|
+
workerModel?: string;
|
|
265
|
+
/** subagent_type / agent name for worker turns (default "executor"). */
|
|
266
|
+
workerAgent?: string;
|
|
267
|
+
team?: string;
|
|
268
|
+
cwd: string;
|
|
269
|
+
/** Feedback from turn N's verdict, prepended into turn N+1's manifest.goal (G1). */
|
|
270
|
+
nextTurnFeedback?: string;
|
|
271
|
+
/** The team-run of the current in-flight turn (for cancel/steer). */
|
|
272
|
+
currentRunId?: string;
|
|
273
|
+
verdicts: GoalVerdict[];
|
|
274
|
+
history: { runId: string; outcome: string; learnedAt: string; turn: number }[];
|
|
275
|
+
createdAt: string;
|
|
276
|
+
updatedAt: string;
|
|
277
|
+
/** Mirror of manifest.async for PID-liveness checks (cf. AsyncRunState). */
|
|
278
|
+
async?: { pid: number; logPath: string; spawnedAt: string };
|
|
279
|
+
}
|
|
280
|
+
|
|
199
281
|
export interface ModelAttemptState {
|
|
200
282
|
model: string;
|
|
201
283
|
success: boolean;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 1.5 worker-thread atomic writer (RFC: 15-PHASE1.5-WORKER-WRITER-RFC.md).
|
|
3
|
+
*
|
|
4
|
+
* Background: multi-step goal-wrapped workflows crash silently and
|
|
5
|
+
* non-deterministically during batch transitions. The crash point moves to
|
|
6
|
+
* every `await` yield in the write path (mkdir, open, stat, rename,
|
|
7
|
+
* appendEvent). Sync replacements regress. Hypothesis: V8/libuv-level race
|
|
8
|
+
* during event-loop yields. Mitigation: route writes through a dedicated
|
|
9
|
+
* worker thread that performs SYNC fs operations with no internal yields.
|
|
10
|
+
*
|
|
11
|
+
* Opt-in via `PI_CREW_WORKER_ATOMIC_WRITER=1`. When disabled, callers fall
|
|
12
|
+
* back to the regular async path. Safe to ship behind a flag.
|
|
13
|
+
*
|
|
14
|
+
* Protocol (main → worker):
|
|
15
|
+
* { kind: "write", id, filePath, content }
|
|
16
|
+
* { kind: "mkdir", id, dirPath }
|
|
17
|
+
* { kind: "append", id, filePath, content }
|
|
18
|
+
*
|
|
19
|
+
* Protocol (worker → main):
|
|
20
|
+
* { kind: "done", id }
|
|
21
|
+
* { kind: "error", id, message }
|
|
22
|
+
*/
|
|
23
|
+
import { Worker } from "node:worker_threads";
|
|
24
|
+
import * as path from "node:path";
|
|
25
|
+
import * as fs from "node:fs";
|
|
26
|
+
import { createRequire } from "node:module";
|
|
27
|
+
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
|
|
30
|
+
let worker: Worker | undefined;
|
|
31
|
+
let nextRequestId = 1;
|
|
32
|
+
const pending = new Map<number, { resolve: () => void; reject: (e: Error) => void }>();
|
|
33
|
+
|
|
34
|
+
/** Worker script source — runs SYNC fs ops with no internal yields. */
|
|
35
|
+
const WORKER_SOURCE = `
|
|
36
|
+
const { parentPort, workerData } = require("node:worker_threads");
|
|
37
|
+
const fs = require("node:fs");
|
|
38
|
+
const path = require("node:path");
|
|
39
|
+
const crypto = require("node:crypto");
|
|
40
|
+
|
|
41
|
+
function isSymlinkSafePath(filePath) {
|
|
42
|
+
try {
|
|
43
|
+
// Safe roots: the system temp dir (resolved through any symlinks, e.g.
|
|
44
|
+
// macOS /var → /private/var) and the current project cwd.
|
|
45
|
+
var tmpReal;
|
|
46
|
+
try { tmpReal = fs.realpathSync(require("node:os").tmpdir()); } catch (e2) { tmpReal = require("node:os").tmpdir(); }
|
|
47
|
+
var cwd = process.cwd();
|
|
48
|
+
// Accept a symlink ancestor as safe if the resolved target (a) IS a safe
|
|
49
|
+
// root, (b) is UNDER a safe root (target is a descendant), or (c) is an
|
|
50
|
+
// ANCESTOR of a safe root (target is a parent dir of a safe root).
|
|
51
|
+
// Case (c) is essential on macOS: /var → /private/var is an ancestor of
|
|
52
|
+
// the tmpdir /private/var/folders/…, so it must be allowed.
|
|
53
|
+
var isSafeAncestor = function (real) {
|
|
54
|
+
var roots = [tmpReal, cwd];
|
|
55
|
+
return roots.some(function (root) {
|
|
56
|
+
return real === root
|
|
57
|
+
|| real.indexOf(root + path.sep) === 0
|
|
58
|
+
|| root.indexOf(real + path.sep) === 0;
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
var currentPath = filePath;
|
|
62
|
+
while (currentPath !== path.dirname(currentPath)) {
|
|
63
|
+
var dir = path.dirname(currentPath);
|
|
64
|
+
try {
|
|
65
|
+
var stat = fs.lstatSync(dir);
|
|
66
|
+
if (stat.isSymbolicLink()) {
|
|
67
|
+
var real = fs.realpathSync(dir);
|
|
68
|
+
if (!isSafeAncestor(real)) return false;
|
|
69
|
+
}
|
|
70
|
+
} catch (e3) { /* not found — fine */ }
|
|
71
|
+
currentPath = dir;
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
} catch (e) { return true; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function syncAtomicWriteFile(filePath, content) {
|
|
78
|
+
if (!isSymlinkSafePath(filePath)) throw new Error("Refusing to write: unsafe path: " + filePath);
|
|
79
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
80
|
+
const tempPath = filePath + "." + crypto.randomUUID() + ".tmp";
|
|
81
|
+
try {
|
|
82
|
+
const fd = fs.openSync(tempPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
|
|
83
|
+
try {
|
|
84
|
+
fs.writeFileSync(fd, content, "utf-8");
|
|
85
|
+
} finally {
|
|
86
|
+
fs.closeSync(fd);
|
|
87
|
+
}
|
|
88
|
+
fs.renameSync(tempPath, filePath);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
try { fs.rmSync(tempPath, { force: true }); } catch {}
|
|
91
|
+
// If rename raced with another writer that produced identical content, swallow.
|
|
92
|
+
if (error && error.code === "EEXIST") {
|
|
93
|
+
try {
|
|
94
|
+
const existing = fs.readFileSync(filePath, "utf-8");
|
|
95
|
+
if (existing === content) return;
|
|
96
|
+
} catch {}
|
|
97
|
+
}
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function syncAppend(filePath, content) {
|
|
103
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
104
|
+
fs.appendFileSync(filePath, content, "utf-8");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
parentPort.on("message", (msg) => {
|
|
108
|
+
try {
|
|
109
|
+
if (msg.kind === "write") syncAtomicWriteFile(msg.filePath, msg.content);
|
|
110
|
+
else if (msg.kind === "mkdir") fs.mkdirSync(msg.dirPath, { recursive: true });
|
|
111
|
+
else if (msg.kind === "append") syncAppend(msg.filePath, msg.content);
|
|
112
|
+
else throw new Error("worker-atomic-writer: unknown message kind: " + msg.kind);
|
|
113
|
+
parentPort.postMessage({ kind: "done", id: msg.id });
|
|
114
|
+
} catch (error) {
|
|
115
|
+
parentPort.postMessage({ kind: "error", id: msg.id, message: error && error.message ? error.message : String(error) });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
`;
|
|
119
|
+
|
|
120
|
+
function getWorker(): Worker {
|
|
121
|
+
if (worker) return worker;
|
|
122
|
+
// Write worker source to a temp file (so Worker can load it as CJS via
|
|
123
|
+
// require() inside the worker, which always has CommonJS available).
|
|
124
|
+
const os = require("node:os");
|
|
125
|
+
const tmpPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "pi-crew-waw-")), "worker.cjs");
|
|
126
|
+
fs.writeFileSync(tmpPath, WORKER_SOURCE, "utf-8");
|
|
127
|
+
worker = new Worker(tmpPath);
|
|
128
|
+
worker.on("message", (msg: { kind: string; id: number; message?: string }) => {
|
|
129
|
+
const entry = pending.get(msg.id);
|
|
130
|
+
if (!entry) return;
|
|
131
|
+
pending.delete(msg.id);
|
|
132
|
+
if (msg.kind === "done") entry.resolve();
|
|
133
|
+
else entry.reject(new Error(msg.message ?? "worker-atomic-writer: unknown error"));
|
|
134
|
+
});
|
|
135
|
+
worker.on("error", (error: Error) => {
|
|
136
|
+
// Reject ALL pending requests — worker died.
|
|
137
|
+
for (const [, entry] of pending) entry.reject(error);
|
|
138
|
+
pending.clear();
|
|
139
|
+
});
|
|
140
|
+
worker.unref(); // don't keep event loop alive (unless tests request it)
|
|
141
|
+
if (keepRefForTests) worker.ref();
|
|
142
|
+
return worker;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function dispatch(kind: "write" | "mkdir" | "append", payload: Record<string, unknown>): Promise<void> {
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
const id = nextRequestId++;
|
|
148
|
+
pending.set(id, { resolve, reject });
|
|
149
|
+
try {
|
|
150
|
+
getWorker().postMessage({ kind, id, ...payload });
|
|
151
|
+
} catch (error) {
|
|
152
|
+
pending.delete(id);
|
|
153
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Whether the worker writer is enabled (env var opt-in). */
|
|
159
|
+
export function isWorkerAtomicWriterEnabled(): boolean {
|
|
160
|
+
return process.env.PI_CREW_WORKER_ATOMIC_WRITER === "1" || process.env.PI_TEAMS_WORKER_ATOMIC_WRITER === "1";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Atomic-write a file via the worker thread. Sync fs ops inside worker. */
|
|
164
|
+
export function atomicWriteFileViaWorker(filePath: string, content: string): Promise<void> {
|
|
165
|
+
return dispatch("write", { filePath, content });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Append to a file via the worker thread (used by event-log). */
|
|
169
|
+
export function appendFileViaWorker(filePath: string, content: string): Promise<void> {
|
|
170
|
+
return dispatch("append", { filePath, content });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Terminate the worker (for tests / cleanup). */
|
|
174
|
+
export function terminateWorkerAtomicWriter(): void {
|
|
175
|
+
if (worker) {
|
|
176
|
+
const w = worker;
|
|
177
|
+
worker = undefined;
|
|
178
|
+
w.terminate().catch(() => { /* ignore */ });
|
|
179
|
+
}
|
|
180
|
+
for (const [, entry] of pending) entry.reject(new Error("worker terminated"));
|
|
181
|
+
pending.clear();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Tests-only knob: keep the worker ref'd so the test runner doesn't exit
|
|
185
|
+
* before promises resolve. Production code leaves this false (worker is
|
|
186
|
+
* unref'd to avoid blocking process exit). */
|
|
187
|
+
let keepRefForTests = false;
|
|
188
|
+
export function __setKeepWorkerRefForTests(value: boolean): void {
|
|
189
|
+
keepRefForTests = value;
|
|
190
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows-essential environment variables that MUST be present in every
|
|
3
|
+
* subprocess env allowlist in pi-crew.
|
|
4
|
+
*
|
|
5
|
+
* Without them, child processes on Windows cannot locate the npm-global prefix
|
|
6
|
+
* (`%APPDATA%\npm`), the user profile, system DLLs (SystemRoot), cmd.exe
|
|
7
|
+
* (ComSpec), or a writable temp dir.
|
|
8
|
+
*
|
|
9
|
+
* Regression root cause (v0.9.0): env allowlists stripped these vars, so child
|
|
10
|
+
* pi/npm resolved the npm-global prefix via the literal `%APPDATA%` (cmd) /
|
|
11
|
+
* `${APPDATA}` (bash) expansion — but APPDATA was missing from the env, so the
|
|
12
|
+
* shell left the literal `${APPDATA}` in place. A phantom `${APPDATA}/npm`
|
|
13
|
+
* directory appeared in the project root and a literal `${APPDATA}` line leaked
|
|
14
|
+
* into `.gitignore`.
|
|
15
|
+
*
|
|
16
|
+
* USAGE: spread this constant into every `allowList` array:
|
|
17
|
+
* `allowList: ["PATH", "HOME", ...WINDOWS_ESSENTIAL_ENV_VARS, ...]`
|
|
18
|
+
*
|
|
19
|
+
* The regression test `test/unit/env-allowlist.test.ts` enforces that no
|
|
20
|
+
* `src/` file hardcodes these vars inline — all sites MUST use this constant.
|
|
21
|
+
*/
|
|
22
|
+
export const WINDOWS_ESSENTIAL_ENV_VARS: readonly string[] = [
|
|
23
|
+
"APPDATA",
|
|
24
|
+
"LOCALAPPDATA",
|
|
25
|
+
"USERPROFILE",
|
|
26
|
+
"SystemRoot",
|
|
27
|
+
"ComSpec",
|
|
28
|
+
"TEMP",
|
|
29
|
+
"TMP",
|
|
30
|
+
];
|