pi-crew 0.11.44 → 0.11.46
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 +67 -1
- package/dist/index.mjs +510 -510
- package/package.json +1 -1
- package/src/config/env-allowlist-registry.ts +4 -0
- package/src/config/env-vars.ts +4 -0
- package/src/extension/cross-extension-rpc.ts +15 -6
- package/src/prompt/prompt-runtime.ts +16 -3
- package/src/runtime/async-runner.ts +5 -7
- package/src/runtime/broker/crew-broker-child.ts +58 -0
- package/src/runtime/broker/crew-broker-delegate-request.ts +381 -0
- package/src/runtime/broker/crew-broker.ts +57 -327
- package/src/runtime/broker/socket-stop.ts +147 -0
- package/src/runtime/broker/stdin-handshake.ts +11 -0
- package/src/runtime/child-pi/child-pi-kill.ts +44 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +53 -4
- package/src/runtime/child-pi/child-pi.ts +32 -1
- package/src/runtime/child-pi/inference-broker.ts +40 -8
- package/src/runtime/goal-workflow/goal-achievement.ts +10 -1
- package/src/runtime/pi-spawn.ts +10 -1
- package/src/runtime/task-runner/post-execution.ts +36 -2
- package/src/utils/socket-path.ts +54 -0
- package/src/worktree/branch-freshness.ts +9 -22
- package/src/worktree/cleanup.ts +78 -65
- package/src/worktree/git-hardening.ts +507 -0
- package/src/worktree/worktree-manager.ts +176 -129
|
@@ -47,22 +47,18 @@ import {
|
|
|
47
47
|
} from "../../state/coordination/mailbox.ts";
|
|
48
48
|
import { appendEventAsync } from "../../state/event-log/event-log.ts";
|
|
49
49
|
import { loadRunManifestById, saveRunManifest, saveRunTasks } from "../../state/stores/state-store.ts";
|
|
50
|
-
import type { TeamRunManifest
|
|
50
|
+
import type { TeamRunManifest } from "../../state/types.ts";
|
|
51
51
|
import { runEventBus } from "../../ui/run-event-bus.ts";
|
|
52
52
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
53
53
|
import { BrokerError, encodeBrokerFrame, MAX_BROKER_FRAME_BYTES, NdjsonDecoder } from "../../utils/ndjson.ts";
|
|
54
54
|
import { redactSecretString } from "../../utils/redaction.ts";
|
|
55
55
|
import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
|
|
56
56
|
import { getBrokerSocketPath, prepareBrokerSocketDir, removeStaleBrokerSocket } from "../../utils/socket-path.ts";
|
|
57
|
-
import { type GrandchildSpawnResult, spawnDelegateGrandchild } from "../delegate-spawn.ts";
|
|
58
|
-
import { resolveCrewMaxDepth } from "../model/pi-args.ts";
|
|
59
57
|
import { NestedSlotBudget } from "../scheduling/nested-slots.ts";
|
|
60
|
-
import {
|
|
58
|
+
import { handleDelegateRequest } from "./crew-broker-delegate-request.ts";
|
|
61
59
|
import { type BrokerToken, BrokerTokenRegistry } from "./crew-broker-tokens.ts";
|
|
62
60
|
import { DelegateAbortRegistry } from "./delegate/delegate-abort.ts";
|
|
63
61
|
import { type DelegateEventTarget, type DelegateEventType, recordDelegateEvent } from "./delegate/delegate-event.ts";
|
|
64
|
-
import { delegateTerminalEvent, finalizeDelegateResult } from "./delegate/delegate-finalize.ts";
|
|
65
|
-
import { promoteShadowToRunning } from "./delegate/shadow-lifecycle.ts";
|
|
66
62
|
import { fanoutMailboxMessage } from "./mailbox-observer/mailbox-fanout.ts";
|
|
67
63
|
import type { CrewBrokerOptions, ServerConnection } from "./protocol/connection-state.ts";
|
|
68
64
|
// Outbound-queue management + caps (wc-gate move — bodies in protocol/connection-state.ts)
|
|
@@ -81,7 +77,6 @@ import {
|
|
|
81
77
|
BROKER_PROTOCOL,
|
|
82
78
|
isHelloParams,
|
|
83
79
|
isRequestObject,
|
|
84
|
-
parseDelegateRequestParams,
|
|
85
80
|
parseMsgSendParams,
|
|
86
81
|
parseWaitRequestParams,
|
|
87
82
|
safeStringify,
|
|
@@ -90,6 +85,7 @@ import {
|
|
|
90
85
|
} from "./protocol/request-parsers.ts";
|
|
91
86
|
import { recordWaitPolicyRejection, waitAuthError } from "./protocol/wait-auth.ts";
|
|
92
87
|
import { handleWaitResolve } from "./protocol/wait-resolve.ts";
|
|
88
|
+
import { captureSocketIdentity, type SocketIdentity, stopServerAndRemoveSocket } from "./socket-stop.ts";
|
|
93
89
|
import { pushWaitingToForegroundWaiter } from "./wait-push.ts";
|
|
94
90
|
import { WaitStatusCache } from "./wait-status-cache.ts";
|
|
95
91
|
|
|
@@ -144,6 +140,10 @@ export class CrewBroker {
|
|
|
144
140
|
private readonly taskTokens = new Map<string, BrokerToken>();
|
|
145
141
|
private server: net.Server | null = null;
|
|
146
142
|
private resolvedSocketPath: string | null = null;
|
|
143
|
+
/** [3] stop-unlink race: dev+ino of the leaf we created, captured right
|
|
144
|
+
* after listen() succeeded. stop() removes the path only while this
|
|
145
|
+
* still matches (a rebound leaf is a new inode); see socket-stop.ts. */
|
|
146
|
+
private socketIdentity: SocketIdentity | null = null;
|
|
147
147
|
private stopped = false;
|
|
148
148
|
private startingPromise: Promise<void> | null = null;
|
|
149
149
|
private readonly connections = new Set<ServerConnection>();
|
|
@@ -350,6 +350,13 @@ export class CrewBroker {
|
|
|
350
350
|
}
|
|
351
351
|
});
|
|
352
352
|
|
|
353
|
+
// 4.5 [3] stop-unlink race: pin the fresh leaf's identity so stop()
|
|
354
|
+
// can prove it still owns the path before removing it (win32
|
|
355
|
+
// named pipes have no fs leaf — nothing to pin).
|
|
356
|
+
if (process.platform !== "win32") {
|
|
357
|
+
this.socketIdentity = await captureSocketIdentity(sockPath);
|
|
358
|
+
}
|
|
359
|
+
|
|
353
360
|
// 5. Tighten socket permissions on POSIX. Node's net module has no
|
|
354
361
|
// `mode` option (unlike `fs`), so we chmod after listen() succeeds.
|
|
355
362
|
if (process.platform !== "win32") {
|
|
@@ -432,43 +439,46 @@ export class CrewBroker {
|
|
|
432
439
|
}
|
|
433
440
|
this.connections.clear();
|
|
434
441
|
|
|
435
|
-
// 2. Close the server
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
442
|
+
// 2+4. Close the server AND remove the socket path in one
|
|
443
|
+
// ownership-gated step. [3] stop-unlink race: libuv unlinks the
|
|
444
|
+
// bind path BY NAME when the listening handle closes, so the
|
|
445
|
+
// unlink/shield decision must wrap the close itself — see
|
|
446
|
+
// broker/socket-stop.ts. We never touch any other path and
|
|
447
|
+
// never `process.kill` anything.
|
|
448
|
+
const srv = this.server;
|
|
449
|
+
this.server = null;
|
|
450
|
+
const sockPath = this.resolvedSocketPath ?? this.options.socketPath;
|
|
451
|
+
const closeServer = () =>
|
|
452
|
+
new Promise<void>((resolve) => {
|
|
439
453
|
if (!srv) return resolve();
|
|
440
454
|
srv.close(() => resolve());
|
|
441
455
|
// If the server is not currently listening, close() resolves
|
|
442
456
|
// synchronously — guard with a hard timeout for safety.
|
|
443
457
|
setTimeout(() => resolve(), 250).unref();
|
|
444
458
|
});
|
|
445
|
-
|
|
459
|
+
if (sockPath && process.platform !== "win32") {
|
|
460
|
+
const outcome = await stopServerAndRemoveSocket(sockPath, this.socketIdentity, closeServer);
|
|
461
|
+
if (outcome === "shielded-foreign" || outcome === "unshielded-foreign") {
|
|
462
|
+
// Path exists but is not provably ours (rebound or never bound):
|
|
463
|
+
// we did not remove it — any corpse is left for
|
|
464
|
+
// removeStaleBrokerSocket at the next start (fail-safe design).
|
|
465
|
+
logInternalError(
|
|
466
|
+
"crew-broker.stop.unlink-skipped",
|
|
467
|
+
new Error(`socket path not provably owned (${outcome}); left for removeStaleBrokerSocket`),
|
|
468
|
+
`path=${redactSecretString(sockPath)}`,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
} else {
|
|
472
|
+
await closeServer();
|
|
446
473
|
}
|
|
474
|
+
this.resolvedSocketPath = null;
|
|
475
|
+
this.socketIdentity = null;
|
|
447
476
|
|
|
448
477
|
// 3. Clear the token map. This is the single point where the heap
|
|
449
478
|
// state for runIds is wiped. No persistence to clean up.
|
|
450
479
|
this.tokens.clear();
|
|
451
480
|
// Task 10: drop the taskId → token index with it.
|
|
452
481
|
this.taskTokens.clear();
|
|
453
|
-
|
|
454
|
-
// 4. Unlink the recorded socket file IF we created it. We never
|
|
455
|
-
// touch any other path. We also never `process.kill` anything.
|
|
456
|
-
const sockPath = this.resolvedSocketPath ?? this.options.socketPath;
|
|
457
|
-
if (sockPath && process.platform !== "win32") {
|
|
458
|
-
try {
|
|
459
|
-
await fsp.unlink(sockPath);
|
|
460
|
-
} catch (err) {
|
|
461
|
-
const code = (err as NodeJS.ErrnoException).code;
|
|
462
|
-
if (code !== "ENOENT") {
|
|
463
|
-
logInternalError(
|
|
464
|
-
"crew-broker.stop.unlink-failed",
|
|
465
|
-
err instanceof Error ? err : new Error(String(err)),
|
|
466
|
-
`path=${redactSecretString(sockPath)}`,
|
|
467
|
-
);
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
this.resolvedSocketPath = null;
|
|
472
482
|
}
|
|
473
483
|
|
|
474
484
|
// Connection lifecycle
|
|
@@ -759,15 +769,7 @@ export class CrewBroker {
|
|
|
759
769
|
}
|
|
760
770
|
}
|
|
761
771
|
|
|
762
|
-
//
|
|
763
|
-
if (typeof taskId !== "string" || taskId.length === 0 || taskId.length > 256) {
|
|
764
|
-
this.sendErrorAndClose(conn, id, "auth", "hello rejected");
|
|
765
|
-
return;
|
|
766
|
-
}
|
|
767
|
-
if (typeof runId !== "string" || runId.length === 0 || runId.length > 256) {
|
|
768
|
-
this.sendErrorAndClose(conn, id, "auth", "hello rejected");
|
|
769
|
-
return;
|
|
770
|
-
}
|
|
772
|
+
// taskId/runId/token bounds (non-empty, ≤256) are enforced upstream by isHelloParams — protocol/request-parsers.ts.
|
|
771
773
|
|
|
772
774
|
// Bind connection identity and ack. Ack never includes the token.
|
|
773
775
|
conn.authed = true;
|
|
@@ -1449,295 +1451,23 @@ export class CrewBroker {
|
|
|
1449
1451
|
recordDelegateEvent(manifest, type, taskId, data);
|
|
1450
1452
|
}
|
|
1451
1453
|
|
|
1454
|
+
/** T3/R5 (ADR-5): delegate.request — governed-nesting admission + background
|
|
1455
|
+
* grandchild spawn with durable mailbox delivery (WP-5 step 5). Handler
|
|
1456
|
+
* body moved to ./crew-broker-delegate-request.ts (wc-gate ITEM C,
|
|
1457
|
+
* 2026-10-27 — pure move, no behavior change; deps threaded via
|
|
1458
|
+
* DelegateRequestDeps). */
|
|
1452
1459
|
private async handleDelegateRequest(conn: ServerConnection, id: string, params: unknown): Promise<void> {
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
// parseWaitRequestParams discipline) — non-integer/non-finite/negative/
|
|
1464
|
-
// over-ceiling maxTurns/budgetTokens never reach admission or the
|
|
1465
|
-
// budget reservation (durable-negative tokensSpent escape).
|
|
1466
|
-
const parsed = parseDelegateRequestParams(params);
|
|
1467
|
-
if (!parsed) {
|
|
1468
|
-
this.sendError(conn, id, "bad-params", "delegate.request: invalid params");
|
|
1469
|
-
return;
|
|
1470
|
-
}
|
|
1471
|
-
const requested = parsed;
|
|
1472
|
-
const cwd = this.options.cwd;
|
|
1473
|
-
if (!cwd) {
|
|
1474
|
-
this.sendError(conn, id, "no-manifest", "broker has no cwd configured");
|
|
1475
|
-
return;
|
|
1476
|
-
}
|
|
1477
|
-
let loaded: NonNullable<ReturnType<typeof loadRunManifestById>>;
|
|
1478
|
-
try {
|
|
1479
|
-
const l = loadRunManifestById(cwd, conn.runId);
|
|
1480
|
-
if (!l) {
|
|
1481
|
-
this.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
|
|
1482
|
-
return;
|
|
1483
|
-
}
|
|
1484
|
-
loaded = l;
|
|
1485
|
-
} catch (err) {
|
|
1486
|
-
this.sendError(conn, id, "no-manifest", (err as Error).message);
|
|
1487
|
-
return;
|
|
1488
|
-
}
|
|
1489
|
-
// Capability gate (ADR-5 §10): fail-closed, NEVER silent. Since the D8
|
|
1490
|
-
// flip the DEFAULT is true, so reaching this branch means the user
|
|
1491
|
-
// closed the surface via config — the message points back at the knob.
|
|
1492
|
-
if (this.options.nestingEnabled !== true) {
|
|
1493
|
-
this.recordDelegateEvent(loaded.manifest, "delegate.rejected", conn.taskId, {
|
|
1494
|
-
reason: "nesting-disabled",
|
|
1495
|
-
policy: "nesting.enabled=false (user config; default is true since D8)",
|
|
1496
|
-
});
|
|
1497
|
-
this.sendError(
|
|
1498
|
-
conn,
|
|
1499
|
-
id,
|
|
1500
|
-
"policy-disabled",
|
|
1501
|
-
"delegate is disabled: nesting.enabled=false (set nesting.enabled=true in user config; delegate.rejected recorded in events.jsonl)",
|
|
1502
|
-
);
|
|
1503
|
-
return;
|
|
1504
|
-
}
|
|
1505
|
-
const runId = conn.runId;
|
|
1506
|
-
const parentTaskId = conn.taskId;
|
|
1507
|
-
// S1#1 fix (security round 1): the subId is minted BEFORE admission so the
|
|
1508
|
-
// grandchild token scopes to the SUBID (never the parent task key) and the
|
|
1509
|
-
// nested slot is acquired INSIDE the same lock as the admission snapshot.
|
|
1510
|
-
const subId = `gc-${randomUUID()}`;
|
|
1511
|
-
this.recordDelegateEvent(loaded.manifest, "delegate.requested", parentTaskId, { subId, role: requested.role ?? "explorer" });
|
|
1512
|
-
// Admission: full spawn-policy matrix (ADR-5 §2-§7), parent state from
|
|
1513
|
-
// the RECORD under the run lock — never the worker's env/self-report.
|
|
1514
|
-
// RR-023 F4: run-locked via bounded busy-retry (protocol/lock-busy.ts).
|
|
1515
|
-
const admissionAttempt = await this.runLockBusyRetry(loaded.manifest, () => {
|
|
1516
|
-
const fresh = loadRunManifestById(loaded.manifest.cwd, runId);
|
|
1517
|
-
if (!fresh) return { code: "no-manifest" as const, message: `run '${runId}' not found` };
|
|
1518
|
-
const task = fresh.tasks.find((t) => t.id === parentTaskId);
|
|
1519
|
-
if (!task) {
|
|
1520
|
-
this.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, { subId, reason: "no-task" });
|
|
1521
|
-
return { code: "no-task" as const, message: `task '${parentTaskId}' not found` };
|
|
1522
|
-
}
|
|
1523
|
-
if (task.status !== "running") {
|
|
1524
|
-
this.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, {
|
|
1525
|
-
subId,
|
|
1526
|
-
reason: "parent-not-running",
|
|
1527
|
-
message: `task is ${task.status}`,
|
|
1528
|
-
});
|
|
1529
|
-
return { code: "bad-params" as const, message: `delegate: parent task '${parentTaskId}' is ${task.status}, not running` };
|
|
1530
|
-
}
|
|
1531
|
-
// RR-012 F03: authoritative execution cwd = the parent task's cwd from
|
|
1532
|
-
// this locked fresh read — ONE source for overlap check, shadow record
|
|
1533
|
-
// and spawner input; broker cwd stays for manifest lookup only.
|
|
1534
|
-
const executionCwd = task.cwd;
|
|
1535
|
-
const catalog = this.options.modelCatalog?.();
|
|
1536
|
-
// S3 fail-closed: a DEFINED loader yielding undefined is a loader failure.
|
|
1537
|
-
const effectiveCatalog = this.options.modelCatalog !== undefined ? (catalog ?? []) : undefined;
|
|
1538
|
-
// ADR-5 §9: count OTHER in-flight executor-class tasks sharing the
|
|
1539
|
-
// parent task's cwd (manifest record — single source of truth).
|
|
1540
|
-
const overlapping = fresh.tasks.filter(
|
|
1541
|
-
(t) =>
|
|
1542
|
-
t.id !== parentTaskId &&
|
|
1543
|
-
t.status === "running" &&
|
|
1544
|
-
(t.role === "executor" || t.role === "test-engineer") &&
|
|
1545
|
-
t.cwd === executionCwd,
|
|
1546
|
-
).length;
|
|
1547
|
-
const decision = evaluateDelegateAdmission({
|
|
1548
|
-
maxDepth: this.options.nestingMaxDepth ?? resolveCrewMaxDepth(undefined), // config knob > env-clamped 1..10, default 4 (D8; ADR-5 §3)
|
|
1549
|
-
parentTask: {
|
|
1550
|
-
taskId: parentTaskId,
|
|
1551
|
-
role: task.role,
|
|
1552
|
-
...(task.depth !== undefined ? { depth: task.depth } : {}),
|
|
1553
|
-
...(task.allocation !== undefined ? { allocation: task.allocation } : {}),
|
|
1554
|
-
},
|
|
1555
|
-
slots: this.nestedSlotBudget().snapshot(),
|
|
1556
|
-
requested,
|
|
1557
|
-
...(effectiveCatalog !== undefined ? { modelCatalog: effectiveCatalog } : {}),
|
|
1558
|
-
// ADR-5 §12: the delegate surface is an escalation — trusted only by the
|
|
1559
|
-
// explicit user opt-in threaded from config.nesting.enabled (sensitive).
|
|
1560
|
-
untrusted: this.options.nestingTrustedEscalation !== true,
|
|
1561
|
-
// M3-3 / R43-N1 SEED: the run-level token budget is the grant every
|
|
1562
|
-
// task inherits when its own record carries no allocation (pre-fix
|
|
1563
|
-
// state of EVERY task — nothing produced `tokensGranted`, so every
|
|
1564
|
-
// budgeted delegate was denied and every unmetered one slipped by).
|
|
1565
|
-
// `budgetUnlimited` opts out of enforcement entirely ⇒ no seed.
|
|
1566
|
-
...(fresh.manifest.budgetTotal !== undefined && fresh.manifest.budgetUnlimited !== true
|
|
1567
|
-
? { parentBudgetGrantTokens: fresh.manifest.budgetTotal }
|
|
1568
|
-
: {}),
|
|
1569
|
-
// M3-3 / R43-N1: unmetered delegation stays available (documented
|
|
1570
|
-
// pre-fix behavior for nesting runs without a budgetTotal) but is now
|
|
1571
|
-
// an EXPLICIT, audited allow (`unmetered: true` on delegate.admitted).
|
|
1572
|
-
unmeteredDelegates: "allow",
|
|
1573
|
-
workspace: {
|
|
1574
|
-
serializeEnabled: this.options.serializeOnPathOverlap === true,
|
|
1575
|
-
overlappingInFlightExecutors: overlapping,
|
|
1576
|
-
},
|
|
1577
|
-
});
|
|
1578
|
-
if (!decision.allowed) {
|
|
1579
|
-
this.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, {
|
|
1580
|
-
subId,
|
|
1581
|
-
reason: decision.reason,
|
|
1582
|
-
message: (decision.message ?? "").slice(0, 120),
|
|
1583
|
-
});
|
|
1584
|
-
return { code: "policy-denied" as const, message: decision.message ?? decision.reason ?? "delegate denied" };
|
|
1585
|
-
}
|
|
1586
|
-
// Slot acquisition INSIDE the lock (no reserve-then-race refund window).
|
|
1587
|
-
if (!this.nestedSlotBudget().tryAcquire(subId)) {
|
|
1588
|
-
this.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, { subId, reason: "slots-exhausted" });
|
|
1589
|
-
return {
|
|
1590
|
-
code: "policy-denied" as const,
|
|
1591
|
-
message: `delegate rejected: nested spawn budget exhausted; ${this.nestedSlotBudget().statusLine}`,
|
|
1592
|
-
};
|
|
1593
|
-
}
|
|
1594
|
-
// Reserve the requested budget pessimistically (ADR-5 §5): tokensSpent
|
|
1595
|
-
// += budgetTokens now. M3-3/R43-N1: the reservation ALSO seeds the parent
|
|
1596
|
-
// allocation when the record has none — the `decision.budget.grantedTokens`
|
|
1597
|
-
// the gate admitted against is the write-once `tokensGranted`
|
|
1598
|
-
// (state-helpers.ts merge keeps disk's value verbatim). Pre-fix the
|
|
1599
|
-
// `parentAllocation` guard silently skipped the reserve for every task
|
|
1600
|
-
// that had no allocation, i.e. for every task.
|
|
1601
|
-
// M3-4/R44-N4: this reservation IS the spend — no settle-time refund.
|
|
1602
|
-
let reserved = 0;
|
|
1603
|
-
const budgetDecision = decision.budget;
|
|
1604
|
-
let tasksAfterReserve = fresh.tasks;
|
|
1605
|
-
if (budgetDecision?.metered === true) {
|
|
1606
|
-
reserved = budgetDecision.requestedTokens ?? 0;
|
|
1607
|
-
const updatedTasks = fresh.tasks.map((t) =>
|
|
1608
|
-
t.id === parentTaskId
|
|
1609
|
-
? {
|
|
1610
|
-
...t,
|
|
1611
|
-
allocation: {
|
|
1612
|
-
tokensGranted: budgetDecision.grantedTokens,
|
|
1613
|
-
tokensSpent: budgetDecision.spentTokens + reserved,
|
|
1614
|
-
},
|
|
1615
|
-
}
|
|
1616
|
-
: t,
|
|
1617
|
-
);
|
|
1618
|
-
tasksAfterReserve = updatedTasks;
|
|
1619
|
-
saveRunTasks(fresh.manifest, updatedTasks);
|
|
1620
|
-
}
|
|
1621
|
-
// S1#1: register the grandchild SHADOW TASK — the subId identity gets a
|
|
1622
|
-
// real depth/role entry (unbounded-chain fix). Built on tasksAfterReserve
|
|
1623
|
-
// so the budget reservation above is never clobbered.
|
|
1624
|
-
saveRunTasks(fresh.manifest, [
|
|
1625
|
-
...tasksAfterReserve,
|
|
1626
|
-
{
|
|
1627
|
-
id: subId,
|
|
1628
|
-
runId,
|
|
1629
|
-
role: requested.role ?? "explorer",
|
|
1630
|
-
agent: "delegate",
|
|
1631
|
-
title: `delegate: ${(requested as { description?: string }).description ?? subId}`,
|
|
1632
|
-
status: "queued",
|
|
1633
|
-
cwd: executionCwd,
|
|
1634
|
-
dependsOn: [],
|
|
1635
|
-
depth: decision.childDepth ?? 2,
|
|
1636
|
-
startedAt: new Date().toISOString(),
|
|
1637
|
-
} satisfies TeamTaskState,
|
|
1638
|
-
]);
|
|
1639
|
-
// RR-023 F3: thread the parent's model out of the locked read (gc fallback).
|
|
1640
|
-
return { code: "ok" as const, decision, reserved, executionCwd, parentModel: task.model, parentRole: task.role };
|
|
1641
|
-
});
|
|
1642
|
-
// RR-023 F4 (finding #4): run.lock held by a live cross-process holder —
|
|
1643
|
-
// degrade to a typed busy frame AND a delegate.rejected event (never
|
|
1644
|
-
// silent); the connection survives (ping still answers).
|
|
1645
|
-
if (!admissionAttempt.ok) {
|
|
1646
|
-
this.recordDelegateEvent(loaded.manifest, "delegate.rejected", parentTaskId, { subId, reason: "run-lock-busy" });
|
|
1647
|
-
this.sendError(conn, id, "busy", admissionAttempt.message);
|
|
1648
|
-
return;
|
|
1649
|
-
}
|
|
1650
|
-
const admissionOutcome = admissionAttempt.value;
|
|
1651
|
-
if (admissionOutcome.code !== "ok") {
|
|
1652
|
-
this.sendError(conn, id, admissionOutcome.code, admissionOutcome.message);
|
|
1653
|
-
return;
|
|
1654
|
-
}
|
|
1655
|
-
const { decision, reserved, executionCwd, parentModel, parentRole } = admissionOutcome;
|
|
1656
|
-
this.recordDelegateEvent(loaded.manifest, "delegate.admitted", parentTaskId, {
|
|
1657
|
-
subId,
|
|
1658
|
-
childDepth: decision.childDepth,
|
|
1659
|
-
role: requested.role ?? "explorer",
|
|
1660
|
-
// M3-3/R43-N1: an unmetered allow is EXPLICIT in the audit trail.
|
|
1661
|
-
...(decision.budget?.metered === false ? { unmetered: true } : {}),
|
|
1662
|
-
...(reserved > 0 ? { reservedTokens: reserved } : {}),
|
|
1460
|
+
await handleDelegateRequest(conn, id, params, {
|
|
1461
|
+
sendError: (c, i, code, msg) => this.sendError(c, i, code, msg),
|
|
1462
|
+
sendResult: (c, i, r) => this.sendResult(c, i, r),
|
|
1463
|
+
options: this.options,
|
|
1464
|
+
nestedSlotBudget: () => this.nestedSlotBudget(),
|
|
1465
|
+
recordDelegateEvent: (m, type, taskId, data) => this.recordDelegateEvent(m, type, taskId, data),
|
|
1466
|
+
runLockBusyRetry: (manifest, fn) => this.runLockBusyRetry(manifest, fn),
|
|
1467
|
+
delegateAborts: this.delegateAborts,
|
|
1468
|
+
socketPath: this.socketPath,
|
|
1469
|
+
issueRunToken: (runId, taskId) => this.issueRunToken(runId, taskId),
|
|
1663
1470
|
});
|
|
1664
|
-
// S1#1: pre-mint the grandchild-scoped token when (and only when) the
|
|
1665
|
-
// child may itself delegate (childDepth < resolved maxDepth — the same
|
|
1666
|
-
// gate as issueForChild). Depth-2 at default maxDepth gets NO creds.
|
|
1667
|
-
const grandchildCreds =
|
|
1668
|
-
(decision.childDepth ?? 2) < (this.options.nestingMaxDepth ?? resolveCrewMaxDepth(undefined))
|
|
1669
|
-
? { socketPath: this.socketPath, token: this.issueRunToken(runId, subId) }
|
|
1670
|
-
: undefined;
|
|
1671
|
-
// Return IMMEDIATELY (principle 7): the tool self-polls the mailbox.
|
|
1672
|
-
this.sendResult(conn, id, { ok: true, grandchildTaskRef: subId, childDepth: decision.childDepth, timeoutSec: decision.timeoutSec });
|
|
1673
|
-
// RR-023 F3 (finding #3, run team_20260929041427): model-less gc spawn fell
|
|
1674
|
-
// through to the pi GLOBAL default — inherit the parent task's model.
|
|
1675
|
-
const gcModel = requested.model ?? parentModel;
|
|
1676
|
-
// M3-5/R43-N3: register the grandchild's abort controller BEFORE the
|
|
1677
|
-
// spawn starts, so a run terminalise / budget abort / broker stop that
|
|
1678
|
-
// lands mid-spawn still cancels it (no orphan outliving the run).
|
|
1679
|
-
const delegateAbort = this.delegateAborts.register(subId, runId);
|
|
1680
|
-
// Background grandchild lifecycle (ADR-5 §1/§6).
|
|
1681
|
-
const spawner = this.options.grandchildSpawner ?? spawnDelegateGrandchild;
|
|
1682
|
-
void (async () => {
|
|
1683
|
-
let outcome: GrandchildSpawnResult;
|
|
1684
|
-
try {
|
|
1685
|
-
outcome = await spawner({
|
|
1686
|
-
cwd: executionCwd,
|
|
1687
|
-
runId,
|
|
1688
|
-
parentTaskId,
|
|
1689
|
-
// R43-S1: transitive tier containment — the spawner applies
|
|
1690
|
-
// min(parentRole, requestedRole) as the grandchild tool policy.
|
|
1691
|
-
parentRole,
|
|
1692
|
-
subId,
|
|
1693
|
-
prompt: requested.prompt,
|
|
1694
|
-
eventsPath: loaded.manifest.eventsPath,
|
|
1695
|
-
role: requested.role ?? "explorer",
|
|
1696
|
-
...(grandchildCreds ? { brokerSpawn: grandchildCreds } : {}),
|
|
1697
|
-
...(gcModel !== undefined ? { model: gcModel } : {}),
|
|
1698
|
-
...(requested.maxTurns !== undefined ? { maxTurns: requested.maxTurns } : {}),
|
|
1699
|
-
timeoutSec: decision.timeoutSec ?? 900,
|
|
1700
|
-
depthOverride: decision.childDepth ?? 2,
|
|
1701
|
-
// M3-5/R43-N3: owner cancellation (registry-driven).
|
|
1702
|
-
signal: delegateAbort.signal,
|
|
1703
|
-
// RR-012 F16: promote shadow queued→running once the process exists.
|
|
1704
|
-
onSpawn: (pid) => {
|
|
1705
|
-
if (pid !== null) promoteShadowToRunning(cwd, runId, subId);
|
|
1706
|
-
},
|
|
1707
|
-
});
|
|
1708
|
-
} catch (err) {
|
|
1709
|
-
outcome = { ok: false, resultText: `delegate spawn failed: ${(err as Error).message}` };
|
|
1710
|
-
}
|
|
1711
|
-
try {
|
|
1712
|
-
// S3 fence hardening: neutralize smuggled end-fence markers in the
|
|
1713
|
-
// grandchild's raw text, then wrap (the client re-fences too — the
|
|
1714
|
-
// mailbox is an unauthenticated same-uid channel by design).
|
|
1715
|
-
const sanitizedText = outcome.resultText.replace(/^--- (end )?delegate /gm, "-- ~delegate ").slice(0, 65_536);
|
|
1716
|
-
const statusTag = outcome.cancelled ? "(cancelled)" : outcome.timedOut ? "(timed out)" : outcome.ok ? "(ok)" : "(failed)";
|
|
1717
|
-
const fenced = `--- delegate ${subId} ${statusTag} ---\n${sanitizedText}\n--- end delegate ${subId} ---`;
|
|
1718
|
-
// Durable delivery + budget charge + shadow terminal flip — all
|
|
1719
|
-
// inside one run lock, off a fresh disk read (delegate-finalize.ts
|
|
1720
|
-
// owns the R43-N4 reroute and the R44-N4 broker-side charge).
|
|
1721
|
-
finalizeDelegateResult({
|
|
1722
|
-
brokerCwd: cwd,
|
|
1723
|
-
runId,
|
|
1724
|
-
parentTaskId,
|
|
1725
|
-
subId,
|
|
1726
|
-
fenced,
|
|
1727
|
-
ok: outcome.ok,
|
|
1728
|
-
...(outcome.timedOut !== undefined ? { timedOut: outcome.timedOut } : {}),
|
|
1729
|
-
reservedTokens: reserved,
|
|
1730
|
-
...(outcome.usageTokens !== undefined ? { selfReportedTokens: outcome.usageTokens } : {}),
|
|
1731
|
-
});
|
|
1732
|
-
} finally {
|
|
1733
|
-
this.delegateAborts.release(subId);
|
|
1734
|
-
this.nestedSlotBudget().release(subId);
|
|
1735
|
-
}
|
|
1736
|
-
this.recordDelegateEvent(loaded.manifest, delegateTerminalEvent(outcome), parentTaskId, {
|
|
1737
|
-
subId,
|
|
1738
|
-
ok: outcome.ok,
|
|
1739
|
-
});
|
|
1740
|
-
})();
|
|
1741
1471
|
}
|
|
1742
1472
|
|
|
1743
1473
|
// recordWaitPolicyRejection: imported directly from protocol/wait-auth.ts.
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* socket-stop.ts — Ownership-gated teardown of the broker's listening socket.
|
|
3
|
+
*
|
|
4
|
+
* An AF_UNIX listener has TWO unlink vectors, and both must be owned-gated:
|
|
5
|
+
* 1. The explicit `fs.unlink` of the recorded path (CrewBroker.stop step 4).
|
|
6
|
+
* 2. libuv itself: `uv__pipe_close` unlinks the server pipe's bind path BY
|
|
7
|
+
* NAME (no inode check) when the listening handle closes — Node's
|
|
8
|
+
* `server.close()` deletes whatever currently sits at the path, even a
|
|
9
|
+
* socket another listener re-bound there after ours was unlinked
|
|
10
|
+
* (empirically verified on Node 22, 2026-10-26).
|
|
11
|
+
*
|
|
12
|
+
* The broker socket path is deterministic per sessionId, so a rebound leaf
|
|
13
|
+
* must survive our stop(). stopServerAndRemoveSocket therefore:
|
|
14
|
+
* - Unlinks the leaf itself FIRST when its dev+ino match the identity
|
|
15
|
+
* captured at listen() time — libuv's close-unlink then finds nothing.
|
|
16
|
+
* - Otherwise, if ANY entry lives at the path (rebound foreign socket, or
|
|
17
|
+
* ownership unprovable because the capture lstat failed), renames it
|
|
18
|
+
* aside (shield) across closeServer() and renames it back afterwards:
|
|
19
|
+
* a brief ENOENT window for new connects instead of permanent deletion
|
|
20
|
+
* of a live foreign socket. B-LOW2: the rename-back happens ONLY while
|
|
21
|
+
* sockPath is still absent — if a new listener re-bound the leaf during
|
|
22
|
+
* the close, its leaf wins and the shield is kept (renaming back would
|
|
23
|
+
* clobber the fresh bind).
|
|
24
|
+
* A stranded shield (crash between renames, or one kept after a rebind)
|
|
25
|
+
* keeps its `.stopping.` suffix and is swept by the corpse sweep inside
|
|
26
|
+
* removeStaleBrokerSocket at the next start — it unlinks a shield ONLY
|
|
27
|
+
* when the pid embedded in its name is provably dead (B-LOW1).
|
|
28
|
+
* - Windows named pipes have no filesystem leaf: plain close only.
|
|
29
|
+
*
|
|
30
|
+
* Never throws; diagnostics flow through logInternalError with redaction.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { randomBytes } from "node:crypto";
|
|
34
|
+
import * as fsp from "node:fs/promises";
|
|
35
|
+
import { logInternalError } from "../../utils/internal-error.ts";
|
|
36
|
+
import { redactSecretString } from "../../utils/redaction.ts";
|
|
37
|
+
|
|
38
|
+
/** dev+ino of the socket leaf created by listen() — captured immediately
|
|
39
|
+
* after a successful bind so stop() can prove continued ownership of the
|
|
40
|
+
* path before removing it. Null when the lstat failed (stop() then treats
|
|
41
|
+
* the leaf as NOT provably ours — the safe direction). */
|
|
42
|
+
export interface SocketIdentity {
|
|
43
|
+
dev: number;
|
|
44
|
+
ino: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Capture the identity of the freshly-bound socket leaf (POSIX only). */
|
|
48
|
+
export async function captureSocketIdentity(sockPath: string): Promise<SocketIdentity | null> {
|
|
49
|
+
try {
|
|
50
|
+
const st = await fsp.lstat(sockPath);
|
|
51
|
+
return { dev: st.dev, ino: st.ino };
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type SocketStopOutcome =
|
|
58
|
+
/** Our leaf (identity matched) was unlinked by us before the close. */
|
|
59
|
+
| "unlinked"
|
|
60
|
+
/** A foreign/unpinned entry existed and was shielded across the close. */
|
|
61
|
+
| "shielded-foreign"
|
|
62
|
+
/** A foreign entry existed but the shield rename failed (close then
|
|
63
|
+
* unlinks it by name — the pre-fix behavior; logged loudly). */
|
|
64
|
+
| "unshielded-foreign"
|
|
65
|
+
/** Nothing lived at the path (or win32: no fs leaf at all). */
|
|
66
|
+
| "absent";
|
|
67
|
+
|
|
68
|
+
/** Close the listening server and remove the socket path ONLY while we
|
|
69
|
+
* provably own it. `closeServer` must perform the net.Server close (the
|
|
70
|
+
* libuv by-name unlink vector) — it is called exactly once, sandwiched
|
|
71
|
+
* inside the ownership decision. Never throws. */
|
|
72
|
+
export async function stopServerAndRemoveSocket(
|
|
73
|
+
sockPath: string,
|
|
74
|
+
identity: SocketIdentity | null,
|
|
75
|
+
closeServer: () => Promise<void>,
|
|
76
|
+
): Promise<SocketStopOutcome> {
|
|
77
|
+
if (process.platform === "win32") {
|
|
78
|
+
await closeServer();
|
|
79
|
+
return "absent";
|
|
80
|
+
}
|
|
81
|
+
const current = await fsp.lstat(sockPath).catch(() => null);
|
|
82
|
+
if (current === null) {
|
|
83
|
+
await closeServer();
|
|
84
|
+
return "absent";
|
|
85
|
+
}
|
|
86
|
+
const owned = identity !== null && current.dev === identity.dev && current.ino === identity.ino;
|
|
87
|
+
if (owned) {
|
|
88
|
+
// Remove our own leaf first so libuv's close-unlink finds nothing.
|
|
89
|
+
// (Microsecond TOCTOU between this unlink and the close-unlink is
|
|
90
|
+
// inherent to path-based APIs — accepted, same class as before.)
|
|
91
|
+
await fsp.unlink(sockPath).catch(() => {
|
|
92
|
+
/* best-effort: the leaf may already be gone */
|
|
93
|
+
});
|
|
94
|
+
await closeServer();
|
|
95
|
+
return "unlinked";
|
|
96
|
+
}
|
|
97
|
+
// Foreign or unprovably-owned entry: shield it across the close.
|
|
98
|
+
const shield = `${sockPath}.stopping.${process.pid}.${randomBytes(3).toString("hex")}`;
|
|
99
|
+
let shielded = false;
|
|
100
|
+
try {
|
|
101
|
+
await fsp.rename(sockPath, shield);
|
|
102
|
+
shielded = true;
|
|
103
|
+
} catch (err) {
|
|
104
|
+
logInternalError(
|
|
105
|
+
"crew-broker.stop.shield-rename-failed",
|
|
106
|
+
err instanceof Error ? err : new Error(String(err)),
|
|
107
|
+
`path=${redactSecretString(sockPath)}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
await closeServer();
|
|
112
|
+
} finally {
|
|
113
|
+
if (shielded) {
|
|
114
|
+
// B-LOW2: restore ONLY while the leaf is still absent. While our
|
|
115
|
+
// handle was closing, a new listener may have bound sockPath — a
|
|
116
|
+
// rename back would REPLACE its fresh leaf (rename clobbers the
|
|
117
|
+
// target), recreating the exact deletion bug the shield exists to
|
|
118
|
+
// prevent. When rebound: keep the shield (the corpse sweep at the
|
|
119
|
+
// next start removes it once this pid is dead) and log loudly.
|
|
120
|
+
let rebound = false;
|
|
121
|
+
try {
|
|
122
|
+
await fsp.lstat(sockPath);
|
|
123
|
+
rebound = true;
|
|
124
|
+
} catch {
|
|
125
|
+
/* still absent — safe to restore */
|
|
126
|
+
}
|
|
127
|
+
if (rebound) {
|
|
128
|
+
logInternalError(
|
|
129
|
+
"crew-broker.stop.shield-kept-rebound",
|
|
130
|
+
new Error(`socket path re-bound during close — keeping shield at ${shield} instead of clobbering the new listener`),
|
|
131
|
+
`path=${redactSecretString(sockPath)}`,
|
|
132
|
+
);
|
|
133
|
+
} else {
|
|
134
|
+
try {
|
|
135
|
+
await fsp.rename(shield, sockPath);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
logInternalError(
|
|
138
|
+
"crew-broker.stop.shield-restore-failed",
|
|
139
|
+
err instanceof Error ? err : new Error(String(err)),
|
|
140
|
+
`path=${redactSecretString(shield)}`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return shielded ? "shielded-foreign" : "unshielded-foreign";
|
|
147
|
+
}
|
|
@@ -50,6 +50,17 @@ export function parseStdinBrokerPayload(raw: string, expectedRunId: string): Std
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** LOW-1 residual closure (2026-10-27): the ONE line a spawner writes to a
|
|
54
|
+
* worker's stdin carrying PER-TASK compound broker tokens. Moved here from
|
|
55
|
+
* async-runner.ts (which re-exports for API stability) so BOTH writer sides —
|
|
56
|
+
* the detached background runner AND the main child-pi spawn path — share the
|
|
57
|
+
* pure protocol piece and cannot drift from the reader parser below (a
|
|
58
|
+
* writer/reader drift silently breaks coordination). Compound (runId+taskId)
|
|
59
|
+
* tokens only: wait.* rejects bare-runId tokens (ADR-0 item 6). */
|
|
60
|
+
export function buildBrokerStdinLine(runId: string, socketPath: string, tasks: Record<string, string>): string {
|
|
61
|
+
return `${JSON.stringify({ v: 2, runId, socketPath, tasks })}\n`;
|
|
62
|
+
}
|
|
63
|
+
|
|
53
64
|
/** Read the FIRST stdin line with a hard cap. Resolves undefined for: no
|
|
54
65
|
* stdin / TTY / silence past the timeout / oversize / error. Never throws,
|
|
55
66
|
* never destroys stdin (leaves it paused for the rest of boot). */
|