pi-crew 0.11.21 → 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 +30 -0
- package/dist/index.mjs +537 -526
- package/package.json +2 -1
- 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/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
|
@@ -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;
|
|
@@ -170,6 +170,18 @@ export interface ChildPiRunInput {
|
|
|
170
170
|
task: string;
|
|
171
171
|
agent: AgentConfig;
|
|
172
172
|
model?: string;
|
|
173
|
+
/**
|
|
174
|
+
* R6-8 (DR-03 round-6): the task's model CANDIDATE CHAIN (primary +
|
|
175
|
+
* fallbacks, e.g. modelRouting.candidates / the executor's attemptModels)
|
|
176
|
+
* for provider env-key scoping in buildChildPiSpawnOptions. Scopes the
|
|
177
|
+
* allowlist to the UNION of the chain's providers — never all-providers —
|
|
178
|
+
* so a fallback attempt on a second provider does not spawn keyless, and
|
|
179
|
+
* a bare-id primary no longer means zero provider keys when a qualified
|
|
180
|
+
* fallback exists. Absent/empty → single-model scoping (previous
|
|
181
|
+
* behavior); a truly-bare single model then warns (env-key auth + bare id
|
|
182
|
+
* is a likely misconfiguration; auth.json users are unaffected).
|
|
183
|
+
*/
|
|
184
|
+
modelChain?: (string | undefined)[];
|
|
173
185
|
skillPaths?: string[];
|
|
174
186
|
signal?: AbortSignal;
|
|
175
187
|
transcriptPath?: string;
|
|
@@ -474,7 +486,12 @@ async function trySurfaceBranch(
|
|
|
474
486
|
// child's built env whose PI_CREW_DEPTH is parentDepth+1 — layer-1
|
|
475
487
|
// guard would misfire on our own tier-1 workers.
|
|
476
488
|
env: depthEnv ?? process.env,
|
|
477
|
-
|
|
489
|
+
// R6-8: thread the model candidate chain so the surface pane's worker env
|
|
490
|
+
// is provider-key scoped to primary + fallbacks (same as the headless path).
|
|
491
|
+
workerEnv: buildFinalChildPiSpawnOptions(input.cwd, mergedEnv, builtEnv, input.model, input.modelChain).env as Record<
|
|
492
|
+
string,
|
|
493
|
+
string
|
|
494
|
+
>,
|
|
478
495
|
config: surfaceConfig,
|
|
479
496
|
role: input.role ?? input.agent.name,
|
|
480
497
|
livePaneCount: degradeController ? degradeController.livePaneCount() : (surfaceOpts?.livePaneCount ?? 0),
|
|
@@ -801,7 +818,8 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
801
818
|
return await new Promise<ChildPiRunResult>((resolve) => {
|
|
802
819
|
// Compose the final SpawnOptions: canary + filter + spread are now
|
|
803
820
|
// owned by buildFinalChildPiSpawnOptions (see child-pi-spawn.ts, BLOCKER 2 / S5).
|
|
804
|
-
|
|
821
|
+
// R6-8: modelChain threads the candidate chain for provider-key scoping.
|
|
822
|
+
const spawnOptions = buildFinalChildPiSpawnOptions(input.cwd, mergedEnv, builtEnv, input.model, input.modelChain);
|
|
805
823
|
const child = spawn(spawnSpec.command, spawnSpec.args, spawnOptions);
|
|
806
824
|
if (child.pid) {
|
|
807
825
|
registerActiveChild(child.pid, child);
|
|
@@ -68,6 +68,29 @@ export async function runMockChildPi(
|
|
|
68
68
|
return { exitCode: 0, stdout, stderr: "" };
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
if (mock === "json-429-empty-settle") {
|
|
72
|
+
// DR-01 P0 test seam: reproduces the live 429-exhaustion shape (runs
|
|
73
|
+
// team_20261014082741 / team_20261014093334): opening assistant text +
|
|
74
|
+
// tool results, then EMPTY assistant message_end events between
|
|
75
|
+
// auto-retry cycles, ending agent_settled — exit 0, NO stopReason error,
|
|
76
|
+
// and finalText keeps the early opening sentence ("finalText lies").
|
|
77
|
+
const opening = JSON.stringify({
|
|
78
|
+
type: "message_end",
|
|
79
|
+
message: { role: "assistant", content: [{ type: "text", text: `[MOCK] Starting work for ${input.agent.name}` }] },
|
|
80
|
+
});
|
|
81
|
+
const toolResult = JSON.stringify({
|
|
82
|
+
type: "message_end",
|
|
83
|
+
message: { role: "toolResult", content: [{ type: "text", text: "[MOCK] tool output" }] },
|
|
84
|
+
});
|
|
85
|
+
const emptyAssistant = JSON.stringify({
|
|
86
|
+
type: "message_end",
|
|
87
|
+
message: { role: "assistant", content: [], usage: { input: 0, output: 0 } },
|
|
88
|
+
});
|
|
89
|
+
const stdout = [opening, toolResult, emptyAssistant, emptyAssistant, JSON.stringify({ type: "agent_settled" })].join("\n") + "\n";
|
|
90
|
+
await observe(input, stdout);
|
|
91
|
+
return { exitCode: 0, stdout, stderr: "" };
|
|
92
|
+
}
|
|
93
|
+
|
|
71
94
|
if (mock === "json-success" || mock === "adaptive-plan") {
|
|
72
95
|
const text =
|
|
73
96
|
mock === "adaptive-plan" && effectiveTask.includes("ADAPTIVE_PLAN_JSON_START")
|
|
@@ -12,6 +12,17 @@
|
|
|
12
12
|
* their distinct separators. Defaults match `compactString`'s pre-P0-A output
|
|
13
13
|
* exactly so that callers that do not opt into additional stages get
|
|
14
14
|
* bit-identical output (L4 backward-compat safety).
|
|
15
|
+
*
|
|
16
|
+
* K6 (DR-03 round-6): budget accounting. The kept important lines are part
|
|
17
|
+
* of the OUTPUT, so their joined length (+ the "\n" seam) is reserved OUT
|
|
18
|
+
* of the head+tail budget (`splitWithImportantLines` `reserveChars`). The
|
|
19
|
+
* composed result therefore fits `maxChars` + {@link TRUNCATION_MARKER_ALLOWANCE}
|
|
20
|
+
* (marker budget) instead of the pre-K6 ~1.15×maxChars, where head+tail
|
|
21
|
+
* alone consumed the full budget and the important-line slack stacked on
|
|
22
|
+
* top unbudgeted. When no important lines are kept, sizing is bit-identical
|
|
23
|
+
* to the legacy behavior (no reserve). The stage's own monotonic-shrink
|
|
24
|
+
* invariant is preserved: if the composed result would be ≥ the input, the
|
|
25
|
+
* input is returned unchanged.
|
|
15
26
|
*/
|
|
16
27
|
import type { ICompactStage } from "../compact-pipeline.ts";
|
|
17
28
|
import { splitWithImportantLines } from "../important-line-classifier.ts";
|
|
@@ -34,6 +45,15 @@ const DEFAULT_MARKER: TruncationMarkerConfig = {
|
|
|
34
45
|
tailSeparator: "\n",
|
|
35
46
|
};
|
|
36
47
|
|
|
48
|
+
/**
|
|
49
|
+
* K6 "small constant" = the marker budget: the longest marker template
|
|
50
|
+
* (with-important-lines variant, 6-digit counters) plus separators plus
|
|
51
|
+
* digit drift. The head+tail budget absorbs the kept-important-line content
|
|
52
|
+
* (joined length + seam), so the composed result exceeds `maxChars` by at
|
|
53
|
+
* most this marker budget — never by the old unbudgeted ~15% slack.
|
|
54
|
+
*/
|
|
55
|
+
export const TRUNCATION_MARKER_ALLOWANCE = 160;
|
|
56
|
+
|
|
37
57
|
export class TruncationStage implements ICompactStage {
|
|
38
58
|
readonly id = "truncation";
|
|
39
59
|
private readonly maxChars: number;
|
|
@@ -55,19 +75,41 @@ export class TruncationStage implements ICompactStage {
|
|
|
55
75
|
}
|
|
56
76
|
apply(text: string): string {
|
|
57
77
|
if (text.length <= this.maxChars) return text;
|
|
58
|
-
|
|
78
|
+
// K6: two-pass budget accounting.
|
|
79
|
+
// Pass 1 (today's semantics, full-budget split) learns which important
|
|
80
|
+
// lines are kept — their joined length is part of the OUTPUT and must be
|
|
81
|
+
// reserved out of the head+tail budget instead of stacking on top.
|
|
82
|
+
const pass1 = splitWithImportantLines(text, this.maxChars, {
|
|
59
83
|
preserveImportant: this.preserveImportant,
|
|
60
84
|
});
|
|
85
|
+
const joined = pass1.importantLines.join("\n");
|
|
86
|
+
// Reserve exactly the kept content (+ the "\n" seam between marker and
|
|
87
|
+
// joined lines). The marker text + separators stay as the tolerated
|
|
88
|
+
// TRUNCATION_MARKER_ALLOWANCE overhead; when no important lines are
|
|
89
|
+
// kept the reserve is 0 and sizing is bit-identical to legacy.
|
|
90
|
+
const reserve = joined.length > 0 ? joined.length + 1 : 0;
|
|
91
|
+
// Pass 2 re-slices head+tail against the shrunken budget. It is a pure
|
|
92
|
+
// budgeted slice: preserveImportant:false skips the (already done)
|
|
93
|
+
// scan, and pass-1's lines are guaranteed to sit inside pass-2's
|
|
94
|
+
// dropped middle — the pass-2 middle is a superset of pass-1's because
|
|
95
|
+
// the shrunken head/tail regions are subsets of pass-1's.
|
|
96
|
+
const { head, tail, baseDropped } = splitWithImportantLines(text, this.maxChars, {
|
|
97
|
+
preserveImportant: false,
|
|
98
|
+
reserveChars: reserve,
|
|
99
|
+
});
|
|
100
|
+
const importantLines = pass1.importantLines;
|
|
61
101
|
let result: string;
|
|
62
102
|
if (importantLines.length === 0) {
|
|
63
103
|
result = `${head}${this.marker.headSeparator}...[pi-crew ${this.marker.verb} ${baseDropped} ${this.marker.unit}, head+tail preserved]...${this.marker.tailSeparator}${tail}`;
|
|
64
104
|
} else {
|
|
65
|
-
const joined = importantLines.join("\n");
|
|
66
105
|
const remaining = text.length - head.length - tail.length - joined.length;
|
|
67
106
|
result = `${head}${this.marker.headSeparator}...[pi-crew ${this.marker.verb} ${baseDropped} ${this.marker.unit}, head+tail + ${importantLines.length} important lines preserved, ${remaining} ${this.marker.unit} remaining dropped]...\n${joined}${this.marker.tailSeparator}${tail}`;
|
|
68
107
|
}
|
|
69
108
|
// Defense-in-depth: this stage's own monotonic-shrink invariant. The
|
|
70
|
-
// pipeline gate is a SECOND line of defense.
|
|
109
|
+
// pipeline gate is a SECOND line of defense. With the K6 budget the
|
|
110
|
+
// composed result is ≤ maxChars + marker budget, so this guard only
|
|
111
|
+
// fires for inputs barely over maxChars (where returning the input
|
|
112
|
+
// verbatim is still within the allowance) or arithmetic bugs.
|
|
71
113
|
if (result.length >= text.length) return text;
|
|
72
114
|
return result;
|
|
73
115
|
}
|