pi-crew 0.10.2 → 0.10.4
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/AGENTS.md +2 -1
- package/CHANGELOG.md +249 -0
- package/README.md +5 -1
- package/dist/index.mjs +10844 -7250
- package/docs/architecture.md +4 -4
- package/docs/commands-reference.md +3 -0
- package/docs/publishing.md +15 -3
- package/install.mjs +90 -39
- package/package.json +9 -3
- package/schema.json +11 -0
- package/scripts/README.md +4 -3
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +7 -2
- package/skills/real-test-pi-crew/SKILL.md +428 -82
- package/src/config/config-merge.ts +11 -1
- package/src/config/config-validation.ts +40 -1
- package/src/config/config.ts +28 -6
- package/src/config/defaults.ts +35 -10
- package/src/config/env-vars.ts +27 -2
- package/src/config/migration-validator.ts +113 -0
- package/src/config/types.ts +36 -0
- package/src/extension/cross-extension-rpc.ts +3 -7
- package/src/extension/register.ts +13 -0
- package/src/extension/registration/lifecycle-handlers.ts +40 -9
- package/src/extension/registration/observability.ts +3 -7
- package/src/extension/registration/subagent-tools.ts +3 -7
- package/src/extension/registration/team-tool.ts +56 -12
- package/src/extension/registration/ui.ts +3 -8
- package/src/extension/registration/viewers.ts +3 -10
- package/src/extension/team-manager-command.ts +3 -7
- package/src/extension/team-tool/api/agent-control.ts +17 -10
- package/src/extension/team-tool/api/heartbeat.ts +4 -3
- package/src/extension/team-tool/api/mailbox.ts +33 -20
- package/src/extension/team-tool/api/plan-approval.ts +5 -5
- package/src/extension/team-tool/api/task-claims.ts +8 -7
- package/src/extension/team-tool/cancel.ts +6 -0
- package/src/extension/team-tool/doctor.ts +364 -7
- package/src/extension/team-tool/handle-settings.ts +23 -1
- package/src/extension/team-tool/inspect.ts +10 -2
- package/src/extension/team-tool/run.ts +3 -7
- package/src/extension/team-tool/status.ts +12 -0
- package/src/extension/team-tool.ts +41 -16
- package/src/hooks/registry.ts +62 -56
- package/src/prompt/inbox-poll.ts +90 -0
- package/src/prompt/message-tool.ts +166 -0
- package/src/prompt/prompt-runtime.ts +201 -18
- package/src/prompt/scratchpad-lifecycle.ts +3 -3
- package/src/prompt/surface-worker.ts +720 -0
- package/src/prompt/worker-events-channel.ts +49 -3
- package/src/runtime/async-runner.ts +29 -1
- package/src/runtime/background-runner.ts +43 -42
- package/src/runtime/broker/broker-issuer.ts +27 -2
- package/src/runtime/broker/crew-broker-tokens.ts +56 -4
- package/src/runtime/broker/crew-broker.ts +334 -443
- package/src/runtime/broker/delegate/delegate-event.ts +37 -0
- package/src/runtime/broker/mailbox-observer/mailbox-fanout.ts +59 -0
- package/src/runtime/broker/protocol/connection-state.ts +103 -0
- package/src/runtime/broker/protocol/events-replay.ts +68 -0
- package/src/runtime/broker/protocol/manifest-loader.ts +20 -0
- package/src/runtime/broker/protocol/msg-inbox.ts +69 -0
- package/src/runtime/broker/protocol/request-parsers.ts +175 -0
- package/src/runtime/broker/protocol/wait-auth.ts +46 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
- package/src/runtime/child-pi/child-pi-streams.ts +9 -1
- package/src/runtime/child-pi/child-pi.ts +368 -5
- package/src/runtime/crew-agent-records.ts +13 -1
- package/src/runtime/dispatch-batch.ts +12 -1
- package/src/runtime/event-log-tail-source.ts +374 -0
- package/src/runtime/finalize-run.ts +19 -7
- package/src/runtime/foreground-control.ts +19 -6
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +6 -0
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -0
- package/src/runtime/goal-workflow/goal-loop-runner.ts +29 -27
- package/src/runtime/goal-workflow/goal-state-store.ts +3 -0
- package/src/runtime/heartbeat/heartbeat-watcher.ts +3 -3
- package/src/runtime/live-session/live-agent-manager.ts +34 -1
- package/src/runtime/live-session/live-control-realtime.ts +10 -0
- package/src/runtime/live-session/live-session-runtime.ts +47 -27
- package/src/runtime/manifest-cache.ts +128 -17
- package/src/runtime/model/pi-args.ts +59 -65
- package/src/runtime/output/sidechain-output.ts +61 -6
- package/src/runtime/plan-replan.ts +3 -0
- package/src/runtime/process/proc-stat.ts +46 -0
- package/src/runtime/process/zombie-scanner.ts +32 -19
- package/src/runtime/spawn-policy.ts +27 -41
- package/src/runtime/stale-reconciler.ts +28 -3
- package/src/runtime/supervisor-contact.ts +3 -0
- package/src/runtime/surface/degrade.ts +776 -0
- package/src/runtime/surface/herdr-provider.ts +546 -0
- package/src/runtime/surface/launch-script.ts +172 -0
- package/src/runtime/surface/resolve-surface.ts +274 -0
- package/src/runtime/surface/surface-provider.ts +129 -0
- package/src/runtime/surface/surface-spawn.ts +475 -0
- package/src/runtime/surface/tmux-provider.ts +400 -0
- package/src/runtime/task-runner/child-executor.ts +80 -0
- package/src/runtime/task-runner/post-execution.ts +57 -2
- package/src/runtime/task-runner/prompt-builder.ts +1 -0
- package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
- package/src/runtime/task-runner/state-helpers.ts +54 -30
- package/src/runtime/task-runner.ts +4 -2
- package/src/runtime/team-runner.ts +104 -3
- package/src/schema/config-schema.ts +24 -0
- package/src/state/atomic-write.ts +219 -40
- package/src/state/coordination/locks.ts +7 -5
- package/src/state/coordination/mailbox.ts +56 -10
- package/src/state/event-log/cursor.ts +413 -23
- package/src/state/event-log/event-log.ts +120 -113
- package/src/state/event-log/sequence-cache.ts +21 -3
- package/src/state/stores/ownership-map.ts +5 -4
- package/src/state/stores/plan-store.ts +12 -0
- package/src/state/stores/state-store.ts +103 -6
- package/src/state/types.ts +51 -0
- package/src/ui/inline-panel/agent-pane.ts +3 -0
- package/src/ui/powerbar-publisher.ts +3 -7
- package/src/ui/render-diff.ts +16 -8
- package/src/ui/run-action-dispatcher.ts +7 -10
- package/src/ui/run-dashboard.ts +87 -42
- package/src/ui/run-event-bus.ts +10 -1
- package/src/ui/run-snapshot-cache.ts +83 -35
- package/src/ui/settings-overlay.ts +4 -1
- package/src/ui/transcript-cache.ts +101 -13
- package/src/ui/transcript-viewer.ts +92 -24
- package/src/ui/widget/index.ts +32 -8
- package/src/utils/visual.ts +43 -0
- package/src/worktree/worktree-manager.ts +65 -4
|
@@ -25,15 +25,8 @@ import { randomUUID } from "node:crypto";
|
|
|
25
25
|
import * as fsp from "node:fs/promises";
|
|
26
26
|
import * as net from "node:net";
|
|
27
27
|
import { withRunLockSync } from "../../state/coordination/locks.ts";
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
type MailboxMessage,
|
|
31
|
-
type MailboxMessageKind,
|
|
32
|
-
type MailboxMessagePriority,
|
|
33
|
-
readMailbox,
|
|
34
|
-
registerMailboxAppendObserver,
|
|
35
|
-
} from "../../state/coordination/mailbox.ts";
|
|
36
|
-
import { appendEventAsync, readEventsCursor } from "../../state/event-log/event-log.ts";
|
|
28
|
+
import { appendMailboxMessageAsync, type MailboxMessage, registerMailboxAppendObserver } from "../../state/coordination/mailbox.ts";
|
|
29
|
+
import { appendEventAsync } from "../../state/event-log/event-log.ts";
|
|
37
30
|
import { loadRunManifestById, saveRunManifest, saveRunTasks } from "../../state/stores/state-store.ts";
|
|
38
31
|
import type { TeamTaskState } from "../../state/types.ts";
|
|
39
32
|
import { runEventBus } from "../../ui/run-event-bus.ts";
|
|
@@ -42,103 +35,55 @@ import { BrokerError, encodeBrokerFrame, MAX_BROKER_FRAME_BYTES, NdjsonDecoder }
|
|
|
42
35
|
import { redactSecretString } from "../../utils/redaction.ts";
|
|
43
36
|
import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
|
|
44
37
|
import { getBrokerSocketPath, prepareBrokerSocketDir, removeStaleBrokerSocket } from "../../utils/socket-path.ts";
|
|
45
|
-
import { type
|
|
38
|
+
import { type GrandchildSpawnResult, spawnDelegateGrandchild } from "../delegate-spawn.ts";
|
|
46
39
|
import { resolveCrewMaxDepth } from "../model/pi-args.ts";
|
|
47
40
|
import { NestedSlotBudget } from "../scheduling/nested-slots.ts";
|
|
48
41
|
import { evaluateDelegateAdmission } from "../spawn-policy.ts";
|
|
49
|
-
import { BrokerTokenRegistry } from "./crew-broker-tokens.ts";
|
|
42
|
+
import { type BrokerToken, BrokerTokenRegistry } from "./crew-broker-tokens.ts";
|
|
43
|
+
import { recordDelegateEvent } from "./delegate/delegate-event.ts";
|
|
44
|
+
import { fanoutMailboxMessage } from "./mailbox-observer/mailbox-fanout.ts";
|
|
45
|
+
import type { CrewBrokerOptions, ServerConnection } from "./protocol/connection-state.ts";
|
|
46
|
+
import { handleEventsSince } from "./protocol/events-replay.ts";
|
|
47
|
+
import { loadRunForHello } from "./protocol/manifest-loader.ts";
|
|
48
|
+
import { handleMsgInbox } from "./protocol/msg-inbox.ts";
|
|
49
|
+
import {
|
|
50
|
+
BROKER_PROTOCOL,
|
|
51
|
+
isHelloParams,
|
|
52
|
+
isRequestObject,
|
|
53
|
+
parseMsgSendParams,
|
|
54
|
+
parseWaitRequestParams,
|
|
55
|
+
parseWaitResolveParams,
|
|
56
|
+
safeStringify,
|
|
57
|
+
WAIT_REQUEST_TIMEOUT_SEC_DEFAULT,
|
|
58
|
+
WAIT_REQUEST_TIMEOUT_SEC_MAX,
|
|
59
|
+
} from "./protocol/request-parsers.ts";
|
|
60
|
+
import { recordWaitPolicyRejection, waitAuthError } from "./protocol/wait-auth.ts";
|
|
50
61
|
import { WaitStatusCache } from "./wait-status-cache.ts";
|
|
51
62
|
|
|
52
|
-
/** Protocol version negotiated at `hello` time. Bump on breaking change.
|
|
53
|
-
|
|
63
|
+
/** Protocol version negotiated at `hello` time. Bump on breaking change.
|
|
64
|
+
* (Re-export removed 2026-09-10 — zero consumers; defined + exported in
|
|
65
|
+
* request-parsers.ts.) */
|
|
54
66
|
|
|
55
67
|
/** Hard hello deadline (per spec). After 1s, the connection is closed with a
|
|
56
|
-
* generic auth/protocol code. */
|
|
68
|
+
* generic auth/protocol code. (Unexported 2026-09-10 — zero consumers.) */
|
|
57
69
|
const HELLO_DEADLINE_MS = 1_000;
|
|
58
70
|
|
|
59
|
-
/**
|
|
60
|
-
|
|
71
|
+
/** Per-connection server-side state.
|
|
72
|
+
* Moved to ./protocol/connection-state.ts (M4 / WI-4.1):
|
|
73
|
+
* - interface CrewBrokerOptions
|
|
74
|
+
* - interface ServerConnection
|
|
75
|
+
* Both re-exported from connection-state.ts; the class body is unchanged.
|
|
76
|
+
*/
|
|
61
77
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
maxFrameBytes?: number;
|
|
69
|
-
/** Per-connection outbound queue cap. Default 256. */
|
|
70
|
-
outboundQueueCap?: number;
|
|
71
|
-
/** Required: when false, start() is a no-op and the server never binds.
|
|
72
|
-
* Lets the lifecycle controller install the broker unconditionally and
|
|
73
|
-
* have a single kill switch. */
|
|
74
|
-
enabled: boolean;
|
|
75
|
-
/** CWD for `loadRunManifestById` (Phase 1 msg.send / msg.inbox resolution).
|
|
76
|
-
* When omitted, manifest-touching methods return no-manifest errors. */
|
|
77
|
-
cwd?: string;
|
|
78
|
-
/** Optional test seam: override the `net` module (allows fake-server tests). */
|
|
79
|
-
netModule?: typeof net;
|
|
80
|
-
/** Optional test seam: inject a pre-configured WaitStatusCache (e.g. one
|
|
81
|
-
* wrapping a loader spy). Production uses a plain cache — see
|
|
82
|
-
* wait-status-cache.ts (R10-3). */
|
|
83
|
-
waitStatusCache?: WaitStatusCache;
|
|
84
|
-
/** WP-2/R2 (ADR-0 2026-08-17-waiting-producer-ask item 7): capability
|
|
85
|
-
* gate for the `wait.*` methods. DEFAULT FALSE — fail-closed. When not
|
|
86
|
-
* explicitly true, wait.request/wait.resolve are rejected with a
|
|
87
|
-
* `policy-disabled` error AND a `policy.action` event is appended to the
|
|
88
|
-
* run's events.jsonl (never silent). The production wiring threads
|
|
89
|
-
* `config.broker.waitMethodsEnabled` here; tests pass it explicitly. */
|
|
90
|
-
waitMethodsEnabled?: boolean;
|
|
91
|
-
/** T3/R5 (ADR-5 §10): capability gate for the `delegate` surface. DEFAULT
|
|
92
|
-
* FALSE — fail-closed until the WP-5 completion gate flips it. The
|
|
93
|
-
* production wiring threads `config.nesting.enabled` here; tests pass it
|
|
94
|
-
* explicitly. Rejections are NEVER silent (delegate.rejected event). */
|
|
95
|
-
nestingEnabled?: boolean;
|
|
96
|
-
/** Optional override for the nested-slot budget size (config nesting.maxSlots). */
|
|
97
|
-
nestingMaxSlots?: number;
|
|
98
|
-
nestingMaxDepth?: number;
|
|
99
|
-
nestingTrustedEscalation?: boolean;
|
|
100
|
-
/** Global worker semaphore size, used to size the nested-slot budget. */
|
|
101
|
-
globalWorkerSemaphore?: number;
|
|
102
|
-
/** Test seam / alternative spawner for delegate grandchildren. Production
|
|
103
|
-
* uses spawnDelegateGrandchild (direct runChildPi call-site, ADR-5 §2). */
|
|
104
|
-
grandchildSpawner?: (input: GrandchildSpawnInput) => Promise<GrandchildSpawnResult>;
|
|
105
|
-
/** Resolved model catalog (canonical provider/id strings) for admission-time
|
|
106
|
-
* model validation (ADR-5 §7). When omitted, model validation is skipped
|
|
107
|
-
* (documented gap — the production wiring must always supply it). */
|
|
108
|
-
modelCatalog?: () => string[] | undefined;
|
|
109
|
-
/** ADR-5 §9: mirrors config limits.serializeOnPathOverlap for the workspace
|
|
110
|
-
* admission gate. Default false. */
|
|
111
|
-
serializeOnPathOverlap?: boolean;
|
|
112
|
-
}
|
|
78
|
+
/** Task 10 (mux-surface A1 §5.2): run statuses after which every hello token
|
|
79
|
+
* is by definition stale — the run will never issue work again, so the error
|
|
80
|
+
* is "stale-token" instead of generic auth. NARROWER than
|
|
81
|
+
* TEAM_TERMINAL_RUN_STATUSES on purpose: "blocked" is recoverable, so a
|
|
82
|
+
* blocked run still authenticates normally. */
|
|
83
|
+
const STALE_RUN_STATUSES: ReadonlySet<string> = new Set(["completed", "failed", "cancelled"]);
|
|
113
84
|
|
|
114
|
-
/**
|
|
115
|
-
|
|
116
|
-
socket: net.Socket;
|
|
117
|
-
decoder: NdjsonDecoder;
|
|
118
|
-
/** Whether the connection has completed `hello` successfully. */
|
|
119
|
-
authed: boolean;
|
|
120
|
-
/** Run id bound by hello. */
|
|
121
|
-
runId?: string;
|
|
122
|
-
/** Task id bound by hello. */
|
|
123
|
-
taskId?: string;
|
|
124
|
-
/** Role bound by hello: orchestrator can steer/msg-send; workers default. */
|
|
125
|
-
role?: "orchestrator" | "worker";
|
|
126
|
-
/** How the hello token matched the registry (ADR-0 item 6). Derived,
|
|
127
|
-
* non-secret metadata recorded at hello time so `wait.*` can reject a
|
|
128
|
-
* legacy bare-runId fallback match WITHOUT keeping the raw token on the
|
|
129
|
-
* connection (tokens stay confined to the heap-only registry). */
|
|
130
|
-
authMatchKind?: "compound" | "runId-fallback";
|
|
131
|
-
/** Outbound queue of encoded frames awaiting drain. */
|
|
132
|
-
outbound: Buffer[];
|
|
133
|
-
/** Set when the queue has hit the cap and a frame was dropped. */
|
|
134
|
-
needsResync: boolean;
|
|
135
|
-
/** Set when the connection is closing (idempotent). */
|
|
136
|
-
closed: boolean;
|
|
137
|
-
/** Timer for the hello deadline. */
|
|
138
|
-
helloTimer: NodeJS.Timeout | null;
|
|
139
|
-
/** Monotonic seq counter for outbound events (diagnostic). */
|
|
140
|
-
outboundSeq: number;
|
|
141
|
-
}
|
|
85
|
+
/** Default per-connection outbound queue cap (events). */
|
|
86
|
+
const DEFAULT_OUTBOUND_QUEUE_CAP = 256;
|
|
142
87
|
|
|
143
88
|
export class CrewBroker {
|
|
144
89
|
private readonly options: Required<Pick<CrewBrokerOptions, "sessionId" | "enabled" | "waitMethodsEnabled" | "nestingEnabled">> &
|
|
@@ -159,6 +104,12 @@ export class CrewBroker {
|
|
|
159
104
|
| "serializeOnPathOverlap"
|
|
160
105
|
>;
|
|
161
106
|
private readonly tokens = new BrokerTokenRegistry();
|
|
107
|
+
/** Task 10 (mux-surface A1 §5.2): taskId → the compound token most
|
|
108
|
+
* recently issued for it. revokeTaskToken(taskId) resolves the exact
|
|
109
|
+
* secret through this map — no runId needed (the broker serves many
|
|
110
|
+
* runs; a colliding taskId in another run revokes both, which is the
|
|
111
|
+
* conservative direction). Heap-only like the registry. */
|
|
112
|
+
private readonly taskTokens = new Map<string, BrokerToken>();
|
|
162
113
|
private server: net.Server | null = null;
|
|
163
114
|
private resolvedSocketPath: string | null = null;
|
|
164
115
|
private stopped = false;
|
|
@@ -236,7 +187,29 @@ export class CrewBroker {
|
|
|
236
187
|
if (typeof runId !== "string" || runId.length === 0) {
|
|
237
188
|
throw new Error("CrewBroker.issueRunToken: runId must be a non-empty string");
|
|
238
189
|
}
|
|
239
|
-
|
|
190
|
+
const token = this.tokens.issue(runId, taskId);
|
|
191
|
+
// Task 10: track the live secret per taskId so revokeTaskToken can
|
|
192
|
+
// resolve it later. A re-issue overwrites the entry; the OLD token
|
|
193
|
+
// keeps whatever revocation it already had (per-secret, not per-key).
|
|
194
|
+
if (taskId !== undefined) this.taskTokens.set(taskId, token);
|
|
195
|
+
return token;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Task 10 (mux-surface A1 §5.2): revoke the token issued for `taskId`.
|
|
199
|
+
* The next hello presenting that token — and every subsequent frame on a
|
|
200
|
+
* connection already authenticated with it — is rejected with code
|
|
201
|
+
* "revoked". Open connections are NOT force-closed (A1 enforces at the
|
|
202
|
+
* next frame boundary; re-issue is the A2 remedy). No-op when no token
|
|
203
|
+
* was ever issued for the task. */
|
|
204
|
+
revokeTaskToken(taskId: string): void {
|
|
205
|
+
if (typeof taskId !== "string" || taskId.length === 0) {
|
|
206
|
+
throw new Error("CrewBroker.revokeTaskToken: taskId must be a non-empty string");
|
|
207
|
+
}
|
|
208
|
+
const token = this.taskTokens.get(taskId);
|
|
209
|
+
if (token !== undefined) {
|
|
210
|
+
this.tokens.revokeToken(token);
|
|
211
|
+
this.taskTokens.delete(taskId);
|
|
212
|
+
}
|
|
240
213
|
}
|
|
241
214
|
|
|
242
215
|
/** Issue the orchestrator token for `runId` (F-06). Cryptographically
|
|
@@ -412,6 +385,8 @@ export class CrewBroker {
|
|
|
412
385
|
// 3. Clear the token map. This is the single point where the heap
|
|
413
386
|
// state for runIds is wiped. No persistence to clean up.
|
|
414
387
|
this.tokens.clear();
|
|
388
|
+
// Task 10: drop the taskId → token index with it.
|
|
389
|
+
this.taskTokens.clear();
|
|
415
390
|
|
|
416
391
|
// 4. Unlink the recorded socket file IF we created it. We never
|
|
417
392
|
// touch any other path. We also never `process.kill` anything.
|
|
@@ -433,9 +408,7 @@ export class CrewBroker {
|
|
|
433
408
|
this.resolvedSocketPath = null;
|
|
434
409
|
}
|
|
435
410
|
|
|
436
|
-
// ------------------------------------------------------------------------
|
|
437
411
|
// Connection lifecycle
|
|
438
|
-
// ------------------------------------------------------------------------
|
|
439
412
|
|
|
440
413
|
private async handleConnection(sock: net.Socket): Promise<void> {
|
|
441
414
|
// B1 (Round 14): a connection event queued after stop() must not be
|
|
@@ -538,30 +511,11 @@ export class CrewBroker {
|
|
|
538
511
|
}
|
|
539
512
|
|
|
540
513
|
/**
|
|
541
|
-
* Phase 1.3:
|
|
542
|
-
*
|
|
543
|
-
* recipients that are offline (they recover via msg.inbox). Never throws.
|
|
514
|
+
* Phase 1.3: see ./mailbox-observer/mailbox-fanout.ts (M4 / WI-4.1 moved).
|
|
515
|
+
* Class method delegates with 1-line binding of connectionsByRun + writers.
|
|
544
516
|
*/
|
|
545
517
|
private fanoutMailboxMessage(msg: MailboxMessage): void {
|
|
546
|
-
|
|
547
|
-
if (!set || set.size === 0) return;
|
|
548
|
-
// Recipient delivery dedup lives in src/prompt/prompt-runtime.ts and is
|
|
549
|
-
// keyed by the same message id in this mailbox event and the steering JSONL.
|
|
550
|
-
const eventFrame = encodeBrokerFrame({
|
|
551
|
-
event: "mailbox.message",
|
|
552
|
-
data: { id: msg.id, from: msg.from, to: msg.to, body: msg.body, kind: msg.kind, priority: msg.priority },
|
|
553
|
-
seq: 0, // mailbox messages don't carry a TeamEvent seq; dedup by msg.id
|
|
554
|
-
});
|
|
555
|
-
for (const conn of set) {
|
|
556
|
-
if (conn.closed || !conn.authed) continue;
|
|
557
|
-
// Recipient filter: deliver to the addressed task, or to all if 'all'.
|
|
558
|
-
if (msg.to && msg.to !== "all" && conn.taskId !== msg.to) continue;
|
|
559
|
-
try {
|
|
560
|
-
this.writeOrQueue(conn, eventFrame, false);
|
|
561
|
-
} catch {
|
|
562
|
-
/* a slow/dead recipient must not break fanout to others */
|
|
563
|
-
}
|
|
564
|
-
}
|
|
518
|
+
fanoutMailboxMessage(this.connectionsByRun, { writeOrQueue: (conn, buf, force) => this.writeOrQueue(conn, buf, force) }, msg);
|
|
565
519
|
}
|
|
566
520
|
|
|
567
521
|
private async handleData(conn: ServerConnection, chunk: Buffer): Promise<void> {
|
|
@@ -604,6 +558,24 @@ export class CrewBroker {
|
|
|
604
558
|
}
|
|
605
559
|
|
|
606
560
|
// Post-hello: dispatch the known set.
|
|
561
|
+
// Task 10 (mux-surface A1 §5.2): a revoked task token is dead on
|
|
562
|
+
// arrival for EVERY frame, not just hellos — an already-authed
|
|
563
|
+
// connection is rejected here, at the next request boundary, with the
|
|
564
|
+
// connection closed (A1: no mid-stream force-close, so the revoke
|
|
565
|
+
// itself never tears a socket out from under a handler).
|
|
566
|
+
// Fix round 1 (BUG #2): WORKER role only — an orchestrator hello may
|
|
567
|
+
// legitimately name a revoked task as its taskId (T11 degrade: revoke
|
|
568
|
+
// → respawn → steer).
|
|
569
|
+
// Fix round 2 (BUG #3): SECRET-based, not key-based — the check
|
|
570
|
+
// evaluates the digest of the secret this connection authenticated
|
|
571
|
+
// with. Looking up the token currently registered for the key let a
|
|
572
|
+
// revoked-secret connection silently regain full capability once the
|
|
573
|
+
// key was re-issued for the respawn (the connection outlived the
|
|
574
|
+
// revoke → re-issue window while staying quiet).
|
|
575
|
+
if (conn.role === "worker" && conn.authedSecretHash !== undefined && this.tokens.isSecretRevoked(conn.authedSecretHash)) {
|
|
576
|
+
this.sendErrorAndClose(conn, id, "revoked", "token revoked");
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
607
579
|
switch (method) {
|
|
608
580
|
case "ping":
|
|
609
581
|
this.sendResult(conn, id, { pong: true, protocol: BROKER_PROTOCOL });
|
|
@@ -680,9 +652,48 @@ export class CrewBroker {
|
|
|
680
652
|
// task-scoped-token rule without retaining the secret candidate.
|
|
681
653
|
const resolved = this.tokens.tokenRoleWithMatchKind(runId, taskId, token);
|
|
682
654
|
if (resolved === null) {
|
|
655
|
+
// Task 10 (mux-surface A1 §5.2): distinguish a STALE token from a
|
|
656
|
+
// wrong one. A worker re-attaching from a durable surface (broker
|
|
657
|
+
// restarted → heap registry lost, run still on disk) presents a
|
|
658
|
+
// token this broker never issued: when the run exists and the task
|
|
659
|
+
// is real, that is a stale token — reject, but say so, because the
|
|
660
|
+
// A2 remedy is a re-issue, not a retry. An unknown run/task keeps
|
|
661
|
+
// the generic auth error (no disclosure of which id was valid).
|
|
662
|
+
const loaded = loadRunForHello(this.options.cwd, runId);
|
|
663
|
+
if (loaded && (loaded.tasks ?? []).some((t) => t.id === taskId)) {
|
|
664
|
+
this.sendErrorAndClose(
|
|
665
|
+
conn,
|
|
666
|
+
id,
|
|
667
|
+
"stale-token",
|
|
668
|
+
"hello rejected: stale token (run/task exist but this broker did not issue the token; re-issue required)",
|
|
669
|
+
);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
683
672
|
this.sendErrorAndClose(conn, id, "auth", "hello rejected");
|
|
684
673
|
return;
|
|
685
674
|
}
|
|
675
|
+
// Task 10: the token matches — but an explicitly revoked secret is
|
|
676
|
+
// reported as "revoked" (more specific than stale), and a WORKER token
|
|
677
|
+
// for a TERMINAL run is stale by definition: the run will never issue
|
|
678
|
+
// work again, so a surface worker must not re-attach with it.
|
|
679
|
+
// Orchestrator connections are exempt from BOTH checks: the
|
|
680
|
+
// orchestrator is in-process (same root session) and legitimately
|
|
681
|
+
// talks to the broker after the run completed (late steer, closeout
|
|
682
|
+
// reads) and after a task token was revoked (T11 degrade flow).
|
|
683
|
+
// Fix round 1 (BUG #2): the revoked check keys on (runId, taskId), so
|
|
684
|
+
// without the role guard an orchestrator hello naming a revoked task
|
|
685
|
+
// as its taskId was rejected 'revoked'.
|
|
686
|
+
if (resolved.role === "worker" && this.tokens.isTaskTokenRevoked(runId, taskId)) {
|
|
687
|
+
this.sendErrorAndClose(conn, id, "revoked", "hello rejected: token revoked");
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
if (resolved.role === "worker") {
|
|
691
|
+
const loaded = loadRunForHello(this.options.cwd, runId);
|
|
692
|
+
if (loaded && STALE_RUN_STATUSES.has(loaded.manifest.status)) {
|
|
693
|
+
this.sendErrorAndClose(conn, id, "stale-token", "hello rejected: run is already terminal (stale token)");
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
686
697
|
|
|
687
698
|
// Bounded identity checks. taskId must be a non-empty string.
|
|
688
699
|
if (typeof taskId !== "string" || taskId.length === 0 || taskId.length > 256) {
|
|
@@ -700,6 +711,9 @@ export class CrewBroker {
|
|
|
700
711
|
conn.taskId = taskId;
|
|
701
712
|
conn.role = resolved.role;
|
|
702
713
|
conn.authMatchKind = resolved.matchKind;
|
|
714
|
+
// Fix round 2 (BUG #3): digest of the authenticated secret for the
|
|
715
|
+
// secret-based frame revocation check below (never the plaintext).
|
|
716
|
+
conn.authedSecretHash = BrokerTokenRegistry.hashToken(token);
|
|
703
717
|
// Phase 1.3: index by runId for live mailbox fanout.
|
|
704
718
|
let connsForRun = this.connectionsByRun.get(runId);
|
|
705
719
|
if (!connsForRun) {
|
|
@@ -720,9 +734,14 @@ export class CrewBroker {
|
|
|
720
734
|
});
|
|
721
735
|
}
|
|
722
736
|
|
|
723
|
-
|
|
737
|
+
/** Task 10 (mux-surface A1 §5.2): best-effort manifest load for the hello
|
|
738
|
+
* decision path. Returns undefined when no cwd is configured or the run
|
|
739
|
+
* is not on disk — callers treat that as "cannot classify" and keep the
|
|
740
|
+
* legacy generic-auth behavior (the heap registry stays the source of
|
|
741
|
+
* truth for authentication). */
|
|
742
|
+
// loadRunForHello: inlined at the 2 call sites (was a 5-line method; M4/WI-4.1).
|
|
743
|
+
|
|
724
744
|
// Outbound queue + drop-newest + needsResync
|
|
725
|
-
// ------------------------------------------------------------------------
|
|
726
745
|
|
|
727
746
|
private sendResult(conn: ServerConnection, id: string, result: unknown): void {
|
|
728
747
|
this.enqueueFrame(conn, { id, result });
|
|
@@ -813,25 +832,35 @@ export class CrewBroker {
|
|
|
813
832
|
}
|
|
814
833
|
}
|
|
815
834
|
|
|
816
|
-
// ------------------------------------------------------------------------
|
|
817
835
|
// Phase 1: msg.send + msg.inbox handlers
|
|
818
|
-
// ------------------------------------------------------------------------
|
|
819
836
|
|
|
820
837
|
/** Phase 1.1: direct or broadcast mailbox write via the durable append path. */
|
|
821
838
|
private async handleMsgSend(conn: ServerConnection, id: string, params: unknown): Promise<void> {
|
|
822
|
-
if (conn.role !== "orchestrator") {
|
|
823
|
-
this.sendError(conn, id, "forbidden", "msg.send requires orchestrator role");
|
|
824
|
-
return;
|
|
825
|
-
}
|
|
826
839
|
if (!conn.runId) {
|
|
827
840
|
this.sendError(conn, id, "auth", "not authed");
|
|
828
841
|
return;
|
|
829
842
|
}
|
|
843
|
+
// D9/§15.2 role gate: workers may send messages (for notifying the
|
|
844
|
+
// orchestrator, DMing a sibling, or broadcasting the group) with strictly
|
|
845
|
+
// bounded privileges. Orchestrator role keeps its full prior surface
|
|
846
|
+
// (arrays / "all" / steer kinds / arbitrary recipient sets).
|
|
847
|
+
const isWorker = conn.role === "worker";
|
|
848
|
+
if (conn.role !== "orchestrator" && !isWorker) {
|
|
849
|
+
this.sendError(conn, id, "forbidden", "msg.send requires orchestrator or worker role");
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
830
852
|
const parsed = parseMsgSendParams(params);
|
|
831
853
|
if (!parsed) {
|
|
832
854
|
this.sendError(conn, id, "bad-params", "msg.send: invalid params");
|
|
833
855
|
return;
|
|
834
856
|
}
|
|
857
|
+
// Worker constraint (3): kind limited to notify|message. Fire-and-forget
|
|
858
|
+
// `notify` vs inbox-facing `message` — both return immediately to the
|
|
859
|
+
// caller; the distinction is receiver-side handling.
|
|
860
|
+
if (isWorker && parsed.kind !== undefined && parsed.kind !== "notify" && parsed.kind !== "message") {
|
|
861
|
+
this.sendError(conn, id, "bad-params", "msg.send: worker kind must be 'notify' or 'message'");
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
835
864
|
const bodyJson = safeStringify(parsed.body);
|
|
836
865
|
if (bodyJson.length > MAX_BROKER_FRAME_BYTES) {
|
|
837
866
|
this.sendError(conn, id, "oversize-frame", "msg.send: body too large");
|
|
@@ -856,136 +885,150 @@ export class CrewBroker {
|
|
|
856
885
|
this.sendError(conn, id, "no-manifest", (err as Error).message);
|
|
857
886
|
return;
|
|
858
887
|
}
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
888
|
+
// ── Recipient resolution ──────────────────────────────────────────────
|
|
889
|
+
// Each target is {label, mailboxTaskId}: `label` is echoed in the ack
|
|
890
|
+
// and message id, `mailboxTaskId` is the mailbox file the append lands
|
|
891
|
+
// in (undefined = run-level inbox, which the orchestrator consumes).
|
|
892
|
+
let targets: Array<{ label: string; mailboxTaskId: string | undefined }>;
|
|
893
|
+
// Task 5b (spec §15.2 wake): set when a worker addresses the parent —
|
|
894
|
+
// the durable write alone would sit unread in the run-level inbox.
|
|
895
|
+
let sentToParent = false;
|
|
896
|
+
if (isWorker) {
|
|
897
|
+
// Worker constraint (1): from is ALWAYS the authenticated taskId.
|
|
898
|
+
// Worker constraint (2): to is limited to parent | valid sibling
|
|
899
|
+
// taskId | group.
|
|
900
|
+
if (!conn.taskId) {
|
|
901
|
+
this.sendError(conn, id, "forbidden", "msg.send worker requires a task-scoped identity");
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
const to = typeof parsed.to === "string" ? parsed.to : undefined;
|
|
905
|
+
if (to === "parent") {
|
|
906
|
+
// Run-level inbox (taskId undefined) → the orchestrator session.
|
|
907
|
+
targets = [{ label: "parent", mailboxTaskId: undefined }];
|
|
908
|
+
sentToParent = true;
|
|
909
|
+
} else if (to === "group") {
|
|
910
|
+
targets = taskIds.map((t) => ({ label: t, mailboxTaskId: t }));
|
|
911
|
+
} else if (to !== undefined && taskIds.includes(to)) {
|
|
912
|
+
targets = [{ label: to, mailboxTaskId: to }];
|
|
913
|
+
} else {
|
|
914
|
+
this.sendError(conn, id, "forbidden", `msg.send: worker cannot target '${to}'`);
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
if (targets.length === 0 || targets.length > 64) {
|
|
918
|
+
this.sendError(conn, id, "bad-params", "msg.send: recipient count out of range");
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
} else {
|
|
922
|
+
const recipients: string[] = Array.isArray(parsed.to)
|
|
923
|
+
? (parsed.to as string[])
|
|
924
|
+
: parsed.to === "all"
|
|
925
|
+
? taskIds
|
|
926
|
+
: [parsed.to as string];
|
|
927
|
+
if (recipients.length === 0 || recipients.length > 64) {
|
|
928
|
+
this.sendError(conn, id, "bad-params", "msg.send: recipient count out of range");
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
targets = recipients.map((recipient) => ({ label: recipient, mailboxTaskId: recipient }));
|
|
867
932
|
}
|
|
868
933
|
const messageId = `msg_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
869
|
-
const fromField = conn.taskId ?? conn.runId;
|
|
934
|
+
const fromField = isWorker ? conn.taskId! : (conn.taskId ?? conn.runId);
|
|
870
935
|
let durable = false;
|
|
871
936
|
try {
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
937
|
+
// PERF (2026-08-24): to:"all" with 50 tasks used to run 50 sequential
|
|
938
|
+
// awaited locked appends (~70 syscalls + 2 fsync each) while the
|
|
939
|
+
// connection's frames queued behind it. Chunked fan-out — independent
|
|
940
|
+
// mailbox files append concurrently; delivery.json stays serialized by
|
|
941
|
+
// its own lock.
|
|
942
|
+
const CHUNK = 8;
|
|
943
|
+
for (let i = 0; i < targets.length; i += CHUNK) {
|
|
944
|
+
const results = await Promise.allSettled(
|
|
945
|
+
targets.slice(i, i + CHUNK).map((target) =>
|
|
946
|
+
appendMailboxMessageAsync(manifest, {
|
|
947
|
+
id: `${messageId}_${target.label}`,
|
|
948
|
+
direction: "inbox",
|
|
949
|
+
from: fromField,
|
|
950
|
+
to: target.label,
|
|
951
|
+
taskId: target.mailboxTaskId,
|
|
952
|
+
body: bodyJson,
|
|
953
|
+
kind: parsed.kind ?? "message",
|
|
954
|
+
priority: parsed.priority ?? "normal",
|
|
955
|
+
deliveryMode: "next_turn",
|
|
956
|
+
replyTo: parsed.replyTo,
|
|
957
|
+
}),
|
|
958
|
+
),
|
|
959
|
+
);
|
|
960
|
+
const failure = results.find((r) => r.status === "rejected") as PromiseRejectedResult | undefined;
|
|
961
|
+
if (failure) throw failure.reason;
|
|
885
962
|
}
|
|
886
963
|
durable = true;
|
|
887
964
|
} catch (err) {
|
|
888
965
|
this.sendError(conn, id, "durable-failed", (err as Error).message);
|
|
889
966
|
return;
|
|
890
967
|
}
|
|
968
|
+
// Task 5b (spec §15.2 wake): a worker message addressed to the parent
|
|
969
|
+
// appends a bounded `worker.message` run event so the host-side event
|
|
970
|
+
// bus (sidebar/widget refresh) and any live orchestrator connection
|
|
971
|
+
// wake up. Only kind/subject are recorded — NEVER the body, to keep the
|
|
972
|
+
// append-only event log lean. Awaited before the ack so the wake signal
|
|
973
|
+
// is durable by the time the caller proceeds; failure is non-fatal (the
|
|
974
|
+
// mailbox write above is the source of truth).
|
|
975
|
+
if (sentToParent) {
|
|
976
|
+
try {
|
|
977
|
+
await appendEventAsync(manifest.eventsPath, {
|
|
978
|
+
type: "worker.message",
|
|
979
|
+
runId: manifest.runId,
|
|
980
|
+
taskId: fromField,
|
|
981
|
+
data: {
|
|
982
|
+
to: "parent",
|
|
983
|
+
kind: parsed.kind ?? "message",
|
|
984
|
+
...(parsed.subject !== undefined ? { subject: parsed.subject } : {}),
|
|
985
|
+
},
|
|
986
|
+
});
|
|
987
|
+
} catch (err) {
|
|
988
|
+
logInternalError(
|
|
989
|
+
"crew-broker.msg.worker-message-event",
|
|
990
|
+
err instanceof Error ? err : new Error(String(err)),
|
|
991
|
+
`runId=${conn.runId}`,
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
891
995
|
this.sendResult(conn, id, {
|
|
892
996
|
messageId,
|
|
893
|
-
recipientCount:
|
|
997
|
+
recipientCount: targets.length,
|
|
894
998
|
durableStatus: durable ? "ok" : "failed",
|
|
895
999
|
liveDeliveryStatus: "ok",
|
|
896
1000
|
});
|
|
897
1001
|
}
|
|
898
1002
|
|
|
899
|
-
/** Phase 1.2: paginated inbox pull
|
|
1003
|
+
/** Phase 1.2: paginated inbox pull — see ./protocol/msg-inbox.ts
|
|
1004
|
+
* (M4 / WI-4.1 moved; label corrected 2026-09-10: Phase 1.1 = msg.send). */
|
|
900
1005
|
private async handleMsgInbox(conn: ServerConnection, id: string, params: unknown): Promise<void> {
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
if (!cwd) {
|
|
912
|
-
this.sendError(conn, id, "no-manifest", "broker has no cwd configured");
|
|
913
|
-
return;
|
|
914
|
-
}
|
|
915
|
-
let manifest: Parameters<typeof readMailbox>[0];
|
|
916
|
-
try {
|
|
917
|
-
const loaded = loadRunManifestById(cwd, conn.runId);
|
|
918
|
-
if (!loaded) {
|
|
919
|
-
this.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
|
|
920
|
-
return;
|
|
921
|
-
}
|
|
922
|
-
manifest = loaded.manifest;
|
|
923
|
-
} catch (err) {
|
|
924
|
-
this.sendError(conn, id, "no-manifest", (err as Error).message);
|
|
925
|
-
return;
|
|
926
|
-
}
|
|
927
|
-
const limit = Math.min(Math.max(parsed.limit ?? 100, 1), 1000);
|
|
928
|
-
const taskId = conn.taskId ?? undefined;
|
|
929
|
-
const all = readMailbox(manifest, "inbox", taskId);
|
|
930
|
-
const filtered = all.filter((m) => m.status !== "acknowledged");
|
|
931
|
-
const offset = parsed.cursor ? parseInt(parsed.cursor, 10) || 0 : 0;
|
|
932
|
-
const page = filtered.slice(offset, offset + limit);
|
|
933
|
-
const nextOffset = offset + page.length;
|
|
934
|
-
const hasMore = nextOffset < filtered.length;
|
|
935
|
-
this.sendResult(conn, id, {
|
|
936
|
-
messages: page,
|
|
937
|
-
nextCursor: hasMore ? String(nextOffset) : undefined,
|
|
938
|
-
hasMore,
|
|
939
|
-
total: filtered.length,
|
|
940
|
-
});
|
|
1006
|
+
await handleMsgInbox(
|
|
1007
|
+
conn,
|
|
1008
|
+
id,
|
|
1009
|
+
params,
|
|
1010
|
+
{
|
|
1011
|
+
sendError: (c, i, code, msg) => this.sendError(c, i, code, msg),
|
|
1012
|
+
sendResult: (c, i, r) => this.sendResult(c, i, r),
|
|
1013
|
+
},
|
|
1014
|
+
this.options.cwd,
|
|
1015
|
+
);
|
|
941
1016
|
}
|
|
942
1017
|
|
|
943
|
-
/**
|
|
944
|
-
*
|
|
945
|
-
*
|
|
946
|
-
* live frame (e.g. after a queue overflow or reconnect). Reuses the same
|
|
947
|
-
* readEventsCursor + seq semantics as runEventBus.onWithReplay.
|
|
948
|
-
*/
|
|
1018
|
+
/** Phase 1.5: events.since — bounded replay; clients resync after a missed
|
|
1019
|
+
* live frame (queue overflow / reconnect). See ./protocol/events-replay.ts
|
|
1020
|
+
* (M4 / WI-4.1 moved; Phase 2 = events.subscribe, not this). */
|
|
949
1021
|
private async handleEventsSince(conn: ServerConnection, id: string, params: unknown): Promise<void> {
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
try {
|
|
961
|
-
const loaded = loadRunManifestById(cwd, conn.runId);
|
|
962
|
-
if (!loaded) {
|
|
963
|
-
this.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
|
|
964
|
-
return;
|
|
965
|
-
}
|
|
966
|
-
eventsPath = loaded.manifest.eventsPath;
|
|
967
|
-
} catch (err) {
|
|
968
|
-
this.sendError(conn, id, "no-manifest", (err as Error).message);
|
|
969
|
-
return;
|
|
970
|
-
}
|
|
971
|
-
const v = params && typeof params === "object" && !Array.isArray(params) ? (params as Record<string, unknown>) : {};
|
|
972
|
-
const sinceSeq = typeof v.sinceSeq === "number" && Number.isFinite(v.sinceSeq) ? Math.max(0, Math.floor(v.sinceSeq)) : 0;
|
|
973
|
-
const limit = typeof v.limit === "number" && Number.isFinite(v.limit) ? Math.min(Math.max(1, Math.floor(v.limit)), 1000) : 1000;
|
|
974
|
-
try {
|
|
975
|
-
const result = readEventsCursor(eventsPath, { sinceSeq, limit });
|
|
976
|
-
// hasMore is true iff the total filtered count exceeds the page we
|
|
977
|
-
// returned. When `total === events.length` we are at the exact end
|
|
978
|
-
// of the stream (caller will discover this on the next call when
|
|
979
|
-
// `nextSeq` is unchanged from `sinceSeq`).
|
|
980
|
-
const hasMore = result.total > result.events.length;
|
|
981
|
-
this.sendResult(conn, id, {
|
|
982
|
-
events: result.events,
|
|
983
|
-
nextSeq: result.nextSeq,
|
|
984
|
-
hasMore,
|
|
985
|
-
});
|
|
986
|
-
} catch (err) {
|
|
987
|
-
this.sendError(conn, id, "replay-failed", (err as Error).message);
|
|
988
|
-
}
|
|
1022
|
+
await handleEventsSince(
|
|
1023
|
+
conn,
|
|
1024
|
+
id,
|
|
1025
|
+
params,
|
|
1026
|
+
{
|
|
1027
|
+
sendError: (c, i, code, msg) => this.sendError(c, i, code, msg),
|
|
1028
|
+
sendResult: (c, i, r) => this.sendResult(c, i, r),
|
|
1029
|
+
},
|
|
1030
|
+
this.options.cwd,
|
|
1031
|
+
);
|
|
989
1032
|
}
|
|
990
1033
|
|
|
991
1034
|
/**
|
|
@@ -1295,39 +1338,19 @@ export class CrewBroker {
|
|
|
1295
1338
|
}
|
|
1296
1339
|
}
|
|
1297
1340
|
|
|
1298
|
-
// ------------------------------------------------------------------------
|
|
1299
1341
|
// WP-2/R2: wait.request / wait.resolve (ADR-0 2026-08-17-waiting-producer-ask)
|
|
1300
|
-
// ------------------------------------------------------------------------
|
|
1301
1342
|
|
|
1302
|
-
|
|
1303
|
-
* ONLY (ADR item 6). A legacy bare-runId fallback match is REJECTED with
|
|
1304
|
-
* a migrate hint; the orchestrator token is rejected by role. Returns the
|
|
1305
|
-
* error to send, or null when auth passes. */
|
|
1343
|
+
// waitAuthError: protocol/wait-auth.ts (M4/WI-4.1).
|
|
1306
1344
|
private waitAuthError(conn: ServerConnection): { code: string; message: string } | null {
|
|
1307
|
-
|
|
1308
|
-
return { code: "forbidden", message: "wait.* requires a worker task-scoped token" };
|
|
1309
|
-
}
|
|
1310
|
-
if (conn.authMatchKind !== "compound") {
|
|
1311
|
-
return {
|
|
1312
|
-
code: "forbidden",
|
|
1313
|
-
message: "wait.* requires a task-scoped token; re-dispatch with PI_CREW_BROKER_TASK_ID",
|
|
1314
|
-
};
|
|
1315
|
-
}
|
|
1316
|
-
return null;
|
|
1345
|
+
return waitAuthError(conn);
|
|
1317
1346
|
}
|
|
1318
1347
|
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
* event-log lock); an append failure is logged, never thrown. */
|
|
1348
|
+
// (The "ADR item 7" doc that used to dangle here documents
|
|
1349
|
+
// recordWaitPolicyRejection — see wait-auth.ts, where it belongs.
|
|
1350
|
+
// Removed 2026-09-10, review F6.)
|
|
1323
1351
|
// T3/R5 (ADR-5): delegate.request — governed-nesting admission + background
|
|
1324
1352
|
// grandchild spawn with durable mailbox delivery (WP-5 step 5).
|
|
1325
|
-
|
|
1326
|
-
if (!this.nestedSlots) {
|
|
1327
|
-
this.nestedSlots = new NestedSlotBudget(this.options.globalWorkerSemaphore ?? 4, this.options.nestingMaxSlots);
|
|
1328
|
-
}
|
|
1329
|
-
return this.nestedSlots;
|
|
1330
|
-
}
|
|
1353
|
+
// getDelegateNestedSlots: inlined at 4 call sites (5-line method; M4/WI-4.1).
|
|
1331
1354
|
|
|
1332
1355
|
private recordDelegateEvent(
|
|
1333
1356
|
manifest: { eventsPath: string; runId: string },
|
|
@@ -1341,15 +1364,7 @@ export class CrewBroker {
|
|
|
1341
1364
|
taskId: string,
|
|
1342
1365
|
data: Record<string, unknown>,
|
|
1343
1366
|
): void {
|
|
1344
|
-
|
|
1345
|
-
type,
|
|
1346
|
-
runId: manifest.runId,
|
|
1347
|
-
taskId,
|
|
1348
|
-
message: `${type}: ${JSON.stringify(data).slice(0, 200)}`,
|
|
1349
|
-
data,
|
|
1350
|
-
}).catch((err) =>
|
|
1351
|
-
logInternalError("crew-broker.delegate.event", err instanceof Error ? err : new Error(String(err)), `runId=${manifest.runId}`),
|
|
1352
|
-
);
|
|
1367
|
+
recordDelegateEvent(manifest, type, taskId, data);
|
|
1353
1368
|
}
|
|
1354
1369
|
|
|
1355
1370
|
private async handleDelegateRequest(conn: ServerConnection, id: string, params: unknown): Promise<void> {
|
|
@@ -1401,17 +1416,19 @@ export class CrewBroker {
|
|
|
1401
1416
|
this.sendError(conn, id, "no-manifest", (err as Error).message);
|
|
1402
1417
|
return;
|
|
1403
1418
|
}
|
|
1404
|
-
// Capability gate (ADR-5 §10): fail-closed, NEVER silent.
|
|
1419
|
+
// Capability gate (ADR-5 §10): fail-closed, NEVER silent. Since the D8
|
|
1420
|
+
// flip the DEFAULT is true, so reaching this branch means the user
|
|
1421
|
+
// closed the surface via config — the message points back at the knob.
|
|
1405
1422
|
if (this.options.nestingEnabled !== true) {
|
|
1406
1423
|
this.recordDelegateEvent(loaded.manifest, "delegate.rejected", conn.taskId, {
|
|
1407
1424
|
reason: "nesting-disabled",
|
|
1408
|
-
policy: "nesting.enabled=false (
|
|
1425
|
+
policy: "nesting.enabled=false (user config; default is true since D8)",
|
|
1409
1426
|
});
|
|
1410
1427
|
this.sendError(
|
|
1411
1428
|
conn,
|
|
1412
1429
|
id,
|
|
1413
1430
|
"policy-disabled",
|
|
1414
|
-
"delegate is disabled: nesting.enabled=false (
|
|
1431
|
+
"delegate is disabled: nesting.enabled=false (set nesting.enabled=true in user config; delegate.rejected recorded in events.jsonl)",
|
|
1415
1432
|
);
|
|
1416
1433
|
return;
|
|
1417
1434
|
}
|
|
@@ -1453,15 +1470,18 @@ export class CrewBroker {
|
|
|
1453
1470
|
t.cwd === task.cwd,
|
|
1454
1471
|
).length;
|
|
1455
1472
|
const decision = evaluateDelegateAdmission({
|
|
1456
|
-
|
|
1457
|
-
maxDepth: this.options.nestingMaxDepth ?? resolveCrewMaxDepth(undefined), // config knob > env-clamped 1..10, default 2 (ADR-5 §3)
|
|
1473
|
+
maxDepth: this.options.nestingMaxDepth ?? resolveCrewMaxDepth(undefined), // config knob > env-clamped 1..10, default 4 (D8; ADR-5 §3)
|
|
1458
1474
|
parentTask: {
|
|
1459
1475
|
taskId: parentTaskId,
|
|
1460
1476
|
role: task.role,
|
|
1461
1477
|
...(task.depth !== undefined ? { depth: task.depth } : {}),
|
|
1462
1478
|
...(task.allocation !== undefined ? { allocation: task.allocation } : {}),
|
|
1463
1479
|
},
|
|
1464
|
-
slots:
|
|
1480
|
+
slots: (() => {
|
|
1481
|
+
if (!this.nestedSlots)
|
|
1482
|
+
this.nestedSlots = new NestedSlotBudget(this.options.globalWorkerSemaphore ?? 4, this.options.nestingMaxSlots);
|
|
1483
|
+
return this.nestedSlots;
|
|
1484
|
+
})().snapshot(),
|
|
1465
1485
|
requested,
|
|
1466
1486
|
...(effectiveCatalog !== undefined ? { modelCatalog: effectiveCatalog } : {}),
|
|
1467
1487
|
// ADR-5 §12: the delegate surface is an escalation — trusted only by the
|
|
@@ -1481,11 +1501,28 @@ export class CrewBroker {
|
|
|
1481
1501
|
return { code: "policy-denied" as const, message: decision.message ?? decision.reason ?? "delegate denied" };
|
|
1482
1502
|
}
|
|
1483
1503
|
// Slot acquisition INSIDE the lock (no reserve-then-race refund window).
|
|
1484
|
-
if (
|
|
1504
|
+
if (
|
|
1505
|
+
!(() => {
|
|
1506
|
+
if (!this.nestedSlots)
|
|
1507
|
+
this.nestedSlots = new NestedSlotBudget(this.options.globalWorkerSemaphore ?? 4, this.options.nestingMaxSlots);
|
|
1508
|
+
return this.nestedSlots;
|
|
1509
|
+
})().tryAcquire(subId)
|
|
1510
|
+
) {
|
|
1485
1511
|
this.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, { subId, reason: "slots-exhausted" });
|
|
1486
1512
|
return {
|
|
1487
1513
|
code: "policy-denied" as const,
|
|
1488
|
-
message: `delegate rejected: nested spawn budget exhausted; ${
|
|
1514
|
+
message: `delegate rejected: nested spawn budget exhausted; ${
|
|
1515
|
+
(
|
|
1516
|
+
() => {
|
|
1517
|
+
if (!this.nestedSlots)
|
|
1518
|
+
this.nestedSlots = new NestedSlotBudget(
|
|
1519
|
+
this.options.globalWorkerSemaphore ?? 4,
|
|
1520
|
+
this.options.nestingMaxSlots,
|
|
1521
|
+
);
|
|
1522
|
+
return this.nestedSlots;
|
|
1523
|
+
}
|
|
1524
|
+
)().statusLine
|
|
1525
|
+
}`,
|
|
1489
1526
|
};
|
|
1490
1527
|
}
|
|
1491
1528
|
// Reserve the requested budget pessimistically (ADR-5 §5): tokensSpent
|
|
@@ -1676,7 +1713,11 @@ export class CrewBroker {
|
|
|
1676
1713
|
}
|
|
1677
1714
|
}
|
|
1678
1715
|
} finally {
|
|
1679
|
-
|
|
1716
|
+
(() => {
|
|
1717
|
+
if (!this.nestedSlots)
|
|
1718
|
+
this.nestedSlots = new NestedSlotBudget(this.options.globalWorkerSemaphore ?? 4, this.options.nestingMaxSlots);
|
|
1719
|
+
return this.nestedSlots;
|
|
1720
|
+
})().release(subId);
|
|
1680
1721
|
}
|
|
1681
1722
|
this.recordDelegateEvent(loaded.manifest, outcome.timedOut ? "delegate.timed_out" : "delegate.completed", parentTaskId, {
|
|
1682
1723
|
subId,
|
|
@@ -1686,16 +1727,7 @@ export class CrewBroker {
|
|
|
1686
1727
|
}
|
|
1687
1728
|
|
|
1688
1729
|
private recordWaitPolicyRejection(manifest: { eventsPath: string; runId: string }, taskId: string, method: string): void {
|
|
1689
|
-
|
|
1690
|
-
void appendEventAsync(manifest.eventsPath, {
|
|
1691
|
-
type: "policy.action",
|
|
1692
|
-
runId,
|
|
1693
|
-
taskId,
|
|
1694
|
-
message: `${method} rejected: waitMethodsEnabled=false (fail-closed)`,
|
|
1695
|
-
data: { action: method, reason: "wait-methods-disabled", policy: "broker.waitMethodsEnabled=false" },
|
|
1696
|
-
}).catch((err) =>
|
|
1697
|
-
logInternalError("crew-broker.wait.policy-event", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`),
|
|
1698
|
-
);
|
|
1730
|
+
recordWaitPolicyRejection(manifest, taskId, method);
|
|
1699
1731
|
}
|
|
1700
1732
|
|
|
1701
1733
|
/** WP-2/R2 step 4: park the calling task while its `ask` tool awaits a
|
|
@@ -1954,155 +1986,14 @@ export class CrewBroker {
|
|
|
1954
1986
|
// Type guards (no `any`)
|
|
1955
1987
|
// ============================================================================
|
|
1956
1988
|
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
function isHelloParams(value: unknown): value is {
|
|
1970
|
-
protocol: number;
|
|
1971
|
-
runId: string;
|
|
1972
|
-
taskId: string;
|
|
1973
|
-
token: string;
|
|
1974
|
-
role?: string;
|
|
1975
|
-
} {
|
|
1976
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1977
|
-
const v = value as Record<string, unknown>;
|
|
1978
|
-
if (v.protocol !== BROKER_PROTOCOL) {
|
|
1979
|
-
// Force exact-type comparison (must be the number 1, not "1").
|
|
1980
|
-
if (typeof v.protocol !== "number" || !Number.isInteger(v.protocol)) return false;
|
|
1981
|
-
}
|
|
1982
|
-
if (typeof v.runId !== "string" || v.runId.length === 0 || v.runId.length > 256) return false;
|
|
1983
|
-
if (typeof v.taskId !== "string" || v.taskId.length === 0 || v.taskId.length > 256) return false;
|
|
1984
|
-
if (typeof v.token !== "string" || v.token.length === 0 || v.token.length > 256) return false;
|
|
1985
|
-
return true;
|
|
1986
|
-
}
|
|
1987
|
-
|
|
1988
|
-
// ============================================================================
|
|
1989
|
-
// Phase 1 parameter parsers (module-level; no `any`)
|
|
1990
|
-
// ============================================================================
|
|
1991
|
-
|
|
1992
|
-
interface MsgSendParams {
|
|
1993
|
-
to: string | string[] | "all";
|
|
1994
|
-
body: unknown;
|
|
1995
|
-
kind?: MailboxMessageKind;
|
|
1996
|
-
priority?: MailboxMessagePriority;
|
|
1997
|
-
replyTo?: string;
|
|
1998
|
-
}
|
|
1999
|
-
|
|
2000
|
-
function parseMsgSendParams(value: unknown): MsgSendParams | undefined {
|
|
2001
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
2002
|
-
const v = value as Record<string, unknown>;
|
|
2003
|
-
const to = v.to;
|
|
2004
|
-
if (typeof to !== "string" && !Array.isArray(to)) return undefined;
|
|
2005
|
-
if (Array.isArray(to) && !to.every((s) => typeof s === "string" && s.length > 0)) return undefined;
|
|
2006
|
-
if (typeof to === "string" && to.length === 0) return undefined;
|
|
2007
|
-
if (v.body === undefined) return undefined;
|
|
2008
|
-
const kind = v.kind as MailboxMessageKind | undefined;
|
|
2009
|
-
if (kind !== undefined && !["message", "steer", "follow-up", "response", "group_join"].includes(kind)) {
|
|
2010
|
-
return undefined;
|
|
2011
|
-
}
|
|
2012
|
-
const priority = v.priority as MailboxMessagePriority | undefined;
|
|
2013
|
-
if (priority !== undefined && !["urgent", "normal", "low"].includes(priority)) {
|
|
2014
|
-
return undefined;
|
|
2015
|
-
}
|
|
2016
|
-
const replyTo = typeof v.replyTo === "string" ? v.replyTo : undefined;
|
|
2017
|
-
return { to: to as string | string[] | "all", body: v.body, kind, priority, replyTo };
|
|
2018
|
-
}
|
|
2019
|
-
|
|
2020
|
-
interface MsgInboxParams {
|
|
2021
|
-
limit?: number;
|
|
2022
|
-
cursor?: string;
|
|
2023
|
-
}
|
|
2024
|
-
|
|
2025
|
-
function parseMsgInboxParams(value: unknown): MsgInboxParams | undefined {
|
|
2026
|
-
if (value === undefined || value === null) return { limit: 100, cursor: undefined };
|
|
2027
|
-
if (typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
2028
|
-
const v = value as Record<string, unknown>;
|
|
2029
|
-
const limit = v.limit;
|
|
2030
|
-
if (limit !== undefined && (typeof limit !== "number" || !Number.isFinite(limit) || limit < 1)) {
|
|
2031
|
-
return undefined;
|
|
2032
|
-
}
|
|
2033
|
-
const cursor = v.cursor;
|
|
2034
|
-
if (cursor !== undefined && typeof cursor !== "string") return undefined;
|
|
2035
|
-
return { limit: limit as number | undefined, cursor: cursor as string | undefined };
|
|
2036
|
-
}
|
|
2037
|
-
|
|
2038
|
-
function safeStringify(value: unknown): string {
|
|
2039
|
-
try {
|
|
2040
|
-
return JSON.stringify(value) ?? "{}";
|
|
2041
|
-
} catch {
|
|
2042
|
-
return "{}";
|
|
2043
|
-
}
|
|
2044
|
-
}
|
|
2045
|
-
|
|
2046
|
-
// ============================================================================
|
|
2047
|
-
// WP-2/R2 wait.* parameter parsers (ADR-0 2026-08-17-waiting-producer-ask)
|
|
2048
|
-
// ============================================================================
|
|
2049
|
-
|
|
2050
|
-
/** Server-side ceiling for the ask deadline (ADR P2-7): worker-controlled
|
|
2051
|
-
* timeoutSec may NEVER exceed 1h — an unbounded timeout would pin slots and
|
|
2052
|
-
* amplify I/O. Applied as deadline = now + min(timeoutSec, 3600). */
|
|
2053
|
-
const WAIT_REQUEST_TIMEOUT_SEC_MAX = 3600;
|
|
2054
|
-
/** Default ask timeout when the caller omits timeoutSec (ADR item 1). */
|
|
2055
|
-
const WAIT_REQUEST_TIMEOUT_SEC_DEFAULT = 600;
|
|
2056
|
-
/** Bounded question payload (defense-in-depth under the 256 KiB frame cap). */
|
|
2057
|
-
const WAIT_QUESTION_MAX_CHARS = 8192;
|
|
2058
|
-
/** Bounded answer-choice list: at most 16 options, 256 chars each. */
|
|
2059
|
-
const WAIT_OPTIONS_MAX = 16;
|
|
2060
|
-
const WAIT_OPTION_MAX_CHARS = 256;
|
|
2061
|
-
|
|
2062
|
-
interface WaitRequestParams {
|
|
2063
|
-
to: string;
|
|
2064
|
-
question: string;
|
|
2065
|
-
options?: string[];
|
|
2066
|
-
timeoutSec?: number;
|
|
2067
|
-
}
|
|
2068
|
-
|
|
2069
|
-
function parseWaitRequestParams(value: unknown): WaitRequestParams | undefined {
|
|
2070
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
2071
|
-
const v = value as Record<string, unknown>;
|
|
2072
|
-
if (typeof v.to !== "string" || v.to.length === 0 || v.to.length > 256) return undefined;
|
|
2073
|
-
if (typeof v.question !== "string" || v.question.length === 0 || v.question.length > WAIT_QUESTION_MAX_CHARS) {
|
|
2074
|
-
return undefined;
|
|
2075
|
-
}
|
|
2076
|
-
let options: string[] | undefined;
|
|
2077
|
-
if (v.options !== undefined) {
|
|
2078
|
-
if (!Array.isArray(v.options) || v.options.length === 0 || v.options.length > WAIT_OPTIONS_MAX) return undefined;
|
|
2079
|
-
for (const o of v.options) {
|
|
2080
|
-
if (typeof o !== "string" || o.length === 0 || o.length > WAIT_OPTION_MAX_CHARS) return undefined;
|
|
2081
|
-
}
|
|
2082
|
-
options = v.options as string[];
|
|
2083
|
-
}
|
|
2084
|
-
// timeoutSec is clamped server-side in the handler (max 3600); the parser
|
|
2085
|
-
// only rejects non-finite values. Non-positive values clamp to 1s.
|
|
2086
|
-
if (v.timeoutSec !== undefined && (typeof v.timeoutSec !== "number" || !Number.isFinite(v.timeoutSec))) {
|
|
2087
|
-
return undefined;
|
|
2088
|
-
}
|
|
2089
|
-
return {
|
|
2090
|
-
to: v.to,
|
|
2091
|
-
question: v.question,
|
|
2092
|
-
options,
|
|
2093
|
-
timeoutSec: v.timeoutSec as number | undefined,
|
|
2094
|
-
};
|
|
2095
|
-
}
|
|
2096
|
-
|
|
2097
|
-
interface WaitResolveParams {
|
|
2098
|
-
to: string;
|
|
2099
|
-
questionId: string;
|
|
2100
|
-
}
|
|
2101
|
-
|
|
2102
|
-
function parseWaitResolveParams(value: unknown): WaitResolveParams | undefined {
|
|
2103
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
2104
|
-
const v = value as Record<string, unknown>;
|
|
2105
|
-
if (typeof v.to !== "string" || v.to.length === 0 || v.to.length > 256) return undefined;
|
|
2106
|
-
if (typeof v.questionId !== "string" || v.questionId.length === 0 || v.questionId.length > 128) return undefined;
|
|
2107
|
-
return { to: v.to, questionId: v.questionId };
|
|
2108
|
-
}
|
|
1989
|
+
/** Moved to ./protocol/request-parsers.ts (M4 / WI-4.1):
|
|
1990
|
+
* - isRequestObject
|
|
1991
|
+
* - isHelloParams + BROKER_PROTOCOL
|
|
1992
|
+
* - parseMsgSendParams + MsgSendParams
|
|
1993
|
+
* - parseMsgInboxParams + MsgInboxParams
|
|
1994
|
+
* - parseWaitRequestParams + WaitRequestParams
|
|
1995
|
+
* - parseWaitResolveParams + WaitResolveParams
|
|
1996
|
+
* - safeStringify
|
|
1997
|
+
* - WAIT_* constants
|
|
1998
|
+
* Removed from this file; re-exported via "./protocol/request-parsers.ts".
|
|
1999
|
+
*/
|