@gajae-code/coding-agent 0.12.19 → 0.12.21
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 +27 -9
- package/dist/types/commands/gc.d.ts +4 -0
- package/dist/types/commands/sdk.d.ts +66 -0
- package/dist/types/config/settings-schema.d.ts +34 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +97 -1
- package/dist/types/modes/acp/acp-agent.d.ts +33 -1
- package/dist/types/modes/components/bash-execution.d.ts +7 -0
- package/dist/types/modes/components/eval-execution.d.ts +7 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +10 -1
- package/dist/types/sdk/acp/adapter.d.ts +9 -17
- package/dist/types/sdk/acp/index.d.ts +1 -1
- package/dist/types/sdk/broker/broker.d.ts +93 -1
- package/dist/types/sdk/broker/ensure.d.ts +17 -0
- package/dist/types/sdk/broker/index.d.ts +1 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +10 -1
- package/dist/types/sdk/broker/lifecycle.d.ts +81 -2
- package/dist/types/sdk/broker/startup-budget.d.ts +44 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +11 -1
- package/dist/types/sdk/bus/discord-daemon.d.ts +1 -0
- package/dist/types/sdk/bus/index.d.ts +20 -9
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -6
- package/dist/types/sdk/bus/slack-daemon.d.ts +2 -2
- package/dist/types/sdk/host/query/revision-store.d.ts +5 -0
- package/dist/types/sdk/host/session-runtime.d.ts +4 -0
- package/dist/types/sdk/lifecycle-session.d.ts +13 -2
- package/dist/types/sdk/prompt-failure.d.ts +13 -0
- package/dist/types/sdk/prompt-watchdog.d.ts +82 -0
- package/dist/types/sdk/session-reconnect.d.ts +20 -0
- package/dist/types/sdk/startup-capability.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +14 -4
- package/dist/types/session/blob-store.d.ts +32 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +40 -2
- package/dist/types/session/internal/session-memory-sidecar.d.ts +881 -0
- package/dist/types/session/session-manager.d.ts +302 -27
- package/dist/types/session/session-storage.d.ts +170 -0
- package/dist/types/task/executor.d.ts +2 -1
- package/dist/types/tools/bash.d.ts +1 -0
- package/dist/types/tools/todo-contract.d.ts +10 -1
- package/dist/types/tui/output-block.d.ts +6 -0
- package/package.json +8 -8
- package/src/cli/args.ts +75 -34
- package/src/commands/acp.ts +7 -1
- package/src/commands/gc.ts +31 -2
- package/src/commands/sdk.ts +141 -7
- package/src/config/settings-schema.ts +43 -0
- package/src/coordinator-mcp/server.ts +6 -1
- package/src/export/html/index.ts +83 -39
- package/src/gjc-runtime/gc-runtime.ts +1283 -4
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/internal-urls/local-protocol.ts +14 -0
- package/src/main.ts +40 -9
- package/src/modes/acp/acp-agent.ts +564 -37
- package/src/modes/acp/terminal-auth.ts +2 -2
- package/src/modes/components/bash-execution.ts +14 -0
- package/src/modes/components/eval-execution.ts +14 -0
- package/src/modes/controllers/command-controller.ts +63 -8
- package/src/modes/utils/ui-helpers.ts +81 -11
- package/src/sdk/acp/adapter.ts +46 -45
- package/src/sdk/acp/index.ts +1 -1
- package/src/sdk/broker/broker.ts +392 -11
- package/src/sdk/broker/ensure.ts +131 -61
- package/src/sdk/broker/index.ts +1 -0
- package/src/sdk/broker/lifecycle-ledger.ts +59 -5
- package/src/sdk/broker/lifecycle.ts +278 -63
- package/src/sdk/broker/startup-budget.ts +103 -0
- package/src/sdk/bus/chat-daemon-control.ts +9 -3
- package/src/sdk/bus/chat-daemon-runtime.ts +565 -29
- package/src/sdk/bus/conversation-store.ts +1 -1
- package/src/sdk/bus/discord-daemon.ts +2 -1
- package/src/sdk/bus/discord-live-provider.ts +34 -6
- package/src/sdk/bus/index.ts +118 -20
- package/src/sdk/bus/kind-aware-reconciliation.ts +2 -1
- package/src/sdk/bus/prompt-reconciliation.ts +4 -9
- package/src/sdk/bus/slack-daemon.ts +91 -18
- package/src/sdk/cli/session-cli.ts +8 -1
- package/src/sdk/host/query/handlers.ts +1 -1
- package/src/sdk/host/query/revision-store.ts +48 -12
- package/src/sdk/host/session-runtime.test.ts +50 -0
- package/src/sdk/host/session-runtime.ts +12 -2
- package/src/sdk/lifecycle-session.ts +26 -3
- package/src/sdk/mcp/server.ts +6 -1
- package/src/sdk/prompt-failure.ts +42 -0
- package/src/sdk/prompt-watchdog.ts +162 -0
- package/src/sdk/session-reconnect.ts +43 -0
- package/src/sdk/startup-capability.ts +13 -4
- package/src/session/agent-session.ts +541 -226
- package/src/session/blob-store.ts +89 -0
- package/src/session/internal/managed-session-scope.ts +36 -26
- package/src/session/internal/managed-session-storage.ts +303 -32
- package/src/session/internal/session-memory-sidecar.ts +2160 -0
- package/src/session/session-manager.ts +7057 -399
- package/src/session/session-storage.ts +1134 -11
- package/src/task/executor.ts +17 -4
- package/src/tools/bash.ts +109 -7
- package/src/tools/descriptors.test.ts +51 -0
- package/src/tools/find.ts +55 -26
- package/src/tools/todo-contract.ts +82 -13
- package/src/tools/todo-write.ts +2 -5
- package/src/tui/output-block.ts +11 -1
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import * as fs from "node:fs/promises";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import { logger } from "@gajae-code/utils";
|
|
4
5
|
import { type IndexedSession, SessionIndex } from "../broker/session-index";
|
|
6
|
+
import { lifecycleRequestTimeoutMs } from "../broker/startup-budget";
|
|
5
7
|
import { SdkClient, SdkClientError } from "../client/client";
|
|
6
8
|
import { readSdkBrokerDiscovery, readSdkSessionEndpoint, type SdkSessionEndpoint } from "../client/discovery";
|
|
7
9
|
import { SESSION_PREPARED_EVENT } from "../host/host";
|
|
10
|
+
import { ACP_SESSION_RECONNECT } from "../session-reconnect";
|
|
8
11
|
|
|
9
12
|
import { createDiscordAdapter, createSlackAdapter } from "./chat-adapters";
|
|
10
13
|
import { type ChatTransport, projectChatCommandOutcome, sendAuthorizedChatOperation } from "./chat-command-policy";
|
|
@@ -36,7 +39,15 @@ export interface ChatDaemonRuntimeConfig {
|
|
|
36
39
|
|
|
37
40
|
export interface ChatDaemonSdkClient {
|
|
38
41
|
onFrame(handler: (frame: Record<string, unknown>) => void): () => void;
|
|
39
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Announces that a replacement socket is live. Optional because command-scoped
|
|
44
|
+
* clients (the broker client, one-shot doubles) never outlive one socket, while
|
|
45
|
+
* every long-lived attachment's client carries it.
|
|
46
|
+
*/
|
|
47
|
+
onReconnect?(handler: () => void): () => void;
|
|
48
|
+
/** Re-dials a retired socket; a no-op on a live one. */
|
|
49
|
+
connect?(): Promise<void>;
|
|
50
|
+
request(frame: Record<string, unknown>, options?: { timeoutMs?: number }): Promise<Record<string, unknown>>;
|
|
40
51
|
close(): Promise<void>;
|
|
41
52
|
send(frame: Record<string, unknown>): void;
|
|
42
53
|
}
|
|
@@ -77,15 +88,107 @@ export interface ChatDaemonRuntimeDeps {
|
|
|
77
88
|
clearInterval?: typeof clearInterval;
|
|
78
89
|
}
|
|
79
90
|
|
|
91
|
+
/** One live frame held behind a replay barrier, kept with the sequence that orders it. */
|
|
92
|
+
type HeldFrame = Readonly<{ seq: number; frame: Record<string, unknown> }>;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Which producer handed a frame to delivery.
|
|
96
|
+
*
|
|
97
|
+
* A reconnect has two at once — the replacement socket, whose subscription never
|
|
98
|
+
* stopped, and the replay answer — and only the socket's frames may be held: the
|
|
99
|
+
* barrier drains by re-entering delivery with the frames it holds, and holding those
|
|
100
|
+
* a second time would never publish anything.
|
|
101
|
+
*/
|
|
102
|
+
type FrameOrigin = "live" | "ordered";
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Ingress fence one attachment applies while its replay is outstanding.
|
|
106
|
+
*
|
|
107
|
+
* `held` is non-undefined exactly while a replay round owns this attachment's live
|
|
108
|
+
* ingress, and the array identity is that round's ticket: a later round or a disposal
|
|
109
|
+
* installs or revokes its own, so an earlier round can tell it no longer owns the
|
|
110
|
+
* buffer instead of stranding — or publishing — someone else's frames.
|
|
111
|
+
*/
|
|
112
|
+
type ReplayBarrier = {
|
|
113
|
+
held: HeldFrame[] | undefined;
|
|
114
|
+
detached: boolean;
|
|
115
|
+
/**
|
|
116
|
+
* Set when a round could not close the gap it fenced — the replay went
|
|
117
|
+
* unanswered, or the hold buffer overflowed. The attachment stops delivering
|
|
118
|
+
* from that point on, because lifting the fence instead would let the next live
|
|
119
|
+
* frame drag the cursor over sequences nobody ever delivered.
|
|
120
|
+
*/
|
|
121
|
+
failed: boolean;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* How many live frames one attachment holds while its replay is outstanding.
|
|
126
|
+
*
|
|
127
|
+
* The window is a single round trip on an open socket, so the bound is only there
|
|
128
|
+
* to keep a flooding session from growing the buffer without limit. Overflow is
|
|
129
|
+
* barrier failure rather than an eviction: the oldest held frame is the one the
|
|
130
|
+
* cursor needs next, and dropping the newest still leaves a hole no drain can
|
|
131
|
+
* close, so the attachment is rebuilt from its cursor and its replay re-fetches
|
|
132
|
+
* the whole gap instead.
|
|
133
|
+
*/
|
|
134
|
+
const REPLAY_BARRIER_LIMIT = 1_024;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* How many times one round re-issues a refused replay, and the backoff it doubles
|
|
138
|
+
* from.
|
|
139
|
+
*
|
|
140
|
+
* A socket that never dropped sends no hello, so nothing but this round can
|
|
141
|
+
* re-issue the replay it owes — `client.onReconnect` fires only for a new
|
|
142
|
+
* connection id. The retry therefore rides the same live socket, and the budget is
|
|
143
|
+
* bounded so a host that never serves the gap ends in a rebuild rather than a
|
|
144
|
+
* barrier that fences ingress forever.
|
|
145
|
+
*/
|
|
146
|
+
const REPLAY_RETRY_ATTEMPTS = 3;
|
|
147
|
+
const REPLAY_RETRY_BACKOFF_MS = 100;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* How many rounds re-serve one sequence whose publication failed before it is conceded.
|
|
151
|
+
*
|
|
152
|
+
* A refused publication holds the cursor below its frame, and the rebuild that follows
|
|
153
|
+
* re-serves exactly that frame. A surface that is down for good would repeat that
|
|
154
|
+
* forever and deliver nothing else, so the rounds are bounded: past this count the frame
|
|
155
|
+
* is conceded the way a retention gap is, and the stream resumes above it.
|
|
156
|
+
*/
|
|
157
|
+
const DELIVERY_ATTEMPT_LIMIT = 3;
|
|
158
|
+
|
|
80
159
|
type AttachedSession = Readonly<{
|
|
81
160
|
id: string;
|
|
82
161
|
sessionId: string;
|
|
83
162
|
endpoint: SdkSessionEndpoint;
|
|
84
163
|
generation: number;
|
|
85
164
|
client: ChatDaemonSdkClient;
|
|
165
|
+
/**
|
|
166
|
+
* How far this attachment has consumed its session's event stream. The record is
|
|
167
|
+
* frozen; this cursor is the one thing that must keep moving with delivery,
|
|
168
|
+
* because a reconnect resumes from exactly here.
|
|
169
|
+
*/
|
|
170
|
+
cursor: { seq: number };
|
|
171
|
+
/**
|
|
172
|
+
* Ordering fence between the two producers a reconnect creates. Frozen with the
|
|
173
|
+
* record like the cursor; only its own fields move.
|
|
174
|
+
*/
|
|
175
|
+
barrier: ReplayBarrier;
|
|
86
176
|
dispose: () => void;
|
|
87
177
|
}>;
|
|
88
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Default transport for one attached session.
|
|
181
|
+
*
|
|
182
|
+
* The attachment is long-lived — its frame subscription survives until an
|
|
183
|
+
* explicit detach or endpoint roll — and it sits under the host's liveness
|
|
184
|
+
* reaper, which drops any session that has not ponged within HEARTBEAT_TTL_MS.
|
|
185
|
+
* The transport's one-shot defaults give up after 175ms, so every stall the host
|
|
186
|
+
* reaps would be unrecoverable; the shared session budget outlives that TTL.
|
|
187
|
+
*/
|
|
188
|
+
async function connectAttachedSession(endpoint: SdkSessionEndpoint): Promise<ChatDaemonSdkClient> {
|
|
189
|
+
return await SdkClient.connect(endpoint.url, endpoint.token, { ...ACP_SESSION_RECONNECT });
|
|
190
|
+
}
|
|
191
|
+
|
|
89
192
|
/**
|
|
90
193
|
* The lifecycle signals that decide whether a chat root exists at all. They are
|
|
91
194
|
* the only event names that close, resume, or withhold a session's publication,
|
|
@@ -143,6 +246,38 @@ function readGeneration(value: unknown): number | undefined {
|
|
|
143
246
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
144
247
|
}
|
|
145
248
|
|
|
249
|
+
function readSequence(value: unknown): number | undefined {
|
|
250
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 ? value : undefined;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* What one `event_replay_result` states about the range it could not answer.
|
|
255
|
+
*
|
|
256
|
+
* A gap is the host's own admission, not an inference this side draws:
|
|
257
|
+
* `sequence_gap` names sequences its ring has already evicted, and
|
|
258
|
+
* `generation_reset` says the stream this cursor belongs to no longer exists.
|
|
259
|
+
* Reading it whole — rather than treating every truthy `gap` alike — is what
|
|
260
|
+
* keeps a bounded, nameable loss apart from an answer nothing can be concluded
|
|
261
|
+
* from, and the two owe opposite responses.
|
|
262
|
+
*/
|
|
263
|
+
type ReplayGap =
|
|
264
|
+
| Readonly<{ kind: "generation_reset"; toGeneration: number }>
|
|
265
|
+
| Readonly<{ kind: "sequence_gap"; fromSeq: number; toSeq: number }>;
|
|
266
|
+
|
|
267
|
+
function readReplayGap(value: unknown): ReplayGap | undefined {
|
|
268
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
269
|
+
const gap = value as Record<string, unknown>;
|
|
270
|
+
if (gap.kind === "generation_reset") {
|
|
271
|
+
const toGeneration = readGeneration(gap.toGeneration);
|
|
272
|
+
return toGeneration === undefined ? undefined : { kind: "generation_reset", toGeneration };
|
|
273
|
+
}
|
|
274
|
+
if (gap.kind !== "sequence_gap") return undefined;
|
|
275
|
+
const fromSeq = readSequence(gap.fromSeq);
|
|
276
|
+
const toSeq = readSequence(gap.toSeq);
|
|
277
|
+
if (fromSeq === undefined || toSeq === undefined || toSeq < fromSeq) return undefined;
|
|
278
|
+
return { kind: "sequence_gap", fromSeq, toSeq };
|
|
279
|
+
}
|
|
280
|
+
|
|
146
281
|
/**
|
|
147
282
|
* What one representation of a frame states about an identity it owns.
|
|
148
283
|
*
|
|
@@ -279,6 +414,19 @@ export class ChatDaemonRuntime {
|
|
|
279
414
|
#stopTimer: (() => void) | undefined;
|
|
280
415
|
readonly #pending = new Set<Promise<void>>();
|
|
281
416
|
readonly #frameTails = new Map<string, Promise<void>>();
|
|
417
|
+
/**
|
|
418
|
+
* The frame an attachment failed to publish, with how many rounds have tried it.
|
|
419
|
+
*
|
|
420
|
+
* Keyed by session rather than by attachment because the retire-and-rebuild a refused
|
|
421
|
+
* publication triggers replaces the attachment: the count has to outlive it, or a
|
|
422
|
+
* surface that is down for good would rebuild from the same sequence forever. It does
|
|
423
|
+
* not outlive the stream, though: a sequence names a frame only within one endpoint
|
|
424
|
+
* generation, so a rolled endpoint reopens a sequence space whose first frame nothing
|
|
425
|
+
* has ever been offered. Charging it for what an earlier generation's frame cost would
|
|
426
|
+
* concede it after a single refusal.
|
|
427
|
+
*/
|
|
428
|
+
readonly #undelivered = new Map<string, { generation: number; seq: number; attempts: number }>();
|
|
429
|
+
readonly #reviving = new Set<string>();
|
|
282
430
|
#reconcileTail: Promise<void> = Promise.resolve();
|
|
283
431
|
|
|
284
432
|
#discord: DiscordNotificationDaemon | undefined;
|
|
@@ -460,24 +608,46 @@ export class ChatDaemonRuntime {
|
|
|
460
608
|
const endpointStat = await fs.stat(endpoint.path).catch(() => undefined);
|
|
461
609
|
if (!endpointStat || endpointStat.mtimeMs !== indexed.endpointMtimeMs) return;
|
|
462
610
|
const existing = this.#sessions.get(indexed.sessionId);
|
|
463
|
-
|
|
464
|
-
existing &&
|
|
611
|
+
const resumable =
|
|
612
|
+
existing !== undefined &&
|
|
465
613
|
existing.endpoint.url === endpoint.url &&
|
|
466
614
|
existing.endpoint.token === endpoint.token &&
|
|
467
|
-
existing.generation === indexed.endpointGeneration
|
|
468
|
-
)
|
|
615
|
+
existing.generation === indexed.endpointGeneration;
|
|
616
|
+
if (existing && resumable && !existing.barrier.failed) {
|
|
617
|
+
// The attachment is current, so reconcile's remaining job is keeping its socket
|
|
618
|
+
// dialed: `SdkClient` retires a closed socket and re-dials only on the next
|
|
619
|
+
// request, and a passive subscription issues none.
|
|
620
|
+
this.#reviveTransport(existing);
|
|
469
621
|
return;
|
|
622
|
+
}
|
|
623
|
+
// A failed barrier is rebuilt rather than revived, and its cursor travels into the
|
|
624
|
+
// replacement: same endpoint, same generation, same stream, so the replay this
|
|
625
|
+
// attach issues asks for exactly the gap the failed round left open, and every
|
|
626
|
+
// sequence already published stays below the cursor that drops it. A rolled
|
|
627
|
+
// generation is a different stream, so that fence resets the cursor to zero.
|
|
628
|
+
const resumeSeq = existing && resumable ? existing.cursor.seq : 0;
|
|
470
629
|
if (existing) {
|
|
471
630
|
this.#sessions.delete(indexed.sessionId);
|
|
472
631
|
existing.dispose();
|
|
473
632
|
await existing.client.close();
|
|
474
633
|
}
|
|
475
|
-
const client = await (this.deps.createClient ?? (
|
|
476
|
-
endpoint,
|
|
477
|
-
);
|
|
634
|
+
const client = await (this.deps.createClient ?? connectAttachedSession)(endpoint);
|
|
478
635
|
let attached: AttachedSession | undefined;
|
|
479
|
-
const
|
|
480
|
-
|
|
636
|
+
const barrier: ReplayBarrier = { held: undefined, detached: false, failed: false };
|
|
637
|
+
const disposeFrames = client.onFrame(frame => {
|
|
638
|
+
if (attached) this.schedule(this.enqueueFrame(attached, frame, "live"));
|
|
639
|
+
});
|
|
640
|
+
// The frame subscription is client-scoped and survives the socket, but a
|
|
641
|
+
// replacement socket resumes delivery where the stream stands then. Only a replay
|
|
642
|
+
// from this attachment's cursor closes the gap the drop left behind, and that cursor
|
|
643
|
+
// is read here — synchronously with the hello — so the replay asks for exactly the
|
|
644
|
+
// gap the barrier it opens is holding the replacement socket's frames ahead of.
|
|
645
|
+
//
|
|
646
|
+
// Deliberately outside `#pending`, like `#reviveTransport`: a replay that never
|
|
647
|
+
// answers must not make `stop()` wait for it. Disposal revokes the barrier and
|
|
648
|
+
// closing the client settles the request, so the round ends without publishing.
|
|
649
|
+
const disposeReconnect = client.onReconnect?.(() => {
|
|
650
|
+
if (attached) void this.#replayAttachment(attached, attached.cursor.seq).catch(() => undefined);
|
|
481
651
|
});
|
|
482
652
|
attached = Object.freeze({
|
|
483
653
|
id: randomUUID(),
|
|
@@ -485,7 +655,16 @@ export class ChatDaemonRuntime {
|
|
|
485
655
|
endpoint,
|
|
486
656
|
generation: indexed.endpointGeneration,
|
|
487
657
|
client,
|
|
488
|
-
|
|
658
|
+
cursor: { seq: resumeSeq },
|
|
659
|
+
barrier,
|
|
660
|
+
dispose: () => {
|
|
661
|
+
disposeFrames();
|
|
662
|
+
disposeReconnect?.();
|
|
663
|
+
// A detached attachment's outstanding replay is dead work: revoking the
|
|
664
|
+
// barrier here is what keeps its held frames off the newer attachment.
|
|
665
|
+
barrier.detached = true;
|
|
666
|
+
barrier.held = undefined;
|
|
667
|
+
},
|
|
489
668
|
});
|
|
490
669
|
this.#sessions.set(indexed.sessionId, attached);
|
|
491
670
|
this.#presentation?.connectSession(indexed.sessionId, {
|
|
@@ -495,15 +674,283 @@ export class ChatDaemonRuntime {
|
|
|
495
674
|
attached.client.send({ type: "reply", id: route.actionId, answer: route.answer });
|
|
496
675
|
},
|
|
497
676
|
});
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
677
|
+
await this.#replayAttachment(attached, resumeSeq);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Keep an established attachment's socket dialed.
|
|
682
|
+
*
|
|
683
|
+
* `SdkClient` never opens a replacement socket on its own: it retires the closed
|
|
684
|
+
* incarnation and re-dials on the next `connect`/`request`. A chat attachment is
|
|
685
|
+
* purely passive, so without this probe a transient drop would silently end
|
|
686
|
+
* delivery for good. `connect()` resolves immediately on a live socket, spends the
|
|
687
|
+
* session reconnect budget on a dead one, and — because reconcile only reaches
|
|
688
|
+
* here for an endpoint it has just re-read as current at this generation — never
|
|
689
|
+
* revives an attachment the index has moved on from.
|
|
690
|
+
*/
|
|
691
|
+
#reviveTransport(attached: AttachedSession): void {
|
|
692
|
+
const connect = attached.client.connect?.bind(attached.client);
|
|
693
|
+
if (!connect || this.#reviving.has(attached.id)) return;
|
|
694
|
+
this.#reviving.add(attached.id);
|
|
695
|
+
// Deliberately outside `#pending`: the reconnect budget outlives the heartbeat
|
|
696
|
+
// TTL, and `stop()` must not wait for it. Closing the client aborts it instead.
|
|
697
|
+
void connect()
|
|
698
|
+
.catch(() => undefined)
|
|
699
|
+
.finally(() => this.#reviving.delete(attached.id));
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Issue one replay for `attached` and publish its answer ahead of the live frames
|
|
704
|
+
* that raced it.
|
|
705
|
+
*
|
|
706
|
+
* A reconnect leaves two producers writing one stream: the replacement socket, whose
|
|
707
|
+
* subscription never stopped, and the replay answer. Ingress is therefore fenced for
|
|
708
|
+
* the whole round trip — frames the socket delivers after the hello are held, the
|
|
709
|
+
* replayed events are published first, and the held frames are drained afterwards in
|
|
710
|
+
* sequence order — so delivery follows sequence rather than arrival. Both paths
|
|
711
|
+
* publish through the same cursor, so an event both producers carried is published
|
|
712
|
+
* exactly once. Frames carrying no sequence of this attachment's own cannot be
|
|
713
|
+
* ordered against a replay at all, so they are never held and never delayed.
|
|
714
|
+
*
|
|
715
|
+
* The replay is fenced exactly like `attach()`: it is issued for the attachment's
|
|
716
|
+
* own endpoint generation and from its own cursor, and it is dropped whole if the
|
|
717
|
+
* runtime has since detached, rolled, or superseded this attachment — so a stale
|
|
718
|
+
* incarnation can never resurrect its events onto a newer one's root.
|
|
719
|
+
*
|
|
720
|
+
* A refused replay loses nothing either: the cursor stays where the request was
|
|
721
|
+
* issued from, the held frames stay held, and the round re-issues on the socket
|
|
722
|
+
* it already has. When that budget runs out the barrier fails whole and the
|
|
723
|
+
* attachment is rebuilt from its cursor. A replay that reports a retention gap
|
|
724
|
+
* is not a round to retry: the sequences it names are evicted at the host, so no
|
|
725
|
+
* rebuild can re-fetch them. That round concedes them instead — loudly — carries
|
|
726
|
+
* the cursor over them, and publishes the suffix the host did keep, so the gap
|
|
727
|
+
* costs exactly the sequences the host lost and nothing that follows them. The
|
|
728
|
+
* concession is what moves the cursor, not the suffix: a host that kept every
|
|
729
|
+
* sequence above the gap can still answer with none of them, and a cursor left
|
|
730
|
+
* below the lost range would concede it again on every later replay. A gap is
|
|
731
|
+
* conceded only where it answers this round's own request: one that opens above
|
|
732
|
+
* the cursor, or that the same answer returns retained events from, states no
|
|
733
|
+
* bounded loss and is fenced like any other answer nothing can be concluded from.
|
|
734
|
+
* What the host evicted is not what delivery lost: a sequence in the conceded
|
|
735
|
+
* range that the replacement socket already carried is held by this round's
|
|
736
|
+
* barrier, so it is published before the cursor steps over the rest.
|
|
737
|
+
*/
|
|
738
|
+
async #replayAttachment(attached: AttachedSession, sinceSeq: number): Promise<void> {
|
|
739
|
+
if (!this.#attachmentLive(attached)) return;
|
|
740
|
+
const held: HeldFrame[] = [];
|
|
741
|
+
attached.barrier.held = held;
|
|
742
|
+
try {
|
|
743
|
+
let replay: Record<string, unknown>;
|
|
744
|
+
for (let attempt = 0; ; attempt++) {
|
|
745
|
+
try {
|
|
746
|
+
replay = await attached.client.request({
|
|
747
|
+
type: "event_replay",
|
|
748
|
+
sinceGeneration: attached.generation,
|
|
749
|
+
sinceSeq,
|
|
750
|
+
});
|
|
751
|
+
break;
|
|
752
|
+
} catch {
|
|
753
|
+
// An unanswered replay is a transport failure, not a delivery decision, and
|
|
754
|
+
// the gap it owed is still owed: the cursor stays at `sinceSeq` and the held
|
|
755
|
+
// frames stay held, so the retry asks for exactly the same gap. It is issued
|
|
756
|
+
// on this attachment's own live socket, which is the only producer that can
|
|
757
|
+
// close the gap when no hello is coming.
|
|
758
|
+
if (attempt >= REPLAY_RETRY_ATTEMPTS) {
|
|
759
|
+
this.#failBarrier(attached, "replay went unanswered");
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
await Bun.sleep(REPLAY_RETRY_BACKOFF_MS * 2 ** attempt);
|
|
763
|
+
// A later round or a disposal may have taken this attachment's ingress over
|
|
764
|
+
// while the retry waited. That round owes the same gap from the same cursor,
|
|
765
|
+
// so this one stands down instead of asking for it twice.
|
|
766
|
+
if (attached.barrier.held !== held) return;
|
|
767
|
+
if (!this.#attachmentLive(attached)) return;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
if (attached.barrier.held !== held) return;
|
|
771
|
+
if (!this.#attachmentLive(attached)) return;
|
|
772
|
+
// The answer rides the same socket as the live frames that preceded it, but
|
|
773
|
+
// ingress is a queue: a frame this socket already delivered can still be in
|
|
774
|
+
// flight when the answer resolves, and the hold buffer does not carry it until
|
|
775
|
+
// it lands. What live delivery already carried is therefore only readable once
|
|
776
|
+
// that queue has caught up, so the round joins it before reading its own
|
|
777
|
+
// answer — otherwise a conceded range would step the cursor over a sequence
|
|
778
|
+
// still on its way into the buffer, and that frame would be dropped as already
|
|
779
|
+
// delivered by a cursor no producer ever published it past.
|
|
780
|
+
await this.#frameTails.get(attached.sessionId)?.catch(() => undefined);
|
|
781
|
+
if (attached.barrier.held !== held) return;
|
|
782
|
+
if (!this.#attachmentLive(attached)) return;
|
|
783
|
+
// The answer's own events are read before anything is concluded from its gap: a
|
|
784
|
+
// concession is only readable against the suffix it arrived with.
|
|
785
|
+
const events = Array.isArray(replay.events)
|
|
786
|
+
? replay.events.filter(
|
|
787
|
+
(event): event is Record<string, unknown> =>
|
|
788
|
+
!!event && typeof event === "object" && !Array.isArray(event),
|
|
789
|
+
)
|
|
790
|
+
: [];
|
|
791
|
+
if (replay.gap !== undefined) {
|
|
792
|
+
// The host answered, and its answer says part of what this cursor asked for
|
|
793
|
+
// is gone for good. Neither a retry nor a rebuild can recover an evicted
|
|
794
|
+
// sequence, so refusing to publish would trade a bounded loss for a total
|
|
795
|
+
// outage on a healthy socket, and the 2-second reconcile would rebuild into
|
|
796
|
+
// the same gap forever. Concede it instead — loudly — and publish the suffix
|
|
797
|
+
// the host did keep, so the gap costs exactly the sequences the host lost and
|
|
798
|
+
// nothing that follows them.
|
|
799
|
+
const gap = readReplayGap(replay.gap);
|
|
800
|
+
if (!gap) {
|
|
801
|
+
this.#failBarrier(attached, "replay reported a gap it did not state");
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
if (gap.kind === "generation_reset") {
|
|
805
|
+
// A reset stream shares no sequence space with this cursor, so nothing in
|
|
806
|
+
// the answer can be ordered against it. Reconcile rebuilds against the
|
|
807
|
+
// generation the index publishes now, which is a different fence entirely.
|
|
808
|
+
this.#failBarrier(attached, `replay reported a generation reset to ${gap.toGeneration}`);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
// A gap is a statement about this round's own request, so it is conceded only
|
|
812
|
+
// where it answers one: it must open at the first sequence this round asked
|
|
813
|
+
// for, and the suffix the host kept must sit entirely above it. A range this
|
|
814
|
+
// cursor never asked about, or one the same answer returns events from, states
|
|
815
|
+
// no bounded loss at all, and conceding it would fence off sequences the host
|
|
816
|
+
// still holds — the one thing a retention gap must never cost.
|
|
817
|
+
if (gap.fromSeq !== sinceSeq + 1) {
|
|
818
|
+
this.#failBarrier(
|
|
819
|
+
attached,
|
|
820
|
+
`replay conceded sequences ${gap.fromSeq}-${gap.toSeq} for a request that resumed from seq ${sinceSeq}`,
|
|
821
|
+
);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
const retained = events
|
|
825
|
+
.map(event => readSequence(event.seq))
|
|
826
|
+
.find(seq => seq !== undefined && seq <= gap.toSeq);
|
|
827
|
+
if (retained !== undefined) {
|
|
828
|
+
this.#failBarrier(
|
|
829
|
+
attached,
|
|
830
|
+
`replay conceded sequences ${gap.fromSeq}-${gap.toSeq} while returning seq ${retained}`,
|
|
831
|
+
);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
// A sequence the host lost from its ring can still have arrived over the
|
|
835
|
+
// replacement socket and be sitting in this round's hold buffer: the two
|
|
836
|
+
// producers fail independently, so eviction at the host says nothing about
|
|
837
|
+
// what live delivery already carried. Those frames are published here — in
|
|
838
|
+
// sequence order, ahead of the retained suffix that sits above them — and
|
|
839
|
+
// leave the buffer, so the concession costs only the sequences neither
|
|
840
|
+
// producer holds and the drain cannot re-offer one the cursor just passed.
|
|
841
|
+
const recovered = held.filter(entry => entry.seq <= gap.toSeq).sort((left, right) => left.seq - right.seq);
|
|
842
|
+
const carried = held.filter(entry => entry.seq > gap.toSeq);
|
|
843
|
+
held.splice(0, held.length, ...carried);
|
|
844
|
+
const recoveredNote =
|
|
845
|
+
recovered.length > 0 ? `, ${recovered.length} of them recovered from live delivery` : "";
|
|
846
|
+
logger.warn(
|
|
847
|
+
`chat daemon replay conceded a retention gap (sequences ${gap.fromSeq}-${gap.toSeq} are gone from the host${recoveredNote}); session ${attached.sessionId} generation ${attached.generation} resumes at seq ${gap.toSeq + 1}.`,
|
|
848
|
+
);
|
|
849
|
+
for (const entry of recovered) await this.enqueueFrame(attached, entry.frame, "ordered");
|
|
850
|
+
// The concession itself carries the cursor over the rest of the lost range,
|
|
851
|
+
// because the retained suffix cannot be trusted to do it: `SessionSdkHost`
|
|
852
|
+
// drops capability-gated kinds from every replay answer, so a host that still
|
|
853
|
+
// holds every sequence above the gap can legitimately answer with none of
|
|
854
|
+
// them. A cursor left below a range no host can re-serve would resume the
|
|
855
|
+
// next replay from the same evicted sequence and concede the same permanent
|
|
856
|
+
// loss on every reconnect, forever.
|
|
857
|
+
if (gap.toSeq > attached.cursor.seq) attached.cursor.seq = gap.toSeq;
|
|
858
|
+
}
|
|
859
|
+
for (const event of events) await this.enqueueFrame(attached, event, "ordered");
|
|
860
|
+
await this.#drainHeldFrames(attached, held);
|
|
861
|
+
} finally {
|
|
862
|
+
// Only this round's own buffer is revoked: a later round or a disposal may have
|
|
863
|
+
// installed its own, and clearing that one would strand the frames it holds.
|
|
864
|
+
if (attached.barrier.held === held) attached.barrier.held = undefined;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Whether `attached` is still the incarnation this runtime publishes through.
|
|
870
|
+
*
|
|
871
|
+
* Detachment, supersession, and a barrier that could not close its gap all retire
|
|
872
|
+
* an attachment the same way: whatever it still carries is dead work, and
|
|
873
|
+
* publishing any of it would either resurrect a stale incarnation or step the
|
|
874
|
+
* cursor over a sequence nobody delivered.
|
|
875
|
+
*/
|
|
876
|
+
#attachmentLive(attached: AttachedSession): boolean {
|
|
877
|
+
return (
|
|
878
|
+
!attached.barrier.detached && !attached.barrier.failed && this.#sessions.get(attached.sessionId) === attached
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Retire one attachment whose barrier could not close the gap it fenced.
|
|
884
|
+
*
|
|
885
|
+
* The cursor is left at the last contiguously published sequence and delivery
|
|
886
|
+
* stops there, so nothing can carry it over the gap. Reconcile then rebuilds the
|
|
887
|
+
* attachment against the same endpoint generation and hands that cursor to the
|
|
888
|
+
* replacement, whose replay re-fetches exactly what this round could not close —
|
|
889
|
+
* held frames are dropped only because that replay re-issues every one of them.
|
|
890
|
+
* Only an inconclusive round retires this way: a host that names what it lost is
|
|
891
|
+
* conceded where the answer is read, because no rebuild could recover it.
|
|
892
|
+
*
|
|
893
|
+
* Loud, because a barrier that cannot close is a transport fault rather than a
|
|
894
|
+
* delivery decision.
|
|
895
|
+
*/
|
|
896
|
+
#failBarrier(attached: AttachedSession, reason: string): void {
|
|
897
|
+
if (attached.barrier.detached || attached.barrier.failed) return;
|
|
898
|
+
attached.barrier.failed = true;
|
|
899
|
+
attached.barrier.held = undefined;
|
|
900
|
+
logger.warn(
|
|
901
|
+
`chat daemon replay barrier failed (${reason}); rebuilding session ${attached.sessionId} at generation ${attached.generation} from seq ${attached.cursor.seq}.`,
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Retire one attachment whose frame reached a surface without landing on it.
|
|
907
|
+
*
|
|
908
|
+
* The cursor is delivery's record, so a frame no surface took must stay above it. The
|
|
909
|
+
* attachment is retired exactly like one whose barrier failed, and the rebuild replays
|
|
910
|
+
* from a cursor that still sits below the frame. Later frames cannot drag the cursor
|
|
911
|
+
* over it either, because a retired attachment publishes nothing.
|
|
912
|
+
*
|
|
913
|
+
* The rounds are bounded, because a surface that is down for good would re-serve that
|
|
914
|
+
* same sequence forever and never deliver another. On the last one the frame is
|
|
915
|
+
* conceded — loudly, like a retention gap — and the cursor steps over it, since a
|
|
916
|
+
* stream that never advances again loses every later frame instead of just this one.
|
|
917
|
+
*/
|
|
918
|
+
#failDelivery(attached: AttachedSession, seq: number, error: unknown): void {
|
|
919
|
+
const previous = this.#undelivered.get(attached.sessionId);
|
|
920
|
+
const attempts = previous?.generation === attached.generation && previous.seq === seq ? previous.attempts + 1 : 1;
|
|
921
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
922
|
+
if (attempts >= DELIVERY_ATTEMPT_LIMIT) {
|
|
923
|
+
this.#undelivered.delete(attached.sessionId);
|
|
924
|
+
attached.cursor.seq = seq;
|
|
925
|
+
logger.warn(
|
|
926
|
+
`chat daemon conceded seq ${seq} of session ${attached.sessionId} at generation ${attached.generation} after ${attempts} refused publications (${reason}); delivery resumes above it.`,
|
|
927
|
+
);
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
this.#undelivered.set(attached.sessionId, { generation: attached.generation, seq, attempts });
|
|
931
|
+
this.#failBarrier(attached, `publication failed at seq ${seq} (${reason})`);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Publish what this round held, lowest sequence first, then reopen live ingress.
|
|
936
|
+
*
|
|
937
|
+
* Frames keep arriving while the drain runs, so the barrier is only lifted by a pass
|
|
938
|
+
* that finds nothing left to publish: lifting it earlier would let a frame arriving
|
|
939
|
+
* mid-drain overtake one still held. That lift is synchronous with the emptiness
|
|
940
|
+
* check, because anything queued behind an await could otherwise land in a buffer
|
|
941
|
+
* nobody owns any more.
|
|
942
|
+
*/
|
|
943
|
+
async #drainHeldFrames(attached: AttachedSession, held: HeldFrame[]): Promise<void> {
|
|
944
|
+
for (;;) {
|
|
945
|
+
if (attached.barrier.held !== held) return;
|
|
946
|
+
if (!this.#attachmentLive(attached)) return;
|
|
947
|
+
if (held.length === 0) {
|
|
948
|
+
attached.barrier.held = undefined;
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
const batch = held.splice(0, held.length).sort((left, right) => left.seq - right.seq);
|
|
952
|
+
for (const entry of batch) await this.enqueueFrame(attached, entry.frame, "ordered");
|
|
953
|
+
}
|
|
507
954
|
}
|
|
508
955
|
|
|
509
956
|
private async resolveEndpoint(sessionId: string): Promise<SlackEndpoint | null> {
|
|
@@ -578,9 +1025,9 @@ export class ChatDaemonRuntime {
|
|
|
578
1025
|
() => this.#pending.delete(task),
|
|
579
1026
|
);
|
|
580
1027
|
}
|
|
581
|
-
private enqueueFrame(attached: AttachedSession, frame: Record<string, unknown
|
|
1028
|
+
private enqueueFrame(attached: AttachedSession, frame: Record<string, unknown>, origin: FrameOrigin): Promise<void> {
|
|
582
1029
|
const previous = this.#frameTails.get(attached.sessionId) ?? Promise.resolve();
|
|
583
|
-
const current = previous.catch(() => undefined).then(async () => await this.handleFrame(attached, frame));
|
|
1030
|
+
const current = previous.catch(() => undefined).then(async () => await this.handleFrame(attached, frame, origin));
|
|
584
1031
|
this.#frameTails.set(attached.sessionId, current);
|
|
585
1032
|
void current.then(
|
|
586
1033
|
() => {
|
|
@@ -592,14 +1039,86 @@ export class ChatDaemonRuntime {
|
|
|
592
1039
|
);
|
|
593
1040
|
return current;
|
|
594
1041
|
}
|
|
595
|
-
private async handleFrame(
|
|
596
|
-
|
|
1042
|
+
private async handleFrame(
|
|
1043
|
+
attached: AttachedSession,
|
|
1044
|
+
frame: Record<string, unknown>,
|
|
1045
|
+
origin: FrameOrigin,
|
|
1046
|
+
): Promise<void> {
|
|
1047
|
+
if (!this.#attachmentLive(attached)) return;
|
|
597
1048
|
// Correlate before anything is acted on. A frame whose envelope and payload
|
|
598
1049
|
// disagree on session, lifecycle identity, or lifecycle generation is not a
|
|
599
1050
|
// usable event, so it is dropped whole — no close, resume, notify, resolve,
|
|
600
1051
|
// root, or mapping mutation — on the replay path and the live path alike.
|
|
601
1052
|
const correlated = correlateFrame(frame);
|
|
602
1053
|
if (!correlated) return;
|
|
1054
|
+
// One event delivered under this attachment's identity is one sequence, and a
|
|
1055
|
+
// reconnect leaves two producers carrying it: the replacement socket, whose
|
|
1056
|
+
// subscription never stopped, and the replay answer. So the cursor both orders
|
|
1057
|
+
// delivery and settles it. A sequence at or below the cursor was already
|
|
1058
|
+
// published and is rejected outright rather than merely declining to advance,
|
|
1059
|
+
// or the frame both producers carried is published twice; and every sequence
|
|
1060
|
+
// above it advances the cursor, including the ones the filters below drop,
|
|
1061
|
+
// because a cursor left behind by dropped-but-delivered frames would re-deliver
|
|
1062
|
+
// every notification interleaved with them. The fences are the two `attach()`
|
|
1063
|
+
// already applies — this session, this endpoint generation — so a foreign or
|
|
1064
|
+
// superseded frame neither moves the cursor nor enters the barrier.
|
|
1065
|
+
const seq = typeof frame.seq === "number" && Number.isSafeInteger(frame.seq) ? frame.seq : undefined;
|
|
1066
|
+
const ownsSequence =
|
|
1067
|
+
correlated.generation === attached.generation &&
|
|
1068
|
+
(correlated.sessionId === undefined || correlated.sessionId === attached.sessionId);
|
|
1069
|
+
if (seq !== undefined && ownsSequence) {
|
|
1070
|
+
if (seq <= attached.cursor.seq) return;
|
|
1071
|
+
const held = attached.barrier.held;
|
|
1072
|
+
if (held && origin === "live") {
|
|
1073
|
+
// A replay is outstanding, so where this frame sits in the stream is not
|
|
1074
|
+
// settled yet: hold it, and let the drain place it after the answer.
|
|
1075
|
+
if (held.length >= REPLAY_BARRIER_LIMIT) {
|
|
1076
|
+
// Overflow is not a choice between frames: evicting the oldest silently
|
|
1077
|
+
// skips the sequence the cursor needs next, and evicting the newest leaves
|
|
1078
|
+
// a hole no drain can close. So the barrier fails whole instead, and the
|
|
1079
|
+
// rebuild re-fetches the gap from this attachment's own cursor — the host
|
|
1080
|
+
// answers with whatever of it survived, and names the rest as lost.
|
|
1081
|
+
this.#failBarrier(attached, `hold buffer overflowed at ${REPLAY_BARRIER_LIMIT} frames`);
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
held.push({ seq, frame });
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
// Publication is the delivery boundary. The cursor records what was delivered, so
|
|
1089
|
+
// it may pass this sequence only once every configured surface has taken the
|
|
1090
|
+
// frame: moving it first turns one refused publication into permanent loss,
|
|
1091
|
+
// because the next replay would resume above a frame nobody ever published.
|
|
1092
|
+
// Rebuilds and replays retain this stream identity, so an acknowledgement that
|
|
1093
|
+
// disappears after provider acceptance is reconciled as the same publication.
|
|
1094
|
+
const publicationId =
|
|
1095
|
+
seq !== undefined && ownsSequence ? `${attached.sessionId}:${attached.generation}:${seq}` : undefined;
|
|
1096
|
+
try {
|
|
1097
|
+
await this.#publishFrame(attached, correlated, publicationId);
|
|
1098
|
+
} catch (error) {
|
|
1099
|
+
// An unsequenced frame has no cursor to hold back, so its failure stays a
|
|
1100
|
+
// rejection for the frame queue to absorb.
|
|
1101
|
+
if (seq === undefined || !ownsSequence) throw error;
|
|
1102
|
+
this.#failDelivery(attached, seq, error);
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
if (seq !== undefined && ownsSequence) {
|
|
1106
|
+
this.#undelivered.delete(attached.sessionId);
|
|
1107
|
+
// The publish was awaited, so a concession may have carried the cursor past this
|
|
1108
|
+
// sequence in the meantime. Delivery records a sequence; it never rewinds one.
|
|
1109
|
+
if (seq > attached.cursor.seq) attached.cursor.seq = seq;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Publish one correlated frame to every surface this runtime fans out to.
|
|
1115
|
+
*
|
|
1116
|
+
* Split from sequencing so the two outcomes stay distinguishable: returning means the
|
|
1117
|
+
* frame is delivered — published, or deliberately dropped by a filter that leaves
|
|
1118
|
+
* nothing to publish — and throwing means no surface took it. Only the caller moves
|
|
1119
|
+
* the cursor, and only on the first.
|
|
1120
|
+
*/
|
|
1121
|
+
async #publishFrame(attached: AttachedSession, correlated: CorrelatedFrame, publicationId?: string): Promise<void> {
|
|
603
1122
|
const normalizedFrame = correlated.body;
|
|
604
1123
|
// The SDK's own request/response traffic arrives on this same observer:
|
|
605
1124
|
// `SdkClient` settles a pending request and still forwards that frame to
|
|
@@ -626,7 +1145,7 @@ export class ChatDaemonRuntime {
|
|
|
626
1145
|
if (name === SESSION_PREPARED_EVENT || bodyType === SESSION_PREPARED_EVENT) return;
|
|
627
1146
|
if (name === "session_ready") {
|
|
628
1147
|
if (correlated.generation !== attached.generation) return;
|
|
629
|
-
await this.resume(sessionId, attached.generation, "GJC session ready.");
|
|
1148
|
+
await this.resume(sessionId, attached.generation, "GJC session ready.", publicationId);
|
|
630
1149
|
return;
|
|
631
1150
|
}
|
|
632
1151
|
const notification = this.#notificationEvent(sessionId, normalizedFrame);
|
|
@@ -652,6 +1171,7 @@ export class ChatDaemonRuntime {
|
|
|
652
1171
|
sessionId,
|
|
653
1172
|
endpointGeneration: attached.generation,
|
|
654
1173
|
content,
|
|
1174
|
+
...(publicationId === undefined ? {} : { publicationId }),
|
|
655
1175
|
...(notification.type === "action_needed"
|
|
656
1176
|
? { actionId: notification.id, options: notification.options }
|
|
657
1177
|
: {}),
|
|
@@ -662,20 +1182,32 @@ export class ChatDaemonRuntime {
|
|
|
662
1182
|
content,
|
|
663
1183
|
notification.type === "action_needed" ? notification.id : undefined,
|
|
664
1184
|
attached.generation,
|
|
1185
|
+
publicationId,
|
|
665
1186
|
);
|
|
666
1187
|
}
|
|
667
1188
|
|
|
668
1189
|
private async close(sessionId: string): Promise<void> {
|
|
1190
|
+
this.#undelivered.delete(sessionId);
|
|
669
1191
|
await this.#discord?.close(sessionId);
|
|
670
1192
|
await this.#slack?.close(sessionId);
|
|
671
1193
|
}
|
|
672
1194
|
|
|
673
|
-
|
|
1195
|
+
// Each surface takes the identity on the call that actually publishes, so a retry
|
|
1196
|
+
// reconciles the attempt it retries instead of adding a second message. Discord's
|
|
1197
|
+
// resume only unarchives the thread — the publication, and the nonce reconciled
|
|
1198
|
+
// against it, belong to the notify below. Slack's resume rolls the root over and
|
|
1199
|
+
// posts the body itself, so it carries the identity directly.
|
|
1200
|
+
private async resume(sessionId: string, generation: number, content: string, publicationId?: string): Promise<void> {
|
|
674
1201
|
if (this.#discord) {
|
|
675
1202
|
await this.#discord.resume(sessionId, generation);
|
|
676
|
-
await this.#discord.notify({
|
|
1203
|
+
await this.#discord.notify({
|
|
1204
|
+
sessionId,
|
|
1205
|
+
endpointGeneration: generation,
|
|
1206
|
+
content,
|
|
1207
|
+
...(publicationId === undefined ? {} : { publicationId }),
|
|
1208
|
+
});
|
|
677
1209
|
}
|
|
678
|
-
if (this.#slack) await this.#slack.resume(sessionId, content, generation);
|
|
1210
|
+
if (this.#slack) await this.#slack.resume(sessionId, content, generation, publicationId);
|
|
679
1211
|
}
|
|
680
1212
|
async #runChatCommand(
|
|
681
1213
|
transport: ChatTransport,
|
|
@@ -741,7 +1273,11 @@ export class ChatDaemonRuntime {
|
|
|
741
1273
|
throw new ChatDeliveryError("pre_send");
|
|
742
1274
|
}
|
|
743
1275
|
try {
|
|
744
|
-
|
|
1276
|
+
const timeoutMs = lifecycleRequestTimeoutMs(operation, input);
|
|
1277
|
+
return await client.request(
|
|
1278
|
+
{ type: "broker_request", operation, input, idempotencyKey },
|
|
1279
|
+
timeoutMs === undefined ? undefined : { timeoutMs },
|
|
1280
|
+
);
|
|
745
1281
|
} finally {
|
|
746
1282
|
await client.close();
|
|
747
1283
|
}
|