pi-crew 0.11.45 → 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 +22 -0
- package/dist/index.mjs +481 -481
- package/package.json +1 -1
- package/src/config/env-allowlist-registry.ts +4 -0
- package/src/config/env-vars.ts +4 -0
- 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 +17 -294
- package/src/runtime/broker/stdin-handshake.ts +11 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +53 -4
- package/src/runtime/child-pi/child-pi.ts +32 -1
|
@@ -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,
|
|
@@ -1456,295 +1451,23 @@ export class CrewBroker {
|
|
|
1456
1451
|
recordDelegateEvent(manifest, type, taskId, data);
|
|
1457
1452
|
}
|
|
1458
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). */
|
|
1459
1459
|
private async handleDelegateRequest(conn: ServerConnection, id: string, params: unknown): Promise<void> {
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
// parseWaitRequestParams discipline) — non-integer/non-finite/negative/
|
|
1471
|
-
// over-ceiling maxTurns/budgetTokens never reach admission or the
|
|
1472
|
-
// budget reservation (durable-negative tokensSpent escape).
|
|
1473
|
-
const parsed = parseDelegateRequestParams(params);
|
|
1474
|
-
if (!parsed) {
|
|
1475
|
-
this.sendError(conn, id, "bad-params", "delegate.request: invalid params");
|
|
1476
|
-
return;
|
|
1477
|
-
}
|
|
1478
|
-
const requested = parsed;
|
|
1479
|
-
const cwd = this.options.cwd;
|
|
1480
|
-
if (!cwd) {
|
|
1481
|
-
this.sendError(conn, id, "no-manifest", "broker has no cwd configured");
|
|
1482
|
-
return;
|
|
1483
|
-
}
|
|
1484
|
-
let loaded: NonNullable<ReturnType<typeof loadRunManifestById>>;
|
|
1485
|
-
try {
|
|
1486
|
-
const l = loadRunManifestById(cwd, conn.runId);
|
|
1487
|
-
if (!l) {
|
|
1488
|
-
this.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
|
|
1489
|
-
return;
|
|
1490
|
-
}
|
|
1491
|
-
loaded = l;
|
|
1492
|
-
} catch (err) {
|
|
1493
|
-
this.sendError(conn, id, "no-manifest", (err as Error).message);
|
|
1494
|
-
return;
|
|
1495
|
-
}
|
|
1496
|
-
// Capability gate (ADR-5 §10): fail-closed, NEVER silent. Since the D8
|
|
1497
|
-
// flip the DEFAULT is true, so reaching this branch means the user
|
|
1498
|
-
// closed the surface via config — the message points back at the knob.
|
|
1499
|
-
if (this.options.nestingEnabled !== true) {
|
|
1500
|
-
this.recordDelegateEvent(loaded.manifest, "delegate.rejected", conn.taskId, {
|
|
1501
|
-
reason: "nesting-disabled",
|
|
1502
|
-
policy: "nesting.enabled=false (user config; default is true since D8)",
|
|
1503
|
-
});
|
|
1504
|
-
this.sendError(
|
|
1505
|
-
conn,
|
|
1506
|
-
id,
|
|
1507
|
-
"policy-disabled",
|
|
1508
|
-
"delegate is disabled: nesting.enabled=false (set nesting.enabled=true in user config; delegate.rejected recorded in events.jsonl)",
|
|
1509
|
-
);
|
|
1510
|
-
return;
|
|
1511
|
-
}
|
|
1512
|
-
const runId = conn.runId;
|
|
1513
|
-
const parentTaskId = conn.taskId;
|
|
1514
|
-
// S1#1 fix (security round 1): the subId is minted BEFORE admission so the
|
|
1515
|
-
// grandchild token scopes to the SUBID (never the parent task key) and the
|
|
1516
|
-
// nested slot is acquired INSIDE the same lock as the admission snapshot.
|
|
1517
|
-
const subId = `gc-${randomUUID()}`;
|
|
1518
|
-
this.recordDelegateEvent(loaded.manifest, "delegate.requested", parentTaskId, { subId, role: requested.role ?? "explorer" });
|
|
1519
|
-
// Admission: full spawn-policy matrix (ADR-5 §2-§7), parent state from
|
|
1520
|
-
// the RECORD under the run lock — never the worker's env/self-report.
|
|
1521
|
-
// RR-023 F4: run-locked via bounded busy-retry (protocol/lock-busy.ts).
|
|
1522
|
-
const admissionAttempt = await this.runLockBusyRetry(loaded.manifest, () => {
|
|
1523
|
-
const fresh = loadRunManifestById(loaded.manifest.cwd, runId);
|
|
1524
|
-
if (!fresh) return { code: "no-manifest" as const, message: `run '${runId}' not found` };
|
|
1525
|
-
const task = fresh.tasks.find((t) => t.id === parentTaskId);
|
|
1526
|
-
if (!task) {
|
|
1527
|
-
this.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, { subId, reason: "no-task" });
|
|
1528
|
-
return { code: "no-task" as const, message: `task '${parentTaskId}' not found` };
|
|
1529
|
-
}
|
|
1530
|
-
if (task.status !== "running") {
|
|
1531
|
-
this.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, {
|
|
1532
|
-
subId,
|
|
1533
|
-
reason: "parent-not-running",
|
|
1534
|
-
message: `task is ${task.status}`,
|
|
1535
|
-
});
|
|
1536
|
-
return { code: "bad-params" as const, message: `delegate: parent task '${parentTaskId}' is ${task.status}, not running` };
|
|
1537
|
-
}
|
|
1538
|
-
// RR-012 F03: authoritative execution cwd = the parent task's cwd from
|
|
1539
|
-
// this locked fresh read — ONE source for overlap check, shadow record
|
|
1540
|
-
// and spawner input; broker cwd stays for manifest lookup only.
|
|
1541
|
-
const executionCwd = task.cwd;
|
|
1542
|
-
const catalog = this.options.modelCatalog?.();
|
|
1543
|
-
// S3 fail-closed: a DEFINED loader yielding undefined is a loader failure.
|
|
1544
|
-
const effectiveCatalog = this.options.modelCatalog !== undefined ? (catalog ?? []) : undefined;
|
|
1545
|
-
// ADR-5 §9: count OTHER in-flight executor-class tasks sharing the
|
|
1546
|
-
// parent task's cwd (manifest record — single source of truth).
|
|
1547
|
-
const overlapping = fresh.tasks.filter(
|
|
1548
|
-
(t) =>
|
|
1549
|
-
t.id !== parentTaskId &&
|
|
1550
|
-
t.status === "running" &&
|
|
1551
|
-
(t.role === "executor" || t.role === "test-engineer") &&
|
|
1552
|
-
t.cwd === executionCwd,
|
|
1553
|
-
).length;
|
|
1554
|
-
const decision = evaluateDelegateAdmission({
|
|
1555
|
-
maxDepth: this.options.nestingMaxDepth ?? resolveCrewMaxDepth(undefined), // config knob > env-clamped 1..10, default 4 (D8; ADR-5 §3)
|
|
1556
|
-
parentTask: {
|
|
1557
|
-
taskId: parentTaskId,
|
|
1558
|
-
role: task.role,
|
|
1559
|
-
...(task.depth !== undefined ? { depth: task.depth } : {}),
|
|
1560
|
-
...(task.allocation !== undefined ? { allocation: task.allocation } : {}),
|
|
1561
|
-
},
|
|
1562
|
-
slots: this.nestedSlotBudget().snapshot(),
|
|
1563
|
-
requested,
|
|
1564
|
-
...(effectiveCatalog !== undefined ? { modelCatalog: effectiveCatalog } : {}),
|
|
1565
|
-
// ADR-5 §12: the delegate surface is an escalation — trusted only by the
|
|
1566
|
-
// explicit user opt-in threaded from config.nesting.enabled (sensitive).
|
|
1567
|
-
untrusted: this.options.nestingTrustedEscalation !== true,
|
|
1568
|
-
// M3-3 / R43-N1 SEED: the run-level token budget is the grant every
|
|
1569
|
-
// task inherits when its own record carries no allocation (pre-fix
|
|
1570
|
-
// state of EVERY task — nothing produced `tokensGranted`, so every
|
|
1571
|
-
// budgeted delegate was denied and every unmetered one slipped by).
|
|
1572
|
-
// `budgetUnlimited` opts out of enforcement entirely ⇒ no seed.
|
|
1573
|
-
...(fresh.manifest.budgetTotal !== undefined && fresh.manifest.budgetUnlimited !== true
|
|
1574
|
-
? { parentBudgetGrantTokens: fresh.manifest.budgetTotal }
|
|
1575
|
-
: {}),
|
|
1576
|
-
// M3-3 / R43-N1: unmetered delegation stays available (documented
|
|
1577
|
-
// pre-fix behavior for nesting runs without a budgetTotal) but is now
|
|
1578
|
-
// an EXPLICIT, audited allow (`unmetered: true` on delegate.admitted).
|
|
1579
|
-
unmeteredDelegates: "allow",
|
|
1580
|
-
workspace: {
|
|
1581
|
-
serializeEnabled: this.options.serializeOnPathOverlap === true,
|
|
1582
|
-
overlappingInFlightExecutors: overlapping,
|
|
1583
|
-
},
|
|
1584
|
-
});
|
|
1585
|
-
if (!decision.allowed) {
|
|
1586
|
-
this.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, {
|
|
1587
|
-
subId,
|
|
1588
|
-
reason: decision.reason,
|
|
1589
|
-
message: (decision.message ?? "").slice(0, 120),
|
|
1590
|
-
});
|
|
1591
|
-
return { code: "policy-denied" as const, message: decision.message ?? decision.reason ?? "delegate denied" };
|
|
1592
|
-
}
|
|
1593
|
-
// Slot acquisition INSIDE the lock (no reserve-then-race refund window).
|
|
1594
|
-
if (!this.nestedSlotBudget().tryAcquire(subId)) {
|
|
1595
|
-
this.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, { subId, reason: "slots-exhausted" });
|
|
1596
|
-
return {
|
|
1597
|
-
code: "policy-denied" as const,
|
|
1598
|
-
message: `delegate rejected: nested spawn budget exhausted; ${this.nestedSlotBudget().statusLine}`,
|
|
1599
|
-
};
|
|
1600
|
-
}
|
|
1601
|
-
// Reserve the requested budget pessimistically (ADR-5 §5): tokensSpent
|
|
1602
|
-
// += budgetTokens now. M3-3/R43-N1: the reservation ALSO seeds the parent
|
|
1603
|
-
// allocation when the record has none — the `decision.budget.grantedTokens`
|
|
1604
|
-
// the gate admitted against is the write-once `tokensGranted`
|
|
1605
|
-
// (state-helpers.ts merge keeps disk's value verbatim). Pre-fix the
|
|
1606
|
-
// `parentAllocation` guard silently skipped the reserve for every task
|
|
1607
|
-
// that had no allocation, i.e. for every task.
|
|
1608
|
-
// M3-4/R44-N4: this reservation IS the spend — no settle-time refund.
|
|
1609
|
-
let reserved = 0;
|
|
1610
|
-
const budgetDecision = decision.budget;
|
|
1611
|
-
let tasksAfterReserve = fresh.tasks;
|
|
1612
|
-
if (budgetDecision?.metered === true) {
|
|
1613
|
-
reserved = budgetDecision.requestedTokens ?? 0;
|
|
1614
|
-
const updatedTasks = fresh.tasks.map((t) =>
|
|
1615
|
-
t.id === parentTaskId
|
|
1616
|
-
? {
|
|
1617
|
-
...t,
|
|
1618
|
-
allocation: {
|
|
1619
|
-
tokensGranted: budgetDecision.grantedTokens,
|
|
1620
|
-
tokensSpent: budgetDecision.spentTokens + reserved,
|
|
1621
|
-
},
|
|
1622
|
-
}
|
|
1623
|
-
: t,
|
|
1624
|
-
);
|
|
1625
|
-
tasksAfterReserve = updatedTasks;
|
|
1626
|
-
saveRunTasks(fresh.manifest, updatedTasks);
|
|
1627
|
-
}
|
|
1628
|
-
// S1#1: register the grandchild SHADOW TASK — the subId identity gets a
|
|
1629
|
-
// real depth/role entry (unbounded-chain fix). Built on tasksAfterReserve
|
|
1630
|
-
// so the budget reservation above is never clobbered.
|
|
1631
|
-
saveRunTasks(fresh.manifest, [
|
|
1632
|
-
...tasksAfterReserve,
|
|
1633
|
-
{
|
|
1634
|
-
id: subId,
|
|
1635
|
-
runId,
|
|
1636
|
-
role: requested.role ?? "explorer",
|
|
1637
|
-
agent: "delegate",
|
|
1638
|
-
title: `delegate: ${(requested as { description?: string }).description ?? subId}`,
|
|
1639
|
-
status: "queued",
|
|
1640
|
-
cwd: executionCwd,
|
|
1641
|
-
dependsOn: [],
|
|
1642
|
-
depth: decision.childDepth ?? 2,
|
|
1643
|
-
startedAt: new Date().toISOString(),
|
|
1644
|
-
} satisfies TeamTaskState,
|
|
1645
|
-
]);
|
|
1646
|
-
// RR-023 F3: thread the parent's model out of the locked read (gc fallback).
|
|
1647
|
-
return { code: "ok" as const, decision, reserved, executionCwd, parentModel: task.model, parentRole: task.role };
|
|
1648
|
-
});
|
|
1649
|
-
// RR-023 F4 (finding #4): run.lock held by a live cross-process holder —
|
|
1650
|
-
// degrade to a typed busy frame AND a delegate.rejected event (never
|
|
1651
|
-
// silent); the connection survives (ping still answers).
|
|
1652
|
-
if (!admissionAttempt.ok) {
|
|
1653
|
-
this.recordDelegateEvent(loaded.manifest, "delegate.rejected", parentTaskId, { subId, reason: "run-lock-busy" });
|
|
1654
|
-
this.sendError(conn, id, "busy", admissionAttempt.message);
|
|
1655
|
-
return;
|
|
1656
|
-
}
|
|
1657
|
-
const admissionOutcome = admissionAttempt.value;
|
|
1658
|
-
if (admissionOutcome.code !== "ok") {
|
|
1659
|
-
this.sendError(conn, id, admissionOutcome.code, admissionOutcome.message);
|
|
1660
|
-
return;
|
|
1661
|
-
}
|
|
1662
|
-
const { decision, reserved, executionCwd, parentModel, parentRole } = admissionOutcome;
|
|
1663
|
-
this.recordDelegateEvent(loaded.manifest, "delegate.admitted", parentTaskId, {
|
|
1664
|
-
subId,
|
|
1665
|
-
childDepth: decision.childDepth,
|
|
1666
|
-
role: requested.role ?? "explorer",
|
|
1667
|
-
// M3-3/R43-N1: an unmetered allow is EXPLICIT in the audit trail.
|
|
1668
|
-
...(decision.budget?.metered === false ? { unmetered: true } : {}),
|
|
1669
|
-
...(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),
|
|
1670
1470
|
});
|
|
1671
|
-
// S1#1: pre-mint the grandchild-scoped token when (and only when) the
|
|
1672
|
-
// child may itself delegate (childDepth < resolved maxDepth — the same
|
|
1673
|
-
// gate as issueForChild). Depth-2 at default maxDepth gets NO creds.
|
|
1674
|
-
const grandchildCreds =
|
|
1675
|
-
(decision.childDepth ?? 2) < (this.options.nestingMaxDepth ?? resolveCrewMaxDepth(undefined))
|
|
1676
|
-
? { socketPath: this.socketPath, token: this.issueRunToken(runId, subId) }
|
|
1677
|
-
: undefined;
|
|
1678
|
-
// Return IMMEDIATELY (principle 7): the tool self-polls the mailbox.
|
|
1679
|
-
this.sendResult(conn, id, { ok: true, grandchildTaskRef: subId, childDepth: decision.childDepth, timeoutSec: decision.timeoutSec });
|
|
1680
|
-
// RR-023 F3 (finding #3, run team_20260929041427): model-less gc spawn fell
|
|
1681
|
-
// through to the pi GLOBAL default — inherit the parent task's model.
|
|
1682
|
-
const gcModel = requested.model ?? parentModel;
|
|
1683
|
-
// M3-5/R43-N3: register the grandchild's abort controller BEFORE the
|
|
1684
|
-
// spawn starts, so a run terminalise / budget abort / broker stop that
|
|
1685
|
-
// lands mid-spawn still cancels it (no orphan outliving the run).
|
|
1686
|
-
const delegateAbort = this.delegateAborts.register(subId, runId);
|
|
1687
|
-
// Background grandchild lifecycle (ADR-5 §1/§6).
|
|
1688
|
-
const spawner = this.options.grandchildSpawner ?? spawnDelegateGrandchild;
|
|
1689
|
-
void (async () => {
|
|
1690
|
-
let outcome: GrandchildSpawnResult;
|
|
1691
|
-
try {
|
|
1692
|
-
outcome = await spawner({
|
|
1693
|
-
cwd: executionCwd,
|
|
1694
|
-
runId,
|
|
1695
|
-
parentTaskId,
|
|
1696
|
-
// R43-S1: transitive tier containment — the spawner applies
|
|
1697
|
-
// min(parentRole, requestedRole) as the grandchild tool policy.
|
|
1698
|
-
parentRole,
|
|
1699
|
-
subId,
|
|
1700
|
-
prompt: requested.prompt,
|
|
1701
|
-
eventsPath: loaded.manifest.eventsPath,
|
|
1702
|
-
role: requested.role ?? "explorer",
|
|
1703
|
-
...(grandchildCreds ? { brokerSpawn: grandchildCreds } : {}),
|
|
1704
|
-
...(gcModel !== undefined ? { model: gcModel } : {}),
|
|
1705
|
-
...(requested.maxTurns !== undefined ? { maxTurns: requested.maxTurns } : {}),
|
|
1706
|
-
timeoutSec: decision.timeoutSec ?? 900,
|
|
1707
|
-
depthOverride: decision.childDepth ?? 2,
|
|
1708
|
-
// M3-5/R43-N3: owner cancellation (registry-driven).
|
|
1709
|
-
signal: delegateAbort.signal,
|
|
1710
|
-
// RR-012 F16: promote shadow queued→running once the process exists.
|
|
1711
|
-
onSpawn: (pid) => {
|
|
1712
|
-
if (pid !== null) promoteShadowToRunning(cwd, runId, subId);
|
|
1713
|
-
},
|
|
1714
|
-
});
|
|
1715
|
-
} catch (err) {
|
|
1716
|
-
outcome = { ok: false, resultText: `delegate spawn failed: ${(err as Error).message}` };
|
|
1717
|
-
}
|
|
1718
|
-
try {
|
|
1719
|
-
// S3 fence hardening: neutralize smuggled end-fence markers in the
|
|
1720
|
-
// grandchild's raw text, then wrap (the client re-fences too — the
|
|
1721
|
-
// mailbox is an unauthenticated same-uid channel by design).
|
|
1722
|
-
const sanitizedText = outcome.resultText.replace(/^--- (end )?delegate /gm, "-- ~delegate ").slice(0, 65_536);
|
|
1723
|
-
const statusTag = outcome.cancelled ? "(cancelled)" : outcome.timedOut ? "(timed out)" : outcome.ok ? "(ok)" : "(failed)";
|
|
1724
|
-
const fenced = `--- delegate ${subId} ${statusTag} ---\n${sanitizedText}\n--- end delegate ${subId} ---`;
|
|
1725
|
-
// Durable delivery + budget charge + shadow terminal flip — all
|
|
1726
|
-
// inside one run lock, off a fresh disk read (delegate-finalize.ts
|
|
1727
|
-
// owns the R43-N4 reroute and the R44-N4 broker-side charge).
|
|
1728
|
-
finalizeDelegateResult({
|
|
1729
|
-
brokerCwd: cwd,
|
|
1730
|
-
runId,
|
|
1731
|
-
parentTaskId,
|
|
1732
|
-
subId,
|
|
1733
|
-
fenced,
|
|
1734
|
-
ok: outcome.ok,
|
|
1735
|
-
...(outcome.timedOut !== undefined ? { timedOut: outcome.timedOut } : {}),
|
|
1736
|
-
reservedTokens: reserved,
|
|
1737
|
-
...(outcome.usageTokens !== undefined ? { selfReportedTokens: outcome.usageTokens } : {}),
|
|
1738
|
-
});
|
|
1739
|
-
} finally {
|
|
1740
|
-
this.delegateAborts.release(subId);
|
|
1741
|
-
this.nestedSlotBudget().release(subId);
|
|
1742
|
-
}
|
|
1743
|
-
this.recordDelegateEvent(loaded.manifest, delegateTerminalEvent(outcome), parentTaskId, {
|
|
1744
|
-
subId,
|
|
1745
|
-
ok: outcome.ok,
|
|
1746
|
-
});
|
|
1747
|
-
})();
|
|
1748
1471
|
}
|
|
1749
1472
|
|
|
1750
1473
|
// recordWaitPolicyRejection: imported directly from protocol/wait-auth.ts.
|
|
@@ -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). */
|
|
@@ -20,10 +20,12 @@ import type { SpawnOptions } from "node:child_process";
|
|
|
20
20
|
import * as fs from "node:fs";
|
|
21
21
|
import * as path from "node:path";
|
|
22
22
|
import { childPiBaseAllowlist } from "../../config/env-allowlist-registry.ts";
|
|
23
|
+
import { getCrewEnv } from "../../config/env-vars.ts";
|
|
23
24
|
import { isScratchpadEnabledForRole } from "../../config/role-tools.ts";
|
|
24
25
|
import { buildScopedAllowList, providerEnvKeys, sanitizeEnvSecrets } from "../../utils/env-filter.ts";
|
|
25
26
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
26
27
|
import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
|
|
28
|
+
import { buildBrokerStdinLine } from "../broker/stdin-handshake.ts";
|
|
27
29
|
import { buildPiWorkerArgs, createSafeTempDir, getPiTempBase } from "../model/pi-args.ts";
|
|
28
30
|
import { getPiSpawnCommand } from "../pi-spawn.ts";
|
|
29
31
|
import { findLatestScratchpadSnapshot } from "../scratchpad/snapshot-lookup.ts";
|
|
@@ -44,6 +46,19 @@ import {
|
|
|
44
46
|
// NOT set this (their sessions persist and benefit from long-lived cache).
|
|
45
47
|
export const PI_CACHE_RETENTION_ENV = "PI_CACHE_RETENTION";
|
|
46
48
|
|
|
49
|
+
/** LOW-1 residual closure (2026-10-27): when the inter-pi broker credentials
|
|
50
|
+
* are present, the per-task compound TOKEN travels over the spawn stdin pipe
|
|
51
|
+
* (one `buildBrokerStdinLine` payload, heap → pipe → heap — same shape as the
|
|
52
|
+
* F4 background-runner handshake) instead of the child env. Set
|
|
53
|
+
* PI_CREW_BROKER_STDIN=0 to roll back to the legacy env route (token in
|
|
54
|
+
* built.env, stdin stays "ignore"). Default ON. Only the headless JSON spawn
|
|
55
|
+
* branch consumes the payload: the RPC branch (stdin is the JSONL protocol
|
|
56
|
+
* channel) and the surface TUI panes keep the env route, so their env is
|
|
57
|
+
* untouched. */
|
|
58
|
+
export function resolveBrokerStdinHandshakeEnabled(): boolean {
|
|
59
|
+
return getCrewEnv("PI_CREW_BROKER_STDIN") !== "0";
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
// ── Env allowlist (base set always passed to children) ──────────────────
|
|
48
63
|
// Provider API keys are injected dynamically via buildScopedAllowList() only
|
|
49
64
|
// when a model is assigned to the task (per-task key scoping).
|
|
@@ -258,6 +273,7 @@ export function buildFinalChildPiSpawnOptions(
|
|
|
258
273
|
model?: string,
|
|
259
274
|
modelChain?: (string | undefined)[],
|
|
260
275
|
workerCacheSplit?: boolean,
|
|
276
|
+
stdinBrokerPayload?: string,
|
|
261
277
|
): SpawnOptions {
|
|
262
278
|
// (a) Canary: builtEnv must contain ONLY PI_CREW_*/PI_TEAMS_* keys.
|
|
263
279
|
assertOnlyControlEnvKeys(builtEnv);
|
|
@@ -287,7 +303,18 @@ export function buildFinalChildPiSpawnOptions(
|
|
|
287
303
|
if (workerCacheSplit) {
|
|
288
304
|
spawnOptions.env[PI_CACHE_RETENTION_ENV] = "short";
|
|
289
305
|
}
|
|
290
|
-
// (d)
|
|
306
|
+
// (d) LOW-1 closure: when the caller will hand the broker token to the child
|
|
307
|
+
// over the stdin pipe (headless JSON branch — it passes the payload it got
|
|
308
|
+
// from prepareSpawnContext), switch stdin from "ignore" to "pipe" and strip
|
|
309
|
+
// the secret from the env as defense-in-depth (prepareSpawnContext already
|
|
310
|
+
// omits it; the delete guards direct builtEnv constructions in tests/callers
|
|
311
|
+
// that still carry it). RPC branch / surface workerEnv snapshots call
|
|
312
|
+
// WITHOUT this param → env keeps the token and nothing else changes.
|
|
313
|
+
if (stdinBrokerPayload !== undefined) {
|
|
314
|
+
spawnOptions.stdio = ["pipe", "pipe", "pipe"];
|
|
315
|
+
delete spawnOptions.env.PI_CREW_BROKER_TOKEN;
|
|
316
|
+
}
|
|
317
|
+
// (e) Return the composed SpawnOptions for spawn(...).
|
|
291
318
|
return spawnOptions;
|
|
292
319
|
}
|
|
293
320
|
|
|
@@ -310,6 +337,12 @@ export interface SpawnContext {
|
|
|
310
337
|
builtEnv: Record<string, string | undefined>;
|
|
311
338
|
/** v0.11.31: inference-broker worker key that owns this spawn's token (cleanup/revoke bookkeeping). */
|
|
312
339
|
inferenceWorkerKey?: string;
|
|
340
|
+
/** LOW-1 closure: one-line broker credential payload to write to the
|
|
341
|
+
* child's stdin right after spawn (headless JSON branch ONLY — the RPC
|
|
342
|
+
* branch owns stdin for its JSONL protocol and surface panes are TTY).
|
|
343
|
+
* Present ⇒ PI_CREW_BROKER_TOKEN is NOT in builtEnv (token travels
|
|
344
|
+
* heap → pipe → heap instead). Undefined ⇒ legacy env route. */
|
|
345
|
+
brokerStdinPayload?: string;
|
|
313
346
|
}
|
|
314
347
|
|
|
315
348
|
/**
|
|
@@ -364,6 +397,9 @@ export function prepareSpawnContext(
|
|
|
364
397
|
// a per-spawn unique.
|
|
365
398
|
const inferenceSnapshot = requireInferenceBrokerIfEnabled();
|
|
366
399
|
let inferenceWorkerKey: string | undefined;
|
|
400
|
+
// LOW-1 closure: stdin handshake payload for the headless JSON branch (see
|
|
401
|
+
// the brokerSpawn block below). Undefined = env route (legacy/rollback).
|
|
402
|
+
let brokerStdinPayload: string | undefined;
|
|
367
403
|
if (inferenceSnapshot.active) {
|
|
368
404
|
// v0.11.36: the agentId derivation is SHARED with the exit-revoke wrapper
|
|
369
405
|
// (runChildPi finally) via inferenceWorkerKeyForAgent — both sides must
|
|
@@ -415,11 +451,23 @@ export function prepareSpawnContext(
|
|
|
415
451
|
// Phase 0 inter-pi broker: inject socket path + token (control-namespace keys,
|
|
416
452
|
// safe under assertOnlyControlEnvKeys). Only when the parent broker issued
|
|
417
453
|
// credentials for this run — i.e. the broker is enabled AND this run is
|
|
418
|
-
// eligible.
|
|
419
|
-
//
|
|
454
|
+
// eligible. NEVER persisted to disk.
|
|
455
|
+
// LOW-1 closure: by default the TOKEN leaves the env entirely — it rides the
|
|
456
|
+
// spawn stdin pipe as a single buildBrokerStdinLine payload (ctx
|
|
457
|
+
// .brokerStdinPayload; the headless JSON branch pipes stdin and writes it
|
|
458
|
+
// right after spawn). SOCKET/RUN_ID/TASK_ID stay in env (non-secret, needed
|
|
459
|
+
// by the worker bootstrap to bind + validate the stdin payload). Rollback
|
|
460
|
+
// gate PI_CREW_BROKER_STDIN=0 (or a legacy shape without runId/taskId to
|
|
461
|
+
// bind the payload to) keeps the env route bit-for-bit.
|
|
420
462
|
if (input.brokerSpawn?.socketPath && input.brokerSpawn.token) {
|
|
421
463
|
built.env.PI_CREW_BROKER_SOCKET = input.brokerSpawn.socketPath;
|
|
422
|
-
|
|
464
|
+
if (resolveBrokerStdinHandshakeEnabled() && input.runId && input.agentId) {
|
|
465
|
+
brokerStdinPayload = buildBrokerStdinLine(input.runId, input.brokerSpawn.socketPath, {
|
|
466
|
+
[input.agentId]: input.brokerSpawn.token,
|
|
467
|
+
});
|
|
468
|
+
} else {
|
|
469
|
+
built.env.PI_CREW_BROKER_TOKEN = input.brokerSpawn.token;
|
|
470
|
+
}
|
|
423
471
|
// runId/taskId are threaded unconditionally above (I5); nothing to add here.
|
|
424
472
|
}
|
|
425
473
|
// WP-9 (R9): the worker self-reporting channel env is UNCONDITIONAL —
|
|
@@ -499,6 +547,7 @@ export function prepareSpawnContext(
|
|
|
499
547
|
tempDir: built.tempDir,
|
|
500
548
|
builtEnv: built.env,
|
|
501
549
|
inferenceWorkerKey,
|
|
550
|
+
...(brokerStdinPayload !== undefined ? { brokerStdinPayload } : {}),
|
|
502
551
|
},
|
|
503
552
|
};
|
|
504
553
|
}
|
|
@@ -852,7 +852,18 @@ async function runChildPiInner(input: ChildPiRunInput): Promise<ChildPiRunResult
|
|
|
852
852
|
// R6-8: modelChain threads the candidate chain for provider-key scoping.
|
|
853
853
|
// v0.11.38 W10-1: workerCacheSplit — headless single-run workers opt into
|
|
854
854
|
// short prompt-cache retention (see PI_CACHE_RETENTION_ENV).
|
|
855
|
-
|
|
855
|
+
// LOW-1 closure: ctx.brokerStdinPayload (when present) switches stdin to
|
|
856
|
+
// "pipe" + strips PI_CREW_BROKER_TOKEN from the child env — the token
|
|
857
|
+
// travels over the stdin pipe right after spawn instead (heap → pipe → heap).
|
|
858
|
+
const spawnOptions = buildFinalChildPiSpawnOptions(
|
|
859
|
+
input.cwd,
|
|
860
|
+
mergedEnv,
|
|
861
|
+
builtEnv,
|
|
862
|
+
input.model,
|
|
863
|
+
input.modelChain,
|
|
864
|
+
true,
|
|
865
|
+
spawnPrep.ctx.brokerStdinPayload,
|
|
866
|
+
);
|
|
856
867
|
// v0.11.36 OS boundary: when the sandbox is on, the headless spawn is
|
|
857
868
|
// wrapped in bwrap. Mounts: cwd RW (the worker edits the project), the
|
|
858
869
|
// scoped inference agent dir RW (pi writes session state under it), and
|
|
@@ -909,6 +920,26 @@ async function runChildPiInner(input: ChildPiRunInput): Promise<ChildPiRunResult
|
|
|
909
920
|
ts: new Date().toISOString(),
|
|
910
921
|
});
|
|
911
922
|
}
|
|
923
|
+
// LOW-1 closure: hand the worker its broker credential over stdin — one
|
|
924
|
+
// buildBrokerStdinLine JSON line (v2, per-task compound token), then close
|
|
925
|
+
// the pipe. Mirror of the F4 async-runner handshake; the worker-side
|
|
926
|
+
// reader (crew-broker-child.ts via stdin-handshake.readStdinFirstLine)
|
|
927
|
+
// binds it by runId + taskId. Best-effort in BOTH directions: any write
|
|
928
|
+
// failure (EPIPE on a fast-exiting child, destroyed stream) leaves the
|
|
929
|
+
// worker creds-less exactly like a broker-less spawn — never throws out
|
|
930
|
+
// of the spawn path.
|
|
931
|
+
if (spawnPrep.ctx.brokerStdinPayload) {
|
|
932
|
+
try {
|
|
933
|
+
child.stdin?.write(spawnPrep.ctx.brokerStdinPayload);
|
|
934
|
+
} catch {
|
|
935
|
+
/* best-effort: worker proceeds creds-less */
|
|
936
|
+
}
|
|
937
|
+
try {
|
|
938
|
+
child.stdin?.end();
|
|
939
|
+
} catch {
|
|
940
|
+
/* EPIPE: child already died — spawn error handling owns that */
|
|
941
|
+
}
|
|
942
|
+
}
|
|
912
943
|
// P0-1: O(1)-amortized bounded accumulators (segment ring) — replaces the O(n²)
|
|
913
944
|
// appendBoundedTail rebuild-per-line pattern that re-scanned 512 KiB every line.
|
|
914
945
|
const stdoutTail = new BoundedTail();
|