@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.
- package/CHANGELOG.md +9 -1
- package/dist/types/async/job-manager.d.ts +46 -7
- package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +11 -0
- package/dist/types/modes/controllers/command-controller.d.ts +2 -2
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -2
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +1 -0
- package/dist/types/modes/types.d.ts +2 -2
- package/dist/types/sdk/broker/ensure.d.ts +9 -0
- package/dist/types/sdk/bus/discord-daemon.d.ts +5 -0
- package/dist/types/sdk/bus/telegram-daemon.d.ts +23 -0
- package/dist/types/tools/subagent-render.d.ts +1 -0
- package/dist/types/tools/subagent.d.ts +6 -1
- package/package.json +8 -8
- package/scripts/build-sdk-package-smoke.ts +5 -0
- package/src/async/job-manager.ts +406 -63
- package/src/gjc-runtime/ultragoal-guard.ts +55 -8
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +102 -1
- package/src/gjc-runtime/ultragoal-runtime.ts +72 -8
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/modes/controllers/command-controller.ts +9 -15
- package/src/modes/controllers/extension-ui-controller.ts +48 -24
- package/src/modes/interactive-mode.ts +28 -16
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +7 -3
- package/src/modes/types.ts +2 -2
- package/src/sdk/broker/discovery.ts +20 -1
- package/src/sdk/broker/ensure.ts +82 -24
- package/src/sdk/bus/chat-effect-journal.ts +6 -4
- package/src/sdk/bus/discord-daemon.ts +25 -8
- package/src/sdk/bus/index.ts +57 -21
- package/src/sdk/bus/native-runtime-compatibility.ts +1 -1
- package/src/sdk/bus/telegram-daemon.ts +102 -10
- package/src/session/agent-session.ts +176 -29
- package/src/task/index.ts +26 -16
- package/src/tools/subagent-render.ts +19 -8
- 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<
|
|
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<
|
|
958
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
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
|
-
|
|
2589
|
+
if (cleanupPreviousSessionUi) cleanupPreviousSessionUi();
|
|
2590
|
+
else this.#extensionUiController.clearExtensionTerminalInputListeners();
|
|
2583
2591
|
this.#planReviewContainer = undefined;
|
|
2584
2592
|
}
|
|
2585
2593
|
|
|
2586
|
-
handleClearCommand(): Promise<
|
|
2587
|
-
this.#
|
|
2588
|
-
|
|
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<
|
|
2597
|
-
this.#
|
|
2598
|
-
|
|
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(
|
|
940
|
-
|
|
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
|
-
|
|
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. */
|
package/src/modes/types.ts
CHANGED
|
@@ -263,9 +263,9 @@ export interface InteractiveModeContext {
|
|
|
263
263
|
handleContextCommand(): void;
|
|
264
264
|
handleDumpCommand(): void;
|
|
265
265
|
handleDebugTranscriptCommand(): Promise<void>;
|
|
266
|
-
handleClearCommand(): Promise<
|
|
266
|
+
handleClearCommand(): Promise<boolean>;
|
|
267
267
|
handleContextClearCommand(): Promise<void>;
|
|
268
|
-
handleDropCommand(): Promise<
|
|
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
|
|
76
|
+
await syncDirectory(dir);
|
|
58
77
|
} finally {
|
|
59
78
|
await fs.rm(temp, { force: true });
|
|
60
79
|
}
|
package/src/sdk/broker/ensure.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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))
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
ensureInFlight.set(settings.agentDir,
|
|
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) ===
|
|
263
|
+
if (ensureInFlight.get(settings.agentDir) === entry) ensureInFlight.delete(settings.agentDir);
|
|
222
264
|
};
|
|
223
|
-
void
|
|
224
|
-
return
|
|
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
|
-
|
|
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:
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
2182
|
+
return this.#track(this.#recoverLeasedEffects()).catch(() => {});
|
|
2166
2183
|
}, delay);
|
|
2167
2184
|
}
|
|
2168
2185
|
async #recoverLeasedEffects(): Promise<void> {
|