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
|
@@ -954,11 +954,33 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
954
954
|
);
|
|
955
955
|
}
|
|
956
956
|
|
|
957
|
+
// R5-6 (DR-02 round-5): send-side clamp for `team steer` bodies. The
|
|
958
|
+
// worker-side receiver (prompt-runtime sanitizeSteerMessage) clamps too (its
|
|
959
|
+
// rejection of >4096-char steers was terminal — the poll cursor advanced
|
|
960
|
+
// past the line, so an oversized leader steer was silently dropped), but the
|
|
961
|
+
// send-side clamp means pendingSteers, the steering JSONL, and the
|
|
962
|
+
// task.steer_queued event all already carry a receiver-safe body with the
|
|
963
|
+
// truncation marker visible to the leader's audit trail. Deliberately
|
|
964
|
+
// duplicated from prompt-runtime's clampSteerMessage (same rationale as the
|
|
965
|
+
// estimateHandoffTokens duplication in task-output-context.ts): importing
|
|
966
|
+
// prompt-runtime here would pull the broker-client/scratchpad/surface-worker
|
|
967
|
+
// graph into the extension registration path (cold-start). Keep the two
|
|
968
|
+
// clamps in sync.
|
|
969
|
+
const TEAM_STEER_MAX_MESSAGE_LENGTH = 4096;
|
|
970
|
+
const TEAM_STEER_TRUNCATION_MARKER = `\n[message truncated at ${TEAM_STEER_MAX_MESSAGE_LENGTH} chars]`;
|
|
971
|
+
function clampSteerBody(message: string): string {
|
|
972
|
+
if (message.length <= TEAM_STEER_MAX_MESSAGE_LENGTH) return message;
|
|
973
|
+
return `${message.slice(0, TEAM_STEER_MAX_MESSAGE_LENGTH - TEAM_STEER_TRUNCATION_MARKER.length)}${TEAM_STEER_TRUNCATION_MARKER}`;
|
|
974
|
+
}
|
|
975
|
+
|
|
957
976
|
export function handleSteer(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
|
|
958
|
-
const { runId, taskId
|
|
959
|
-
|
|
977
|
+
const { runId, taskId } = params;
|
|
978
|
+
const rawMessage = params.message;
|
|
979
|
+
if (!runId || !taskId || !rawMessage) {
|
|
960
980
|
return result("steer requires runId, taskId, and message", { action: "steer", status: "error" }, true);
|
|
961
981
|
}
|
|
982
|
+
// R5-6: clamp BEFORE any sink (pending queue, JSONL, event) sees the body.
|
|
983
|
+
const message = clampSteerBody(String(rawMessage));
|
|
962
984
|
const runCwd = locateRunCwd(runId, ctx.cwd);
|
|
963
985
|
if (!runCwd) return result(`Run '${runId}' not found`, { action: "steer", status: "error" }, true);
|
|
964
986
|
const loaded = loadRunManifestById(runCwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
package/src/prompt/inbox-poll.ts
CHANGED
|
@@ -23,12 +23,19 @@
|
|
|
23
23
|
* must be dropped at consume time);
|
|
24
24
|
* - dedup by message id across polls via a caller-owned seen-set and/or a
|
|
25
25
|
* `sinceTs` watermark — one message delivers once;
|
|
26
|
-
* - U5 (2026-10-10): dedup is ALSO durable across restarts —
|
|
27
|
-
*
|
|
26
|
+
* - U5 (2026-10-10): dedup is ALSO durable across restarts — delivered
|
|
27
|
+
* rows are recorded in delivery.json (`messages[id]="acknowledged"` +
|
|
28
28
|
* `${direction}:${requestId}` index) and re-read on the next poll, so a
|
|
29
29
|
* worker that restarts mid-run (fresh process, EMPTY seen-set) never
|
|
30
30
|
* re-delivers. A resend carrying the same requestId is dropped by the
|
|
31
31
|
* same index; duplicate rows under one requestId deliver exactly once.
|
|
32
|
+
* - R5-1 (DR-02, 2026-10-15): the durable record is written in TWO PHASES.
|
|
33
|
+
* `pollWorkerInbox` only picks rows and marks the IN-MEMORY seen-set;
|
|
34
|
+
* the durable "acknowledged" write lives in
|
|
35
|
+
* `acknowledgeWorkerInboxDelivery`, which the delivery loop calls ONLY
|
|
36
|
+
* after `pi.sendMessage` succeeded. A send that throws must instead call
|
|
37
|
+
* `unmarkSeenInboxIds` so the next tick re-picks the batch — at-least-once
|
|
38
|
+
* beats the pre-fix at-most-once-with-a-false-"delivered"-audit-record.
|
|
32
39
|
*/
|
|
33
40
|
import * as path from "node:path";
|
|
34
41
|
import {
|
|
@@ -125,13 +132,22 @@ function emitPickupEvent(stateRoot: string, runId: string, taskId: string, picke
|
|
|
125
132
|
|
|
126
133
|
/**
|
|
127
134
|
* Read the worker's inbox mailbox and return the messages that should be
|
|
128
|
-
* surfaced as fenced context on the next turn.
|
|
135
|
+
* surfaced as fenced context on the next turn. PICKUP PHASE ONLY (R5-1):
|
|
136
|
+
* picked rows are marked in the caller-owned in-memory seen-set, but the
|
|
137
|
+
* durable delivery record is NOT written here — the caller must run
|
|
138
|
+
* {@link acknowledgeWorkerInboxDelivery} after its send succeeded, and
|
|
139
|
+
* {@link unmarkSeenInboxIds} when the send threw.
|
|
140
|
+
*
|
|
141
|
+
* `limit` (R5-2, DR-02): pick at most this many rows per call (callers pass
|
|
142
|
+
* their per-tick batch cap). Leftover rows are simply not picked — not
|
|
143
|
+
* marked, in memory or durably — and flow on a later tick.
|
|
129
144
|
*
|
|
130
145
|
* Stateless apart from the optional caller-owned `seenIds`/`sinceTs` — safe
|
|
131
|
-
* to call on every 500ms poll tick. U5:
|
|
132
|
-
*
|
|
146
|
+
* to call on every 500ms poll tick. U5: the durable dedup-read checks
|
|
147
|
+
* (acknowledged rows / delivered requestIds) still run here so dedup
|
|
148
|
+
* survives a restart.
|
|
133
149
|
*/
|
|
134
|
-
export function pollWorkerInbox(pickup: WorkerInboxPickup): MailboxMessage[] {
|
|
150
|
+
export function pollWorkerInbox(pickup: WorkerInboxPickup, limit?: number): MailboxMessage[] {
|
|
135
151
|
const { stateRoot, runId, taskId } = pickup;
|
|
136
152
|
if (!stateRoot || !runId || !taskId) return [];
|
|
137
153
|
const manifest = { stateRoot, runId } as unknown as TeamRunManifest;
|
|
@@ -157,6 +173,11 @@ export function pollWorkerInbox(pickup: WorkerInboxPickup): MailboxMessage[] {
|
|
|
157
173
|
const byId = new Set<string>();
|
|
158
174
|
const byRequestId = new Set<string>();
|
|
159
175
|
for (const m of messages) {
|
|
176
|
+
// R5-2 (DR-02): enforce the batch cap AT PICKUP, not at the send site —
|
|
177
|
+
// every picked row must also be a row the caller can actually send (and
|
|
178
|
+
// later acknowledge). The pre-fix shape picked N rows, durably acked all
|
|
179
|
+
// N, then sliced 8 for the send — the surplus was never re-delivered.
|
|
180
|
+
if (limit !== undefined && picked.length >= limit) break;
|
|
160
181
|
// Kind gate: only durable `message`s (per §15.2). Notify is
|
|
161
182
|
// fire-and-forget (its own channel), steer/response/follow-up are
|
|
162
183
|
// other delivery paths.
|
|
@@ -204,25 +225,56 @@ export function pollWorkerInbox(pickup: WorkerInboxPickup): MailboxMessage[] {
|
|
|
204
225
|
seen?.add(m.id);
|
|
205
226
|
picked.push(m);
|
|
206
227
|
}
|
|
207
|
-
if (picked.length > 0) {
|
|
208
|
-
// U5: persist the pickup — the durable record that powers the two
|
|
209
|
-
// layers above after a restart. Best-effort on failure: the in-memory
|
|
210
|
-
// seen-set still dedups within THIS process, and logInternalError
|
|
211
|
-
// makes the lost-durability window visible.
|
|
212
|
-
try {
|
|
213
|
-
recordMailboxRequestIdDelivery(
|
|
214
|
-
manifest,
|
|
215
|
-
picked.map((m) => ({
|
|
216
|
-
direction: m.direction,
|
|
217
|
-
messageId: m.id,
|
|
218
|
-
requestId: messageIdempotencyKey(m),
|
|
219
|
-
})),
|
|
220
|
-
);
|
|
221
|
-
emitPickupEvent(stateRoot, runId, taskId, picked);
|
|
222
|
-
} catch (error) {
|
|
223
|
-
logInternalError("inbox-poll.record-delivery", error, `runId=${runId} taskId=${taskId}`);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
228
|
// Deterministic delivery order (same sort readAllMailboxMessages applies).
|
|
227
229
|
return picked;
|
|
228
230
|
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* R5-1 (DR-02, 2026-10-15): durable-ack phase of the pickup, split OUT of
|
|
234
|
+
* `pollWorkerInbox`. The caller invokes this ONLY after the picked rows were
|
|
235
|
+
* successfully delivered (`pi.sendMessage` returned) — the durable
|
|
236
|
+
* "acknowledged" record and the pickup audit event must never precede a send
|
|
237
|
+
* that can fail (the pre-fix bug wrote them BEFORE the send, so a send
|
|
238
|
+
* failure produced a permanent false "delivered" record plus silent message
|
|
239
|
+
* loss). Mid-batch all-or-nothing: recordMailboxRequestIdDelivery is a single
|
|
240
|
+
* locked RMW, so acks are atomic per call.
|
|
241
|
+
*
|
|
242
|
+
* Best-effort on failure (never throws into the poll tick): the in-memory
|
|
243
|
+
* seen-set still dedups within THIS process, and logInternalError makes the
|
|
244
|
+
* lost-durability window visible.
|
|
245
|
+
*/
|
|
246
|
+
export function acknowledgeWorkerInboxDelivery(
|
|
247
|
+
pickup: Pick<WorkerInboxPickup, "stateRoot" | "runId" | "taskId">,
|
|
248
|
+
picked: MailboxMessage[],
|
|
249
|
+
): void {
|
|
250
|
+
const { stateRoot, runId, taskId } = pickup;
|
|
251
|
+
if (!stateRoot || !runId || !taskId || picked.length === 0) return;
|
|
252
|
+
const manifest = { stateRoot, runId } as unknown as TeamRunManifest;
|
|
253
|
+
try {
|
|
254
|
+
// U5: persist the delivery — the durable record that powers the two
|
|
255
|
+
// dedup layers above after a restart.
|
|
256
|
+
recordMailboxRequestIdDelivery(
|
|
257
|
+
manifest,
|
|
258
|
+
picked.map((m) => ({
|
|
259
|
+
direction: m.direction,
|
|
260
|
+
messageId: m.id,
|
|
261
|
+
requestId: messageIdempotencyKey(m),
|
|
262
|
+
})),
|
|
263
|
+
);
|
|
264
|
+
emitPickupEvent(stateRoot, runId, taskId, picked);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
logInternalError("inbox-poll.record-delivery", error, `runId=${runId} taskId=${taskId}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* R5-1 (DR-02): release the in-memory seen marks for a batch whose
|
|
272
|
+
* `pi.sendMessage` THREW, so the next poll tick re-picks and re-delivers it
|
|
273
|
+
* (at-least-once beats at-most-once with a false "delivered" audit record).
|
|
274
|
+
* Mirrors the steer channel's `seenSteers.unmark` + offset-rewind
|
|
275
|
+
* compensation in prompt-runtime.ts pollSteering.
|
|
276
|
+
*/
|
|
277
|
+
export function unmarkSeenInboxIds(seen: Set<string> | undefined, picked: MailboxMessage[]): void {
|
|
278
|
+
if (!seen) return;
|
|
279
|
+
for (const m of picked) seen.delete(m.id);
|
|
280
|
+
}
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* sending, so two workers cannot loop message each other to death.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
import { createHash } from "node:crypto";
|
|
27
28
|
import { type Static, Type } from "@sinclair/typebox";
|
|
28
29
|
import { getCrewEnv } from "../config/env-vars.ts";
|
|
29
30
|
import { CrewBrokerClient } from "../runtime/broker/crew-broker-client.ts";
|
|
@@ -38,6 +39,14 @@ const MSG_TO_MAX_CHARS = 256;
|
|
|
38
39
|
/** §15.2 rate-limit: 10 messages/min/task, sliding window. */
|
|
39
40
|
const MSG_RATE_LIMIT_MAX = 10;
|
|
40
41
|
const MSG_RATE_LIMIT_WINDOW_MS = 60_000;
|
|
42
|
+
/** R6-6 (DR-03 round-6 2026-10-16): explicit per-request timeout for
|
|
43
|
+
* msg.send. The client's 15s default (REQUEST_TIMEOUT_DEFAULT_MS) turns a
|
|
44
|
+
* merely-slow durable send (multi-recipient chunked appends + fsync + audit
|
|
45
|
+
* event) into a reported "NOT sent" — and the model's natural retry into a
|
|
46
|
+
* duplicate delivery. 60s matches the worst bounded fanout (64 recipients,
|
|
47
|
+
* 8-wide chunks) with headroom while staying below the tool's own "never a
|
|
48
|
+
* hang" budget (the request still resolves via this timeout, never hangs). */
|
|
49
|
+
export const MSG_SEND_TIMEOUT_MS = 60_000;
|
|
41
50
|
|
|
42
51
|
export const MessageToolParamsSchema = Type.Object({
|
|
43
52
|
to: Type.Union([Type.Literal("parent"), Type.Literal("group"), Type.String({ minLength: 1, maxLength: MSG_TO_MAX_CHARS })]),
|
|
@@ -49,12 +58,46 @@ export const MessageToolParamsSchema = Type.Object({
|
|
|
49
58
|
export type MessageToolParams = Static<typeof MessageToolParamsSchema>;
|
|
50
59
|
|
|
51
60
|
/** Minimal broker-client surface the message tool needs (structural subset of
|
|
52
|
-
* CrewBrokerClient — tests substitute a recorder).
|
|
61
|
+
* CrewBrokerClient — tests substitute a recorder). R6-6: the optional third
|
|
62
|
+
* argument carries the per-request timeout; existing 2-arg recorders remain
|
|
63
|
+
* structurally assignable. */
|
|
53
64
|
export interface MessageBrokerClientSurface {
|
|
54
|
-
request(
|
|
65
|
+
request(
|
|
66
|
+
method: string,
|
|
67
|
+
params: unknown,
|
|
68
|
+
opts?: { timeoutMs?: number },
|
|
69
|
+
): Promise<{ ok: true; value: unknown } | { ok: false; fallback?: boolean; errorCode?: string }>;
|
|
55
70
|
close?(): Promise<void>;
|
|
56
71
|
}
|
|
57
72
|
|
|
73
|
+
/** R6-6: stable idempotency key derived from message identity. A retry of
|
|
74
|
+
* the same logical message (same sender, target, kind, content — e.g. the
|
|
75
|
+
* model re-invoking `message` after a lost/slow ack) computes the SAME
|
|
76
|
+
* requestId, and the broker's U5 durable (direction, requestId) index in
|
|
77
|
+
* delivery.json + appendMailboxMessageIdempotentAsync turn the resend into
|
|
78
|
+
* a no-op instead of a duplicate row. Pure content hash: matches the
|
|
79
|
+
* request-parsers `^[\\w:.-]{1,256}$` requestId charset (hex + `-`). */
|
|
80
|
+
export function computeMessageRequestId(identity: {
|
|
81
|
+
runId: string;
|
|
82
|
+
taskId: string;
|
|
83
|
+
to: string;
|
|
84
|
+
kind: string;
|
|
85
|
+
body: string;
|
|
86
|
+
subject?: string;
|
|
87
|
+
priority?: string;
|
|
88
|
+
}): string {
|
|
89
|
+
const material = JSON.stringify([
|
|
90
|
+
identity.runId,
|
|
91
|
+
identity.taskId,
|
|
92
|
+
identity.to,
|
|
93
|
+
identity.kind,
|
|
94
|
+
identity.body,
|
|
95
|
+
identity.subject ?? null,
|
|
96
|
+
identity.priority ?? null,
|
|
97
|
+
]);
|
|
98
|
+
return `msgtool-${createHash("sha256").update(material).digest("hex").slice(0, 32)}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
58
101
|
export interface MessageToolDeps {
|
|
59
102
|
/** Env source override (tests). Production reads via getCrewEnv. */
|
|
60
103
|
env?: NodeJS.ProcessEnv;
|
|
@@ -137,11 +180,26 @@ export function createMessageTool(deps: MessageToolDeps = {}): MessageTool {
|
|
|
137
180
|
return new CrewBrokerClient({ runId, taskId, socketPath, token });
|
|
138
181
|
})();
|
|
139
182
|
if (!client) return brokerUnavailable();
|
|
140
|
-
|
|
183
|
+
// R6-6: stable requestId (idempotency across retries) + explicit
|
|
184
|
+
// timeoutMs (a slow durable ack is NOT a failure).
|
|
185
|
+
const requestParams: Record<string, unknown> = {
|
|
186
|
+
to: params.to,
|
|
187
|
+
kind: params.kind,
|
|
188
|
+
body: params.body,
|
|
189
|
+
requestId: computeMessageRequestId({
|
|
190
|
+
runId,
|
|
191
|
+
taskId,
|
|
192
|
+
to: params.to,
|
|
193
|
+
kind: params.kind,
|
|
194
|
+
body: params.body,
|
|
195
|
+
subject: params.subject,
|
|
196
|
+
priority: params.priority,
|
|
197
|
+
}),
|
|
198
|
+
};
|
|
141
199
|
if (params.subject) requestParams.subject = params.subject;
|
|
142
200
|
if (params.priority) requestParams.priority = params.priority;
|
|
143
201
|
try {
|
|
144
|
-
const sent = await client.request("msg.send", requestParams);
|
|
202
|
+
const sent = await client.request("msg.send", requestParams, { timeoutMs: MSG_SEND_TIMEOUT_MS });
|
|
145
203
|
if (!sent.ok) {
|
|
146
204
|
// Broker rejection (role gate / policy / auth / fallback) — all
|
|
147
205
|
// fast-fail, non-blocking.
|
|
@@ -12,7 +12,7 @@ import { appendEventFireAndForget } from "../state/event-log/event-log.ts";
|
|
|
12
12
|
import type { TeamRunManifest } from "../state/types.ts";
|
|
13
13
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
14
14
|
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
15
|
-
import { pollWorkerInbox } from "./inbox-poll.ts";
|
|
15
|
+
import { acknowledgeWorkerInboxDelivery, pollWorkerInbox, unmarkSeenInboxIds } from "./inbox-poll.ts";
|
|
16
16
|
import { createMessageTool, type MessageToolParams as MessageToolInputs, shouldRegisterMessageTool } from "./message-tool.ts";
|
|
17
17
|
import { registerScratchpadLifecycle } from "./scratchpad-lifecycle.ts";
|
|
18
18
|
import { createWorkerActivityTracker, registerSurfaceWorkerLifecycle, trackToolActivity } from "./surface-worker.ts";
|
|
@@ -32,13 +32,31 @@ const DATE_HEADER = "\nCurrent date:";
|
|
|
32
32
|
// ── FIX-02: Steering content sanitization limits ──────────────────────────
|
|
33
33
|
// Bounded to keep a malformed/malicious steer entry from blowing up the
|
|
34
34
|
// worker's prompt budget or smuggling control sequences into the agent.
|
|
35
|
-
const MAX_STEER_MESSAGE_LENGTH = 4096;
|
|
35
|
+
export const MAX_STEER_MESSAGE_LENGTH = 4096;
|
|
36
36
|
const MAX_STEER_MESSAGE_NEWLINES = 50;
|
|
37
37
|
// C0 control characters minus the printable whitespace (\t \n \r). These
|
|
38
38
|
// are the bytes most useful for ANSI escapes, terminal-control tricks, and
|
|
39
39
|
// NUL-injection attacks when steer content reaches the worker's UI.
|
|
40
40
|
const STEER_CONTROL_CHAR_PATTERN = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/;
|
|
41
41
|
|
|
42
|
+
/** R5-6 (DR-02 round-5): truncation marker for oversized steer bodies —
|
|
43
|
+
* mirrors renderInboxMessage's 8192-char marker pattern below. The clamp
|
|
44
|
+
* slices so the FINAL message (slice + marker) stays ≤
|
|
45
|
+
* {@link MAX_STEER_MESSAGE_LENGTH}, keeping the receiver's length invariant
|
|
46
|
+
* honest. */
|
|
47
|
+
const STEER_TRUNCATION_MARKER = `\n[message truncated at ${MAX_STEER_MESSAGE_LENGTH} chars]`;
|
|
48
|
+
|
|
49
|
+
/** R5-6 (DR-02 round-5): clamp a steer body to
|
|
50
|
+
* {@link MAX_STEER_MESSAGE_LENGTH} with a truncation marker. The send side
|
|
51
|
+
* (team-tool handleSteer) clamps at dispatch; this helper is the receiver's
|
|
52
|
+
* clean seam — every delivery path (file poll AND broker push) funnels
|
|
53
|
+
* through `sanitizeSteerMessage`, so an oversized steer from ANY writer is
|
|
54
|
+
* delivered truncated-with-marker instead of rejected-and-dropped. */
|
|
55
|
+
export function clampSteerMessage(message: string): string {
|
|
56
|
+
if (message.length <= MAX_STEER_MESSAGE_LENGTH) return message;
|
|
57
|
+
return `${message.slice(0, MAX_STEER_MESSAGE_LENGTH - STEER_TRUNCATION_MARKER.length)}${STEER_TRUNCATION_MARKER}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
42
60
|
export interface SteerSanitizeResult {
|
|
43
61
|
valid: boolean;
|
|
44
62
|
reason?: string;
|
|
@@ -118,25 +136,28 @@ export function createSeenSteerIdSet(): {
|
|
|
118
136
|
|
|
119
137
|
/**
|
|
120
138
|
* Validate a single steering-file entry before forwarding it to
|
|
121
|
-
* `pi.sendMessage`. FIX-02: reject
|
|
122
|
-
*
|
|
123
|
-
|
|
139
|
+
* `pi.sendMessage`. FIX-02: reject excessive newlines or control characters
|
|
140
|
+
* that could be used to confuse the worker UI. R5-6 (DR-02 round-5): an
|
|
141
|
+
* OVERSIZED payload (>4096 chars) is no longer rejected-and-dropped — the
|
|
142
|
+
* rejection was terminal (the poll cursor advanced past the line, warn-only)
|
|
143
|
+
* and the send side had no clamp, so a leader steer over the limit silently
|
|
144
|
+
* vanished. It is now truncated-with-marker ({@link clampSteerMessage}) and
|
|
145
|
+
* delivered; the newline/control-char rejections remain terminal (different
|
|
146
|
+
* failure modes — UI confusion / escape smuggling, not length). */
|
|
124
147
|
export function sanitizeSteerMessage(entry: SteerEntry): SteerSanitizeResult {
|
|
125
148
|
const message = entry.message;
|
|
126
149
|
if (typeof message !== "string" || message.length === 0) {
|
|
127
150
|
return { valid: false, reason: "missing-or-empty-message" };
|
|
128
151
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
const newlineCount = (message.match(/\n/g) ?? []).length;
|
|
152
|
+
const clamped = clampSteerMessage(message);
|
|
153
|
+
const newlineCount = (clamped.match(/\n/g) ?? []).length;
|
|
133
154
|
if (newlineCount > MAX_STEER_MESSAGE_NEWLINES) {
|
|
134
155
|
return { valid: false, reason: `too-many-newlines:${newlineCount}` };
|
|
135
156
|
}
|
|
136
|
-
if (STEER_CONTROL_CHAR_PATTERN.test(
|
|
157
|
+
if (STEER_CONTROL_CHAR_PATTERN.test(clamped)) {
|
|
137
158
|
return { valid: false, reason: "contains-control-characters" };
|
|
138
159
|
}
|
|
139
|
-
return { valid: true, message };
|
|
160
|
+
return { valid: true, message: clamped };
|
|
140
161
|
}
|
|
141
162
|
|
|
142
163
|
// ── FIX-03: Steering file path containment validation ─────────────────────
|
|
@@ -1067,45 +1088,74 @@ export default function registerPiTeamsPromptRuntime(pi: ExtensionAPI): void {
|
|
|
1067
1088
|
// (`deliverAs:"steer"`, customType "crew-inbox") so the model sees
|
|
1068
1089
|
// conversations between agents. Dormant-until-env: only when this worker
|
|
1069
1090
|
// carries broker credentials (child-pi-spawn sets them for every worker)
|
|
1070
|
-
// + a state root.
|
|
1071
|
-
//
|
|
1072
|
-
//
|
|
1091
|
+
// + a state root. R5-1/R5-2 (DR-02, 2026-10-15): delivery is ack-AFTER-send
|
|
1092
|
+
// — the poll picks (in-memory seen-set) up to INBOX_BATCH_MAX rows, the
|
|
1093
|
+
// DURABLE "acknowledged" record is written (acknowledgeWorkerInboxDelivery)
|
|
1094
|
+
// only after pi.sendMessage succeeded; a send throw unmarks the batch
|
|
1095
|
+
// (unmarkSeenInboxIds) so the next tick re-delivers it. Pickup is thus
|
|
1096
|
+
// at-least-once across send failures and exactly-once once the durable
|
|
1097
|
+
// ack lands (it also survives a restart — U5 semantics).
|
|
1073
1098
|
const inboxBrokerRunId = getCrewEnv(PI_CREW_BROKER_RUN_ID_ASK_ENV);
|
|
1074
1099
|
const inboxStateRoot = getCrewEnv(PI_CREW_STATE_ROOT_ENV);
|
|
1075
1100
|
const inboxTaskId = getCrewEnv(PI_CREW_TASK_ID_ASK_ENV) ?? getCrewEnv(PI_CREW_BROKER_TASK_ID_ASK_ENV);
|
|
1076
1101
|
if (inboxBrokerRunId && inboxStateRoot && inboxTaskId) {
|
|
1077
1102
|
const seenInboxIds = new Set<string>();
|
|
1078
1103
|
// Batch cap per tick: bounds a single steer frame that could otherwise
|
|
1079
|
-
// pile up many fenced messages in one delivery.
|
|
1104
|
+
// pile up many fenced messages in one delivery. R5-2 (DR-02): the cap
|
|
1105
|
+
// is enforced AT PICKUP (pollWorkerInbox `limit`) so every picked row is
|
|
1106
|
+
// also a row actually sent + acked — the pre-fix shape acked N rows
|
|
1107
|
+
// durably but sliced 8 for the send, orphaning the surplus forever.
|
|
1080
1108
|
const INBOX_BATCH_MAX = 8;
|
|
1081
1109
|
const pollInbox = (): void => {
|
|
1082
1110
|
try {
|
|
1083
|
-
const picked = pollWorkerInbox(
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1111
|
+
const picked = pollWorkerInbox(
|
|
1112
|
+
{
|
|
1113
|
+
stateRoot: inboxStateRoot,
|
|
1114
|
+
runId: inboxBrokerRunId,
|
|
1115
|
+
taskId: inboxTaskId,
|
|
1116
|
+
seenIds: seenInboxIds,
|
|
1117
|
+
},
|
|
1118
|
+
INBOX_BATCH_MAX,
|
|
1119
|
+
);
|
|
1089
1120
|
if (picked.length === 0) return;
|
|
1090
|
-
const batch = picked.slice(0, INBOX_BATCH_MAX);
|
|
1091
1121
|
// §15.2 trust boundary: the sender's body is DATA, never
|
|
1092
1122
|
// instructions. pollWorkerInbox hands back raw mailbox entries —
|
|
1093
1123
|
// fence each body (control chars stripped, closing fence
|
|
1094
1124
|
// neutralized, length capped) before it reaches the agent.
|
|
1095
|
-
const fenced =
|
|
1125
|
+
const fenced = picked.map((m) => ({
|
|
1096
1126
|
from: m.from,
|
|
1097
1127
|
to: m.to,
|
|
1098
1128
|
body: renderInboxMessage(m),
|
|
1099
1129
|
}));
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1130
|
+
try {
|
|
1131
|
+
pi.sendMessage(
|
|
1132
|
+
{
|
|
1133
|
+
customType: "crew-inbox",
|
|
1134
|
+
content: fenced.map((e) => e.body).join("\n"),
|
|
1135
|
+
display: false,
|
|
1136
|
+
details: { messages: fenced, count: picked.length },
|
|
1137
|
+
},
|
|
1138
|
+
{ deliverAs: "steer" },
|
|
1139
|
+
);
|
|
1140
|
+
} catch (sendErr) {
|
|
1141
|
+
// R5-1: the send FAILED (e.g. pre-bind notInitialized — exactly
|
|
1142
|
+
// what the registration-time synchronous pollInbox() below can
|
|
1143
|
+
// hit). No durable ack was written yet, so there is no false
|
|
1144
|
+
// "delivered" record; release the seen marks so the next tick
|
|
1145
|
+
// re-picks and re-delivers the batch — mirrors seenSteers.unmark
|
|
1146
|
+
// + offset rewind in pollSteering above (at-least-once).
|
|
1147
|
+
unmarkSeenInboxIds(seenInboxIds, picked);
|
|
1148
|
+
logInternalError(
|
|
1149
|
+
"prompt-runtime.inbox-delivery-failed",
|
|
1150
|
+
sendErr instanceof Error ? sendErr : new Error(String(sendErr)),
|
|
1151
|
+
`runId=${inboxBrokerRunId} taskId=${inboxTaskId} count=${picked.length}`,
|
|
1152
|
+
"warn",
|
|
1153
|
+
);
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
// R5-1: the durable "acknowledged" record + pickup audit event
|
|
1157
|
+
// happen ONLY after a successful send — never before it.
|
|
1158
|
+
acknowledgeWorkerInboxDelivery({ stateRoot: inboxStateRoot, runId: inboxBrokerRunId, taskId: inboxTaskId }, picked);
|
|
1109
1159
|
} catch {
|
|
1110
1160
|
// A transient mailbox read error must never break the tick — the
|
|
1111
1161
|
// next 500ms (or 50ms under live-session realtime) poll retries.
|
|
@@ -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;
|