pi-crew 0.11.55 → 0.11.56

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.11.55",
3
+ "version": "0.11.56",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -28,6 +28,7 @@ import {
28
28
  import { type BrokerSpawnCredentials, setActiveBrokerIssuer, setActiveBrokerRevoker } from "../../runtime/broker/broker-issuer.ts";
29
29
  import { CrewBroker } from "../../runtime/broker/crew-broker.ts";
30
30
  import { setActiveDelegateAborter } from "../../runtime/broker/delegate/delegate-abort.ts";
31
+ import { getActiveDispatcherVerifyKey } from "../../runtime/broker/dispatcher-key-registry.ts";
31
32
  import { DEFAULT_LOCK_BUSY_RETRY_DELAYS_MS, withRunLockBusyRetryAsync } from "../../runtime/broker/protocol/lock-busy.ts";
32
33
  import { terminateActiveChildPiProcesses } from "../../runtime/child-pi/child-pi.ts";
33
34
  import type { createManifestCache } from "../../runtime/manifest-cache.ts";
@@ -43,7 +44,7 @@ import { createSessionSnapshot } from "../../runtime/session-snapshot.ts";
43
44
  import { applyCrewSettingsTiersToConfig, loadCrewSettingsTiers, scheduledJobsHiddenCountOf } from "../../runtime/settings-store.ts";
44
45
  import type { ReconcileResult } from "../../runtime/stale-reconciler.ts";
45
46
  import { flushReapEscalationsSync } from "../../runtime/stale-reconciler.ts";
46
- import { getMailboxVerifyKeyPublic } from "../../state/coordination/mailbox.ts";
47
+ import { ensureCoordinationPublicVerifyKey, getMailboxVerifyKeyPublic } from "../../state/coordination/mailbox.ts";
47
48
  import { loadRunManifestById, saveRunManifest } from "../../state/stores/state-store.ts";
48
49
  import type { TeamRunManifest } from "../../state/types.ts";
49
50
  import { installInlinePanel } from "../../ui/inline-panel/index.ts";
@@ -852,6 +853,14 @@ export function installCrewBrokerLifecycleController(_pi: ExtensionAPI, _ctx: Re
852
853
  ? { globalWorkerSemaphore: nestingCfg.limits.maxConcurrentWorkers }
853
854
  : {}),
854
855
  serializeOnPathOverlap: nestingCfg?.limits?.serializeOnPathOverlap ?? false,
856
+ // HƯỚNG A (ADR Option 3, 2026-11-01): heap-only CR binding for
857
+ // respond.append. Detached-runner processes carry the DISPATCHER's
858
+ // public key in the start-options registry (stdin handshake); a root
859
+ // dispatcher host (sync AND async dispatch today: the broker host IS
860
+ // the dispatcher's process) binds its OWN public key through the same
861
+ // registry fallback. NEVER the manifest file — it is unauthenticated
862
+ // and worker-writable (w1a NO-GO). PUBLIC material only.
863
+ dispatcherVerifyKey: getActiveDispatcherVerifyKey() ?? ensureCoordinationPublicVerifyKey(),
855
864
  modelCatalog: () => {
856
865
  try {
857
866
  return configuredModelInfosFromPiConfig(process.cwd()).map((info) => info.fullId);
@@ -0,0 +1,429 @@
1
+ /**
2
+ * respond-delivery.ts — Broker-first delivery for `respond` answer rows.
3
+ *
4
+ * HƯỚNG A (ADR Option 3, run team_20261101000805, 2026-11-01): the remaining
5
+ * hướng C (0.11.55) gaps are responders that are NOT the dispatch process —
6
+ * a restarted leader, a foreign session, a scheduler host. Their locally
7
+ * signed rows fail the w1a Ed25519 verify in the worker (the worker's key set
8
+ * only carries its spawner + the manifest-threaded dispatcher key) and drop
9
+ * as noise. Hướng A closes that by having the BROKER process append the row:
10
+ * the embedded broker's keypair IS the worker's spawner keypair
11
+ * (lifecycle-handlers.ts), so a broker-appended row verifies in the worker
12
+ * with NO additional key distribution.
13
+ *
14
+ * WIRE CONTRACT — pinned by the broker-side slice (src/runtime/broker/):
15
+ * - `respond.challenge` / `respond.append` are PRE-hello methods on
16
+ * un-authed connections (no token — the callers this path exists for hold
17
+ * none). The connection still dies at the broker's 1s hello deadline, so
18
+ * the whole challenge→append exchange rides ONE short-lived connection
19
+ * (the nonce is bound per-connection) and any failure falls back locally.
20
+ * - Challenge: `{ runId }` → `{ nonce, expiresInMs, runId }`.
21
+ * - Auth signature: the dispatcher proves possession of its heap-only
22
+ * coordination private key over `respondAppendSignInput(nonce, params)`
23
+ * — the request-parsers.ts EXPORT (contract: plain `${nonce}${canonical}`
24
+ * concatenation, NO separator, runId NOT part of the signed payload;
25
+ * the doc there mandates client signers use the export, never
26
+ * re-implement) via mailbox.ts's `signCoordinationChallenge`. The broker
27
+ * verifies against its construct-time heap-only dispatcher key binding.
28
+ * - Append: common params + `nonce` + `sig` →
29
+ * `{ messageId, requestId, deduped, auth }` — the id of the BROKER-SIGNED
30
+ * row appended with requestId = questionId idempotency. Worker-token
31
+ * connections are rejected server-side (defense-in-depth).
32
+ * - Not CrewBrokerClient: hello there hard-requires a per-run token the
33
+ * responder does not hold, and its connect path cannot skip hello — this
34
+ * is a minimal NDJSON connector following the same framing
35
+ * (encodeBrokerFrame / NdjsonDecoder / 256 KiB cap).
36
+ *
37
+ * Failure semantics: `deliverResponseRow` NEVER lets a broker failure escape
38
+ * — it falls back to the local signed idempotent append (hướng C behavior)
39
+ * and logs a warn. Only the local append's own failure propagates, exactly
40
+ * like the pre-hướng-A inline append did. The requestId = questionId key
41
+ * makes the fallback exactly-once even when the broker appended but the ack
42
+ * frame was lost — CONDITIONED on the responder and the broker sharing the
43
+ * same run state root (same-host same-dir, true for every current topology:
44
+ * the responder's local fallback dedup-scan sees the broker's row in the
45
+ * shared inbox file and reuses its id; a future remote-broker topology
46
+ * would lose this guarantee).
47
+ */
48
+
49
+ import { randomUUID } from "node:crypto";
50
+ import * as net from "node:net";
51
+ import { type RespondCommonParams, respondAppendSignInput } from "../../runtime/broker/protocol/request-parsers.ts";
52
+ import {
53
+ appendMailboxMessageIdempotentAsync,
54
+ getMailboxVerifyKeyPublic,
55
+ type MailboxDeliveryMode,
56
+ type MailboxMessagePriority,
57
+ signCoordinationChallenge,
58
+ } from "../../state/coordination/mailbox.ts";
59
+ import type { TeamRunManifest } from "../../state/types.ts";
60
+ import { logInternalError } from "../../utils/internal-error.ts";
61
+ import { encodeBrokerFrame, NdjsonDecoder } from "../../utils/ndjson.ts";
62
+ import { getBrokerSocketPath } from "../../utils/socket-path.ts";
63
+
64
+ /** Connect deadline. Short: respond is interactive and the local fallback is
65
+ * cheap + correct, so a dead socket must fail fast (ECONNREFUSED/ENOENT on
66
+ * a missing endpoint is usually immediate anyway). The broker's own 1s
67
+ * hello deadline for un-authed connections bounds the exchange regardless. */
68
+ const BROKER_CONNECT_TIMEOUT_MS = 2_500;
69
+
70
+ /** Per-request deadline (challenge, append). Mirrors the client seam's
71
+ * bounded-request discipline (F5) — a half-dead socket must not stall the
72
+ * respond caller past this. */
73
+ const BROKER_REQUEST_TIMEOUT_MS = 5_000;
74
+
75
+ /** The response row deliverResponseRow accepts — the exact shape the ALIVE
76
+ * branch of handleRespond builds (plus the mandatory requestId = questionId
77
+ * idempotency key). Structurally compatible with the mailbox append input
78
+ * (MailboxAppendInput is module-private in mailbox.ts; both append paths
79
+ * accept this shape). replyFrom/replyDeadline ride the LOCAL fallback row
80
+ * (they are not part of the broker wire contract's common params — the
81
+ * broker parser drops them; informational fields only). */
82
+ export interface RespondDeliveryRow {
83
+ direction: "inbox";
84
+ from: "leader";
85
+ to: string;
86
+ taskId: string;
87
+ body: string;
88
+ kind: "response";
89
+ priority: MailboxMessagePriority;
90
+ deliveryMode: MailboxDeliveryMode;
91
+ /** Park correlation id — must match task.waiting.questionId exactly. */
92
+ questionId: string;
93
+ /** Durable idempotency key (= questionId) — makes the out-of-lock append
94
+ * exactly-once against both the broker path and concurrent responds. */
95
+ requestId: string;
96
+ data?: Record<string, unknown>;
97
+ replyTo?: string;
98
+ replyFrom?: string;
99
+ replyDeadline?: number;
100
+ }
101
+
102
+ /** Where the delivered row landed — surfaced for events/observability. */
103
+ export interface RespondDeliveryOutcome {
104
+ messageId: string;
105
+ via: "broker" | "forced" | "local";
106
+ /** Local path only: true when an existing (direction, requestId) row was
107
+ * reused instead of appending a new one (exactly-once re-entry). */
108
+ deduped: boolean;
109
+ }
110
+
111
+ /** Typed transport failure — always funnels into the local fallback. */
112
+ class RespondBrokerDeliveryError extends Error {
113
+ readonly code: string;
114
+
115
+ constructor(code: string, message: string) {
116
+ super(message);
117
+ this.code = code;
118
+ this.name = "RespondBrokerDeliveryError";
119
+ }
120
+ }
121
+
122
+ interface BrokerResponseFrame {
123
+ id?: unknown;
124
+ result?: unknown;
125
+ error?: { code?: unknown; message?: unknown };
126
+ }
127
+
128
+ interface RespondBrokerStep {
129
+ method: string;
130
+ params: unknown;
131
+ }
132
+
133
+ /** Sequential NDJSON round-trips over ONE short-lived pre-hello connection
134
+ * (the respond.append nonce is bound to the connection object server-side,
135
+ * so challenge + append MUST share a socket). `nextStep` receives the
136
+ * previous step's result (undefined for the first call) and returns the next
137
+ * request, or undefined when the exchange is complete; it may throw to abort
138
+ * (e.g. malformed challenge result). Resolves with each step's `result`;
139
+ * rejects with a typed RespondBrokerDeliveryError on any transport /
140
+ * protocol / error-frame failure. No retries: the local fallback IS the
141
+ * retry strategy. */
142
+ /** Single-connection NDJSON round-trip helper — shared by the respond and
143
+ * steer force tiers (sends steps until the step fn returns undefined;
144
+ * resolves with every result frame in order). Throws
145
+ * RespondBrokerDeliveryError on socket/protocol errors. */
146
+ export function brokerRoundTrip(
147
+ socketPath: string,
148
+ nextStep: (previousResult: unknown) => RespondBrokerStep | undefined,
149
+ ): Promise<unknown[]> {
150
+ return new Promise<unknown[]>((resolve, reject) => {
151
+ const socket = net.createConnection(socketPath);
152
+ const decoder = new NdjsonDecoder();
153
+ const results: unknown[] = [];
154
+ let settled = false;
155
+ let currentId: string | null = null;
156
+ let timer: NodeJS.Timeout | null = null;
157
+
158
+ const finishOk = (): void => {
159
+ if (settled) return;
160
+ settled = true;
161
+ if (timer) clearTimeout(timer);
162
+ socket.removeAllListeners();
163
+ try {
164
+ socket.destroy();
165
+ } catch {
166
+ /* ignore */
167
+ }
168
+ resolve(results);
169
+ };
170
+ const fail = (code: string, message: string): void => {
171
+ if (settled) return;
172
+ settled = true;
173
+ if (timer) clearTimeout(timer);
174
+ socket.removeAllListeners();
175
+ try {
176
+ socket.destroy();
177
+ } catch {
178
+ /* ignore */
179
+ }
180
+ reject(new RespondBrokerDeliveryError(code, message));
181
+ };
182
+ const armTimer = (): void => {
183
+ if (timer) clearTimeout(timer);
184
+ timer = setTimeout(
185
+ () => fail("request-timeout", `no broker response within ${BROKER_REQUEST_TIMEOUT_MS}ms`),
186
+ BROKER_REQUEST_TIMEOUT_MS,
187
+ );
188
+ timer.unref?.();
189
+ };
190
+ const sendStep = (): void => {
191
+ let step: RespondBrokerStep | undefined;
192
+ try {
193
+ step = nextStep(results.length > 0 ? results[results.length - 1] : undefined);
194
+ } catch (error) {
195
+ fail("bad-step", error instanceof Error ? error.message : String(error));
196
+ return;
197
+ }
198
+ if (step === undefined) {
199
+ finishOk();
200
+ return;
201
+ }
202
+ currentId = `respond-${randomUUID()}`;
203
+ armTimer();
204
+ try {
205
+ socket.write(encodeBrokerFrame({ id: currentId, method: step.method, params: step.params }));
206
+ } catch (error) {
207
+ fail("encode-failed", error instanceof Error ? error.message : String(error));
208
+ }
209
+ };
210
+
211
+ // Connect deadline — cleared the moment the endpoint answers.
212
+ timer = setTimeout(
213
+ () => fail("connect-timeout", `broker socket ${socketPath} did not accept within ${BROKER_CONNECT_TIMEOUT_MS}ms`),
214
+ BROKER_CONNECT_TIMEOUT_MS,
215
+ );
216
+ timer.unref?.();
217
+
218
+ socket.once("connect", () => {
219
+ if (settled) return;
220
+ sendStep();
221
+ });
222
+ socket.on("data", (chunk: Buffer | string) => {
223
+ if (settled) return;
224
+ const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
225
+ let frames: unknown[];
226
+ try {
227
+ frames = decoder.push(buf);
228
+ } catch (error) {
229
+ fail("protocol", error instanceof Error ? error.message : String(error));
230
+ return;
231
+ }
232
+ for (const frame of frames) {
233
+ if (frame === null || typeof frame !== "object") {
234
+ fail("protocol", "non-object broker frame");
235
+ return;
236
+ }
237
+ const f = frame as BrokerResponseFrame;
238
+ // Unsolicited event frames (no id) — ignored, never fail the
239
+ // exchange on a push.
240
+ if (typeof f.id !== "string") continue;
241
+ if (f.id !== currentId) continue;
242
+ if (f.error) {
243
+ const code = typeof f.error.code === "string" ? f.error.code : "request-failed";
244
+ const message = typeof f.error.message === "string" ? f.error.message : "broker rejected respond request";
245
+ fail(code, message);
246
+ return;
247
+ }
248
+ results.push(f.result);
249
+ sendStep();
250
+ return;
251
+ }
252
+ });
253
+ socket.once("error", (error: Error & { code?: string }) => {
254
+ fail(error.code ?? "connect-failed", error.message);
255
+ });
256
+ socket.once("close", () => {
257
+ fail("close", "broker connection closed before the respond append completed");
258
+ });
259
+ });
260
+ }
261
+
262
+ /** Extract a non-empty string field from a broker result object. */
263
+ function stringField(value: unknown, field: string): string | undefined {
264
+ if (value === null || typeof value !== "object") return undefined;
265
+ const raw = (value as Record<string, unknown>)[field];
266
+ return typeof raw === "string" && raw.length > 0 ? raw : undefined;
267
+ }
268
+
269
+ /** Broker CR path: challenge → nonce → dispatcher-key signature → append,
270
+ * all on ONE connection (see header). Throws RespondBrokerDeliveryError on
271
+ * ANY failure — the caller falls back toward respond.force / local. */
272
+ async function appendResponseRowViaBroker(
273
+ manifest: TeamRunManifest,
274
+ row: RespondDeliveryRow,
275
+ socketPath: string,
276
+ ): Promise<{ messageId: string; deduped: boolean }> {
277
+ // Common params — the EXACT object both the append frame and the auth
278
+ // signature are derived from (respondAppendSignInput covers it whole).
279
+ const appendParams: RespondCommonParams = {
280
+ runId: manifest.runId,
281
+ taskId: row.taskId,
282
+ questionId: row.questionId,
283
+ body: row.body,
284
+ ...(row.replyTo !== undefined ? { replyTo: row.replyTo } : {}),
285
+ priority: row.priority,
286
+ deliveryMode: row.deliveryMode,
287
+ requestId: row.requestId,
288
+ };
289
+ let nonce: string | undefined;
290
+ let appended = false;
291
+ const results = await brokerRoundTrip(socketPath, (previous) => {
292
+ if (appended) return undefined; // append ack received — exchange done
293
+ if (nonce === undefined) {
294
+ if (previous !== undefined) {
295
+ const candidate = stringField(previous, "nonce");
296
+ if (candidate === undefined) {
297
+ throw new RespondBrokerDeliveryError("protocol", "respond.challenge result missing nonce");
298
+ }
299
+ nonce = candidate;
300
+ } else {
301
+ return { method: "respond.challenge", params: { runId: manifest.runId } };
302
+ }
303
+ }
304
+ appended = true;
305
+ return {
306
+ method: "respond.append",
307
+ params: {
308
+ ...appendParams,
309
+ nonce,
310
+ sig: signCoordinationChallenge(respondAppendSignInput(nonce, appendParams)),
311
+ },
312
+ };
313
+ });
314
+ const appendResult = results.length > 1 ? results[results.length - 1] : undefined;
315
+ const messageId = stringField(appendResult, "messageId");
316
+ if (messageId === undefined) {
317
+ throw new RespondBrokerDeliveryError("protocol", "respond.append result missing messageId");
318
+ }
319
+ // Broker contract: { messageId, requestId, deduped, auth } — `deduped`
320
+ // reports an idempotent re-use of the existing (direction, requestId) row.
321
+ const deduped = appendResult !== null && typeof appendResult === "object" && (appendResult as Record<string, unknown>).deduped === true;
322
+ return { messageId, deduped };
323
+ }
324
+
325
+ /** HƯỚNG A follow-up (probes 14–17, 2026-11-01): token-less one-shot force
326
+ * append for a LIVE park — the designed lane for a responder that is NOT
327
+ * the dispatcher (restarted leader / foreign session: the heap-only
328
+ * dispatcher keypair died with the original process, so CR can never
329
+ * verify). The broker enforces live-park + one-shot-per-park + heap-side
330
+ * dedup and appends a BROKER-SIGNED row the worker verifies (probe13:
331
+ * auth:"forced" delivered end-to-end). Single frame, no challenge. */
332
+ async function forceResponseRowViaBroker(
333
+ socketPath: string,
334
+ manifest: TeamRunManifest,
335
+ row: RespondDeliveryRow,
336
+ ): Promise<{ messageId: string; deduped: boolean }> {
337
+ const forceParams: RespondCommonParams = {
338
+ runId: manifest.runId,
339
+ taskId: row.taskId,
340
+ questionId: row.questionId,
341
+ body: row.body,
342
+ ...(row.replyTo !== undefined ? { replyTo: row.replyTo } : {}),
343
+ priority: row.priority,
344
+ deliveryMode: row.deliveryMode,
345
+ requestId: row.requestId,
346
+ };
347
+ let sent = false;
348
+ const results = await brokerRoundTrip(socketPath, () => {
349
+ if (sent) return undefined; // force ack received — exchange done
350
+ sent = true;
351
+ return { method: "respond.force", params: forceParams };
352
+ });
353
+ const forceResult = results.length > 0 ? results[results.length - 1] : undefined;
354
+ const messageId = stringField(forceResult, "messageId");
355
+ if (messageId === undefined) {
356
+ throw new RespondBrokerDeliveryError("protocol", "respond.force result missing messageId");
357
+ }
358
+ const deduped = forceResult !== null && typeof forceResult === "object" && (forceResult as Record<string, unknown>).deduped === true;
359
+ return { messageId, deduped };
360
+ }
361
+
362
+ /**
363
+ * Deliver one `kind:"response"` row for a LIVE park: broker-first, local
364
+ * fallback — NEVER holds a run lock (the caller must have released
365
+ * withRunLockSync before calling; the broker handler reads run state via
366
+ * withRunLockBusyRetry, so an in-lock RPC window would surface as cross-
367
+ * process `busy` churn even though it cannot deadlock).
368
+ *
369
+ * - Broker path pre-check: the socket is derivable (manifest.sessionId) AND
370
+ * this process holds the DISPATCHER keypair — CR can only prove against the
371
+ * broker's construct-time heap binding when the signer IS the dispatcher
372
+ * (`getMailboxVerifyKeyPublic() === manifest.dispatcherVerifyKey`; the
373
+ * get-or-create keypair is the exact seam the dispatcher stamped at run
374
+ * creation). A restarted/foreign responder skips straight to the fallback
375
+ * instead of burning an RPC that cannot verify (its keypair is unknown to
376
+ * the binding — that gap is respond.force's lane, not CR's).
377
+ * - Any broker failure (socket absent, timeout, auth-reject, protocol drift)
378
+ * → warn log (`respond.broker-delivery-fallback`) + local signed idempotent
379
+ * append — hướng C behavior preserved.
380
+ */
381
+ export async function deliverResponseRow(manifest: TeamRunManifest, row: RespondDeliveryRow): Promise<RespondDeliveryOutcome> {
382
+ const sessionId = manifest.sessionId;
383
+ const stamped = manifest.brokerSocketPath;
384
+ const socketPath =
385
+ typeof stamped === "string" && stamped.length > 0
386
+ ? stamped
387
+ : typeof sessionId === "string" && sessionId.length > 0
388
+ ? getBrokerSocketPath(sessionId)
389
+ : undefined;
390
+ if (socketPath !== undefined) {
391
+ const isDispatcher = manifest.dispatcherVerifyKey === getMailboxVerifyKeyPublic();
392
+ if (isDispatcher) {
393
+ try {
394
+ const brokerOutcome = await appendResponseRowViaBroker(manifest, row, socketPath);
395
+ return { messageId: brokerOutcome.messageId, via: "broker", deduped: brokerOutcome.deduped };
396
+ } catch (error) {
397
+ logInternalError(
398
+ "respond.broker-delivery-fallback",
399
+ error instanceof Error ? error : new Error(String(error)),
400
+ `runId=${manifest.runId} taskId=${row.taskId} questionId=${row.questionId}`,
401
+ "warn",
402
+ );
403
+ }
404
+ }
405
+ // HƯỚNG A follow-up (probes 14–17): a non-dispatcher responder (restarted
406
+ // leader / foreign session — the heap-only dispatcher keypair is gone)
407
+ // could previously only reach the LOCAL append, whose row is signed with
408
+ // a key the worker does not hold → dropped as noise → the ask timed out
409
+ // with NO product surface able to answer it. respond.force is the
410
+ // designed lane: token-less, live-park-gated, one-shot, broker-SIGNED
411
+ // (probe13 end-to-end). Same-uid trust domain unchanged — any local
412
+ // process could already speak the protocol; this surfaces it through the
413
+ // product respond tool. Failure (dead park / no broker) falls through to
414
+ // the local append as the last-resort event trail.
415
+ try {
416
+ const forced = await forceResponseRowViaBroker(socketPath, manifest, row);
417
+ return { messageId: forced.messageId, via: "forced", deduped: forced.deduped };
418
+ } catch (error) {
419
+ logInternalError(
420
+ "respond.force-delivery-fallback",
421
+ error instanceof Error ? error : new Error(String(error)),
422
+ `runId=${manifest.runId} taskId=${row.taskId} questionId=${row.questionId}`,
423
+ "warn",
424
+ );
425
+ }
426
+ }
427
+ const outcome = await appendMailboxMessageIdempotentAsync(manifest, row);
428
+ return { messageId: outcome.message.id, via: "local", deduped: outcome.deduped };
429
+ }