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
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* Inline cases (graph, search, onboard, explain, cache, checkpoint, recommend,
|
|
9
9
|
* help) are moved verbatim from the former handleTeamTool switch.
|
|
10
10
|
*/
|
|
11
|
+
import * as fs from "node:fs";
|
|
11
12
|
import * as path from "node:path";
|
|
12
13
|
import { allAgents, discoverAgents } from "../../../agents/discover-agents.ts";
|
|
13
14
|
import { loadConfig } from "../../../config/config.ts";
|
|
@@ -16,6 +17,7 @@ import { getSkillCacheStats } from "../../../runtime/skill-instructions.ts";
|
|
|
16
17
|
import type { TeamToolParamsValue } from "../../../schema/team-tool-schema.ts";
|
|
17
18
|
import { computeRunCacheKey, getCachedRun, getCacheStats } from "../../../state/stores/run-cache.ts";
|
|
18
19
|
import { listRunGraphs, loadRunGraph } from "../../../state/stores/run-graph.ts";
|
|
20
|
+
import type { TaskCheckpointState } from "../../../state/types.ts";
|
|
19
21
|
import { allTeams, discoverTeams } from "../../../teams/discover-teams.ts";
|
|
20
22
|
import { searchAgents, searchTeams } from "../../../utils/bm25-search.ts";
|
|
21
23
|
import { projectCrewRoot } from "../../../utils/paths.ts";
|
|
@@ -200,14 +202,27 @@ export async function handleStatusDomain(params: TeamToolParamsValue, ctx: TeamC
|
|
|
200
202
|
assertSafePathId("runId", params.runId);
|
|
201
203
|
assertSafePathId("taskId", params.taskId);
|
|
202
204
|
const stateRoot = path.join(projectCrewRoot(ctx.cwd), "state", "runs", params.runId);
|
|
205
|
+
// R7-8 (DR-04 round-7): `task.checkpoint` in tasks.json is the REAL
|
|
206
|
+
// production source — `checkpointTask` (task-runner/state-helpers.ts)
|
|
207
|
+
// writes the checkpoint into the task record and persists it via
|
|
208
|
+
// saveRunTasks. FileCheckpointStore is only a fallback for runs whose
|
|
209
|
+
// task record carries no checkpoint. The output says which source won.
|
|
210
|
+
const taskCheckpoint = readTaskCheckpointFromTasksJson(stateRoot, params.taskId);
|
|
211
|
+
if (taskCheckpoint) {
|
|
212
|
+
return result(
|
|
213
|
+
`Checkpoint (source: task.checkpoint in tasks.json): phase=${taskCheckpoint.phase}, updatedAt=${taskCheckpoint.updatedAt}` +
|
|
214
|
+
(taskCheckpoint.childPid !== undefined ? `, childPid=${taskCheckpoint.childPid}` : ""),
|
|
215
|
+
{ action: "checkpoint", status: "ok", data: { source: "task-record", checkpoint: taskCheckpoint } },
|
|
216
|
+
);
|
|
217
|
+
}
|
|
203
218
|
const store = new FileCheckpointStore(stateRoot);
|
|
204
219
|
const checkpoint = store.load(params.runId, params.taskId);
|
|
205
220
|
if (!checkpoint) {
|
|
206
221
|
return result("No checkpoint found.", { action: "checkpoint", status: "error" }, true);
|
|
207
222
|
}
|
|
208
223
|
return result(
|
|
209
|
-
`Checkpoint: step=${checkpoint.step}, progress=${checkpoint.progress}, savedAt=${new Date(checkpoint.savedAt).toISOString()}`,
|
|
210
|
-
{ action: "checkpoint", status: "ok", data: { checkpoint } },
|
|
224
|
+
`Checkpoint (source: checkpoint store): step=${checkpoint.step}, progress=${checkpoint.progress}, savedAt=${new Date(checkpoint.savedAt).toISOString()}`,
|
|
225
|
+
{ action: "checkpoint", status: "ok", data: { source: "checkpoint-store", checkpoint } },
|
|
211
226
|
);
|
|
212
227
|
}
|
|
213
228
|
default: {
|
|
@@ -221,3 +236,28 @@ export async function handleStatusDomain(params: TeamToolParamsValue, ctx: TeamC
|
|
|
221
236
|
}
|
|
222
237
|
}
|
|
223
238
|
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* R7-8 (DR-04 round-7): read `task.checkpoint` from tasks.json — the source
|
|
242
|
+
* production writers actually use (`checkpointTask` persists the checkpoint
|
|
243
|
+
* into the task record via saveRunTasks). Read-only and tolerant on purpose:
|
|
244
|
+
* a status query must never mutate run state (no quarantine/reconstruction),
|
|
245
|
+
* so a missing/corrupt tasks.json simply falls through to the store fallback.
|
|
246
|
+
* Handles both on-disk shapes: bare `TeamTaskState[]` (v0, current writer) and
|
|
247
|
+
* the defensive `{ schemaVersion, tasks }` envelope (see manifest-io.ts).
|
|
248
|
+
*/
|
|
249
|
+
function readTaskCheckpointFromTasksJson(stateRoot: string, taskId: string): TaskCheckpointState | undefined {
|
|
250
|
+
try {
|
|
251
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(path.join(stateRoot, "tasks.json"), "utf-8"));
|
|
252
|
+
const tasks: unknown = Array.isArray(parsed) ? parsed : (parsed as { tasks?: unknown }).tasks;
|
|
253
|
+
if (!Array.isArray(tasks)) return undefined;
|
|
254
|
+
for (const task of tasks) {
|
|
255
|
+
if (task === null || typeof task !== "object" || (task as { id?: unknown }).id !== taskId) continue;
|
|
256
|
+
const checkpoint = (task as { checkpoint?: unknown }).checkpoint;
|
|
257
|
+
return checkpoint !== null && typeof checkpoint === "object" ? (checkpoint as TaskCheckpointState) : undefined;
|
|
258
|
+
}
|
|
259
|
+
return undefined;
|
|
260
|
+
} catch {
|
|
261
|
+
return undefined; // ENOENT / corrupt tasks.json → caller falls back to the store
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -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.
|