@gajae-code/coding-agent 0.11.0 → 0.11.1

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +9 -1
  2. package/dist/types/async/job-manager.d.ts +46 -7
  3. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +11 -0
  4. package/dist/types/modes/controllers/command-controller.d.ts +2 -2
  5. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -0
  6. package/dist/types/modes/interactive-mode.d.ts +2 -2
  7. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +1 -0
  8. package/dist/types/modes/types.d.ts +2 -2
  9. package/dist/types/sdk/broker/ensure.d.ts +9 -0
  10. package/dist/types/sdk/bus/discord-daemon.d.ts +5 -0
  11. package/dist/types/sdk/bus/telegram-daemon.d.ts +23 -0
  12. package/dist/types/tools/subagent-render.d.ts +1 -0
  13. package/dist/types/tools/subagent.d.ts +6 -1
  14. package/package.json +8 -8
  15. package/scripts/build-sdk-package-smoke.ts +5 -0
  16. package/src/async/job-manager.ts +406 -63
  17. package/src/gjc-runtime/ultragoal-guard.ts +55 -8
  18. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +102 -1
  19. package/src/gjc-runtime/ultragoal-runtime.ts +72 -8
  20. package/src/internal-urls/docs-index.generated.ts +2 -2
  21. package/src/modes/controllers/command-controller.ts +9 -15
  22. package/src/modes/controllers/extension-ui-controller.ts +48 -24
  23. package/src/modes/interactive-mode.ts +28 -16
  24. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +7 -3
  25. package/src/modes/types.ts +2 -2
  26. package/src/sdk/broker/discovery.ts +20 -1
  27. package/src/sdk/broker/ensure.ts +82 -24
  28. package/src/sdk/bus/chat-effect-journal.ts +6 -4
  29. package/src/sdk/bus/discord-daemon.ts +25 -8
  30. package/src/sdk/bus/index.ts +57 -21
  31. package/src/sdk/bus/native-runtime-compatibility.ts +1 -1
  32. package/src/sdk/bus/telegram-daemon.ts +102 -10
  33. package/src/session/agent-session.ts +176 -29
  34. package/src/task/index.ts +26 -16
  35. package/src/tools/subagent-render.ts +19 -8
  36. package/src/tools/subagent.ts +84 -32
@@ -916,22 +916,15 @@ export class CommandController {
916
916
  }
917
917
  }
918
918
 
919
- async #runNewSessionFlow(options?: NewSessionOptions, label: string = "New session started"): Promise<void> {
919
+ async #runNewSessionFlow(options?: NewSessionOptions, label: string = "New session started"): Promise<boolean> {
920
+ if (!(await this.ctx.session.newSession(options))) return false;
921
+
920
922
  if (this.ctx.loadingAnimation) {
921
923
  this.ctx.loadingAnimation.stop();
922
924
  this.ctx.loadingAnimation = undefined;
923
925
  }
924
926
  this.ctx.statusContainer.clear();
925
-
926
- if (this.ctx.session.isCompacting) {
927
- this.ctx.session.abortCompaction();
928
- while (this.ctx.session.isCompacting) {
929
- await Bun.sleep(10);
930
- }
931
- }
932
- if (!(await this.ctx.session.newSession(options))) return;
933
927
  this.ctx.resetIrcSidebarSession();
934
-
935
928
  this.ctx.resetObserverRegistry();
936
929
  setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd());
937
930
 
@@ -952,10 +945,11 @@ export class CommandController {
952
945
  this.ctx.chatContainer.addChild(new Text(`${theme.fg("accent", `${theme.status.success} ${label}`)}`, 1, 0));
953
946
  await this.ctx.reloadTodos();
954
947
  this.ctx.ui.requestRender();
948
+ return true;
955
949
  }
956
950
 
