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/CHANGELOG.md +136 -0
- package/dist/index.mjs +530 -529
- package/package.json +1 -1
- package/src/extension/registration/lifecycle-handlers.ts +10 -1
- package/src/extension/team-tool/respond-delivery.ts +429 -0
- package/src/extension/team-tool/respond.ts +135 -50
- package/src/extension/team-tool.ts +41 -1
- package/src/runtime/async-runner.ts +10 -1
- package/src/runtime/background-runner.ts +93 -17
- package/src/runtime/broker/crew-broker.ts +146 -1
- package/src/runtime/broker/dispatcher-key-registry.ts +39 -0
- package/src/runtime/broker/protocol/broker-respond.ts +365 -0
- package/src/runtime/broker/protocol/broker-steer.ts +151 -0
- package/src/runtime/broker/protocol/connection-state.ts +10 -0
- package/src/runtime/broker/protocol/request-parsers.ts +137 -1
- package/src/runtime/broker/runner-broker.ts +189 -0
- package/src/runtime/broker/stdin-handshake.ts +39 -4
- package/src/runtime/run-worker.ts +17 -2
- package/src/runtime/spawn-phase-trace.ts +53 -0
- package/src/runtime/task-runner/child-executor.ts +14 -0
- package/src/runtime/task-runner/state-helpers.ts +32 -0
- package/src/state/contracts.ts +15 -0
- package/src/state/coordination/mailbox.ts +15 -0
- package/src/state/types.ts +11 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|