@gajae-code/coding-agent 0.5.2 → 0.5.4
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 +23 -0
- package/dist/types/async/job-manager.d.ts +6 -0
- package/dist/types/config/model-profiles.d.ts +10 -0
- package/dist/types/dap/client.d.ts +2 -1
- package/dist/types/edit/read-file.d.ts +6 -0
- package/dist/types/eval/js/context-manager.d.ts +3 -0
- package/dist/types/eval/js/executor.d.ts +1 -0
- package/dist/types/exec/bash-executor.d.ts +2 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +7 -1
- package/dist/types/lsp/types.d.ts +2 -0
- package/dist/types/modes/bridge/bridge-mode.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/components/oauth-selector.d.ts +1 -0
- package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -0
- package/dist/types/modes/components/tool-execution.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/runtime/process-lifecycle.d.ts +108 -0
- package/dist/types/runtime-mcp/transports/stdio.d.ts +1 -0
- package/dist/types/runtime-mcp/types.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +29 -1
- package/dist/types/session/artifacts.d.ts +4 -1
- package/dist/types/session/streaming-output.d.ts +12 -0
- package/dist/types/slash-commands/helpers/fast-status-report.d.ts +76 -0
- package/dist/types/tools/bash.d.ts +1 -0
- package/dist/types/tools/browser/tab-supervisor.d.ts +9 -0
- package/dist/types/tools/sqlite-reader.d.ts +2 -1
- package/dist/types/web/search/providers/codex.d.ts +4 -4
- package/package.json +7 -7
- package/src/async/job-manager.ts +181 -43
- package/src/config/file-lock.ts +9 -1
- package/src/config/model-profile-activation.ts +71 -3
- package/src/config/model-profiles.ts +39 -14
- package/src/dap/client.ts +105 -64
- package/src/dap/session.ts +44 -7
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +11 -2
- package/src/defaults/gjc/skills/ralplan/SKILL.md +2 -2
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -2
- package/src/edit/read-file.ts +19 -1
- package/src/eval/js/context-manager.ts +228 -65
- package/src/eval/js/executor.ts +2 -0
- package/src/eval/js/index.ts +1 -0
- package/src/eval/js/worker-core.ts +10 -6
- package/src/eval/py/executor.ts +68 -19
- package/src/eval/py/kernel.ts +46 -22
- package/src/eval/py/runner.py +68 -14
- package/src/exec/bash-executor.ts +49 -13
- package/src/gjc-runtime/deep-interview-runtime.ts +14 -13
- package/src/gjc-runtime/ralplan-runtime.ts +10 -0
- package/src/gjc-runtime/state-runtime.ts +73 -0
- package/src/gjc-runtime/tmux-gc.ts +86 -37
- package/src/gjc-runtime/tmux-sessions.ts +44 -6
- package/src/gjc-runtime/ultragoal-runtime.ts +8 -4
- package/src/internal-urls/artifact-protocol.ts +10 -1
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/lsp/client.ts +64 -26
- package/src/lsp/index.ts +2 -1
- package/src/lsp/lspmux.ts +33 -9
- package/src/lsp/types.ts +2 -0
- package/src/modes/bridge/bridge-mode.ts +21 -0
- package/src/modes/components/assistant-message.ts +10 -2
- package/src/modes/components/bash-execution.ts +5 -1
- package/src/modes/components/eval-execution.ts +5 -1
- package/src/modes/components/model-selector.ts +34 -2
- package/src/modes/components/oauth-selector.ts +5 -0
- package/src/modes/components/runtime-mcp-add-wizard.ts +58 -7
- package/src/modes/components/skill-message.ts +24 -16
- package/src/modes/components/tool-execution.ts +6 -0
- package/src/modes/controllers/extension-ui-controller.ts +33 -6
- package/src/modes/controllers/input-controller.ts +19 -0
- package/src/modes/controllers/selector-controller.ts +6 -1
- package/src/modes/interactive-mode.ts +13 -0
- package/src/modes/types.ts +1 -0
- package/src/modes/utils/ui-helpers.ts +5 -2
- package/src/prompts/agents/executor.md +1 -1
- package/src/runtime/process-lifecycle.ts +400 -0
- package/src/runtime-mcp/manager.ts +164 -50
- package/src/runtime-mcp/transports/http.ts +12 -11
- package/src/runtime-mcp/transports/stdio.ts +64 -38
- package/src/runtime-mcp/types.ts +3 -0
- package/src/sdk.ts +27 -0
- package/src/session/agent-session.ts +271 -25
- package/src/session/artifacts.ts +17 -2
- package/src/session/blob-store.ts +36 -2
- package/src/session/session-manager.ts +29 -13
- package/src/session/streaming-output.ts +95 -3
- package/src/setup/model-onboarding-guidance.ts +10 -3
- package/src/skill-state/active-state.ts +79 -7
- package/src/slash-commands/builtin-registry.ts +30 -3
- package/src/slash-commands/helpers/fast-status-report.ts +111 -0
- package/src/tools/archive-reader.ts +10 -1
- package/src/tools/bash.ts +11 -4
- package/src/tools/browser/registry.ts +17 -1
- package/src/tools/browser/tab-supervisor.ts +22 -0
- package/src/tools/browser.ts +38 -4
- package/src/tools/cron.ts +2 -6
- package/src/tools/read.ts +11 -12
- package/src/tools/sqlite-reader.ts +19 -5
- package/src/web/search/providers/codex.ts +6 -5
|
@@ -375,6 +375,7 @@ export class ToolExecutionComponent extends Container {
|
|
|
375
375
|
this.#renderState.spinnerFrame = this.#spinnerFrame;
|
|
376
376
|
this.#ui.requestRender();
|
|
377
377
|
}, 80);
|
|
378
|
+
this.#spinnerInterval?.unref?.();
|
|
378
379
|
} else if (!needsSpinner && this.#spinnerInterval) {
|
|
379
380
|
clearInterval(this.#spinnerInterval);
|
|
380
381
|
this.#spinnerInterval = undefined;
|
|
@@ -394,6 +395,11 @@ export class ToolExecutionComponent extends Container {
|
|
|
394
395
|
this.#editDiffAbort = undefined;
|
|
395
396
|
}
|
|
396
397
|
|
|
398
|
+
override dispose(): void {
|
|
399
|
+
this.stopAnimation();
|
|
400
|
+
super.dispose();
|
|
401
|
+
}
|
|
402
|
+
|
|
397
403
|
setExpanded(expanded: boolean): void {
|
|
398
404
|
this.#expanded = expanded;
|
|
399
405
|
this.#updateDisplay();
|
|
@@ -36,9 +36,20 @@ export class ExtensionUiController {
|
|
|
36
36
|
#hookWidgetsAbove = new Map<string, ExtensionUiComponent>();
|
|
37
37
|
#hookWidgetsBelow = new Map<string, ExtensionUiComponent>();
|
|
38
38
|
#hookSelectorMouseReportingEnabled = false;
|
|
39
|
+
#activeHookCustomComponent?: Component & { dispose?(): void };
|
|
40
|
+
#activeHookCustomOverlay?: OverlayHandle;
|
|
39
41
|
|
|
40
42
|
constructor(private ctx: InteractiveModeContext) {}
|
|
41
43
|
|
|
44
|
+
#clearActiveHookCustom(): void {
|
|
45
|
+
const component = this.#activeHookCustomComponent;
|
|
46
|
+
const overlay = this.#activeHookCustomOverlay;
|
|
47
|
+
this.#activeHookCustomComponent = undefined;
|
|
48
|
+
this.#activeHookCustomOverlay = undefined;
|
|
49
|
+
component?.dispose?.();
|
|
50
|
+
overlay?.hide();
|
|
51
|
+
}
|
|
52
|
+
|
|
42
53
|
/**
|
|
43
54
|
* Initialize the hook system with TUI-based UI context.
|
|
44
55
|
*/
|
|
@@ -301,7 +312,11 @@ export class ExtensionUiController {
|
|
|
301
312
|
spacerWhenEmpty: boolean,
|
|
302
313
|
leadingSpacer: boolean,
|
|
303
314
|
): void {
|
|
304
|
-
|
|
315
|
+
// Detach (not dispose): hook widgets are persistent instances owned by the
|
|
316
|
+
// #hookWidgets* maps and re-added on every rebuild. Disposal happens only on
|
|
317
|
+
// explicit removal (#removeHookWidget) or clearHookWidgets(), so a rebuild must
|
|
318
|
+
// not tear down a still-live widget (e.g. an extension CancellableLoader timer).
|
|
319
|
+
container.detachAll();
|
|
305
320
|
|
|
306
321
|
if (widgets.size === 0) {
|
|
307
322
|
if (spacerWhenEmpty) {
|
|
@@ -665,6 +680,9 @@ export class ExtensionUiController {
|
|
|
665
680
|
: undefined,
|
|
666
681
|
},
|
|
667
682
|
);
|
|
683
|
+
// Detach (not dispose) the reusable editor before mounting the transient hook UI, so the
|
|
684
|
+
// disposing clear() only tears down a prior transient — the editor is re-added intact on close.
|
|
685
|
+
this.ctx.editorContainer.detachChild(this.ctx.editor);
|
|
668
686
|
this.ctx.editorContainer.clear();
|
|
669
687
|
this.ctx.editorContainer.addChild(this.ctx.hookSelector);
|
|
670
688
|
this.ctx.ui.setFocus(this.ctx.hookSelector);
|
|
@@ -743,6 +761,9 @@ export class ExtensionUiController {
|
|
|
743
761
|
tui: this.ctx.ui,
|
|
744
762
|
},
|
|
745
763
|
);
|
|
764
|
+
// Detach (not dispose) the reusable editor before mounting the transient hook UI, so the
|
|
765
|
+
// disposing clear() only tears down a prior transient — the editor is re-added intact on close.
|
|
766
|
+
this.ctx.editorContainer.detachChild(this.ctx.editor);
|
|
746
767
|
this.ctx.editorContainer.clear();
|
|
747
768
|
this.ctx.editorContainer.addChild(this.ctx.hookInput);
|
|
748
769
|
this.ctx.ui.setFocus(this.ctx.hookInput);
|
|
@@ -791,6 +812,9 @@ export class ExtensionUiController {
|
|
|
791
812
|
editorOptions,
|
|
792
813
|
);
|
|
793
814
|
|
|
815
|
+
// Detach (not dispose) the reusable editor before mounting the transient hook UI, so the
|
|
816
|
+
// disposing clear() only tears down a prior transient — the editor is re-added intact on close.
|
|
817
|
+
this.ctx.editorContainer.detachChild(this.ctx.editor);
|
|
794
818
|
this.ctx.editorContainer.clear();
|
|
795
819
|
this.ctx.editorContainer.addChild(this.ctx.hookEditor);
|
|
796
820
|
this.ctx.ui.setFocus(this.ctx.hookEditor);
|
|
@@ -840,15 +864,12 @@ export class ExtensionUiController {
|
|
|
840
864
|
|
|
841
865
|
const { promise, resolve } = Promise.withResolvers<T>();
|
|
842
866
|
let component: (Component & { dispose?(): void }) | undefined;
|
|
843
|
-
let overlayHandle: OverlayHandle | undefined;
|
|
844
867
|
let closed = false;
|
|
845
868
|
|
|
846
869
|
const close = (result: T) => {
|
|
847
870
|
if (closed) return;
|
|
848
871
|
closed = true;
|
|
849
|
-
|
|
850
|
-
overlayHandle?.hide();
|
|
851
|
-
overlayHandle = undefined;
|
|
872
|
+
this.#clearActiveHookCustom();
|
|
852
873
|
if (!options?.overlay) {
|
|
853
874
|
this.ctx.editorContainer.clear();
|
|
854
875
|
this.ctx.editorContainer.addChild(this.ctx.editor);
|
|
@@ -859,14 +880,16 @@ export class ExtensionUiController {
|
|
|
859
880
|
resolve(result);
|
|
860
881
|
};
|
|
861
882
|
|
|
883
|
+
this.#clearActiveHookCustom();
|
|
862
884
|
Promise.try(() => factory(this.ctx.ui, theme, keybindings, close)).then(c => {
|
|
863
885
|
if (closed) {
|
|
864
886
|
c.dispose?.();
|
|
865
887
|
return;
|
|
866
888
|
}
|
|
867
889
|
component = c;
|
|
890
|
+
this.#activeHookCustomComponent = c;
|
|
868
891
|
if (options?.overlay) {
|
|
869
|
-
|
|
892
|
+
this.#activeHookCustomOverlay = this.ctx.ui.showOverlay(component, {
|
|
870
893
|
anchor: "bottom-center",
|
|
871
894
|
width: "100%",
|
|
872
895
|
maxHeight: "100%",
|
|
@@ -874,6 +897,9 @@ export class ExtensionUiController {
|
|
|
874
897
|
});
|
|
875
898
|
return;
|
|
876
899
|
}
|
|
900
|
+
// Detach (not dispose) the reusable editor before mounting the transient hook UI, so the
|
|
901
|
+
// disposing clear() only tears down a prior transient — the editor is re-added intact on close.
|
|
902
|
+
this.ctx.editorContainer.detachChild(this.ctx.editor);
|
|
877
903
|
this.ctx.editorContainer.clear();
|
|
878
904
|
this.ctx.editorContainer.addChild(component);
|
|
879
905
|
this.ctx.ui.setFocus(component);
|
|
@@ -895,6 +921,7 @@ export class ExtensionUiController {
|
|
|
895
921
|
}
|
|
896
922
|
|
|
897
923
|
clearHookWidgets(): void {
|
|
924
|
+
this.#clearActiveHookCustom();
|
|
898
925
|
for (const widget of this.#hookWidgetsAbove.values()) {
|
|
899
926
|
widget.dispose?.();
|
|
900
927
|
}
|
|
@@ -84,6 +84,20 @@ export class InputController {
|
|
|
84
84
|
}
|
|
85
85
|
this.#steerConsumePending = false;
|
|
86
86
|
}
|
|
87
|
+
// Normal input state with user-typed text: Esc must not interrupt a
|
|
88
|
+
// running task (streaming turn, bash/eval). A double Esc within the
|
|
89
|
+
// 500ms window clears the composer instead. Bash/Python input modes
|
|
90
|
+
// keep their own Esc handling in the chain below.
|
|
91
|
+
if (!this.ctx.isBashMode && !this.ctx.isPythonMode && this.ctx.editor.getText().trim()) {
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
if (now - this.ctx.lastComposerClearEscapeTime < 500) {
|
|
94
|
+
this.ctx.clearEditor();
|
|
95
|
+
this.ctx.lastComposerClearEscapeTime = 0;
|
|
96
|
+
} else {
|
|
97
|
+
this.ctx.lastComposerClearEscapeTime = now;
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
87
101
|
if (this.ctx.loadingAnimation) {
|
|
88
102
|
if (this.ctx.cancelPendingSubmission()) {
|
|
89
103
|
return;
|
|
@@ -887,6 +901,11 @@ export class InputController {
|
|
|
887
901
|
this.ctx.session.agent.hideThinkingSummary = this.ctx.hideThinkingBlock;
|
|
888
902
|
|
|
889
903
|
// Rebuild chat from session messages
|
|
904
|
+
// Detach the live streaming component before the disposing clear() so the
|
|
905
|
+
// component we re-add below is not torn down (detach != dispose).
|
|
906
|
+
if (this.ctx.streamingComponent) {
|
|
907
|
+
this.ctx.chatContainer.detachChild(this.ctx.streamingComponent);
|
|
908
|
+
}
|
|
890
909
|
this.ctx.chatContainer.clear();
|
|
891
910
|
this.ctx.rebuildChatFromMessages();
|
|
892
911
|
|
|
@@ -684,7 +684,12 @@ export class SelectorController {
|
|
|
684
684
|
done();
|
|
685
685
|
this.ctx.ui.requestRender();
|
|
686
686
|
},
|
|
687
|
-
{
|
|
687
|
+
{
|
|
688
|
+
...options,
|
|
689
|
+
sessionId: this.ctx.session.sessionId,
|
|
690
|
+
isFastForProvider: provider => this.ctx.session.isFastForProvider(provider),
|
|
691
|
+
isFastForSubagentProvider: provider => this.ctx.session.isFastForSubagentProvider(provider),
|
|
692
|
+
},
|
|
688
693
|
);
|
|
689
694
|
return { component: selector, focus: selector };
|
|
690
695
|
});
|
|
@@ -292,6 +292,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
292
292
|
#pendingSubmissionDispose: (() => void) | undefined;
|
|
293
293
|
lastSigintTime = 0;
|
|
294
294
|
lastEscapeTime = 0;
|
|
295
|
+
lastComposerClearEscapeTime = 0;
|
|
295
296
|
shutdownRequested = false;
|
|
296
297
|
#isShuttingDown = false;
|
|
297
298
|
hookSelector: HookSelectorComponent | undefined = undefined;
|
|
@@ -306,6 +307,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
306
307
|
#baseSlashCommands: SlashCommand[] = [];
|
|
307
308
|
#baseReservedSlashCommandNames: Set<string> = new Set();
|
|
308
309
|
#cleanupUnsubscribe?: () => void;
|
|
310
|
+
#subprocessTeardownUnsubscribe?: () => void;
|
|
309
311
|
readonly #version: string;
|
|
310
312
|
readonly #changelogMarkdown: string | undefined;
|
|
311
313
|
#planModePreviousTools: string[] | undefined;
|
|
@@ -447,6 +449,14 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
447
449
|
// Register session manager flush for signal handlers (SIGINT, SIGTERM, SIGHUP)
|
|
448
450
|
this.#cleanupUnsubscribe = postmortem.register("session-manager-flush", () => this.sessionManager.flush());
|
|
449
451
|
|
|
452
|
+
// Tear down subprocess-spawning tools (browser Chrome, Python eval kernel) on a
|
|
453
|
+
// signal kill (SIGINT/SIGTERM/SIGHUP) so they aren't reparented to PID 1 (#698).
|
|
454
|
+
// The graceful /quit path already releases these via session.dispose(); this hook
|
|
455
|
+
// is the bounded, idempotent fallback for an external kill that bypasses it.
|
|
456
|
+
this.#subprocessTeardownUnsubscribe = postmortem.register("session-subprocess-teardown", () =>
|
|
457
|
+
this.session.disposeChildSubprocesses(),
|
|
458
|
+
);
|
|
459
|
+
|
|
450
460
|
await logger.time(
|
|
451
461
|
"InteractiveMode.init:slashCommands",
|
|
452
462
|
this.refreshSlashCommandState.bind(this),
|
|
@@ -1908,6 +1918,9 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
1908
1918
|
if (this.#cleanupUnsubscribe) {
|
|
1909
1919
|
this.#cleanupUnsubscribe();
|
|
1910
1920
|
}
|
|
1921
|
+
if (this.#subprocessTeardownUnsubscribe) {
|
|
1922
|
+
this.#subprocessTeardownUnsubscribe();
|
|
1923
|
+
}
|
|
1911
1924
|
if (this.isInitialized) {
|
|
1912
1925
|
this.ui.stop();
|
|
1913
1926
|
this.isInitialized = false;
|
package/src/modes/types.ts
CHANGED
|
@@ -116,6 +116,7 @@ export interface InteractiveModeContext {
|
|
|
116
116
|
locallySubmittedUserSignatures: Set<string>;
|
|
117
117
|
lastSigintTime: number;
|
|
118
118
|
lastEscapeTime: number;
|
|
119
|
+
lastComposerClearEscapeTime: number;
|
|
119
120
|
shutdownRequested: boolean;
|
|
120
121
|
hookSelector: HookSelectorComponent | undefined;
|
|
121
122
|
hookInput: HookInputComponent | undefined;
|
|
@@ -706,13 +706,16 @@ export class UiHelpers {
|
|
|
706
706
|
|
|
707
707
|
/** Move pending bash components from pending area to chat */
|
|
708
708
|
flushPendingBashComponents(): void {
|
|
709
|
+
// Move (detach, not dispose) the live execution components from the pending
|
|
710
|
+
// area into the chat transcript — they are reused instances, so a disposing
|
|
711
|
+
// removeChild() would tear them down before re-adding.
|
|
709
712
|
for (const component of this.ctx.pendingBashComponents) {
|
|
710
|
-
this.ctx.pendingMessagesContainer.
|
|
713
|
+
this.ctx.pendingMessagesContainer.detachChild(component);
|
|
711
714
|
this.ctx.chatContainer.addChild(component);
|
|
712
715
|
}
|
|
713
716
|
this.ctx.pendingBashComponents = [];
|
|
714
717
|
for (const component of this.ctx.pendingPythonComponents) {
|
|
715
|
-
this.ctx.pendingMessagesContainer.
|
|
718
|
+
this.ctx.pendingMessagesContainer.detachChild(component);
|
|
716
719
|
this.ctx.chatContainer.addChild(component);
|
|
717
720
|
}
|
|
718
721
|
this.ctx.pendingPythonComponents = [];
|
|
@@ -37,7 +37,7 @@ This mode activates only when the assignment explicitly labels Executor as Ultra
|
|
|
37
37
|
When active:
|
|
38
38
|
- Start from the approved plan/spec/acceptance criteria, then user-facing contracts, then implementation code only as supporting evidence. Treat plan/code mismatches as blockers.
|
|
39
39
|
- Exercise the real user-facing invocation rather than inspecting internals alone. Live artifacts must be runtime-valid: GUI/web needs a real automation transcript plus non-uniform screenshot; CLI needs executed argv-only replay; native/desktop/TUI needs a real screenshot, PTY capture with control codes, or app-automation transcript. `inlineEvidence` is supplemental only and is never sole proof for live surfaces.
|
|
40
|
-
- For CLI evidence, emit argv-only replay JSON with `schemaVersion: 1`, `kind: "cli-replay"`, `replaySafe: true`, and `command` as a string array. Use only allowlisted deterministic executables/arguments, or mark unsafe/non-deterministic commands with audited `replayExempt` metadata plus a valid structural fallback artifact.
|
|
40
|
+
- For CLI evidence, emit argv-only replay JSON with `schemaVersion: 1`, `kind: "cli-replay"`, `replaySafe: true`, and `command` as a string array. Use only allowlisted deterministic executables/arguments, or mark unsafe/non-deterministic commands with audited `replayExempt` metadata plus a valid structural fallback artifact. `replayExempt` must use exact fields `reasonCode`, `reason`, `approvedBy`, and `fallbackArtifactRefs`; allowed `reasonCode` values are exactly `unsafe_side_effect`, `requires_credentials`, `requires_network`, `non_deterministic_external`, `destructive`, `interactive_only`, and `platform_unavailable`.
|
|
41
41
|
- Native/TUI evidence must be structural, not prose-only: screenshot, app transcript, or PTY artifact with terminal control codes.
|
|
42
42
|
- Do not call the `ask` tool while an Ultragoal run is active; record unresolved decisions with `gjc ultragoal record-review-blockers`.
|
|
43
43
|
- Try to break the work with adversarial cases, not just happy-path confirmations.
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared runtime lifecycle foundation.
|
|
3
|
+
*
|
|
4
|
+
* Two minimal, deliberately small primitives that subsystem runtimes
|
|
5
|
+
* (DAP/LSP/MCP stdio, eval workers, etc.) adopt so spawned children and
|
|
6
|
+
* non-process resources cannot outlive their owner:
|
|
7
|
+
*
|
|
8
|
+
* F1(a) `spawnOwnedProcess` — wraps `ptree.spawn` with explicit
|
|
9
|
+
* process-group ownership, escalating (SIGTERM -> grace -> SIGKILL)
|
|
10
|
+
* tree termination, bounded `awaitExit`, abort-listener cleanup on
|
|
11
|
+
* settle, idempotent `dispose`, and a single postmortem hook that
|
|
12
|
+
* reaps every still-live owned process group on fatal/normal shutdown.
|
|
13
|
+
*
|
|
14
|
+
* F1(b) `registerResourceOwner` — a generic, idempotent postmortem adapter
|
|
15
|
+
* for non-process resources (Bun Workers, VM contexts, timers,
|
|
16
|
+
* sockets) built on the existing `postmortem.register` facility.
|
|
17
|
+
*
|
|
18
|
+
* Ownership is keyed to the *process group*, not the root process. A root that
|
|
19
|
+
* exits after backgrounding descendants (`sh -c "worker & exit 0"`) keeps the
|
|
20
|
+
* owner registered until the group is actually gone, so the descendant tree is
|
|
21
|
+
* still reaped by `dispose()`/postmortem.
|
|
22
|
+
*
|
|
23
|
+
* This module intentionally owns only these primitives. It does not migrate
|
|
24
|
+
* existing call sites; subsystem PRs adopt it incrementally.
|
|
25
|
+
*
|
|
26
|
+
* Note: `ptree.spawn` always pipes stdout/stderr. Adopters that expect output
|
|
27
|
+
* (DAP/LSP/MCP protocol servers) must consume `owner.child.stdout`; F1 does not
|
|
28
|
+
* drain it, so a chatty child whose stdout is never read can still block on a
|
|
29
|
+
* full pipe. That draining is the adopter's responsibility.
|
|
30
|
+
*/
|
|
31
|
+
import { logger, postmortem, ptree } from "@gajae-code/utils";
|
|
32
|
+
|
|
33
|
+
const DEFAULT_GRACEFUL_MS = 2_000;
|
|
34
|
+
// Hard cap for how long `dispose()` waits after SIGKILL before giving up so a
|
|
35
|
+
// wedged, unkillable child can never block shutdown forever.
|
|
36
|
+
const SIGKILL_REAP_CAP_MS = 2_000;
|
|
37
|
+
// After the root process exits on its own, how long to wait for the process
|
|
38
|
+
// group to drain before deregistering. Clean servers drain immediately; a root
|
|
39
|
+
// that backgrounded descendants stays registered past this window.
|
|
40
|
+
const ROOT_EXIT_DRAIN_MS = 250;
|
|
41
|
+
|
|
42
|
+
const isPosix = process.platform !== "win32";
|
|
43
|
+
|
|
44
|
+
const delay = (ms: number): Promise<void> =>
|
|
45
|
+
new Promise(resolve => {
|
|
46
|
+
const timer = setTimeout(resolve, Math.max(0, ms));
|
|
47
|
+
timer.unref?.();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** Poll `predicate` until it is true or `timeoutMs` elapses. Returns the final value. */
|
|
51
|
+
async function pollUntil(predicate: () => boolean, timeoutMs: number, intervalMs = 20): Promise<boolean> {
|
|
52
|
+
if (predicate()) return true;
|
|
53
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
54
|
+
while (Date.now() < deadline) {
|
|
55
|
+
await delay(Math.min(intervalMs, Math.max(0, deadline - Date.now())));
|
|
56
|
+
if (predicate()) return true;
|
|
57
|
+
}
|
|
58
|
+
return predicate();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether a POSIX process group still has any member (zombies count as alive). */
|
|
62
|
+
function groupAlive(pgid: number): boolean {
|
|
63
|
+
try {
|
|
64
|
+
process.kill(-pgid, 0);
|
|
65
|
+
return true;
|
|
66
|
+
} catch (err) {
|
|
67
|
+
// EPERM => the group exists but we cannot signal it; treat as alive.
|
|
68
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Options for {@link spawnOwnedProcess}. */
|
|
73
|
+
export interface SpawnOwnedOptions {
|
|
74
|
+
cwd?: string;
|
|
75
|
+
env?: Record<string, string | undefined>;
|
|
76
|
+
/** stdin mode passed through to the child. Defaults to `"ignore"`. */
|
|
77
|
+
stdin?: "pipe" | "ignore";
|
|
78
|
+
/** When aborted, the owned process tree is disposed (escalating kill). */
|
|
79
|
+
signal?: AbortSignal;
|
|
80
|
+
/** Grace period (ms) between SIGTERM and SIGKILL on dispose. Default 2000. */
|
|
81
|
+
gracefulMs?: number;
|
|
82
|
+
/**
|
|
83
|
+
* Spawn the child as its own process-group leader so the whole descendant
|
|
84
|
+
* tree can be signalled on dispose. Defaults to `true` on POSIX. Has no
|
|
85
|
+
* effect on Windows, where teardown falls back to single-process kill.
|
|
86
|
+
*/
|
|
87
|
+
processGroup?: boolean;
|
|
88
|
+
/** Label used in diagnostics. */
|
|
89
|
+
name?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Result of a bounded {@link OwnedProcess.awaitExit}. */
|
|
93
|
+
export interface AwaitExitResult {
|
|
94
|
+
/** `true` when the process has exited; `false` when the timeout fired first. */
|
|
95
|
+
exited: boolean;
|
|
96
|
+
/** Exit code if known, else `null`. */
|
|
97
|
+
code: number | null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A spawned child process owned by the runtime with guaranteed teardown. */
|
|
101
|
+
export interface OwnedProcess {
|
|
102
|
+
readonly child: ptree.ChildProcess;
|
|
103
|
+
readonly pid: number | undefined;
|
|
104
|
+
/** Resolves/rejects when the root child exits (mirrors ptree's `exited`). */
|
|
105
|
+
readonly exited: Promise<number>;
|
|
106
|
+
/** `true` once `dispose()` has started. */
|
|
107
|
+
readonly disposed: boolean;
|
|
108
|
+
/**
|
|
109
|
+
* Wait for the root child to exit, optionally bounded by `timeoutMs`. With no
|
|
110
|
+
* timeout it resolves only when the child exits. Never rejects.
|
|
111
|
+
*/
|
|
112
|
+
awaitExit(opts?: { timeoutMs?: number }): Promise<AwaitExitResult>;
|
|
113
|
+
/**
|
|
114
|
+
* Idempotently terminate the owned process *group*: SIGTERM the group, wait
|
|
115
|
+
* `gracefulMs`, then SIGKILL, polling group liveness throughout. Removes the
|
|
116
|
+
* abort listener and deregisters from the live-owner set only after teardown
|
|
117
|
+
* has completed. Repeated/concurrent calls return the same in-flight promise.
|
|
118
|
+
*/
|
|
119
|
+
dispose(): Promise<void>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const liveOwners = new Set<OwnedProcess>();
|
|
123
|
+
let ownedPostmortemRegistered = false;
|
|
124
|
+
|
|
125
|
+
function ensureOwnedPostmortem(): void {
|
|
126
|
+
if (ownedPostmortemRegistered) return;
|
|
127
|
+
ownedPostmortemRegistered = true;
|
|
128
|
+
postmortem.register("runtime:owned-processes", async () => {
|
|
129
|
+
await Promise.all([...liveOwners].map(owner => owner.dispose().catch(() => undefined)));
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Spawn a child process owned by the runtime. The returned {@link OwnedProcess}
|
|
135
|
+
* is registered for postmortem cleanup and tears down its whole process group
|
|
136
|
+
* on dispose/abort.
|
|
137
|
+
*/
|
|
138
|
+
export function spawnOwnedProcess(cmd: string[], opts: SpawnOwnedOptions = {}): OwnedProcess {
|
|
139
|
+
const gracefulMs = opts.gracefulMs ?? DEFAULT_GRACEFUL_MS;
|
|
140
|
+
const useGroup = (opts.processGroup ?? true) && isPosix;
|
|
141
|
+
|
|
142
|
+
ensureOwnedPostmortem();
|
|
143
|
+
|
|
144
|
+
// We deliberately do NOT forward `opts.signal` to `ptree.spawn`: ptree's
|
|
145
|
+
// `attachSignal` only kills the single process, whereas owned teardown must
|
|
146
|
+
// signal the whole group. We wire our own abort listener below and remove it
|
|
147
|
+
// on settle so long-lived signals never accumulate listeners.
|
|
148
|
+
const child = ptree.spawn(cmd, {
|
|
149
|
+
cwd: opts.cwd,
|
|
150
|
+
env: opts.env,
|
|
151
|
+
stdin: opts.stdin ?? "ignore",
|
|
152
|
+
detached: useGroup,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// On POSIX with `detached`, the child is its own process-group leader, so the
|
|
156
|
+
// group id equals its pid. `undefined` => single-process (Windows/opt-out).
|
|
157
|
+
const pgid = useGroup ? child.pid : undefined;
|
|
158
|
+
|
|
159
|
+
let disposed = false;
|
|
160
|
+
let disposePromise: Promise<void> | undefined;
|
|
161
|
+
let deregistered = false;
|
|
162
|
+
// Terminal once teardown/reconciliation has confirmed the group is gone. A
|
|
163
|
+
// late dispose() must then be a true no-op and never re-probe a pgid the OS
|
|
164
|
+
// may have recycled into an unrelated group.
|
|
165
|
+
let terminated = false;
|
|
166
|
+
let onAbort: (() => void) | undefined;
|
|
167
|
+
|
|
168
|
+
const removeAbort = (): void => {
|
|
169
|
+
if (onAbort && opts.signal) {
|
|
170
|
+
opts.signal.removeEventListener("abort", onAbort);
|
|
171
|
+
onAbort = undefined;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const deregister = (): void => {
|
|
176
|
+
if (deregistered) return;
|
|
177
|
+
deregistered = true;
|
|
178
|
+
terminated = true;
|
|
179
|
+
liveOwners.delete(owner);
|
|
180
|
+
removeAbort();
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const signalTree = (signal: NodeJS.Signals): void => {
|
|
184
|
+
const pid = child.pid;
|
|
185
|
+
if (pid === undefined) return;
|
|
186
|
+
if (pgid !== undefined) {
|
|
187
|
+
try {
|
|
188
|
+
// Negative pid signals the entire process group (child is leader).
|
|
189
|
+
process.kill(-pgid, signal);
|
|
190
|
+
return;
|
|
191
|
+
} catch {
|
|
192
|
+
// Group already gone; nothing to do.
|
|
193
|
+
}
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (signal === "SIGKILL") {
|
|
197
|
+
try {
|
|
198
|
+
process.kill(pid, "SIGKILL");
|
|
199
|
+
} catch {
|
|
200
|
+
/* already gone */
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
// ptree's kill terminates the single process via the native handle.
|
|
204
|
+
child.kill();
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const owner: OwnedProcess = {
|
|
209
|
+
child,
|
|
210
|
+
get pid() {
|
|
211
|
+
return child.pid;
|
|
212
|
+
},
|
|
213
|
+
get exited() {
|
|
214
|
+
return child.exited;
|
|
215
|
+
},
|
|
216
|
+
get disposed() {
|
|
217
|
+
return disposed;
|
|
218
|
+
},
|
|
219
|
+
async awaitExit({ timeoutMs }: { timeoutMs?: number } = {}): Promise<AwaitExitResult> {
|
|
220
|
+
const exitedResult = child.exited
|
|
221
|
+
.then(code => ({ exited: true as const, code: code as number | null }))
|
|
222
|
+
.catch(() => ({ exited: true as const, code: child.exitCode }));
|
|
223
|
+
if (timeoutMs === undefined) return exitedResult;
|
|
224
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
225
|
+
const timeout = new Promise<AwaitExitResult>(resolve => {
|
|
226
|
+
timer = setTimeout(() => resolve({ exited: false, code: child.exitCode }), Math.max(0, timeoutMs));
|
|
227
|
+
timer.unref?.();
|
|
228
|
+
});
|
|
229
|
+
try {
|
|
230
|
+
return await Promise.race([exitedResult, timeout]);
|
|
231
|
+
} finally {
|
|
232
|
+
if (timer) clearTimeout(timer);
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
dispose(): Promise<void> {
|
|
236
|
+
// Already terminal (e.g. clean drain reconciled and deregistered):
|
|
237
|
+
// never re-probe the pgid; treat dispose as a settled no-op.
|
|
238
|
+
if (terminated) {
|
|
239
|
+
disposed = true;
|
|
240
|
+
if (!disposePromise) disposePromise = Promise.resolve();
|
|
241
|
+
return disposePromise;
|
|
242
|
+
}
|
|
243
|
+
if (disposePromise) return disposePromise;
|
|
244
|
+
disposed = true;
|
|
245
|
+
removeAbort();
|
|
246
|
+
disposePromise = (async () => {
|
|
247
|
+
try {
|
|
248
|
+
if (pgid !== undefined) {
|
|
249
|
+
// Group ownership: reap until the whole group is gone, even if
|
|
250
|
+
// the root has already exited (it may have backgrounded children).
|
|
251
|
+
if (!groupAlive(pgid)) return;
|
|
252
|
+
signalTree("SIGTERM");
|
|
253
|
+
if (await pollUntil(() => !groupAlive(pgid), gracefulMs)) return;
|
|
254
|
+
signalTree("SIGKILL");
|
|
255
|
+
if (!(await pollUntil(() => !groupAlive(pgid), SIGKILL_REAP_CAP_MS))) {
|
|
256
|
+
logger.warn("owned process group still alive after SIGKILL", {
|
|
257
|
+
name: opts.name,
|
|
258
|
+
pgid,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
// Single-process fallback (Windows / processGroup:false).
|
|
264
|
+
if (child.exitCode !== null) return;
|
|
265
|
+
signalTree("SIGTERM");
|
|
266
|
+
if ((await owner.awaitExit({ timeoutMs: gracefulMs })).exited) return;
|
|
267
|
+
signalTree("SIGKILL");
|
|
268
|
+
await owner.awaitExit({ timeoutMs: SIGKILL_REAP_CAP_MS });
|
|
269
|
+
} catch (err) {
|
|
270
|
+
logger.warn("owned process dispose failed", {
|
|
271
|
+
name: opts.name,
|
|
272
|
+
error: err instanceof Error ? err.message : String(err),
|
|
273
|
+
});
|
|
274
|
+
} finally {
|
|
275
|
+
// FIX: deregister only after teardown has completed so a postmortem
|
|
276
|
+
// firing mid-grace still sees the owner and awaits this dispose.
|
|
277
|
+
deregister();
|
|
278
|
+
}
|
|
279
|
+
})();
|
|
280
|
+
return disposePromise;
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
liveOwners.add(owner);
|
|
285
|
+
|
|
286
|
+
// When the root exits on its own (not via dispose), reconcile ownership by
|
|
287
|
+
// the *group*. After a short drain window: if the group is empty, deregister;
|
|
288
|
+
// if descendants are still alive, reap the owned group (no child outlives its
|
|
289
|
+
// owner). Either way the owner never lingers holding a stale pgid that the OS
|
|
290
|
+
// could later recycle and a stray dispose could mis-signal.
|
|
291
|
+
void child.exited
|
|
292
|
+
.catch(() => undefined)
|
|
293
|
+
.finally(() => {
|
|
294
|
+
if (disposed) return; // dispose() owns deregistration
|
|
295
|
+
if (pgid === undefined) {
|
|
296
|
+
deregister();
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
void (async () => {
|
|
300
|
+
const drained = await pollUntil(() => !groupAlive(pgid), ROOT_EXIT_DRAIN_MS);
|
|
301
|
+
if (disposed) return;
|
|
302
|
+
if (drained) {
|
|
303
|
+
deregister();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
// Root exited but the owned group still has descendants: reap them.
|
|
307
|
+
// dispose() escalates SIGTERM->SIGKILL and deregisters in its finally.
|
|
308
|
+
await owner.dispose();
|
|
309
|
+
})();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
if (opts.signal) {
|
|
313
|
+
if (opts.signal.aborted) {
|
|
314
|
+
void owner.dispose();
|
|
315
|
+
} else {
|
|
316
|
+
onAbort = () => void owner.dispose();
|
|
317
|
+
opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return owner;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Number of currently live owned processes. Exposed for leak assertions/tests. */
|
|
325
|
+
export function liveOwnedProcessCount(): number {
|
|
326
|
+
return liveOwners.size;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Dispose every live owned process. For owner-scoped teardown and tests. */
|
|
330
|
+
export async function disposeAllOwnedProcesses(): Promise<void> {
|
|
331
|
+
await Promise.all([...liveOwners].map(owner => owner.dispose().catch(() => undefined)));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── F1(b) generic resource owners ────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
type ResourceDisposer = () => void | Promise<void>;
|
|
337
|
+
|
|
338
|
+
const resourceOwners = new Map<string, ResourceDisposer>();
|
|
339
|
+
let resourcePostmortemRegistered = false;
|
|
340
|
+
|
|
341
|
+
function ensureResourcePostmortem(): void {
|
|
342
|
+
if (resourcePostmortemRegistered) return;
|
|
343
|
+
resourcePostmortemRegistered = true;
|
|
344
|
+
// Postmortem isolates per-callback failures; swallow the aggregate here so
|
|
345
|
+
// shutdown continues, while direct callers of disposeAllResourceOwners still
|
|
346
|
+
// observe the AggregateError.
|
|
347
|
+
postmortem.register("runtime:resource-owners", () =>
|
|
348
|
+
disposeAllResourceOwners().catch(err => {
|
|
349
|
+
logger.warn("resource owner postmortem cleanup had failures", {
|
|
350
|
+
error: err instanceof Error ? err.message : String(err),
|
|
351
|
+
});
|
|
352
|
+
}),
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Register a non-process resource for postmortem/fatal-exit cleanup.
|
|
358
|
+
*
|
|
359
|
+
* Idempotent by `name`: re-registering the same name replaces the prior
|
|
360
|
+
* disposer (last wins). Returns an unregister function that removes the owner
|
|
361
|
+
* only while it is still the active registration for that name.
|
|
362
|
+
*/
|
|
363
|
+
export function registerResourceOwner(name: string, disposer: ResourceDisposer): () => void {
|
|
364
|
+
resourceOwners.set(name, disposer);
|
|
365
|
+
ensureResourcePostmortem();
|
|
366
|
+
let unregistered = false;
|
|
367
|
+
return () => {
|
|
368
|
+
if (unregistered) return;
|
|
369
|
+
unregistered = true;
|
|
370
|
+
if (resourceOwners.get(name) === disposer) {
|
|
371
|
+
resourceOwners.delete(name);
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Number of registered resource owners. Exposed for leak assertions/tests. */
|
|
377
|
+
export function resourceOwnerCount(): number {
|
|
378
|
+
return resourceOwners.size;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Run and clear every registered resource disposer. Attempts all disposers even
|
|
383
|
+
* if some throw, then surfaces the failures as an `AggregateError` so callers
|
|
384
|
+
* can distinguish "all closed" from "a resource may still be alive".
|
|
385
|
+
*/
|
|
386
|
+
export async function disposeAllResourceOwners(): Promise<void> {
|
|
387
|
+
const disposers = [...resourceOwners.values()];
|
|
388
|
+
resourceOwners.clear();
|
|
389
|
+
const errors: unknown[] = [];
|
|
390
|
+
for (const disposer of disposers) {
|
|
391
|
+
try {
|
|
392
|
+
await disposer();
|
|
393
|
+
} catch (err) {
|
|
394
|
+
errors.push(err);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (errors.length > 0) {
|
|
398
|
+
throw new AggregateError(errors, `${errors.length} resource disposer(s) failed during teardown`);
|
|
399
|
+
}
|
|
400
|
+
}
|