957
- async handleClearCommand(): Promise<void> {
958
- await this.#runNewSessionFlow();
951
+ async handleClearCommand(): Promise<boolean> {
952
+ return this.#runNewSessionFlow();
959
953
  }
960
954
 
961
955
  async handleContextClearCommand(): Promise<void> {
@@ -995,12 +989,12 @@ export class CommandController {
995
989
  this.ctx.ui.requestRender();
996
990
  }
997
991
 
998
- async handleDropCommand(): Promise<void> {
992
+ async handleDropCommand(): Promise<boolean> {
999
993
  if (!this.ctx.sessionManager.getSessionFile()) {
1000
994
  this.ctx.showError("Nothing to drop (in-memory session)");
1001
- return;
995
+ return false;
1002
996
  }
1003
- await this.#runNewSessionFlow({ drop: true }, "Session dropped");
997
+ return this.#runNewSessionFlow({ drop: true }, "Session dropped");
1004
998
  }
1005
999
 
1006
1000
  async handleForkCommand(): Promise<void> {
@@ -53,6 +53,40 @@ export class ExtensionUiController {
53
53
  overlay?.hide();
54
54
  }
55
55
 
56
+ captureSessionUiCleanup(): () => void {
57
+ const terminalInputUnsubscribers = [...this.#extensionTerminalInputUnsubscribers];
58
+ const widgetsAbove = [...this.#hookWidgetsAbove.entries()];
59
+ const widgetsBelow = [...this.#hookWidgetsBelow.entries()];
60
+ const activeHookCustomComponent = this.#activeHookCustomComponent;
61
+ const activeHookCustomOverlay = this.#activeHookCustomOverlay;
62
+ return () => {
63
+ for (const unsubscribe of terminalInputUnsubscribers) {
64
+ unsubscribe();
65
+ this.#extensionTerminalInputUnsubscribers.delete(unsubscribe);
66
+ }
67
+ let widgetsChanged = false;
68
+ for (const [key, widget] of widgetsAbove) {
69
+ if (this.#hookWidgetsAbove.get(key) !== widget) continue;
70
+ this.#hookWidgetsAbove.delete(key);
71
+ widget.dispose?.();
72
+ widgetsChanged = true;
73
+ }
74
+ for (const [key, widget] of widgetsBelow) {
75
+ if (this.#hookWidgetsBelow.get(key) !== widget) continue;
76
+ this.#hookWidgetsBelow.delete(key);
77
+ widget.dispose?.();
78
+ widgetsChanged = true;
79
+ }
80
+ if (
81
+ this.#activeHookCustomComponent === activeHookCustomComponent &&
82
+ this.#activeHookCustomOverlay === activeHookCustomOverlay
83
+ ) {
84
+ this.#clearActiveHookCustom();
85
+ }
86
+ if (widgetsChanged) this.#rebuildHookWidgets();
87
+ };
88
+ }
89
+
56
90
  #sdkControl = async (operation: string, input: Record<string, unknown>): Promise<unknown> => {
57
91
  const session = this.ctx.session;
58
92
  switch (operation) {
@@ -414,36 +448,30 @@ export class ExtensionUiController {
414
448
  this.ctx.showStatus("Reloaded session");
415
449
  },
416
450
  newSession: async options => {
417
- // Stop any loading animation
451
+ const cleanupPreviousSessionUi = this.captureSessionUiCleanup();
452
+ const success = await this.ctx.session.newSession({ parentSession: options?.parentSession });
453
+ if (!success) {
454
+ return { cancelled: true };
455
+ }
456
+ cleanupPreviousSessionUi();
457
+
418
458
  if (this.ctx.loadingAnimation) {
419
459
  this.ctx.loadingAnimation.stop();
420
460
  this.ctx.loadingAnimation = undefined;
421
461
  }
422
462
  this.ctx.statusContainer.clear();
423
-
424
- // Create new session
425
- this.clearExtensionTerminalInputListeners();
426
- this.clearHookWidgets();
427
- const success = await this.ctx.session.newSession({ parentSession: options?.parentSession });
428
- if (!success) {
429
- return { cancelled: true };
430
- }
431
463
  this.ctx.resetIrcSidebarSession();
432
-
433
464
  setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd());
434
465
 
435
- // Call setup callback if provided
436
466
  if (options?.setup) {
437
467
  await options.setup(this.ctx.sessionManager);
438
468
  }
439
469
 
440
- // Reset and update status line
441
470
  this.ctx.statusLine.invalidate();
442
471
  this.ctx.statusLine.setSessionStartTime(Date.now());
443
472
  this.ctx.updateEditorTopBorder();
444
473
  this.ctx.ui.requestRender();
445
474
 
446
- // Clear UI state
447
475
  prepareTranscriptRebuild(this.ctx.ui, "replace-identity");
448
476
  this.ctx.chatContainer.clear();
449
477
  this.ctx.pendingMessagesContainer.clear();
@@ -717,28 +745,24 @@ export class ExtensionUiController {
717
745
  if (this.ctx.isBackgrounded) {
718
746
  return { cancelled: true };
719
747
  }
720
- // Stop any loading animation
748
+ const cleanupPreviousSessionUi = this.captureSessionUiCleanup();
749
+ const success = await this.ctx.session.newSession({ parentSession: options?.parentSession });
750
+ if (!success) {
751
+ return { cancelled: true };
752
+ }
753
+ cleanupPreviousSessionUi();
754
+
721
755
  if (this.ctx.loadingAnimation) {
722
756
  this.ctx.loadingAnimation.stop();
723
757
  this.ctx.loadingAnimation = undefined;
724
758
  }
725
759
  this.ctx.statusContainer.clear();
726
-
727
- // Create new session
728
- this.clearExtensionTerminalInputListeners();
729
- this.clearHookWidgets();
730
- const success = await this.ctx.session.newSession({ parentSession: options?.parentSession });
731
- if (!success) {
732
- return { cancelled: true };
733
- }
734
760
  this.ctx.resetIrcSidebarSession();
735
761
 
736
- // Call setup callback if provided
737
762
  if (options?.setup) {
738
763
  await options.setup(this.ctx.sessionManager);
739
764
  }
740
765
 
741
- // Clear UI state
742
766
  prepareTranscriptRebuild(this.ctx.ui, "replace-identity");
743
767
  this.ctx.chatContainer.clear();
744
768
  this.ctx.pendingMessagesContainer.clear();
@@ -1820,19 +1820,22 @@ export class InteractiveMode implements InteractiveModeContext {
1820
1820
  if (options.compactBeforeExecute) {
1821
1821
  this.session.markPlanCompactAbortPending();
1822
1822
  }
1823
+ let sessionSwitchCompleted = true;
1823
1824
  let compactOutcome: CompactionOutcome | undefined;
1824
1825
  try {
1825
1826
  await this.#exitPlanMode({ silent: true, paused: false });
1826
1827
 
1827
1828
  if (!options.preserveContext) {
1828
- await this.handleClearCommand();
1829
- // The new session has a fresh local:// root — persist the approved plan there
1830
- // so `local://<title>.md` resolves correctly in the execution session.
1831
- const newLocalPath = resolveLocalUrlToPath(options.finalPlanFilePath, {
1832
- getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
1833
- getSessionId: () => this.sessionManager.getSessionId(),
1834
- });
1835
- await Bun.write(newLocalPath, planContent);
1829
+ sessionSwitchCompleted = await this.handleClearCommand();
1830
+ if (sessionSwitchCompleted) {
1831
+ // The new session has a fresh local:// root — persist the approved plan there
1832
+ // so `local://<title>.md` resolves correctly in the execution session.
1833
+ const newLocalPath = resolveLocalUrlToPath(options.finalPlanFilePath, {
1834
+ getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
1835
+ getSessionId: () => this.sessionManager.getSessionId(),
1836
+ });
1837
+ await Bun.write(newLocalPath, planContent);
1838
+ }
1836
1839
  } else if (options.compactBeforeExecute) {
1837
1840
  // Distill the plan-mode transcript before the execution turn is queued so
1838
1841
  // the plan-approved synthetic prompt lands as a fresh cache anchor.
@@ -1866,6 +1869,10 @@ export class InteractiveMode implements InteractiveModeContext {
1866
1869
  if (previousTools.length > 0) {
1867
1870
  await this.session.setActiveToolsByName(previousTools);
1868
1871
  }
1872
+ if (!sessionSwitchCompleted) {
1873
+ this.showWarning("Plan approved, but the new session could not be created — execution was not dispatched.");
1874
+ return;
1875
+ }
1869
1876
  this.session.setPlanReferencePath(options.finalPlanFilePath);
1870
1877
 
1871
1878
  if (compactOutcome === "cancelled") {
@@ -2577,15 +2584,18 @@ export class InteractiveMode implements InteractiveModeContext {
2577
2584
  this.#commandController.handleContextCommand();
2578
2585
  }
2579
2586
 
2580
- #prepareSessionSwitch(): void {
2587
+ #prepareSessionSwitch(cleanupPreviousSessionUi?: () => void): void {
2581
2588
  this.#btwController.dispose();
2582
- this.#extensionUiController.clearExtensionTerminalInputListeners();
2589
+ if (cleanupPreviousSessionUi) cleanupPreviousSessionUi();
2590
+ else this.#extensionUiController.clearExtensionTerminalInputListeners();
2583
2591
  this.#planReviewContainer = undefined;
2584
2592
  }
2585
2593
 
2586
- handleClearCommand(): Promise<void> {
2587
- this.#prepareSessionSwitch();
2588
- return this.#commandController.handleClearCommand();
2594
+ async handleClearCommand(): Promise<boolean> {
2595
+ const cleanupPreviousSessionUi = this.#extensionUiController.captureSessionUiCleanup();
2596
+ const switched = await this.#commandController.handleClearCommand();
2597
+ if (switched) this.#prepareSessionSwitch(cleanupPreviousSessionUi);
2598
+ return switched;
2589
2599
  }
2590
2600
 
2591
2601
  handleContextClearCommand(): Promise<void> {
@@ -2593,9 +2603,11 @@ export class InteractiveMode implements InteractiveModeContext {
2593
2603
  return this.#commandController.handleContextClearCommand();
2594
2604
  }
2595
2605
 
2596
- handleDropCommand(): Promise<void> {
2597
- this.#prepareSessionSwitch();
2598
- return this.#commandController.handleDropCommand();
2606
+ async handleDropCommand(): Promise<boolean> {
2607
+ const cleanupPreviousSessionUi = this.#extensionUiController.captureSessionUiCleanup();
2608
+ const switched = await this.#commandController.handleDropCommand();
2609
+ if (switched) this.#prepareSessionSwitch(cleanupPreviousSessionUi);
2610
+ return switched;
2599
2611
  }
2600
2612
 
2601
2613
  handleForkCommand(): Promise<void> {
@@ -936,10 +936,14 @@ function assertPersistedGate(
936
936
  invalidFileState(filePath);
937
937
  }
938
938
 
939
- function isUnsupportedWindowsDirectorySyncError(error: unknown): boolean {
940
- if (process.platform !== "win32") return false;
939
+ export function isUnsupportedWindowsDirectorySyncError(
940
+ error: unknown,
941
+ platform: NodeJS.Platform = process.platform,
942
+ ): boolean {
943
+ if (platform !== "win32") return false;
941
944
  const code = (error as NodeJS.ErrnoException | undefined)?.code;
942
- return code === "EINVAL" || code === "ENOTSUP" || code === "EOPNOTSUPP";
945
+ // Bun reports EPERM when fsyncSync is applied to a Windows directory handle.
946
+ return code === "EINVAL" || code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EPERM";
943
947
  }
944
948
 
945
949
  /** A write may have reached `rename` but not the directory fsync durability barrier. */
@@ -263,9 +263,9 @@ export interface InteractiveModeContext {
263
263
  handleContextCommand(): void;
264
264
  handleDumpCommand(): void;
265
265
  handleDebugTranscriptCommand(): Promise<void>;
266
- handleClearCommand(): Promise<void>;
266
+ handleClearCommand(): Promise<boolean>;
267
267
  handleContextClearCommand(): Promise<void>;
268
- handleDropCommand(): Promise<void>;
268
+ handleDropCommand(): Promise<boolean>;
269
269
  handleForkCommand(): Promise<void>;
270
270
  handleBashCommand(command: string, excludeFromContext?: boolean): Promise<void>;
271
271
  handlePythonCommand(code: string, excludeFromContext?: boolean): Promise<void>;
@@ -40,6 +40,25 @@ async function syncFile(file: string): Promise<void> {
40
40
  await handle.close();
41
41
  }
42
42
  }
43
+
44
+ async function syncDirectory(directory: string): Promise<void> {
45
+ let handle: fs.FileHandle;
46
+ try {
47
+ handle = await fs.open(directory, "r");
48
+ } catch (error) {
49
+ const code = (error as NodeJS.ErrnoException).code;
50
+ if (process.platform === "win32" && (code === "EPERM" || code === "EACCES")) return;
51
+ throw error;
52
+ }
53
+ try {
54
+ await handle.sync();
55
+ } catch (error) {
56
+ const code = (error as NodeJS.ErrnoException).code;
57
+ if (process.platform !== "win32" || (code !== "EPERM" && code !== "EACCES")) throw error;
58
+ } finally {
59
+ await handle.close();
60
+ }
61
+ }
43
62
  export async function writeBrokerDiscovery(agentDir: string, discovery: BrokerDiscoveryWrite): Promise<void> {
44
63
  const incarnation = discovery.incarnation ?? brokerProcessIncarnation(discovery.pid);
45
64
  if (!incarnation) throw new Error(`Broker process incarnation is unavailable for pid ${discovery.pid}.`);
@@ -54,7 +73,7 @@ export async function writeBrokerDiscovery(agentDir: string, discovery: BrokerDi
54
73
  await fs.chmod(temp, 0o600);
55
74
  await syncFile(temp);
56
75
  await fs.rename(temp, file);
57
- await syncFile(dir);
76
+ await syncDirectory(dir);
58
77
  } finally {
59
78
  await fs.rm(temp, { force: true });
60
79
  }
@@ -17,13 +17,33 @@ const DISCOVERY_TIMEOUT_MS = 10_000;
17
17
  // owned-process teardown convention (SIGTERM -> grace -> SIGKILL -> hard cap).
18
18
  const REAP_GRACEFUL_MS = 2_000;
19
19
  const REAP_SIGKILL_CAP_MS = 2_000;
20
+ export interface FixtureBrokerLease {
21
+ close(): Promise<void>;
22
+ }
23
+
24
+ export interface StartedFixtureBroker {
25
+ discovery: BrokerDiscovery;
26
+ lease: FixtureBrokerLease;
27
+ }
28
+
20
29
  interface BrokerOwner {
21
30
  stop(): Promise<void>;
22
31
  canReuse(discovery: BrokerDiscovery | null): boolean;
23
32
  markReady(discovery: BrokerDiscovery): boolean;
24
33
  }
34
+ type EnsureInitiator = "discovery" | "fixture-lease";
35
+ type EnsureOutcome =
36
+ | { kind: "external-discovery"; discovery: BrokerDiscovery }
37
+ | { kind: "prior-local-owner"; discovery: BrokerDiscovery; owner: BrokerOwner }
38
+ | { kind: "local-started-discovery"; discovery: BrokerDiscovery }
39
+ | { kind: "local-started-fixture"; discovery: BrokerDiscovery; owner: BrokerOwner };
40
+ interface EnsureInFlight {
41
+ initiator: EnsureInitiator;
42
+ promise: Promise<EnsureOutcome>;
43
+ discovery: Promise<BrokerDiscovery>;
44
+ }
25
45
  const owners = new Map<string, BrokerOwner>();
26
- const ensureInFlight = new Map<string, Promise<BrokerDiscovery>>();
46
+ const ensureInFlight = new Map<string, EnsureInFlight>();
27
47
  const reapErrorGuards = new WeakSet<ChildProcess>();
28
48
  interface ReapTiming {
29
49
  gracefulMs: number;
@@ -142,18 +162,44 @@ function brokerSpawnEnvironment(command: SdkInternalSpawnCommand, override?: Nod
142
162
  return environment;
143
163
  }
144
164
 
145
- async function ensureBrokerOnce(settings: EnsureBrokerSettings): Promise<BrokerDiscovery> {
165
+ function fixtureLeaseUnavailable(): Error {
166
+ return new Error("fixture_broker_lease_unavailable");
167
+ }
168
+
169
+ function createFixtureLease(owner: BrokerOwner): FixtureBrokerLease {
170
+ let closeAttempt: Promise<void> | undefined;
171
+ let closed = false;
172
+ return {
173
+ close(): Promise<void> {
174
+ if (closed) return Promise.resolve();
175
+ if (closeAttempt) return closeAttempt;
176
+ closeAttempt = owner.stop().then(
177
+ () => {
178
+ closed = true;
179
+ },
180
+ (error: unknown) => {
181
+ closeAttempt = undefined;
182
+ throw error;
183
+ },
184
+ );
185
+ return closeAttempt;
186
+ },
187
+ };
188
+ }
189
+
190
+ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: EnsureInitiator): Promise<EnsureOutcome> {
146
191
  const priorOwner = owners.get(settings.agentDir);
147
192
  const existing = await readBrokerDiscovery(settings.agentDir, settings.heartbeatTtlMs);
193
+ if (initiator === "fixture-lease" && (priorOwner || existing)) throw fixtureLeaseUnavailable();
148
194
  if (priorOwner) {
149
195
  // A retained cleanup failure fences every discovery record. Only a ready
150
196
  // record bound to this exact child incarnation may be reused.
151
- if (priorOwner.canReuse(existing)) return existing!;
197
+ if (priorOwner.canReuse(existing)) return { kind: "prior-local-owner", discovery: existing!, owner: priorOwner };
152
198
  await priorOwner.stop();
153
199
  const discoveredAfterCleanup = await readBrokerDiscovery(settings.agentDir, settings.heartbeatTtlMs);
154
- if (discoveredAfterCleanup) return discoveredAfterCleanup;
200
+ if (discoveredAfterCleanup) return { kind: "external-discovery", discovery: discoveredAfterCleanup };
155
201
  } else if (existing) {
156
- return existing;
202
+ return { kind: "external-discovery", discovery: existing };
157
203
  }
158
204
 
159
205
  const command = resolveSdkInternalSpawnCommand("broker-internal");
@@ -172,27 +218,23 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings): Promise<BrokerD
172
218
  const deadline = Date.now() + DISCOVERY_TIMEOUT_MS;
173
219
  let discoveryError: unknown;
174
220
  while (Date.now() < deadline) {
175
- // Fail fast: if the spawn failed or the child already exited, discovery can
176
- // never succeed — stop (a no-op once dead) and surface the failure instead
177
- // of running out the discovery timeout with an orphaned child.
178
221
  if (spawnError || child.exitCode !== null || child.signalCode !== null) break;
179
222
  try {
180
223
  const discovered = await readBrokerDiscovery(settings.agentDir, settings.heartbeatTtlMs);
181
224
  if (discovered) {
182
- if (owner.markReady(discovered)) return discovered;
225
+ if (owner.markReady(discovered)) {
226
+ return initiator === "fixture-lease"
227
+ ? { kind: "local-started-fixture", discovery: discovered, owner }
228
+ : { kind: "local-started-discovery", discovery: discovered };
229
+ }
183
230
  await owner.stop();
184
- return discovered;
231
+ return { kind: "external-discovery", discovery: discovered };
185
232
  }
186
233
  } catch (error) {
187
- // A transient read failure (e.g. a half-written record) must not orphan the
188
- // child; remember it and keep polling. It is surfaced if discovery fails.
189
234
  discoveryError = error;
190
235
  }
191
236
  await sleep(50);
192
237
  }
193
- // Discovery failed (timeout, early child exit, spawn error, or unreadable
194
- // discovery): the spawned broker never became reachable. Terminate and reap it
195
- // so the failure cannot leave a detached orphan behind, then surface why.
196
238
  const exitedBeforeDiscovery = child.exitCode !== null || child.signalCode !== null;
197
239
  const failure = spawnError
198
240
  ? new Error(`Failed to spawn detached SDK broker: ${spawnError.message}`)
@@ -211,17 +253,33 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings): Promise<BrokerD
211
253
  throw failure;
212
254
  }
213
255
 
214
- /** Starts the detached broker entrypoint when discovery has no live owner. */
215
- export function ensureBroker(settings: EnsureBrokerSettings): Promise<BrokerDiscovery> {
216
- const existing = ensureInFlight.get(settings.agentDir);
217
- if (existing) return existing;
218
- const pending = ensureBrokerOnce(settings);
219
- ensureInFlight.set(settings.agentDir, pending);
256
+ function startEnsure(settings: EnsureBrokerSettings, initiator: EnsureInitiator): EnsureInFlight {
257
+ const promise = ensureBrokerOnce(settings, initiator);
258
+ const discovery = promise.then(outcome => outcome.discovery);
259
+ void discovery.catch(() => {});
260
+ const entry = { initiator, promise, discovery };
261
+ ensureInFlight.set(settings.agentDir, entry);
220
262
  const clear = (): void => {
221
- if (ensureInFlight.get(settings.agentDir) === pending) ensureInFlight.delete(settings.agentDir);
263
+ if (ensureInFlight.get(settings.agentDir) === entry) ensureInFlight.delete(settings.agentDir);
222
264
  };
223
- void pending.then(clear, clear);
224
- return pending;
265
+ void promise.then(clear, clear);
266
+ return entry;
267
+ }
268
+
269
+ /** Starts the detached broker entrypoint when discovery has no live owner. */
270
+ export function ensureBroker(settings: EnsureBrokerSettings): Promise<BrokerDiscovery> {
271
+ const inFlight = ensureInFlight.get(settings.agentDir) ?? startEnsure(settings, "discovery");
272
+ return inFlight.discovery;
273
+ }
274
+
275
+ /** Starts one fresh fixture broker and returns its sole exact-child close lease. */
276
+ export function startFixtureBrokerWithLeaseForTest(settings: EnsureBrokerSettings): Promise<StartedFixtureBroker> {
277
+ if (ensureInFlight.has(settings.agentDir)) return Promise.reject(fixtureLeaseUnavailable());
278
+ const inFlight = startEnsure(settings, "fixture-lease");
279
+ return inFlight.promise.then(outcome => {
280
+ if (outcome.kind !== "local-started-fixture") throw fixtureLeaseUnavailable();
281
+ return { discovery: outcome.discovery, lease: createFixtureLease(outcome.owner) };
282
+ });
225
283
  }
226
284
 
227
285
  /** Test hook: returns a stop handle for the detached broker this process spawned. */
@@ -340,13 +340,15 @@ export class ChatEffectJournal {
340
340
  }
341
341
 
342
342
  async #pruneTerminal(): Promise<void> {
343
- const referenced = new Set<string>();
344
- for (const mapping of Object.values((await this.#mappings.load()).conversations))
345
- collectEffectReferences(mapping, referenced);
346
343
  const terminal = (await this.list())
347
344
  .filter(effect => effect.state === "terminal")
348
345
  .sort((left, right) => left.updatedAt - right.updatedAt);
349
- for (const effect of terminal.slice(0, Math.max(0, terminal.length - MAX_TERMINAL_CHAT_EFFECTS))) {
346
+ if (terminal.length <= MAX_TERMINAL_CHAT_EFFECTS) return;
347
+
348
+ const referenced = new Set<string>();
349
+ for (const mapping of Object.values((await this.#mappings.load()).conversations))
350
+ collectEffectReferences(mapping, referenced);
351
+ for (const effect of terminal.slice(0, terminal.length - MAX_TERMINAL_CHAT_EFFECTS)) {
350
352
  if (!referenced.has(effect.id)) await this.#store.delete(effect.id, effect.generation);
351
353
  }
352
354
  }
@@ -78,6 +78,11 @@ export interface DiscordEndpointBinding {
78
78
  send(frame: Record<string, unknown>): void | Promise<void>;
79
79
  }
80
80
 
81
+ export interface DiscordLeaseRecoveryScheduler {
82
+ setTimeout(callback: () => void | Promise<void>, delayMs: number): unknown;
83
+ clearTimeout(handle: unknown): void;
84
+ }
85
+
81
86
  export interface DiscordNotificationDaemonOptions {
82
87
  agentDir: string;
83
88
  repo: string;
@@ -85,6 +90,7 @@ export interface DiscordNotificationDaemonOptions {
85
90
  parentChannelId: string;
86
91
  provider: DiscordProvider;
87
92
  now?: () => number;
93
+ leaseRecoveryScheduler?: DiscordLeaseRecoveryScheduler;
88
94
  resolveEndpoint: (sessionId: string, expectedGeneration?: number) => Promise<DiscordEndpointBinding | null>;
89
95
  onCommand?: (
90
96
  sessionId: string,
@@ -151,6 +157,7 @@ type DiscordInboundClaim = {
151
157
  export class DiscordNotificationDaemon {
152
158
  readonly #store: ConversationStore<DiscordConversation>;
153
159
  readonly #now: () => number;
160
+ readonly #leaseRecoveryScheduler: DiscordLeaseRecoveryScheduler;
154
161
  readonly #creates = new Map<string, Promise<DiscordConversation>>();
155
162
  readonly #resumes = new Map<string, Promise<DiscordConversation | undefined>>();
156
163
  readonly #archives = new Map<string, Promise<void>>();
@@ -167,7 +174,7 @@ export class DiscordNotificationDaemon {
167
174
  #stopTask: Promise<void> | undefined;
168
175
  #providerStarting = false;
169
176
 
170
- #leaseRecoveryTimer: ReturnType<typeof setTimeout> | undefined;
177
+ #leaseRecoveryTimer: unknown;
171
178
  #leaseRecoveryAt: number | undefined;
172
179
  #leaseRecoveryFailures = 0;
173
180
 
@@ -179,6 +186,10 @@ export class DiscordNotificationDaemon {
179
186
  this.#store = new ConversationStore({ agentDir: options.agentDir, kind: "discord", now: options.now });
180
187
  this.#effects = new ChatEffectJournal({ agentDir: options.agentDir, transport: "discord", now: options.now });
181
188
  this.#now = options.now ?? Date.now;
189
+ this.#leaseRecoveryScheduler = options.leaseRecoveryScheduler ?? {
190
+ setTimeout: (callback, delayMs) => setTimeout(() => void callback(), delayMs),
191
+ clearTimeout: handle => clearTimeout(handle as ReturnType<typeof setTimeout>),
192
+ };
182
193
  this.#resolveEndpoint = options.resolveEndpoint;
183
194
  }
184
195
 
@@ -240,7 +251,8 @@ export class DiscordNotificationDaemon {
240
251
  } catch (error) {
241
252
  if (lifecycleGeneration === this.#lifecycleGeneration) {
242
253
  this.#started = false;
243
- if (this.#leaseRecoveryTimer) clearTimeout(this.#leaseRecoveryTimer);
254
+ if (this.#leaseRecoveryTimer !== undefined)
255
+ this.#leaseRecoveryScheduler.clearTimeout(this.#leaseRecoveryTimer);
244
256
  this.#leaseRecoveryTimer = undefined;
245
257
  this.#leaseRecoveryAt = undefined;
246
258
  }
@@ -255,7 +267,7 @@ export class DiscordNotificationDaemon {
255
267
  const stopProvider = this.#started || this.#providerStarting;
256
268
  ++this.#lifecycleGeneration;
257
269
  this.#started = false;
258
- if (this.#leaseRecoveryTimer) clearTimeout(this.#leaseRecoveryTimer);
270
+ if (this.#leaseRecoveryTimer !== undefined) this.#leaseRecoveryScheduler.clearTimeout(this.#leaseRecoveryTimer);
259
271
  this.#leaseRecoveryTimer = undefined;
260
272
  this.#leaseRecoveryAt = undefined;
261
273
 
@@ -2128,8 +2140,11 @@ export class DiscordNotificationDaemon {
2128
2140
  }
2129
2141
  async #scheduleLeaseRecovery(recoveryFailed = false): Promise<void> {
2130
2142
  if (!this.#started) return;
2143
+ const lifecycleGeneration = this.#lifecycleGeneration;
2144
+ const effects = await this.#effects.list();
2145
+ if (!this.#started || lifecycleGeneration !== this.#lifecycleGeneration) return;
2131
2146
  const now = this.#now();
2132
- const recoveryAt = (await this.#effects.list())
2147
+ const recoveryAt = effects
2133
2148
  .filter(effect => effect.transport === "discord")
2134
2149
  .reduce<number | undefined>(
2135
2150
  (earliest, effect) => {
@@ -2146,23 +2161,25 @@ export class DiscordNotificationDaemon {
2146
2161
  recoveryFailed ? now : undefined,
2147
2162
  );
2148
2163
  if (recoveryAt === undefined) {
2149
- if (this.#leaseRecoveryTimer) clearTimeout(this.#leaseRecoveryTimer);
2164
+ if (this.#leaseRecoveryTimer !== undefined)
2165
+ this.#leaseRecoveryScheduler.clearTimeout(this.#leaseRecoveryTimer);
2150
2166
  this.#leaseRecoveryTimer = undefined;
2151
2167
  this.#leaseRecoveryAt = undefined;
2152
2168
  this.#leaseRecoveryFailures = 0;
2153
2169
  return;
2154
2170
  }
2155
2171
  if (this.#leaseRecoveryAt !== undefined && this.#leaseRecoveryAt <= recoveryAt) return;
2156
- if (this.#leaseRecoveryTimer) clearTimeout(this.#leaseRecoveryTimer);
2172
+ if (this.#leaseRecoveryTimer !== undefined) this.#leaseRecoveryScheduler.clearTimeout(this.#leaseRecoveryTimer);
2157
2173
  this.#leaseRecoveryAt = recoveryAt;
2158
2174
  const delay =
2159
2175
  recoveryAt <= now
2160
2176
  ? Math.min(1_000, 25 * 2 ** Math.min(this.#leaseRecoveryFailures, 5))
2161
2177
  : Math.min(recoveryAt - now, 2_147_483_647);
2162
- this.#leaseRecoveryTimer = setTimeout(() => {
2178
+ this.#leaseRecoveryTimer = this.#leaseRecoveryScheduler.setTimeout(() => {
2179
+ if (lifecycleGeneration !== this.#lifecycleGeneration) return;
2163
2180
  this.#leaseRecoveryTimer = undefined;
2164
2181
  this.#leaseRecoveryAt = undefined;
2165
- void this.#track(this.#recoverLeasedEffects());
2182
+ return this.#track(this.#recoverLeasedEffects()).catch(() => {});
2166
2183
  }, delay);
2167
2184
  }
2168
2185
  async #recoverLeasedEffects(): Promise<void> {