pi-crew 0.11.20 → 0.11.22
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 +43 -0
- package/dist/index.mjs +537 -526
- package/package.json +2 -1
- package/skills/real-test-pi-crew/SKILL.md +21 -0
- package/src/errors.ts +14 -1
- package/src/extension/async-notifier.ts +42 -17
- package/src/extension/cross-extension-rpc.ts +42 -5
- package/src/extension/notification-router.ts +15 -2
- package/src/extension/registration/lifecycle-handlers.ts +36 -13
- package/src/extension/registration/runtime-cleanup.ts +17 -0
- package/src/extension/registration/wire-cross-extension.ts +26 -0
- package/src/extension/rpc-hmac.ts +53 -2
- package/src/extension/team-tool/cancel.ts +34 -21
- package/src/extension/team-tool/dispatch/index.ts +6 -5
- package/src/extension/team-tool/dispatch/status.ts +42 -2
- package/src/extension/team-tool.ts +24 -2
- package/src/prompt/inbox-poll.ts +77 -25
- package/src/prompt/message-tool.ts +62 -4
- package/src/prompt/prompt-runtime.ts +82 -32
- package/src/runtime/broker/crew-broker-client.ts +74 -16
- package/src/runtime/broker/crew-broker.ts +111 -77
- package/src/runtime/broker/protocol/msg-send-targets.ts +74 -0
- package/src/runtime/broker/wait-status-cache.ts +14 -5
- package/src/runtime/child-pi/child-pi-spawn.ts +47 -9
- package/src/runtime/child-pi/child-pi.ts +20 -2
- package/src/runtime/child-pi/mock-fixtures.ts +23 -0
- package/src/runtime/compaction/compact-stages/truncation-stage.ts +45 -3
- package/src/runtime/compaction/important-line-classifier.ts +85 -11
- package/src/runtime/compaction/tool-output-pruner.ts +35 -11
- package/src/runtime/crew-agent-records.ts +7 -1
- package/src/runtime/delivery-coordinator.ts +30 -0
- package/src/runtime/dwf-state-store.ts +41 -7
- package/src/runtime/finalize-run.ts +13 -2
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +192 -64
- package/src/runtime/goal-workflow/goal-loop-runner.ts +130 -3
- package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
- package/src/runtime/live-session/embedded-tier.ts +3 -1
- package/src/runtime/live-session/live-session-runtime.ts +54 -1
- package/src/runtime/model/model-fallback.ts +23 -1
- package/src/runtime/output/pi-json-output.ts +26 -1
- package/src/runtime/policy-engine.ts +18 -0
- package/src/runtime/process/zombie-scanner.ts +78 -14
- package/src/runtime/process-status.ts +6 -1
- package/src/runtime/recovery/crash-recovery.ts +170 -28
- package/src/runtime/rpc/rpc-worker.ts +44 -1
- package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -3
- package/src/runtime/scheduling/scheduler.ts +74 -11
- package/src/runtime/scratchpad/engine.ts +26 -3
- package/src/runtime/settings-store.ts +22 -2
- package/src/runtime/surface/tmux-provider.ts +24 -3
- package/src/runtime/task-health.ts +16 -1
- package/src/runtime/task-output-context.ts +143 -71
- package/src/runtime/task-packet.ts +70 -17
- package/src/runtime/task-runner/child-executor.ts +27 -0
- package/src/runtime/task-runner/output-splitter.ts +79 -16
- package/src/runtime/task-runner/post-execution.ts +75 -15
- package/src/runtime/task-runner/state-helpers.ts +92 -2
- package/src/runtime/usage-tracker.ts +11 -0
- package/src/runtime/verification/green-contract.ts +17 -0
- package/src/runtime/verification/verification-gates.ts +24 -6
- package/src/runtime/workspace-lock.ts +53 -0
- package/src/schema/team-tool-schema.ts +10 -4
- package/src/state/coordination/mailbox.ts +133 -38
- package/src/state/coordination/process-lock.ts +22 -2
- package/src/state/decision-ledger.ts +56 -8
- package/src/state/event-log/event-log-rotation.ts +83 -5
- package/src/state/event-log/event-log.ts +181 -12
- package/src/state/stores/active-run-registry.ts +10 -3
- package/src/state/types.ts +9 -1
- package/src/ui/agents-jobs-browser.ts +22 -8
- package/src/ui/dashboard-panes/agents-pane.ts +5 -2
- package/src/ui/events-state-source.ts +67 -28
- package/src/ui/inline-panel/agent-pane.ts +13 -6
- package/src/ui/inline-panel/agent-transcript.ts +33 -21
- package/src/ui/inline-panel/agent-view-overlay.ts +18 -3
- package/src/ui/inline-panel/index.ts +21 -1
- package/src/ui/live-run-sidebar.ts +14 -3
- package/src/ui/overlays/mailbox-compose-overlay.ts +10 -4
- package/src/ui/powerbar-publisher.ts +6 -3
- package/src/ui/rail.ts +7 -1
- package/src/ui/render-scheduler.ts +53 -1
- package/src/ui/run-action-dispatcher.ts +68 -20
- package/src/ui/status-colors.ts +9 -0
- package/src/utils/env-filter.ts +8 -0
|
@@ -2,16 +2,25 @@
|
|
|
2
2
|
* crew-broker-client.ts — Child-side connector for the local broker.
|
|
3
3
|
* PHASE 0 skeleton (sub-task 0.3).
|
|
4
4
|
*
|
|
5
|
-
* - Connects only on the first request/subscribe.
|
|
5
|
+
* - Connects only on the first request/subscribe. The connect is
|
|
6
|
+
* SINGLE-FLIGHT (H7, DR-03 round-6 2026-10-16): concurrent request()s
|
|
7
|
+
* on a fresh client share one in-flight connectAndHello() instead of
|
|
8
|
+
* racing N sockets into existence.
|
|
6
9
|
* - Sends `hello` first; any other method before ack is a protocol error.
|
|
7
10
|
* - Bounded backoff (50/100/200/400/800 ms + jitter, max 4 attempts).
|
|
8
11
|
* After exhaustion, no hot reconnect loop is scheduled. The only way back
|
|
9
12
|
* to the live path is an explicit `reconnect()` (or a new worker lifecycle).
|
|
10
13
|
* - On ANY connect/auth/timeout/close error, transitions to `fallback` mode
|
|
11
|
-
* and never lets an exception escape `request()
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
14
|
+
* and never lets an exception escape `request()`. The caller can read
|
|
15
|
+
* `client.mode` or the `{ok:false, fallback:true}` result and continue
|
|
16
|
+
* using today's file-based paths. (`subscribe()` is the ONE deliberate
|
|
17
|
+
* exception — it throws "not supported" (H8) because a silent no-op
|
|
18
|
+
* unsubscribe let callers believe they were subscribed; it has zero
|
|
19
|
+
* production callers.)
|
|
20
|
+
* - `close()` removes all listeners and pending requests. Any teardown of
|
|
21
|
+
* the current socket (close/reconnect) rejects stranded pending entries
|
|
22
|
+
* IMMEDIATELY with a typed `connection-closed` error (H8) instead of
|
|
23
|
+
* leaving them to their request timers.
|
|
15
24
|
* - All diagnostic strings pass through `redactSecretString` from
|
|
16
25
|
* `src/utils/redaction.ts`. Token and payload bytes are never logged.
|
|
17
26
|
*
|
|
@@ -112,6 +121,13 @@ export class CrewBrokerClient {
|
|
|
112
121
|
}> = [];
|
|
113
122
|
/** Set in close(); gates reconnect attempts and backoff loops. */
|
|
114
123
|
private closed = false;
|
|
124
|
+
/** H7: in-flight connect (single-flight). Concurrent request()s on an
|
|
125
|
+
* unstarted client await the SAME connectAndHello() promise instead of
|
|
126
|
+
* each opening its own socket (previously two concurrent first-requests
|
|
127
|
+
* raced two sockets; the loser's socket leaked until its hello deadline).
|
|
128
|
+
* Cleared on settle — success moves `mode` to "connected" and failure to
|
|
129
|
+
* "fallback", so a null value never masks a needed reconnect. */
|
|
130
|
+
private connecting: Promise<BrokerClientResult<true>> | null = null;
|
|
115
131
|
/** Handle to the in-flight backoff timer so close() can cancel it. */
|
|
116
132
|
private backoffTimer: NodeJS.Timeout | null = null;
|
|
117
133
|
/** Resolver for the in-flight backoff await so close() can unblock it.
|
|
@@ -171,9 +187,10 @@ export class CrewBrokerClient {
|
|
|
171
187
|
return { ok: false, fallback: true, errorCode: "bad-method" };
|
|
172
188
|
}
|
|
173
189
|
|
|
174
|
-
// Lazy connect + hello on first use.
|
|
190
|
+
// Lazy connect + hello on first use. H7: single-flight — concurrent
|
|
191
|
+
// requests share one connectAndHello() (one socket), not N.
|
|
175
192
|
if (this._mode === "unstarted" || !this.socket) {
|
|
176
|
-
const connectResult = await this.
|
|
193
|
+
const connectResult = await this.ensureConnected();
|
|
177
194
|
if (!connectResult.ok) {
|
|
178
195
|
return connectResult;
|
|
179
196
|
}
|
|
@@ -246,18 +263,17 @@ export class CrewBrokerClient {
|
|
|
246
263
|
}
|
|
247
264
|
|
|
248
265
|
/**
|
|
249
|
-
* Subscribe to a per-run event stream.
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
266
|
+
* Subscribe to a per-run event stream. NOT SUPPORTED (H8, DR-03
|
|
267
|
+
* round-6 2026-10-16): Phase 0 never implemented the event stream, and
|
|
268
|
+
* the old silent no-op unsubscribe let a caller believe it was
|
|
269
|
+
* subscribed while frames went nowhere. Zero production callers exist,
|
|
270
|
+
* so this now throws instead of silently misbehaving. Live frames are
|
|
271
|
+
* available via the `onEvent` constructor option; durable resync via
|
|
272
|
+
* `msg.inbox` / `events.since` polling.
|
|
253
273
|
*/
|
|
254
274
|
subscribe(options: { runId: string; sinceSeq: number; onEvent: (event: unknown) => void }): () => void {
|
|
255
|
-
// Phase 0: subscription is a typed not-implemented. We register a
|
|
256
|
-
// no-op unsubscribe so the caller can call it without errors.
|
|
257
275
|
void options;
|
|
258
|
-
|
|
259
|
-
/* no-op */
|
|
260
|
-
};
|
|
276
|
+
throw new BrokerError("not-implemented", "subscribe is not supported; use onEvent / events.since polling");
|
|
261
277
|
}
|
|
262
278
|
|
|
263
279
|
/**
|
|
@@ -331,6 +347,19 @@ export class CrewBrokerClient {
|
|
|
331
347
|
// Connect + hello with bounded backoff
|
|
332
348
|
// ------------------------------------------------------------------------
|
|
333
349
|
|
|
350
|
+
/** H7: single-flight wrapper around connectAndHello. The first caller
|
|
351
|
+
* creates the in-flight promise; concurrent callers await the same one.
|
|
352
|
+
* Settled promises are dropped so a later reconnect() (explicit
|
|
353
|
+
* `unstarted` reset) starts a genuinely fresh connect. */
|
|
354
|
+
private ensureConnected(): Promise<BrokerClientResult<true>> {
|
|
355
|
+
if (!this.connecting) {
|
|
356
|
+
this.connecting = this.connectAndHello().finally(() => {
|
|
357
|
+
this.connecting = null;
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
return this.connecting;
|
|
361
|
+
}
|
|
362
|
+
|
|
334
363
|
private async connectAndHello(): Promise<BrokerClientResult<true>> {
|
|
335
364
|
if (!this.options.socketPath || !this.options.token) {
|
|
336
365
|
this.enterFallbackOnce("missing-credentials", new Error("socket or token not provided"));
|
|
@@ -642,6 +671,35 @@ export class CrewBrokerClient {
|
|
|
642
671
|
}
|
|
643
672
|
|
|
644
673
|
private teardownSocket(): void {
|
|
674
|
+
// H8: reject stranded pending entries IMMEDIATELY. A teardown means
|
|
675
|
+
// the socket they were written to is going away (close() semantics, a
|
|
676
|
+
// reconnect() replacing it, or the closed-race in connectAndHello) —
|
|
677
|
+
// their responses can never arrive on it. Leaving them to their
|
|
678
|
+
// request timers kept callers (e.g. an await inside reconnect())
|
|
679
|
+
// hanging for up to the full request timeout on a dead connection.
|
|
680
|
+
// The typed `connection-closed` error funnels into request()'s catch →
|
|
681
|
+
// {ok:false, fallback:true, errorCode:"connection-closed"}.
|
|
682
|
+
if (this.pending.size > 0) {
|
|
683
|
+
const clearTimeoutFn = this.options.clearTimeoutFn ?? ((t: NodeJS.Timeout) => clearTimeout(t));
|
|
684
|
+
for (const [, p] of this.pending) {
|
|
685
|
+
if (p.timer) {
|
|
686
|
+
try {
|
|
687
|
+
clearTimeoutFn(p.timer);
|
|
688
|
+
} catch {
|
|
689
|
+
/* best-effort */
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
p.reject(
|
|
693
|
+
// Code outside the closed BrokerErrorCode union (which lives in
|
|
694
|
+
// ndjson.ts and gates protocol responses); same cast precedent as
|
|
695
|
+
// the wire-error path in attemptHello. Surfaces to callers as
|
|
696
|
+
// errorCode "connection-closed" (BrokerClientResult.errorCode is a
|
|
697
|
+
// plain string).
|
|
698
|
+
new BrokerError("connection-closed" as never, "connection torn down before response"),
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
this.pending.clear();
|
|
702
|
+
}
|
|
645
703
|
if (!this.socket) {
|
|
646
704
|
this.socket = null;
|
|
647
705
|
this.decoder = null;
|
|
@@ -1,24 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* crew-broker.ts — Root-only local socket server for cross-process pi-worker
|
|
3
|
-
* message transport
|
|
3
|
+
* message transport (grew out of the Phase 0 skeleton,
|
|
4
|
+
* sub-task 0.4 — see history note below).
|
|
4
5
|
*
|
|
5
6
|
* - Bound to a `node:net` Unix domain socket or Windows named pipe.
|
|
6
7
|
* - One broker per root session; per-run token auth on first `hello`.
|
|
7
8
|
* - NDJSON framing, 256 KiB UTF-8 cap, 1s hello deadline.
|
|
8
|
-
* - Per-connection outbound queue cap (default 256)
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* - Per-connection outbound queue cap (default 256). Fanout/event frames
|
|
10
|
+
* are drop-newest at cap, and the drop now CLOSES the connection with a
|
|
11
|
+
* typed `protocol.close` reason ("outbound-overflow-resync") so the
|
|
12
|
+
* client reconnects and replays (R6-5). Request RESPONSE frames
|
|
13
|
+
* (sendResult/sendError acks) bypass the cap — bounded by in-flight
|
|
14
|
+
* request count, never the fanout storm.
|
|
15
|
+
* - Dispatches 12 methods post-hello: `ping`, `hello`, `msg.send`,
|
|
16
|
+
* `msg.inbox`, `events.since`, `events.subscribe`, `task.waitStatus`,
|
|
17
|
+
* `steer.push`, `escalate`, `wait.request`/`wait.resolve`
|
|
18
|
+
* (capability-gated via `options.waitMethodsEnabled`, fail-closed), and
|
|
19
|
+
* `delegate.request` (governed-nesting grandchild spawn through
|
|
20
|
+
* `delegate-spawn.ts`, capability-gated via `options.nestingEnabled`,
|
|
21
|
+
* fail-closed). Unknown methods return a typed `not-implemented`.
|
|
22
|
+
* - Mailbox/event persistence flows through the `src/state/` stores
|
|
23
|
+
* (`appendMailboxMessage*`, event-log) — the broker itself keeps no
|
|
24
|
+
* durable state of its own.
|
|
13
25
|
* - Token map is HEAP ONLY; cleared on `stop()`. Never serialized.
|
|
14
26
|
* - `stop()` is idempotent. Never calls `process.kill`.
|
|
15
27
|
* - All log/error scopes use `crew-broker.*` prefix; every diagnostic
|
|
16
28
|
* passes through `redactSecretString` from `src/utils/redaction.ts`.
|
|
17
29
|
*
|
|
18
|
-
* NO outbound TCP. NO
|
|
30
|
+
* NO outbound TCP. NO process killing.
|
|
19
31
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
32
|
+
* History: the Phase 0 contract (hello/ping only, no persistence, no
|
|
33
|
+
* children) lives in `reports/inter-pi-broker-impl-plan-2026-07-21.md` §0.4;
|
|
34
|
+
* the 12-method surface above supersedes it. DR-01 (2026-10-14) rewrote
|
|
35
|
+
* this header after it had drifted ~1900 LOC past the skeleton it
|
|
36
|
+
* described.
|
|
22
37
|
*/
|
|
23
38
|
|
|
24
39
|
import { randomUUID } from "node:crypto";
|
|
@@ -53,6 +68,7 @@ import { handleEventsSince } from "./protocol/events-replay.ts";
|
|
|
53
68
|
import { DEFAULT_LOCK_BUSY_RETRY_DELAYS_MS, type LockBusyOutcome, withRunLockBusyRetry } from "./protocol/lock-busy.ts";
|
|
54
69
|
import { loadRunForHello } from "./protocol/manifest-loader.ts";
|
|
55
70
|
import { handleMsgInbox } from "./protocol/msg-inbox.ts";
|
|
71
|
+
import { resolveMsgSendTargets } from "./protocol/msg-send-targets.ts";
|
|
56
72
|
import {
|
|
57
73
|
BROKER_PROTOCOL,
|
|
58
74
|
isHelloParams,
|
|
@@ -93,6 +109,26 @@ const STALE_RUN_STATUSES: ReadonlySet<string> = new Set(["completed", "failed",
|
|
|
93
109
|
/** Default per-connection outbound queue cap (events). */
|
|
94
110
|
const DEFAULT_OUTBOUND_QUEUE_CAP = 256;
|
|
95
111
|
|
|
112
|
+
/** R6-5 (2026-10-16): typed protocol close-reason sent as a best-effort
|
|
113
|
+
* `protocol.close` event frame (direct write, jumps the saturated queue)
|
|
114
|
+
* before the connection is closed after a fanout/event frame was dropped
|
|
115
|
+
* at the queue cap. Clients observe the reason, reconnect, and replay via
|
|
116
|
+
* `events.since` (Phase 1.5). */
|
|
117
|
+
export const OUTBOUND_OVERFLOW_RESYNC_CLOSE_REASON = "outbound-overflow-resync";
|
|
118
|
+
|
|
119
|
+
/** R6-5: admission class for an outbound frame.
|
|
120
|
+
* - `false` — fanout/event class: drop-newest at cap → resync close.
|
|
121
|
+
* - `true` — close-reason class: bypass the cap, attempt an immediate
|
|
122
|
+
* direct write (existing `sendErrorAndClose` behavior).
|
|
123
|
+
* - `"response"` — request-response class (sendResult/sendError acks):
|
|
124
|
+
* bypass the drop-newest cap but stay IN the queue (ordering preserved,
|
|
125
|
+
* backpressure honored). Bounded by the client's in-flight request count
|
|
126
|
+
* — one response frame per request — so it can never storm the way a
|
|
127
|
+
* fanout burst can. Kept a `boolean | "response"` union (not a fresh
|
|
128
|
+
* enum) so the external FanoutWriters interface (mailbox-fanout.ts) keeps
|
|
129
|
+
* its `(conn, buf, force: boolean)` shape. */
|
|
130
|
+
type OutboundAdmission = boolean | "response";
|
|
131
|
+
|
|
96
132
|
export class CrewBroker {
|
|
97
133
|
private readonly options: Required<Pick<CrewBrokerOptions, "sessionId" | "enabled" | "waitMethodsEnabled" | "nestingEnabled">> &
|
|
98
134
|
Pick<
|
|
@@ -750,7 +786,7 @@ export class CrewBroker {
|
|
|
750
786
|
* truth for authentication). */
|
|
751
787
|
// loadRunForHello: inlined at the 2 call sites (was a 5-line method; M4/WI-4.1).
|
|
752
788
|
|
|
753
|
-
// Outbound queue + drop-newest +
|
|
789
|
+
// Outbound queue + drop-newest + resync-close (R6-5)
|
|
754
790
|
|
|
755
791
|
private sendResult(conn: ServerConnection, id: string, result: unknown): void {
|
|
756
792
|
this.enqueueFrame(conn, { id, result });
|
|
@@ -787,13 +823,27 @@ export class CrewBroker {
|
|
|
787
823
|
);
|
|
788
824
|
return;
|
|
789
825
|
}
|
|
790
|
-
|
|
826
|
+
// R6-5: request responses (the acks for ping/msg.send/wait.request/…)
|
|
827
|
+
// are response-class — they bypass the drop-newest cap so a saturated
|
|
828
|
+
// fanout queue can no longer swallow an ack and leave the caller to its
|
|
829
|
+
// request-timeout (a delivered send reported "NOT sent").
|
|
830
|
+
this.writeOrQueue(conn, buf, "response");
|
|
791
831
|
}
|
|
792
832
|
|
|
793
|
-
private writeOrQueue(conn: ServerConnection, buf: Buffer, force:
|
|
833
|
+
private writeOrQueue(conn: ServerConnection, buf: Buffer, force: OutboundAdmission): void {
|
|
794
834
|
if (conn.closed) return;
|
|
795
835
|
const cap = this.options.outboundQueueCap ?? DEFAULT_OUTBOUND_QUEUE_CAP;
|
|
796
836
|
if (conn.outbound.length >= cap) {
|
|
837
|
+
if (force === "response") {
|
|
838
|
+
// R6-5(a): response frames queue PAST the cap instead of being
|
|
839
|
+
// drop-newest'd. Bounded: at most one response per in-flight
|
|
840
|
+
// request, and the client's request pipeline is serial — this can
|
|
841
|
+
// never grow unbounded the way a fanout burst can. Ordering is
|
|
842
|
+
// preserved (pushed at the tail; drainOutbound honors backpressure).
|
|
843
|
+
conn.outbound.push(buf);
|
|
844
|
+
this.drainOutbound(conn);
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
797
847
|
if (force) {
|
|
798
848
|
// Forced sends (e.g. close-reason) bypass the cap and attempt
|
|
799
849
|
// to flush directly; if the socket is busy they may still drop.
|
|
@@ -804,13 +854,13 @@ export class CrewBroker {
|
|
|
804
854
|
}
|
|
805
855
|
return;
|
|
806
856
|
}
|
|
807
|
-
//
|
|
808
|
-
//
|
|
809
|
-
//
|
|
810
|
-
|
|
811
|
-
//
|
|
812
|
-
//
|
|
813
|
-
|
|
857
|
+
// R6-5(b): fanout/event frame dropped at cap (drop-newest). The old
|
|
858
|
+
// `needsResync` marker was write-only (set, never read) — the claimed
|
|
859
|
+
// "pause + client notices" recovery did not exist, so live frames
|
|
860
|
+
// were silently dropped for the connection's remaining lifetime.
|
|
861
|
+
// Now the drop CLOSES the connection with a typed close-reason so
|
|
862
|
+
// the client reconnects and replays (events.since / msg.inbox).
|
|
863
|
+
this.handleOutboundOverflow(conn);
|
|
814
864
|
return;
|
|
815
865
|
}
|
|
816
866
|
conn.outbound.push(buf);
|
|
@@ -841,6 +891,33 @@ export class CrewBroker {
|
|
|
841
891
|
}
|
|
842
892
|
}
|
|
843
893
|
|
|
894
|
+
/** R6-5(b): a fanout/event frame was dropped at the outbound queue cap.
|
|
895
|
+
* Close the connection with a typed protocol close-reason so the client
|
|
896
|
+
* can reconnect + replay instead of silently missing live frames
|
|
897
|
+
* forever. The reason frame is written DIRECTLY (jumps the saturated
|
|
898
|
+
* queue — those frames are already doomed once we close) and delivery
|
|
899
|
+
* is best-effort, exactly like `sendErrorAndClose`. */
|
|
900
|
+
private handleOutboundOverflow(conn: ServerConnection): void {
|
|
901
|
+
// Single-flight: fanoutMailboxMessage iterates a snapshot of the run's
|
|
902
|
+
// connections, so one burst can re-enter here for an already-overflowing
|
|
903
|
+
// conn before `closeConnection`'s `closed` guard becomes visible. The
|
|
904
|
+
// flag is the "close already initiated" bit (its only read).
|
|
905
|
+
if (conn.needsResync) return;
|
|
906
|
+
conn.needsResync = true;
|
|
907
|
+
logInternalError(
|
|
908
|
+
"crew-broker.outbound.overflow",
|
|
909
|
+
new Error("outbound queue overflow — closing connection for resync"),
|
|
910
|
+
`sessionId=${this.options.sessionId} runId=${conn.runId ?? "?"} queued=${conn.outbound.length} seq=${conn.outboundSeq}`,
|
|
911
|
+
);
|
|
912
|
+
try {
|
|
913
|
+
const buf = encodeBrokerFrame({ event: "protocol.close", data: { reason: OUTBOUND_OVERFLOW_RESYNC_CLOSE_REASON } });
|
|
914
|
+
conn.socket.write(buf);
|
|
915
|
+
} catch {
|
|
916
|
+
/* socket may be dead; closeConnection below sweeps regardless */
|
|
917
|
+
}
|
|
918
|
+
this.closeConnection(conn);
|
|
919
|
+
}
|
|
920
|
+
|
|
844
921
|
// Phase 1: msg.send + msg.inbox handlers
|
|
845
922
|
|
|
846
923
|
/** Phase 1.1: direct or broadcast mailbox write via the durable append path. */
|
|
@@ -894,67 +971,25 @@ export class CrewBroker {
|
|
|
894
971
|
this.sendError(conn, id, "no-manifest", (err as Error).message);
|
|
895
972
|
return;
|
|
896
973
|
}
|
|
897
|
-
// ── Recipient resolution
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
// Task 5b (spec §15.2 wake): set when a worker addresses the parent —
|
|
903
|
-
// the durable write alone would sit unread in the run-level inbox.
|
|
904
|
-
let sentToParent = false;
|
|
905
|
-
if (isWorker) {
|
|
906
|
-
// Worker constraint (1): from is ALWAYS the authenticated taskId.
|
|
907
|
-
// Worker constraint (2): to is limited to parent | valid sibling
|
|
908
|
-
// taskId | group.
|
|
909
|
-
if (!conn.taskId) {
|
|
910
|
-
this.sendError(conn, id, "forbidden", "msg.send worker requires a task-scoped identity");
|
|
911
|
-
return;
|
|
912
|
-
}
|
|
913
|
-
const to = typeof parsed.to === "string" ? parsed.to : undefined;
|
|
914
|
-
if (to === "parent") {
|
|
915
|
-
// Run-level inbox (taskId undefined) → the orchestrator session.
|
|
916
|
-
targets = [{ label: "parent", mailboxTaskId: undefined }];
|
|
917
|
-
sentToParent = true;
|
|
918
|
-
} else if (to === "group") {
|
|
919
|
-
targets = taskIds.map((t) => ({ label: t, mailboxTaskId: t }));
|
|
920
|
-
} else if (to !== undefined && taskIds.includes(to)) {
|
|
921
|
-
targets = [{ label: to, mailboxTaskId: to }];
|
|
922
|
-
} else {
|
|
923
|
-
this.sendError(conn, id, "forbidden", `msg.send: worker cannot target '${to}'`);
|
|
924
|
-
return;
|
|
925
|
-
}
|
|
926
|
-
if (targets.length === 0 || targets.length > 64) {
|
|
927
|
-
this.sendError(conn, id, "bad-params", "msg.send: recipient count out of range");
|
|
928
|
-
return;
|
|
929
|
-
}
|
|
930
|
-
} else {
|
|
931
|
-
const recipients: string[] = Array.isArray(parsed.to)
|
|
932
|
-
? (parsed.to as string[])
|
|
933
|
-
: parsed.to === "all"
|
|
934
|
-
? taskIds
|
|
935
|
-
: [parsed.to as string];
|
|
936
|
-
if (recipients.length === 0 || recipients.length > 64) {
|
|
937
|
-
this.sendError(conn, id, "bad-params", "msg.send: recipient count out of range");
|
|
938
|
-
return;
|
|
939
|
-
}
|
|
940
|
-
targets = recipients.map((recipient) => ({ label: recipient, mailboxTaskId: recipient }));
|
|
974
|
+
// ── Recipient resolution: pure D9/§15.2 matrix in protocol/msg-send-targets.ts (wc-gate).
|
|
975
|
+
const resolved = resolveMsgSendTargets({ isWorker, connTaskId: conn.taskId, to: parsed.to, taskIds });
|
|
976
|
+
if (!resolved.ok) {
|
|
977
|
+
this.sendError(conn, id, resolved.code, resolved.message);
|
|
978
|
+
return;
|
|
941
979
|
}
|
|
980
|
+
const { targets, sentToParent } = resolved;
|
|
942
981
|
const messageId = `msg_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
943
982
|
const fromField = isWorker ? conn.taskId! : (conn.taskId ?? conn.runId);
|
|
944
|
-
// U5
|
|
945
|
-
//
|
|
946
|
-
//
|
|
947
|
-
// delivery.json + the idempotent append below make resend-after-resume a
|
|
948
|
-
// no-op instead of a duplicate delivery.
|
|
983
|
+
// U5: every send carries a unique requestId — client-supplied (idempotent
|
|
984
|
+
// retries reuse the SAME one) or broker-generated. The durable
|
|
985
|
+
// (direction, requestId) index makes resend-after-resume a no-op.
|
|
949
986
|
const requestId = parsed.requestId ?? `${manifest.runId}:${fromField}:${randomUUID()}`;
|
|
950
987
|
let dedupedRecipients = 0;
|
|
951
988
|
let durable = false;
|
|
952
989
|
try {
|
|
953
|
-
// PERF
|
|
954
|
-
//
|
|
955
|
-
//
|
|
956
|
-
// mailbox files append concurrently; delivery.json stays serialized by
|
|
957
|
-
// its own lock.
|
|
990
|
+
// PERF: to:"all" with 50 tasks used to run 50 sequential awaited locked
|
|
991
|
+
// appends while the connection's frames queued behind it. Chunked
|
|
992
|
+
// fan-out — independent mailbox files append concurrently.
|
|
958
993
|
const CHUNK = 8;
|
|
959
994
|
for (let i = 0; i < targets.length; i += CHUNK) {
|
|
960
995
|
const results = await Promise.allSettled(
|
|
@@ -985,10 +1020,9 @@ export class CrewBroker {
|
|
|
985
1020
|
this.sendError(conn, id, "durable-failed", (err as Error).message);
|
|
986
1021
|
return;
|
|
987
1022
|
}
|
|
988
|
-
// U5: one bounded audit event per send — requestId + dedup count make
|
|
989
|
-
// exactly-once chain auditable
|
|
990
|
-
//
|
|
991
|
-
// non-fatal: the mailbox rows are the source of truth.
|
|
1023
|
+
// U5: one bounded audit event per send — requestId + dedup count make
|
|
1024
|
+
// the exactly-once chain auditable. Never the body; mailbox rows are
|
|
1025
|
+
// the source of truth.
|
|
992
1026
|
try {
|
|
993
1027
|
await appendEventAsync(manifest.eventsPath, {
|
|
994
1028
|
type: "mailbox.send",
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure recipient resolution for the broker's `msg.send` handler.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from crew-broker.ts (DR-03 wave, wc-gate: no module over 2000
|
|
5
|
+
* lines — same precedent as live-session/usage-bridge.ts): the worker/
|
|
6
|
+
* orchestrator recipient matrices are pure functions of
|
|
7
|
+
* {role, taskId, parsed.to, taskIds} — no broker state involved.
|
|
8
|
+
*
|
|
9
|
+
* Worker constraints (D9/§15.2): from is always the authenticated taskId;
|
|
10
|
+
* to is limited to `parent` | valid sibling taskId | `group`; kind limited
|
|
11
|
+
* to notify|message (checked by the caller BEFORE resolution).
|
|
12
|
+
* Orchestrator keeps the full prior surface (arrays / "all" / arbitrary
|
|
13
|
+
* recipient sets).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type MsgSendTarget = { label: string; mailboxTaskId: string | undefined };
|
|
17
|
+
|
|
18
|
+
export type MsgSendTargetResolution =
|
|
19
|
+
| { ok: true; targets: MsgSendTarget[]; sentToParent: boolean }
|
|
20
|
+
| { ok: false; code: "forbidden" | "bad-params"; message: string };
|
|
21
|
+
|
|
22
|
+
/** Max recipients per send (both roles) — matches the historical inline check. */
|
|
23
|
+
export const MSG_SEND_MAX_RECIPIENTS = 64;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve `parsed.to` into mailbox targets.
|
|
27
|
+
*
|
|
28
|
+
* Each target is `{label, mailboxTaskId}`: `label` is echoed in the ack and
|
|
29
|
+
* message id; `mailboxTaskId` is the mailbox file the append lands in
|
|
30
|
+
* (undefined = run-level inbox, which the orchestrator consumes).
|
|
31
|
+
* `sentToParent` is set when a worker addresses the parent — the durable
|
|
32
|
+
* write alone would sit unread in the run-level inbox (spec §15.2 wake).
|
|
33
|
+
*/
|
|
34
|
+
export function resolveMsgSendTargets(input: {
|
|
35
|
+
isWorker: boolean;
|
|
36
|
+
connTaskId: string | undefined;
|
|
37
|
+
to: unknown;
|
|
38
|
+
taskIds: string[];
|
|
39
|
+
}): MsgSendTargetResolution {
|
|
40
|
+
const { isWorker, connTaskId, taskIds } = input;
|
|
41
|
+
|
|
42
|
+
if (isWorker) {
|
|
43
|
+
// Worker constraint (1): from is ALWAYS the authenticated taskId.
|
|
44
|
+
if (!connTaskId) {
|
|
45
|
+
return { ok: false, code: "forbidden", message: "msg.send worker requires a task-scoped identity" };
|
|
46
|
+
}
|
|
47
|
+
// Worker constraint (2): to is limited to parent | valid sibling
|
|
48
|
+
// taskId | group.
|
|
49
|
+
const to = typeof input.to === "string" ? input.to : undefined;
|
|
50
|
+
let targets: MsgSendTarget[];
|
|
51
|
+
let sentToParent = false;
|
|
52
|
+
if (to === "parent") {
|
|
53
|
+
// Run-level inbox (taskId undefined) → the orchestrator session.
|
|
54
|
+
targets = [{ label: "parent", mailboxTaskId: undefined }];
|
|
55
|
+
sentToParent = true;
|
|
56
|
+
} else if (to === "group") {
|
|
57
|
+
targets = taskIds.map((t) => ({ label: t, mailboxTaskId: t }));
|
|
58
|
+
} else if (to !== undefined && taskIds.includes(to)) {
|
|
59
|
+
targets = [{ label: to, mailboxTaskId: to }];
|
|
60
|
+
} else {
|
|
61
|
+
return { ok: false, code: "forbidden", message: `msg.send: worker cannot target '${to}'` };
|
|
62
|
+
}
|
|
63
|
+
if (targets.length === 0 || targets.length > MSG_SEND_MAX_RECIPIENTS) {
|
|
64
|
+
return { ok: false, code: "bad-params", message: "msg.send: recipient count out of range" };
|
|
65
|
+
}
|
|
66
|
+
return { ok: true, targets, sentToParent };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const recipients: string[] = Array.isArray(input.to) ? (input.to as string[]) : input.to === "all" ? taskIds : [input.to as string];
|
|
70
|
+
if (recipients.length === 0 || recipients.length > MSG_SEND_MAX_RECIPIENTS) {
|
|
71
|
+
return { ok: false, code: "bad-params", message: "msg.send: recipient count out of range" };
|
|
72
|
+
}
|
|
73
|
+
return { ok: true, targets: recipients.map((recipient) => ({ label: recipient, mailboxTaskId: recipient })), sentToParent: false };
|
|
74
|
+
}
|
|
@@ -119,9 +119,18 @@ export class WaitStatusCache {
|
|
|
119
119
|
/**
|
|
120
120
|
* Stat-gated load. Fast path (stamp unchanged): 2 statSync calls, zero
|
|
121
121
|
* parses. Slow path (first sight or changed mtime/size): delegate to the
|
|
122
|
-
* loader, then record the
|
|
123
|
-
*
|
|
124
|
-
*
|
|
122
|
+
* loader, then record the PRE-load stamp (H5, DR-03 round-6 2026-10-16).
|
|
123
|
+
*
|
|
124
|
+
* Why PRE-load: keying the entry on the POST-load stamp had a
|
|
125
|
+
* parse→stat TOCTOU bug — a writer landing between the parse and the
|
|
126
|
+
* after-stat produced an entry whose stamp was FRESH while its cached
|
|
127
|
+
* value was STALE, and every later tick with an unchanged disk served
|
|
128
|
+
* the stale value forever (stale-under-fresh pinning). With the PRE-load
|
|
129
|
+
* stamp, a writer that lands anywhere between the pre-stat and the parse
|
|
130
|
+
* leaves the entry keyed on the OLDER stamp, so the very next tick sees
|
|
131
|
+
* the stamp change and reloads. Worst case is one spurious reload
|
|
132
|
+
* (writer landed before the parse — the loaded value was already fresh),
|
|
133
|
+
* never a missed one.
|
|
125
134
|
*/
|
|
126
135
|
load(cwd: string, runId: string): LoadedRun {
|
|
127
136
|
// Same path derivation as resolveRunStateRoot inside
|
|
@@ -135,13 +144,13 @@ export class WaitStatusCache {
|
|
|
135
144
|
}
|
|
136
145
|
const loaded = this.loader(cwd, runId);
|
|
137
146
|
this.reloadsValue += 1;
|
|
138
|
-
const after = statStamp(paths.manifestPath, paths.tasksPath);
|
|
139
147
|
if (this.entries.size >= WAIT_STATUS_CACHE_MAX && !this.entries.has(runId)) {
|
|
140
148
|
// Map preserves insertion order — evict the oldest entry.
|
|
141
149
|
const oldest = this.entries.keys().next().value;
|
|
142
150
|
if (oldest !== undefined) this.entries.delete(oldest);
|
|
143
151
|
}
|
|
144
|
-
|
|
152
|
+
// H5: key on the PRE-load stamp (`before`), NOT a post-load re-stat.
|
|
153
|
+
this.entries.set(runId, { ...before, loaded });
|
|
145
154
|
return loaded;
|
|
146
155
|
}
|
|
147
156
|
|
|
@@ -21,7 +21,7 @@ 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
23
|
import { isScratchpadEnabledForRole } from "../../config/role-tools.ts";
|
|
24
|
-
import { buildScopedAllowList, sanitizeEnvSecrets } from "../../utils/env-filter.ts";
|
|
24
|
+
import { buildScopedAllowList, providerEnvKeys, sanitizeEnvSecrets } from "../../utils/env-filter.ts";
|
|
25
25
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
26
26
|
import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
|
|
27
27
|
import { buildPiWorkerArgs, createSafeTempDir, getPiTempBase } from "../model/pi-args.ts";
|
|
@@ -48,8 +48,23 @@ export const BASE_ALLOWLIST: string[] = childPiBaseAllowlist();
|
|
|
48
48
|
* 4. Add PI_CREW_PARENT_PID for the child-side parent-guard (consumed by the
|
|
49
49
|
* reactive zombie-scanner + background-runner parent-guard; unused only by
|
|
50
50
|
* the pi dist binary — see note at the PI_CREW_PARENT_PID assignment below).
|
|
51
|
+
*
|
|
52
|
+
* R6-8 (DR-03 round-6): `modelChain` threads the task's model CANDIDATE CHAIN
|
|
53
|
+
* (primary + fallbacks, e.g. modelRouting.candidates) so the allowlist is
|
|
54
|
+
* scoped to the UNION of the chain's providers. Previously only the single
|
|
55
|
+
* current model was passed: a bare-id primary (no "provider/" prefix)
|
|
56
|
+
* resolved to ZERO provider env keys → the child failed auth with a
|
|
57
|
+
* non-retryable /api key/i error → the loop broke even though qualified
|
|
58
|
+
* fallbacks were queued (the env-filter.ts doc contract already promised
|
|
59
|
+
* "primary + fallbacks"). Scoping is NEVER weakened by this: keys stay
|
|
60
|
+
* limited to the chain's providers ONLY — never all-providers.
|
|
51
61
|
*/
|
|
52
|
-
export function buildChildPiSpawnOptions(
|
|
62
|
+
export function buildChildPiSpawnOptions(
|
|
63
|
+
cwd: string,
|
|
64
|
+
env: NodeJS.ProcessEnv,
|
|
65
|
+
model?: string,
|
|
66
|
+
modelChain?: (string | undefined)[],
|
|
67
|
+
): SpawnOptions {
|
|
53
68
|
// SECURITY FIX (Issue #1): Validate cwd before passing to spawn.
|
|
54
69
|
// If cwd comes from an untrusted source (user input, workspace config), a malicious cwd
|
|
55
70
|
// could cause the child process to operate in an attacker-controlled directory,
|
|
@@ -81,9 +96,33 @@ export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv, mo
|
|
|
81
96
|
// Bug #12 fix: essential env vars (PATH, HOME, etc.) are always preserved so child can find npm/node.
|
|
82
97
|
//
|
|
83
98
|
// PER-TASK KEY SCOPING: when a model is provided, only the env keys for that
|
|
84
|
-
// provider are injected (via buildScopedAllowList).
|
|
85
|
-
//
|
|
86
|
-
|
|
99
|
+
// provider are injected (via buildScopedAllowList). R6-8: when the caller
|
|
100
|
+
// threads the model CANDIDATE CHAIN (primary + fallbacks), keys are scoped
|
|
101
|
+
// to the UNION of the chain's providers so a fallback attempt on a second
|
|
102
|
+
// provider does not spawn keyless. When no model is given, only
|
|
103
|
+
// BASE_ALLOWLIST system vars pass through — no provider keys leak.
|
|
104
|
+
const scopeModels: (string | undefined)[] = modelChain && modelChain.length > 0 ? modelChain : model !== undefined ? [model] : [];
|
|
105
|
+
const allowList = scopeModels.length > 0 ? buildScopedAllowList(BASE_ALLOWLIST, scopeModels) : BASE_ALLOWLIST;
|
|
106
|
+
// R6-8: warn when the scoped models resolve to ZERO provider env keys AND at
|
|
107
|
+
// least one is a truly-bare id (no "provider/" prefix) — env-key auth + bare
|
|
108
|
+
// id means the child worker will very likely fail authentication (observed as
|
|
109
|
+
// the R6-8 stall: bare id → zero keys → /api key/i non-retryable → chain break
|
|
110
|
+
// even though qualified fallbacks were queued). NOT an error, and it never
|
|
111
|
+
// widens the allowlist: users authenticating via auth.json (or custom
|
|
112
|
+
// providers with non-env credentials) are unaffected — the child reads
|
|
113
|
+
// auth.json credentials exactly like the parent process does. Qualified
|
|
114
|
+
// unknown providers ("custom/foo") do not warn: that is a deliberate
|
|
115
|
+
// custom-provider choice, not a likely misconfiguration.
|
|
116
|
+
const anyProviderKeys = scopeModels.some((scoped) => providerEnvKeys(scoped).length > 0);
|
|
117
|
+
const anyBareId = scopeModels.some((scoped) => typeof scoped === "string" && !scoped.includes("/"));
|
|
118
|
+
if (scopeModels.length > 0 && anyBareId && !anyProviderKeys) {
|
|
119
|
+
logInternalError(
|
|
120
|
+
"child-pi-spawn.bare-model-no-provider-keys",
|
|
121
|
+
undefined,
|
|
122
|
+
`model "${model ?? scopeModels.find((scoped) => typeof scoped === "string")}" is a bare id (no provider prefix) and resolved zero provider env keys — the child worker gets no provider API key via env; env-key users should qualify the model id (e.g. "openai/<id>") or configure a fallback chain (auth.json users are unaffected)`,
|
|
123
|
+
"warn",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
87
126
|
const filteredEnv = sanitizeEnvSecrets(env, { allowList });
|
|
88
127
|
// FIX: Removed delete workarounds — with explicit allowlist, these vars
|
|
89
128
|
// are no longer auto-leaked. The wildcard approach was fragile.
|
|
@@ -192,12 +231,14 @@ export function buildFinalChildPiSpawnOptions(
|
|
|
192
231
|
mergedEnv: NodeJS.ProcessEnv,
|
|
193
232
|
builtEnv: Record<string, string | undefined>,
|
|
194
233
|
model?: string,
|
|
234
|
+
modelChain?: (string | undefined)[],
|
|
195
235
|
): SpawnOptions {
|
|
196
236
|
// (a) Canary: builtEnv must contain ONLY PI_CREW_*/PI_TEAMS_* keys.
|
|
197
237
|
assertOnlyControlEnvKeys(builtEnv);
|
|
198
238
|
// (b) Build the allowlist-filtered base SpawnOptions (cwd validation, env
|
|
199
239
|
// filtering, provider-key scoping when model is set, NODE_PATH guard).
|
|
200
|
-
|
|
240
|
+
// R6-8: modelChain threads the candidate chain for provider-key scoping.
|
|
241
|
+
const spawnOptions = buildChildPiSpawnOptions(cwd, mergedEnv, model, modelChain);
|
|
201
242
|
// (c) Spread builtEnv back on top — the allowlist in step (b) intentionally
|
|
202
243
|
// strips PI_CREW_*/PI_TEAMS_* keys, so without this spread the child
|
|
203
244
|
// process would never see steering file, kind, role, or broker creds.
|
|
@@ -395,6 +436,3 @@ export function prepareSpawnContext(
|
|
|
395
436
|
},
|
|
396
437
|
};
|
|
397
438
|
}
|
|
398
|
-
|
|
399
|
-
// Silence unused-import warning for logInternalError if not consumed by future helpers.
|
|
400
|
-
void logInternalError;
|