pi-crew 0.11.11 → 0.11.13
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 +102 -0
- package/dist/index.mjs +551 -519
- package/package.json +1 -1
- package/src/config/config-validation.ts +9 -0
- package/src/config/defaults.ts +7 -0
- package/src/config/env-vars.ts +24 -0
- package/src/config/types.ts +11 -0
- package/src/extension/registration/tool-registration.ts +7 -0
- package/src/extension/registration/verify-gate-tool.ts +263 -0
- package/src/extension/team-tool/run-deadline.ts +22 -1
- package/src/extension/team-tool/run.ts +38 -2
- package/src/extension/team-tool-types.ts +4 -0
- package/src/extension/team-tool.ts +31 -3
- package/src/prompt/inbox-poll.ts +141 -3
- package/src/runtime/broker/crew-broker.ts +48 -2
- package/src/runtime/broker/protocol/lock-busy.ts +47 -1
- package/src/runtime/broker/protocol/request-parsers.ts +8 -1
- package/src/runtime/budget-enforcement.ts +36 -20
- package/src/runtime/child-pi/child-pi-constants.ts +24 -0
- package/src/runtime/child-pi/child-pi-kill.ts +59 -2
- package/src/runtime/child-pi/child-pi-rpc.ts +1021 -0
- package/src/runtime/child-pi/child-pi-steering.ts +10 -8
- package/src/runtime/child-pi/child-pi-streams.ts +69 -19
- package/src/runtime/child-pi/child-pi.ts +29 -30
- package/src/runtime/child-pi/owned-process.ts +434 -0
- package/src/runtime/child-pi/postmortem-registry.ts +134 -0
- package/src/runtime/classifier/ambient-noise-gate.ts +126 -0
- package/src/runtime/classifier/classifier-service.ts +277 -61
- package/src/runtime/dispatch-batch.ts +32 -0
- package/src/runtime/live-session/live-agent-control.ts +2 -0
- package/src/runtime/live-session/live-agent-manager.ts +72 -2
- package/src/runtime/live-session/live-session-runtime.ts +435 -40
- package/src/runtime/merge-loop.ts +63 -35
- package/src/runtime/recovery/crash-recovery.ts +141 -3
- package/src/runtime/recovery/retry-executor.ts +29 -0
- package/src/runtime/run-tracker.ts +28 -0
- package/src/runtime/stale-reconciler.ts +36 -1
- package/src/runtime/task-runner.ts +103 -0
- package/src/runtime/verification/classifier-pre-gate.ts +262 -0
- package/src/runtime/verification/verification-gates.ts +5 -1
- package/src/runtime/verification/verify-gate.ts +394 -0
- package/src/schema/config-schema.ts +13 -0
- package/src/state/contracts.ts +18 -0
- package/src/state/coordination/mailbox.ts +203 -55
- package/src/state/stores/sqlite-run-state.ts +378 -0
- package/src/state/stores/state-store.ts +134 -6
- package/src/ui/agents-jobs-browser.ts +154 -8
- package/src/ui/events-state-source.ts +358 -0
- package/src/ui/run-snapshot-cache.ts +87 -3
- package/src/ui/widget/widget-model.ts +13 -2
|
@@ -116,6 +116,19 @@ export const PiTeamsRuntimeConfigSchema = Type.Object(
|
|
|
116
116
|
{ additionalProperties: false },
|
|
117
117
|
),
|
|
118
118
|
),
|
|
119
|
+
// U4 (live-session v2): tunables for the in-process live-session worker —
|
|
120
|
+
// responseTimeoutMs bounds one prompt() wall-clock; hungToolGraceMs is
|
|
121
|
+
// the escape hatch's settle-grace after a cooperative abort. Behavior
|
|
122
|
+
// picks, not authority — no sensitive mark.
|
|
123
|
+
liveSession: Type.Optional(
|
|
124
|
+
Type.Object(
|
|
125
|
+
{
|
|
126
|
+
responseTimeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
127
|
+
hungToolGraceMs: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
128
|
+
},
|
|
129
|
+
{ additionalProperties: false },
|
|
130
|
+
),
|
|
131
|
+
),
|
|
119
132
|
excludeContextBash: Type.Optional(Type.Boolean()),
|
|
120
133
|
// Handoff budget: est-token (chars/4) cap on the dynamic.dependencyContext
|
|
121
134
|
// prompt layer. Matches parseHandoffBudgetTokens in config-validation.ts
|
package/src/state/contracts.ts
CHANGED
|
@@ -111,6 +111,14 @@ export const TEAM_EVENT_TYPES = [
|
|
|
111
111
|
// classifier answer informed the retry-vs-skip decision (dormant unless
|
|
112
112
|
// runtime.classifierEnabled). Diagnostic only — never transitions state.
|
|
113
113
|
"task.retry_triage",
|
|
114
|
+
// U6A (upgrade spec 2026-10-09 §U6 Phase A): dispatch-batch ambient-noise
|
|
115
|
+
// gate decision — fired by classifyAmbientNoiseGate (retry-executor
|
|
116
|
+
// shouldRetry hook) ONLY when a real classifier answer informed the
|
|
117
|
+
// retry-vs-give-up decision (dormant unless runtime.classifierEnabled —
|
|
118
|
+
// same flag as task.retry_triage). Diagnostic only, never transitions
|
|
119
|
+
// state; every classifier failure mode falls back to proceed-with-retry
|
|
120
|
+
// without emitting this event.
|
|
121
|
+
"task.noise_gate",
|
|
114
122
|
// WP-2/R2 waiting-producer (ADR-0 2026-08-17-waiting-producer-ask item 10):
|
|
115
123
|
// `ask` tool lifecycle — requested on park acceptance, answered on delivery
|
|
116
124
|
// (mailbox or requeue+inject), timedout on deadline expiry (both the
|
|
@@ -246,6 +254,11 @@ export const TEAM_EVENT_TYPES = [
|
|
|
246
254
|
"run.exported",
|
|
247
255
|
"run.forget_requested",
|
|
248
256
|
"run.goal_achievement",
|
|
257
|
+
// RELIABILITY FIX 2026-10-10 (run team_20261010100956 incident): run.lock
|
|
258
|
+
// contention observed by a scheduler merge site (mergeUnitResult /
|
|
259
|
+
// terminaliseRunWithDrain) that is being retried with back-off — diagnostic
|
|
260
|
+
// only; never transitions run state.
|
|
261
|
+
"run.lock_retry",
|
|
249
262
|
"run.resume_requested",
|
|
250
263
|
// SDD-3 W-C G4 (team-tool.ts handleResume): forced resume of a FOREIGN-owned
|
|
251
264
|
// run — ownership was bypassed with force:true (liveness was NOT bypassable;
|
|
@@ -271,6 +284,11 @@ export const TEAM_EVENT_TYPES = [
|
|
|
271
284
|
"task.model_dropped",
|
|
272
285
|
"task.mcp_enforcement_degraded",
|
|
273
286
|
"task.output_validation",
|
|
287
|
+
// U13/U6B (upgrade wave 3, 2026-10-10): deterministic verifier pre-gate
|
|
288
|
+
// decision — emitted by verify-gate/classifier-pre-gate when a task's
|
|
289
|
+
// verifier spawn was skipped (deterministic pass / classifier block) or
|
|
290
|
+
// carried gate context. Observability only; no run/task state transition.
|
|
291
|
+
"task.verifier_pre_gate",
|
|
274
292
|
"task.steer_queued",
|
|
275
293
|
"task.steer_dropped",
|
|
276
294
|
"task.surface_lost",
|
|
@@ -72,6 +72,13 @@ export interface MailboxMessage {
|
|
|
72
72
|
* Carried by `kind:"response"` entries; matches task.waiting.questionId —
|
|
73
73
|
* matching must be exact equality (never prefix/substring). */
|
|
74
74
|
questionId?: string;
|
|
75
|
+
/** U5 (2026-10-10): stable per-send idempotency key — uuid or
|
|
76
|
+
* `{runId}:{taskId}:{seq}`. Every append auto-stamps one when the caller
|
|
77
|
+
* omits it; resend-safe callers (broker msg.send) pass an explicit one so
|
|
78
|
+
* `appendMailboxMessageIdempotent` can dedup by (direction, requestId) in
|
|
79
|
+
* the SAME mailbox file (the durable analog of pi-durable's
|
|
80
|
+
* `tx.submissionByRequest()` exactly-once root-submit). */
|
|
81
|
+
requestId?: string;
|
|
75
82
|
acknowledgedAt?: string;
|
|
76
83
|
data?: Record<string, unknown>;
|
|
77
84
|
/** ID of the original message this is a reply to. */
|
|
@@ -89,6 +96,13 @@ export interface MailboxMessage {
|
|
|
89
96
|
export interface MailboxDeliveryState {
|
|
90
97
|
messages: Record<string, MailboxMessageStatus>;
|
|
91
98
|
updatedAt: string;
|
|
99
|
+
/** U5 (2026-10-10): durable (direction, requestId) delivered index —
|
|
100
|
+
* key `${direction}:${requestId}` → id of the first message row that was
|
|
101
|
+
* consumed under it. Written by the worker inbox pickup
|
|
102
|
+
* (recordMailboxRequestIdDelivery) and read back after a restart so a
|
|
103
|
+
* redelivered/resent row is dropped (exactly-once pickup). Optional so
|
|
104
|
+
* pre-U5 delivery.json files parse unchanged. */
|
|
105
|
+
requestIds?: Record<string, string>;
|
|
92
106
|
}
|
|
93
107
|
|
|
94
108
|
export interface MailboxValidationIssue {
|
|
@@ -307,6 +321,7 @@ function parseMailboxMessage(raw: unknown, expectedDirection: MailboxDirection):
|
|
|
307
321
|
deliveryMode: isDeliveryMode(obj.deliveryMode) ? obj.deliveryMode : undefined,
|
|
308
322
|
taskId: typeof obj.taskId === "string" ? obj.taskId : undefined,
|
|
309
323
|
questionId: typeof obj.questionId === "string" ? obj.questionId : undefined,
|
|
324
|
+
requestId: typeof obj.requestId === "string" ? obj.requestId : undefined,
|
|
310
325
|
acknowledgedAt: typeof obj.acknowledgedAt === "string" ? obj.acknowledgedAt : undefined,
|
|
311
326
|
data,
|
|
312
327
|
replyTo: typeof obj.replyTo === "string" ? obj.replyTo : undefined,
|
|
@@ -519,6 +534,14 @@ export function readDeliveryState(manifest: TeamRunManifest): MailboxDeliverySta
|
|
|
519
534
|
messages,
|
|
520
535
|
updatedAt: typeof obj.updatedAt === "string" ? obj.updatedAt : new Date().toISOString(),
|
|
521
536
|
};
|
|
537
|
+
// U5: optional durable (direction, requestId) delivered index — absent
|
|
538
|
+
// in pre-U5 delivery.json files (treated as "nothing delivered yet").
|
|
539
|
+
if (obj.requestIds && typeof obj.requestIds === "object" && !Array.isArray(obj.requestIds)) {
|
|
540
|
+
const requestIds: Record<string, string> = {};
|
|
541
|
+
for (const [key, id] of Object.entries(obj.requestIds as Record<string, unknown>))
|
|
542
|
+
if (typeof id === "string") requestIds[key] = id;
|
|
543
|
+
if (Object.keys(requestIds).length > 0) state.requestIds = requestIds;
|
|
544
|
+
}
|
|
522
545
|
setDeliveryCacheEntry(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, state });
|
|
523
546
|
return state;
|
|
524
547
|
} catch (error) {
|
|
@@ -640,6 +663,7 @@ function pruneDeliveryMessages(manifest: TeamRunManifest, state: MailboxDelivery
|
|
|
640
663
|
const entries = Object.entries(state.messages);
|
|
641
664
|
const ackedCount = entries.reduce((count, [, status]) => (status === "acknowledged" ? count + 1 : count), 0);
|
|
642
665
|
if (ackedCount > ACK_SWEEP_MIN_ACKS) sweepDeadAcknowledgements(manifest, state);
|
|
666
|
+
pruneDeliveredRequestIds(state);
|
|
643
667
|
const remaining = Object.entries(state.messages);
|
|
644
668
|
if (remaining.length <= MAX_DELIVERY_MESSAGES) return;
|
|
645
669
|
// Stable sort: within a status tier the previous insertion order (which is
|
|
@@ -677,6 +701,19 @@ function pruneDeliveryMessages(manifest: TeamRunManifest, state: MailboxDelivery
|
|
|
677
701
|
state.messages = Object.fromEntries(remaining.filter(([id]) => keptIds.has(id)));
|
|
678
702
|
}
|
|
679
703
|
|
|
704
|
+
/** U5: bound the durable (direction, requestId) delivered index. Keys insert
|
|
705
|
+
* chronologically (first delivery under that requestId), so eviction keeps
|
|
706
|
+
* the most recent MAX_DELIVERY_MESSAGES keys — dropping a key can only
|
|
707
|
+
* re-enable delivery of an extremely old duplicate row, the same bounded
|
|
708
|
+
* risk the messages map already accepts. */
|
|
709
|
+
function pruneDeliveredRequestIds(state: MailboxDeliveryState): void {
|
|
710
|
+
if (!state.requestIds) return;
|
|
711
|
+
const keys = Object.keys(state.requestIds);
|
|
712
|
+
if (keys.length <= MAX_DELIVERY_MESSAGES) return;
|
|
713
|
+
const keep = new Set(keys.slice(keys.length - MAX_DELIVERY_MESSAGES));
|
|
714
|
+
for (const key of keys) if (!keep.has(key)) delete state.requestIds[key];
|
|
715
|
+
}
|
|
716
|
+
|
|
680
717
|
function writeDeliveryState(
|
|
681
718
|
manifest: TeamRunManifest,
|
|
682
719
|
state: MailboxDeliveryState,
|
|
@@ -704,29 +741,17 @@ function writeDeliveryState(
|
|
|
704
741
|
}
|
|
705
742
|
}
|
|
706
743
|
|
|
707
|
-
/**
|
|
708
|
-
*
|
|
709
|
-
*
|
|
710
|
-
*
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
*/
|
|
719
|
-
export function appendMailboxMessage(
|
|
720
|
-
manifest: TeamRunManifest,
|
|
721
|
-
message: Omit<MailboxMessage, "id" | "runId" | "createdAt" | "status"> & {
|
|
722
|
-
id?: string;
|
|
723
|
-
status?: MailboxMessageStatus;
|
|
724
|
-
},
|
|
725
|
-
): MailboxMessage {
|
|
726
|
-
if (message.taskId) ensureTaskMailbox(manifest, message.taskId);
|
|
727
|
-
else ensureRunMailbox(manifest);
|
|
728
|
-
const createdAt = new Date().toISOString();
|
|
729
|
-
const complete: MailboxMessage = {
|
|
744
|
+
/** U5 (2026-10-10): shared message construction for every append path.
|
|
745
|
+
* Auto-stamps a unique `requestId` (`req_<uuid>`) when the caller omits one
|
|
746
|
+
* so EVERY mailbox row carries one (receiver dedup keys on it); callers
|
|
747
|
+
* that need resend idempotency pass an explicit stable id instead. */
|
|
748
|
+
type MailboxAppendInput = Omit<MailboxMessage, "id" | "runId" | "createdAt" | "status"> & {
|
|
749
|
+
id?: string;
|
|
750
|
+
status?: MailboxMessageStatus;
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
function buildMailboxMessage(manifest: TeamRunManifest, message: MailboxAppendInput, createdAt: string): MailboxMessage {
|
|
754
|
+
return {
|
|
730
755
|
// RR-021 WI-4.3j: randomUUID instead of Date.now()+Math.random() —
|
|
731
756
|
// collision-free under the msg_ prefix, no clock-ordering leakage.
|
|
732
757
|
id: message.id ?? `msg_${randomUUID()}`,
|
|
@@ -742,6 +767,10 @@ export function appendMailboxMessage(
|
|
|
742
767
|
deliveryMode: message.deliveryMode,
|
|
743
768
|
taskId: message.taskId,
|
|
744
769
|
questionId: message.questionId,
|
|
770
|
+
// U5: every send carries a requestId — explicit (idempotent senders)
|
|
771
|
+
// or auto-stamped uuid (one per row; still unique per send).
|
|
772
|
+
requestId: message.requestId ?? `req_${randomUUID()}`,
|
|
773
|
+
acknowledgedAt: message.acknowledgedAt,
|
|
745
774
|
data: message.data,
|
|
746
775
|
replyTo: message.replyTo,
|
|
747
776
|
replyFrom: message.replyFrom,
|
|
@@ -749,6 +778,25 @@ export function appendMailboxMessage(
|
|
|
749
778
|
repliedAt: message.repliedAt,
|
|
750
779
|
replyContent: message.replyContent,
|
|
751
780
|
};
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Append a message to a run's or task's mailbox.
|
|
785
|
+
*
|
|
786
|
+
* SECURITY NOTE: The `from` field is caller-declared — there is no cryptographic
|
|
787
|
+
* sender authentication. This is acceptable because `appendMailboxMessage` is an
|
|
788
|
+
* internal API only callable from within the pi-crew process (no external input).
|
|
789
|
+
* All callers (handleSteer, handleRespond, handleFollowUp) derive `from` from
|
|
790
|
+
* authenticated context (session role, task assignment).
|
|
791
|
+
*
|
|
792
|
+
* If pi-crew ever exposes mailbox writes to external/untrusted input, sender
|
|
793
|
+
* authentication (HMAC or session key) must be added.
|
|
794
|
+
*/
|
|
795
|
+
export function appendMailboxMessage(manifest: TeamRunManifest, message: MailboxAppendInput): MailboxMessage {
|
|
796
|
+
if (message.taskId) ensureTaskMailbox(manifest, message.taskId);
|
|
797
|
+
else ensureRunMailbox(manifest);
|
|
798
|
+
const createdAt = new Date().toISOString();
|
|
799
|
+
const complete = buildMailboxMessage(manifest, message, createdAt);
|
|
752
800
|
// ST-3: collapse to ONE lock namespace (.flock) for ALL mailbox-file
|
|
753
801
|
// operations — sync append, async append, and full-file rewrite. Previously
|
|
754
802
|
// this used withEventLogLockSync (.mkdirlock) while async append used an
|
|
@@ -846,39 +894,11 @@ export function appendFollowUpMessage(
|
|
|
846
894
|
* steering/follow-up delivery. readDeliveryState/writeDeliveryState remain
|
|
847
895
|
* sync but are cheap thanks to the FIND-01 delivery cache.
|
|
848
896
|
*/
|
|
849
|
-
export async function appendMailboxMessageAsync(
|
|
850
|
-
manifest: TeamRunManifest,
|
|
851
|
-
message: Omit<MailboxMessage, "id" | "runId" | "createdAt" | "status"> & {
|
|
852
|
-
id?: string;
|
|
853
|
-
status?: MailboxMessageStatus;
|
|
854
|
-
},
|
|
855
|
-
): Promise<MailboxMessage> {
|
|
897
|
+
export async function appendMailboxMessageAsync(manifest: TeamRunManifest, message: MailboxAppendInput): Promise<MailboxMessage> {
|
|
856
898
|
if (message.taskId) ensureTaskMailbox(manifest, message.taskId);
|
|
857
899
|
else ensureRunMailbox(manifest);
|
|
858
900
|
const createdAt = new Date().toISOString();
|
|
859
|
-
const complete
|
|
860
|
-
// RR-021 WI-4.3j: randomUUID instead of Date.now()+Math.random() —
|
|
861
|
-
// collision-free under the msg_ prefix, no clock-ordering leakage.
|
|
862
|
-
id: message.id ?? `msg_${randomUUID()}`,
|
|
863
|
-
runId: manifest.runId,
|
|
864
|
-
direction: message.direction,
|
|
865
|
-
from: message.from,
|
|
866
|
-
to: message.to,
|
|
867
|
-
body: message.body,
|
|
868
|
-
createdAt,
|
|
869
|
-
status: message.status ?? "queued",
|
|
870
|
-
kind: message.kind,
|
|
871
|
-
priority: message.priority,
|
|
872
|
-
deliveryMode: message.deliveryMode,
|
|
873
|
-
taskId: message.taskId,
|
|
874
|
-
questionId: message.questionId,
|
|
875
|
-
data: message.data,
|
|
876
|
-
replyTo: message.replyTo,
|
|
877
|
-
replyFrom: message.replyFrom,
|
|
878
|
-
replyDeadline: message.replyDeadline,
|
|
879
|
-
repliedAt: message.repliedAt,
|
|
880
|
-
replyContent: message.replyContent,
|
|
881
|
-
};
|
|
901
|
+
const complete = buildMailboxMessage(manifest, message, createdAt);
|
|
882
902
|
const mbFile = mailboxFile(manifest, complete.direction, complete.taskId);
|
|
883
903
|
await withFileLockAsync(mbFile, async () => {
|
|
884
904
|
await fs.promises.appendFile(mbFile, `${JSON.stringify(redactSecrets(complete))}\n`, "utf-8");
|
|
@@ -955,6 +975,126 @@ export async function appendFollowUpMessageAsync(
|
|
|
955
975
|
});
|
|
956
976
|
}
|
|
957
977
|
|
|
978
|
+
// ============================================================================
|
|
979
|
+
// U5 (2026-10-10): requestId idempotency — every send carries a unique
|
|
980
|
+
// requestId; resend-safe senders reuse the SAME one and are deduped per
|
|
981
|
+
// target mailbox by (direction, requestId) via an in-lock row scan (the
|
|
982
|
+
// durable analog of pi-durable's `tx.submissionByRequest()` exactly-once
|
|
983
|
+
// root-submit, per upgrade-spec U5).
|
|
984
|
+
// ============================================================================
|
|
985
|
+
|
|
986
|
+
export interface IdempotentMailboxAppendResult {
|
|
987
|
+
message: MailboxMessage;
|
|
988
|
+
/** true when an existing (direction, requestId) row in the SAME mailbox
|
|
989
|
+
* file was reused — NO new row, NO delivery-state write, NO observer
|
|
990
|
+
* notification (the original append already did all three). */
|
|
991
|
+
deduped: boolean;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/** Row-level (direction, requestId) match against one mailbox file — the
|
|
995
|
+
* same keying findMailboxMessageByRequestId exposes. Checks the U5
|
|
996
|
+
* top-level field and the legacy `data.requestId` shape (group_join). */
|
|
997
|
+
function findRequestIdRowInFile(filePath: string, direction: MailboxDirection, requestId: string): MailboxMessage | undefined {
|
|
998
|
+
return safeReadMailboxFile(filePath, direction).find(
|
|
999
|
+
(message) => message.requestId === requestId || message.data?.requestId === requestId,
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function recordDeliveryEntry(manifest: TeamRunManifest, complete: MailboxMessage, createdAt: string): void {
|
|
1004
|
+
withFileLockSync(deliveryFile(manifest, true), () => {
|
|
1005
|
+
const delivery = readDeliveryState(manifest);
|
|
1006
|
+
delivery.messages[complete.id] = complete.status;
|
|
1007
|
+
delivery.updatedAt = createdAt;
|
|
1008
|
+
writeDeliveryState(manifest, delivery);
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/** U5: idempotent SYNC append — if the target mailbox file already holds a
|
|
1013
|
+
* row with this (direction, requestId), return that row (deduped) without
|
|
1014
|
+
* writing anything. The check-then-append runs INSIDE the mailbox file lock
|
|
1015
|
+
* so two concurrent sends with the same requestId cannot both append. */
|
|
1016
|
+
export function appendMailboxMessageIdempotent(
|
|
1017
|
+
manifest: TeamRunManifest,
|
|
1018
|
+
message: MailboxAppendInput & { requestId: string },
|
|
1019
|
+
): IdempotentMailboxAppendResult {
|
|
1020
|
+
if (message.taskId) ensureTaskMailbox(manifest, message.taskId);
|
|
1021
|
+
else ensureRunMailbox(manifest);
|
|
1022
|
+
const createdAt = new Date().toISOString();
|
|
1023
|
+
const complete = buildMailboxMessage(manifest, message, createdAt);
|
|
1024
|
+
const mbFile = mailboxFile(manifest, complete.direction, complete.taskId);
|
|
1025
|
+
let stored: MailboxMessage | undefined;
|
|
1026
|
+
withFileLockSync(mbFile, () => {
|
|
1027
|
+
const existing = findRequestIdRowInFile(mbFile, complete.direction, message.requestId);
|
|
1028
|
+
if (existing) {
|
|
1029
|
+
stored = existing;
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
fs.appendFileSync(mbFile, `${JSON.stringify(redactSecrets(complete))}\n`, "utf-8");
|
|
1033
|
+
rotateMailboxFileIfNeeded(mbFile);
|
|
1034
|
+
});
|
|
1035
|
+
if (stored) return { message: stored, deduped: true };
|
|
1036
|
+
recordDeliveryEntry(manifest, complete, createdAt);
|
|
1037
|
+
notifyMailboxAppended(complete);
|
|
1038
|
+
return { message: complete, deduped: false };
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/** U5: async twin of appendMailboxMessageIdempotent for the broker fan-out
|
|
1042
|
+
* path (withFileLockAsync — promise-chain, no sleepSync stall). The
|
|
1043
|
+
* delivery RMW keeps the cross-process sync lock, mirroring
|
|
1044
|
+
* appendMailboxMessageAsync. */
|
|
1045
|
+
export async function appendMailboxMessageIdempotentAsync(
|
|
1046
|
+
manifest: TeamRunManifest,
|
|
1047
|
+
message: MailboxAppendInput & { requestId: string },
|
|
1048
|
+
): Promise<IdempotentMailboxAppendResult> {
|
|
1049
|
+
if (message.taskId) ensureTaskMailbox(manifest, message.taskId);
|
|
1050
|
+
else ensureRunMailbox(manifest);
|
|
1051
|
+
const createdAt = new Date().toISOString();
|
|
1052
|
+
const complete = buildMailboxMessage(manifest, message, createdAt);
|
|
1053
|
+
const mbFile = mailboxFile(manifest, complete.direction, complete.taskId);
|
|
1054
|
+
let stored: MailboxMessage | undefined;
|
|
1055
|
+
await withFileLockAsync(mbFile, async () => {
|
|
1056
|
+
const existing = findRequestIdRowInFile(mbFile, complete.direction, message.requestId);
|
|
1057
|
+
if (existing) {
|
|
1058
|
+
stored = existing;
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
await fs.promises.appendFile(mbFile, `${JSON.stringify(redactSecrets(complete))}\n`, "utf-8");
|
|
1062
|
+
rotateMailboxFileIfNeeded(mbFile);
|
|
1063
|
+
});
|
|
1064
|
+
if (stored) return { message: stored, deduped: true };
|
|
1065
|
+
recordDeliveryEntry(manifest, complete, createdAt);
|
|
1066
|
+
notifyMailboxAppended(complete);
|
|
1067
|
+
return { message: complete, deduped: false };
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/** U5: durable receiver-side dedup record — the worker inbox pickup marks the
|
|
1071
|
+
* messages it surfaced as consumed: `messages[id] = "acknowledged"` (the F09
|
|
1072
|
+
* durable record; the inbox line itself never flips status) plus the
|
|
1073
|
+
* `${direction}:${requestId}` index. Both survive restart, so a later poll
|
|
1074
|
+
* (fresh process, empty seen-set) still drops them. */
|
|
1075
|
+
export interface MailboxRequestIdDeliveryRecord {
|
|
1076
|
+
direction: MailboxDirection;
|
|
1077
|
+
messageId: string;
|
|
1078
|
+
/** Optional: rows without a requestId (pre-U5 legacy) only get the
|
|
1079
|
+
* by-id acknowledged record. */
|
|
1080
|
+
requestId?: string;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
export function recordMailboxRequestIdDelivery(manifest: TeamRunManifest, records: MailboxRequestIdDeliveryRecord[]): MailboxDeliveryState {
|
|
1084
|
+
return withFileLockSync(deliveryFile(manifest, true), () => {
|
|
1085
|
+
const delivery = readDeliveryState(manifest);
|
|
1086
|
+
for (const record of records) {
|
|
1087
|
+
delivery.messages[record.messageId] = "acknowledged";
|
|
1088
|
+
if (record.requestId) (delivery.requestIds ??= {})[`${record.direction}:${record.requestId}`] = record.messageId;
|
|
1089
|
+
}
|
|
1090
|
+
delivery.updatedAt = new Date().toISOString();
|
|
1091
|
+
// Pickup is terminal for that message on the worker side — full durability
|
|
1092
|
+
// (same rationale as acknowledgeMailboxMessage).
|
|
1093
|
+
writeDeliveryState(manifest, delivery, { durability: "full" });
|
|
1094
|
+
return delivery;
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
|
|
958
1098
|
export function listMailboxByKind(manifest: TeamRunManifest, kind: MailboxMessageKind, direction?: MailboxDirection): MailboxMessage[] {
|
|
959
1099
|
const messages = direction
|
|
960
1100
|
? readAllMessages(manifest, direction)
|
|
@@ -964,8 +1104,16 @@ export function listMailboxByKind(manifest: TeamRunManifest, kind: MailboxMessag
|
|
|
964
1104
|
return messages.filter((message) => message.kind === kind || message.data?.kind === kind);
|
|
965
1105
|
}
|
|
966
1106
|
|
|
967
|
-
export function findMailboxMessageByRequestId(
|
|
968
|
-
|
|
1107
|
+
export function findMailboxMessageByRequestId(
|
|
1108
|
+
manifest: TeamRunManifest,
|
|
1109
|
+
requestId: string,
|
|
1110
|
+
scope?: { direction?: MailboxDirection; taskId?: string },
|
|
1111
|
+
): MailboxMessage | undefined {
|
|
1112
|
+
// U5: also match the top-level `requestId` field (new sends) alongside the
|
|
1113
|
+
// legacy `data.requestId` shape (group_join) so one lookup covers both.
|
|
1114
|
+
const matches = (message: MailboxMessage) => message.requestId === requestId || message.data?.requestId === requestId;
|
|
1115
|
+
if (scope) return readMailbox(manifest, scope.direction, scope.taskId).find(matches);
|
|
1116
|
+
return readMailbox(manifest).find(matches);
|
|
969
1117
|
}
|
|
970
1118
|
|
|
971
1119
|
export function readMailboxMessage(manifest: TeamRunManifest, messageId: string): MailboxMessage | undefined {
|