@gajae-code/coding-agent 0.5.1 → 0.5.3
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 +31 -0
- package/README.md +1 -1
- package/dist/types/async/job-manager.d.ts +6 -0
- package/dist/types/cli/setup-cli.d.ts +8 -1
- package/dist/types/commands/setup.d.ts +7 -0
- package/dist/types/config/file-lock.d.ts +24 -2
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/config/models-config-schema.d.ts +5 -0
- package/dist/types/config/settings-schema.d.ts +62 -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/state-writer.d.ts +64 -2
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +7 -1
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +10 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +29 -0
- 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/provider-onboarding-selector.d.ts +1 -1
- 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 -1
- package/dist/types/modes/rpc/rpc-mode.d.ts +56 -1
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
- package/dist/types/modes/theme/defaults/index.d.ts +302 -0
- package/dist/types/modes/theme/theme.d.ts +1 -0
- package/dist/types/modes/types.d.ts +1 -1
- 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 +17 -1
- package/dist/types/session/artifacts.d.ts +4 -1
- package/dist/types/session/history-storage.d.ts +2 -2
- package/dist/types/session/session-manager.d.ts +10 -1
- package/dist/types/session/streaming-output.d.ts +5 -0
- package/dist/types/setup/credential-import.d.ts +79 -0
- package/dist/types/slash-commands/helpers/fast-status-report.d.ts +76 -0
- package/dist/types/task/executor.d.ts +1 -0
- package/dist/types/task/render.d.ts +1 -1
- 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/tools/subagent-render.d.ts +7 -1
- package/dist/types/tools/subagent.d.ts +21 -0
- package/dist/types/tools/ultragoal-ask-guard.d.ts +5 -0
- package/dist/types/web/search/index.d.ts +4 -4
- package/dist/types/web/search/provider.d.ts +16 -20
- package/dist/types/web/search/providers/base.d.ts +2 -1
- package/dist/types/web/search/providers/openai-compatible.d.ts +9 -0
- package/dist/types/web/search/types.d.ts +14 -2
- package/package.json +7 -7
- package/scripts/build-binary.ts +7 -0
- package/src/async/job-manager.ts +153 -39
- package/src/cli/args.ts +2 -0
- package/src/cli/fast-help.ts +2 -0
- package/src/cli/setup-cli.ts +138 -3
- package/src/commands/setup.ts +5 -1
- package/src/commands/ultragoal.ts +3 -1
- package/src/config/file-lock-gc.ts +14 -2
- package/src/config/file-lock.ts +63 -13
- package/src/config/model-profile-activation.ts +15 -3
- package/src/config/model-profiles.ts +15 -15
- package/src/config/model-registry.ts +21 -1
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings-schema.ts +62 -0
- package/src/dap/client.ts +105 -64
- package/src/dap/session.ts +44 -7
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +30 -8
- 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-recorder.ts +40 -0
- package/src/gjc-runtime/launch-tmux.ts +3 -4
- package/src/gjc-runtime/ralplan-runtime.ts +174 -12
- package/src/gjc-runtime/state-runtime.ts +2 -1
- package/src/gjc-runtime/state-writer.ts +254 -7
- package/src/gjc-runtime/tmux-gc.ts +88 -38
- package/src/gjc-runtime/tmux-sessions.ts +44 -6
- package/src/gjc-runtime/ultragoal-guard.ts +155 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +1227 -31
- package/src/gjc-runtime/workflow-manifest.generated.json +44 -0
- package/src/gjc-runtime/workflow-manifest.ts +12 -0
- package/src/harness-control-plane/owner.ts +3 -2
- package/src/harness-control-plane/rpc-adapter.ts +1 -1
- package/src/hooks/skill-state.ts +121 -2
- package/src/internal-urls/artifact-protocol.ts +10 -1
- package/src/internal-urls/docs-index.generated.ts +14 -10
- package/src/lsp/client.ts +64 -26
- package/src/lsp/defaults.json +1 -0
- package/src/lsp/index.ts +2 -1
- package/src/lsp/lspmux.ts +33 -9
- package/src/lsp/types.ts +2 -0
- package/src/main.ts +14 -4
- package/src/modes/acp/acp-agent.ts +4 -2
- package/src/modes/bridge/bridge-mode.ts +23 -1
- 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/history-search.ts +5 -2
- package/src/modes/components/model-selector.ts +60 -2
- package/src/modes/components/oauth-selector.ts +5 -0
- package/src/modes/components/provider-onboarding-selector.ts +6 -1
- 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 +5 -0
- package/src/modes/controllers/selector-controller.ts +86 -2
- package/src/modes/interactive-mode.ts +11 -1
- package/src/modes/rpc/rpc-mode.ts +132 -18
- package/src/modes/shared/agent-wire/command-dispatch.ts +5 -2
- package/src/modes/shared/agent-wire/host-tool-bridge.ts +3 -0
- package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
- package/src/modes/theme/defaults/claude-code.json +100 -0
- package/src/modes/theme/defaults/codex.json +100 -0
- package/src/modes/theme/defaults/index.ts +6 -0
- package/src/modes/theme/defaults/opencode.json +102 -0
- package/src/modes/theme/theme.ts +2 -2
- package/src/modes/types.ts +1 -1
- package/src/modes/utils/ui-helpers.ts +5 -2
- package/src/prompts/agents/executor.md +5 -2
- 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 +39 -1
- package/src/session/agent-session.ts +190 -33
- package/src/session/artifacts.ts +17 -2
- package/src/session/blob-store.ts +36 -2
- package/src/session/history-storage.ts +32 -11
- package/src/session/session-manager.ts +99 -31
- package/src/session/streaming-output.ts +54 -3
- package/src/setup/credential-import.ts +429 -0
- package/src/skill-state/deep-interview-mutation-guard.ts +2 -1
- package/src/slash-commands/builtin-registry.ts +30 -3
- package/src/slash-commands/helpers/fast-status-report.ts +111 -0
- package/src/task/executor.ts +7 -1
- package/src/task/render.ts +18 -7
- package/src/tools/archive-reader.ts +10 -1
- package/src/tools/ask.ts +4 -2
- package/src/tools/bash.ts +11 -4
- package/src/tools/browser/tab-supervisor.ts +22 -0
- package/src/tools/browser.ts +38 -4
- package/src/tools/cron.ts +1 -1
- package/src/tools/read.ts +11 -12
- package/src/tools/sqlite-reader.ts +19 -5
- package/src/tools/subagent-render.ts +119 -29
- package/src/tools/subagent.ts +147 -7
- package/src/tools/ultragoal-ask-guard.ts +39 -0
- package/src/web/search/index.ts +25 -25
- package/src/web/search/provider.ts +178 -87
- package/src/web/search/providers/base.ts +2 -1
- package/src/web/search/providers/openai-compatible.ts +151 -0
- package/src/web/search/types.ts +47 -22
|
@@ -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
|
+
}
|