@ravefox/space 0.2.0 → 0.3.0
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/LICENSE +21 -203
- package/README.md +89 -26
- package/bin/space.js +106 -0
- package/lib/agents.js +119 -0
- package/lib/credentials.js +95 -0
- package/lib/daemon.js +294 -0
- package/lib/harness.js +166 -0
- package/lib/http.js +101 -0
- package/lib/login.js +68 -0
- package/lib/mcp.js +34 -0
- package/lib/runtime.js +241 -0
- package/lib/store.js +96 -0
- package/lib/tools.js +93 -0
- package/package.json +28 -40
- package/NOTICE +0 -44
- package/assets/webhook-sdk/python/bot.py +0 -63
- package/assets/webhook-sdk/python/commonly.py +0 -232
- package/node_modules/@commonlyai/seats/README.md +0 -25
- package/node_modules/@commonlyai/seats/package.json +0 -22
- package/node_modules/@commonlyai/seats/src/adapters/hermes.js +0 -264
- package/node_modules/@commonlyai/seats/src/adapters/stub.js +0 -24
- package/node_modules/@commonlyai/seats/src/contract.js +0 -48
- package/node_modules/@commonlyai/seats/src/failures.js +0 -44
- package/node_modules/@commonlyai/seats/src/harness-plan.js +0 -329
- package/node_modules/@commonlyai/seats/src/index.js +0 -22
- package/node_modules/@commonlyai/seats/src/process-group.js +0 -269
- package/node_modules/@commonlyai/seats/src/record/format.js +0 -737
- package/node_modules/@commonlyai/seats/src/record/otel.js +0 -238
- package/node_modules/@commonlyai/seats/src/runtime-observation.js +0 -29
- package/node_modules/@commonlyai/seats/src/seat-model.js +0 -17
- package/node_modules/@commonlyai/seats/src/supervisor.js +0 -308
- package/node_modules/@commonlyai/seats/src/trace/events.js +0 -46
- package/node_modules/@commonlyai/seats/src/trace/store.js +0 -276
- package/node_modules/@commonlyai/seats/src/turn-processes.js +0 -226
- package/node_modules/@commonlyai/seats/src/upstream-refusal.js +0 -418
- package/node_modules/@commonlyai/seats/src/valet/env.js +0 -201
- package/node_modules/@commonlyai/seats/src/valet/grant.js +0 -123
- package/node_modules/@commonlyai/seats/src/valet/permissions.js +0 -213
- package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +0 -529
- package/skills/commonly/SKILL.md +0 -233
- package/src/commands/agent.js +0 -6334
- package/src/commands/daemon.js +0 -933
- package/src/commands/dev.js +0 -153
- package/src/commands/doctor.js +0 -141
- package/src/commands/inbox.js +0 -330
- package/src/commands/login.js +0 -194
- package/src/commands/mcp.js +0 -56
- package/src/commands/pod.js +0 -120
- package/src/commands/task.js +0 -279
- package/src/commands/verify.js +0 -48
- package/src/index.js +0 -99
- package/src/lib/adapter-login.js +0 -76
- package/src/lib/adapters/broker-bridge-entry.js +0 -76
- package/src/lib/adapters/claude.js +0 -2207
- package/src/lib/adapters/codex-broker-bridge.mjs +0 -298
- package/src/lib/adapters/codex.js +0 -1898
- package/src/lib/adapters/dsh.js +0 -513
- package/src/lib/adapters/goose.js +0 -510
- package/src/lib/adapters/hermes.js +0 -18
- package/src/lib/adapters/index.js +0 -68
- package/src/lib/adapters/opencode.js +0 -1681
- package/src/lib/adapters/pi-commonly-mcp.mjs +0 -71
- package/src/lib/adapters/pi-mcp-client.mjs +0 -617
- package/src/lib/adapters/pi-shell-env.mjs +0 -41
- package/src/lib/adapters/pi.js +0 -1025
- package/src/lib/adapters/qwen.js +0 -523
- package/src/lib/adapters/seat-model.js +0 -8
- package/src/lib/adapters/stub.js +0 -1
- package/src/lib/adapters/tested-version.js +0 -106
- package/src/lib/api.js +0 -205
- package/src/lib/claim-outcome.js +0 -128
- package/src/lib/claimed-work-gate.js +0 -73
- package/src/lib/cli-hint.js +0 -33
- package/src/lib/commit-attribution.js +0 -391
- package/src/lib/config.js +0 -155
- package/src/lib/content-refusal.js +0 -94
- package/src/lib/credential-file.js +0 -208
- package/src/lib/daemon-agent-states.js +0 -95
- package/src/lib/daemon-hint.js +0 -18
- package/src/lib/daemon-logs.js +0 -43
- package/src/lib/daemon-service-windows.js +0 -263
- package/src/lib/daemon-service.js +0 -486
- package/src/lib/daemon-state.js +0 -84
- package/src/lib/daemon-store.js +0 -119
- package/src/lib/daemon-supervisor.js +0 -629
- package/src/lib/daemon-watch.js +0 -115
- package/src/lib/declared-mcp-guard.js +0 -284
- package/src/lib/default-environment.js +0 -207
- package/src/lib/device-login.js +0 -126
- package/src/lib/digest-frame.js +0 -151
- package/src/lib/doctor.js +0 -291
- package/src/lib/egress-proxy.js +0 -160
- package/src/lib/enforcement.js +0 -946
- package/src/lib/environment.js +0 -681
- package/src/lib/first-output-watchdog.js +0 -191
- package/src/lib/git-readonly-server.js +0 -138
- package/src/lib/git-readonly.js +0 -463
- package/src/lib/git-remote-commonly.mjs +0 -100
- package/src/lib/grant-broker-guard.js +0 -345
- package/src/lib/hooks-config.js +0 -238
- package/src/lib/host-gh-broker.js +0 -152
- package/src/lib/host-gh-shim.mjs +0 -98
- package/src/lib/inbox.js +0 -483
- package/src/lib/instance-proxy.js +0 -76
- package/src/lib/machine-host.js +0 -29
- package/src/lib/mcp-credential-delivery.js +0 -121
- package/src/lib/mcp-first-run.js +0 -50
- package/src/lib/mcp-home.js +0 -644
- package/src/lib/mcp-ready.js +0 -68
- package/src/lib/mcp-server-version.js +0 -123
- package/src/lib/mcp-token.js +0 -36
- package/src/lib/mcp-warm-child.mjs +0 -412
- package/src/lib/memory-bridge.js +0 -191
- package/src/lib/memory-import.js +0 -156
- package/src/lib/missing-harness.js +0 -31
- package/src/lib/model-unavailable.js +0 -154
- package/src/lib/native-runtime-inventory.js +0 -40
- package/src/lib/outsider-grant-mcp.js +0 -82
- package/src/lib/own-key-launch.js +0 -42
- package/src/lib/pod-catch-up.js +0 -55
- package/src/lib/pod-focus.js +0 -224
- package/src/lib/pod-tail.js +0 -68
- package/src/lib/poll-liveness.js +0 -103
- package/src/lib/poll-retry.js +0 -81
- package/src/lib/poller.js +0 -200
- package/src/lib/provider-proxy.js +0 -42
- package/src/lib/public-seat-mcp-flag.js +0 -36
- package/src/lib/public-tool-error.js +0 -60
- package/src/lib/public-withheld-tools.js +0 -48
- package/src/lib/reply-receipt.js +0 -55
- package/src/lib/resolve-on-path.js +0 -22
- package/src/lib/runtime-inventory.js +0 -56
- package/src/lib/runtime-observation.js +0 -37
- package/src/lib/sandbox/bwrap.js +0 -275
- package/src/lib/sandbox/codex-perimeter.js +0 -16
- package/src/lib/sandbox/host-check.js +0 -268
- package/src/lib/sandbox/mode.js +0 -49
- package/src/lib/sandbox/opencode-loopback.mjs +0 -68
- package/src/lib/sandbox/seatbelt.js +0 -684
- package/src/lib/sandbox/turn-dir.js +0 -56
- package/src/lib/sandbox/windows-unsandboxed.js +0 -183
- package/src/lib/sandbox/workspace-git-config.js +0 -243
- package/src/lib/sandbox/workspace-pin.js +0 -158
- package/src/lib/seat-runtime-controls.js +0 -61
- package/src/lib/seat-stop.js +0 -97
- package/src/lib/server-skills.js +0 -32
- package/src/lib/session-recycle.js +0 -219
- package/src/lib/session-store.js +0 -215
- package/src/lib/skill-prompt.js +0 -74
- package/src/lib/skills-import.js +0 -113
- package/src/lib/spawn-credential.js +0 -402
- package/src/lib/spawn-retry.js +0 -311
- package/src/lib/stable-install.js +0 -176
- package/src/lib/supervised-turn.js +0 -352
- package/src/lib/task.js +0 -235
- package/src/lib/turn-failure-notice.js +0 -177
- package/src/lib/unconfined-seat-guard.js +0 -94
- package/src/lib/update-check.js +0 -192
- package/src/lib/upstream-refusal.js +0 -1
- package/src/lib/webhook-server.js +0 -109
- package/src/lib/windows-command.js +0 -393
- package/src/lib/workspace-git.js +0 -87
- package/src/lib/workspace-trust.js +0 -162
|
@@ -1,329 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Which harness a seat runs a turn on, and what it does when that harness
|
|
3
|
-
* cannot serve.
|
|
4
|
-
*
|
|
5
|
-
* A seat declares a run config: a primary entry {harness, model, effort?} and
|
|
6
|
-
* an ordered fallback list of the same shape. `runTurnWithFallback` tries the
|
|
7
|
-
* active entry. When the attempt fails with one of FALLBACK_KINDS (the harness
|
|
8
|
-
* cannot serve anyone right now: not signed in, out of quota, the provider
|
|
9
|
-
* does not take this model), it moves to the next entry in the same turn, so
|
|
10
|
-
* the turn is still served. Every other failure is thrown unchanged and no
|
|
11
|
-
* switch happens. A content refusal in particular never switches: re-sending
|
|
12
|
-
* refused content through another provider is refusal-shopping.
|
|
13
|
-
*
|
|
14
|
-
* While a fallback is active, the next turn after the failed entry's cooldown
|
|
15
|
-
* starts from the primary again. That turn is the probe: if the primary
|
|
16
|
-
* answers, the seat is back on it. If it fails with a fallback kind or a
|
|
17
|
-
* retryable liveness kind (PROBE_CARRY_KINDS), the same turn carries on down
|
|
18
|
-
* the list. Any other failure, an unnamed one included, ends the turn there:
|
|
19
|
-
* an adapter's upstream refusal reaches this module as an unnamed error, and
|
|
20
|
-
* carrying it on would be the same refusal-shopping. Either way the cooldown
|
|
21
|
-
* restarts, so the next turn stays on the seat's own entry.
|
|
22
|
-
*
|
|
23
|
-
* An entry the host's policy blocks is skipped and never attempted. An entry
|
|
24
|
-
* the host cannot take right now (a per-model concurrency cap, a stop file;
|
|
25
|
-
* the caller's `acquire`) is skipped for this turn only. If no entry served,
|
|
26
|
-
* the turn fails visibly; nothing substitutes a harness the config does not
|
|
27
|
-
* list.
|
|
28
|
-
*
|
|
29
|
-
* Pure: state goes in and comes out as plain data, persisted by the caller,
|
|
30
|
-
* and time is passed in. Nothing here reads a file or the clock.
|
|
31
|
-
*/
|
|
32
|
-
import { RunFailure, failureKindOf } from './failures.js';
|
|
33
|
-
|
|
34
|
-
/** The failure kinds that move a turn to the next entry. Nothing else does. */
|
|
35
|
-
export const FALLBACK_KINDS = Object.freeze(['not_authenticated', 'quota', 'provider_unsupported']);
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* The failure kinds on which a failed probe carries on to the seat's own
|
|
39
|
-
* entry: the fallback kinds, and the retryable kinds that say the primary
|
|
40
|
-
* never produced an answer. An allowlist, so a kind added later ends a probe
|
|
41
|
-
* turn until someone decides otherwise.
|
|
42
|
-
*/
|
|
43
|
-
export const PROBE_CARRY_KINDS = Object.freeze([...FALLBACK_KINDS, 'stuck', 'timeout', 'exited']);
|
|
44
|
-
|
|
45
|
-
/** How long a failed entry rests before a turn probes it again, per kind. */
|
|
46
|
-
export const PROBE_COOLDOWN_MS = Object.freeze({
|
|
47
|
-
quota: 15 * 60 * 1000,
|
|
48
|
-
not_authenticated: 30 * 60 * 1000,
|
|
49
|
-
provider_unsupported: 60 * 60 * 1000,
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Why a seat's active entry changed, as `harness.switched` records it:
|
|
54
|
-
* a fallback kind, `blocked` (the active entry is blocked by policy), `probe`
|
|
55
|
-
* (a probe moved back up the list) and `operator` (`commonly agent use`).
|
|
56
|
-
*/
|
|
57
|
-
export const SWITCH_REASONS = Object.freeze([...FALLBACK_KINDS, 'blocked', 'probe', 'operator']);
|
|
58
|
-
|
|
59
|
-
/** Fallback entries a config may list after its primary. */
|
|
60
|
-
export const MAX_FALLBACK = 5;
|
|
61
|
-
/** Switches kept in state.history, newest last. */
|
|
62
|
-
export const HISTORY_LIMIT = 20;
|
|
63
|
-
|
|
64
|
-
const HARNESS = /^[a-z][a-z0-9-]{0,31}$/;
|
|
65
|
-
// No '@' and no whitespace: '@' separates the effort in the short form.
|
|
66
|
-
const MODEL = /^[A-Za-z0-9][A-Za-z0-9._:/+-]{0,127}$/;
|
|
67
|
-
const EFFORT = /^[a-z]{1,16}$/;
|
|
68
|
-
const REASON = /^[A-Za-z0-9][A-Za-z0-9 ._:/#()-]{0,119}$/;
|
|
69
|
-
|
|
70
|
-
const fail = (message) => { throw new Error(`harness plan: ${message}`); };
|
|
71
|
-
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
72
|
-
const refuseUnknownKeys = (value, allowed, where) => {
|
|
73
|
-
for (const key of Object.keys(value)) if (!allowed.includes(key)) fail(`unknown field ${where}${key}`);
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* One entry, as an object or the short form `harness/model[@effort]`
|
|
78
|
-
* (`claude/claude-sonnet-5-5@high`). The harness is everything before the first '/',
|
|
79
|
-
* so a model may itself contain '/' (`opencode/litellm/deepseek-flash-1017`).
|
|
80
|
-
*/
|
|
81
|
-
export const parseEntry = (value, where = 'entry') => {
|
|
82
|
-
let entry = value;
|
|
83
|
-
if (typeof value === 'string') {
|
|
84
|
-
const slash = value.indexOf('/');
|
|
85
|
-
if (slash < 1) fail(`${where} must read harness/model[@effort], got ${JSON.stringify(value)}`);
|
|
86
|
-
const rest = value.slice(slash + 1);
|
|
87
|
-
const at = rest.lastIndexOf('@');
|
|
88
|
-
entry = at === -1
|
|
89
|
-
? { harness: value.slice(0, slash), model: rest }
|
|
90
|
-
: { harness: value.slice(0, slash), model: rest.slice(0, at), effort: rest.slice(at + 1) };
|
|
91
|
-
}
|
|
92
|
-
if (!isPlainObject(entry)) fail(`${where} must be an object or harness/model[@effort]`);
|
|
93
|
-
refuseUnknownKeys(entry, ['harness', 'model', 'effort'], `${where}.`);
|
|
94
|
-
if (typeof entry.harness !== 'string' || !HARNESS.test(entry.harness)) fail(`${where}.harness must match ${HARNESS}`);
|
|
95
|
-
if (typeof entry.model !== 'string' || !MODEL.test(entry.model)) fail(`${where}.model must match ${MODEL}`);
|
|
96
|
-
if (entry.effort !== undefined && (typeof entry.effort !== 'string' || !EFFORT.test(entry.effort))) {
|
|
97
|
-
fail(`${where}.effort must match ${EFFORT}, or be absent`);
|
|
98
|
-
}
|
|
99
|
-
return Object.freeze({ harness: entry.harness, model: entry.model, ...(entry.effort ? { effort: entry.effort } : {}) });
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
export const entryLabel = (entry) => `${entry.harness}/${entry.model}${entry.effort ? `@${entry.effort}` : ''}`;
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* `{harness, model, effort?, fallback?: [entry, …]}` → `{primary, fallback, chain, key}`.
|
|
106
|
-
* Refuses unknown fields, a fallback longer than MAX_FALLBACK and a repeated
|
|
107
|
-
* entry (an entry listed twice would be retried under another position).
|
|
108
|
-
*/
|
|
109
|
-
export const validateRunConfig = (config) => {
|
|
110
|
-
if (!isPlainObject(config)) fail('run config must be an object');
|
|
111
|
-
refuseUnknownKeys(config, ['harness', 'model', 'effort', 'fallback'], 'config.');
|
|
112
|
-
const primary = parseEntry({
|
|
113
|
-
harness: config.harness, model: config.model, ...(config.effort !== undefined ? { effort: config.effort } : {}),
|
|
114
|
-
}, 'config');
|
|
115
|
-
const list = config.fallback ?? [];
|
|
116
|
-
if (!Array.isArray(list)) fail('config.fallback must be an array');
|
|
117
|
-
if (list.length > MAX_FALLBACK) fail(`config.fallback lists at most ${MAX_FALLBACK} entries`);
|
|
118
|
-
const fallback = list.map((item, index) => parseEntry(item, `config.fallback[${index}]`));
|
|
119
|
-
const chain = [primary, ...fallback];
|
|
120
|
-
const labels = chain.map(entryLabel);
|
|
121
|
-
const repeated = labels.find((label, index) => labels.indexOf(label) !== index);
|
|
122
|
-
if (repeated) fail(`config lists ${repeated} twice`);
|
|
123
|
-
return Object.freeze({ primary, fallback: Object.freeze(fallback), chain: Object.freeze(chain), key: labels.join(' > ') });
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Host policy: `{blocked?: [{harness?, model?, reason}], limits?: [{model, maxConcurrent}]}`.
|
|
128
|
-
* A blocked rule names a harness, a model or both, and matches an entry when
|
|
129
|
-
* every field it names is equal. The reason is a short operator word (a task
|
|
130
|
-
* id), recorded with each skip. A limit caps how many turns on this host run
|
|
131
|
-
* one model at once; it is enforced by the caller's `acquire`, which this
|
|
132
|
-
* module only consults. Absent or empty policy blocks and caps nothing.
|
|
133
|
-
*/
|
|
134
|
-
export const validatePolicy = (policy) => {
|
|
135
|
-
if (policy === undefined || policy === null) return Object.freeze({ blocked: Object.freeze([]), limits: Object.freeze([]) });
|
|
136
|
-
if (!isPlainObject(policy)) fail('policy must be an object');
|
|
137
|
-
refuseUnknownKeys(policy, ['blocked', 'limits'], 'policy.');
|
|
138
|
-
const list = policy.blocked ?? [];
|
|
139
|
-
if (!Array.isArray(list)) fail('policy.blocked must be an array');
|
|
140
|
-
const blocked = list.map((rule, index) => {
|
|
141
|
-
const where = `policy.blocked[${index}]`;
|
|
142
|
-
if (!isPlainObject(rule)) fail(`${where} must be an object`);
|
|
143
|
-
refuseUnknownKeys(rule, ['harness', 'model', 'reason'], `${where}.`);
|
|
144
|
-
if (rule.harness === undefined && rule.model === undefined) fail(`${where} names a harness, a model or both`);
|
|
145
|
-
if (rule.harness !== undefined && (typeof rule.harness !== 'string' || !HARNESS.test(rule.harness))) fail(`${where}.harness must match ${HARNESS}`);
|
|
146
|
-
if (rule.model !== undefined && (typeof rule.model !== 'string' || !MODEL.test(rule.model))) fail(`${where}.model must match ${MODEL}`);
|
|
147
|
-
if (typeof rule.reason !== 'string' || !REASON.test(rule.reason)) fail(`${where}.reason must match ${REASON}`);
|
|
148
|
-
return Object.freeze({ ...rule });
|
|
149
|
-
});
|
|
150
|
-
const capList = policy.limits ?? [];
|
|
151
|
-
if (!Array.isArray(capList)) fail('policy.limits must be an array');
|
|
152
|
-
const limits = capList.map((limit, index) => {
|
|
153
|
-
const where = `policy.limits[${index}]`;
|
|
154
|
-
if (!isPlainObject(limit)) fail(`${where} must be an object`);
|
|
155
|
-
refuseUnknownKeys(limit, ['model', 'maxConcurrent'], `${where}.`);
|
|
156
|
-
if (typeof limit.model !== 'string' || !MODEL.test(limit.model)) fail(`${where}.model must match ${MODEL}`);
|
|
157
|
-
if (!Number.isInteger(limit.maxConcurrent) || limit.maxConcurrent < 1) fail(`${where}.maxConcurrent must be an integer >= 1`);
|
|
158
|
-
return Object.freeze({ model: limit.model, maxConcurrent: limit.maxConcurrent });
|
|
159
|
-
});
|
|
160
|
-
const models = limits.map((limit) => limit.model);
|
|
161
|
-
const twice = models.find((model, index) => models.indexOf(model) !== index);
|
|
162
|
-
if (twice) fail(`policy.limits lists ${twice} twice`);
|
|
163
|
-
return Object.freeze({ blocked: Object.freeze(blocked), limits: Object.freeze(limits) });
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
/** The concurrency cap for this entry's model, or null. */
|
|
167
|
-
export const limitFor = (entry, policy) => (policy?.limits || []).find((limit) => limit.model === entry.model) || null;
|
|
168
|
-
|
|
169
|
-
/** The rule that blocks this entry, or null. */
|
|
170
|
-
export const blockedBy = (entry, policy) => (policy?.blocked || []).find((rule) => (
|
|
171
|
-
(rule.harness === undefined || rule.harness === entry.harness)
|
|
172
|
-
&& (rule.model === undefined || rule.model === entry.model)
|
|
173
|
-
)) || null;
|
|
174
|
-
|
|
175
|
-
/** State for a config that has never run: on the primary, nothing to probe. */
|
|
176
|
-
export const initialState = (plan) => ({
|
|
177
|
-
v: 1, configKey: plan.key, active: 0, since: null, reason: null, nextProbeAt: null, history: [],
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
// A persisted probe time as ms. An ISO string (a hand-edited or foreign
|
|
181
|
-
// state file) is read as its time; anything unreadable means the probe is due.
|
|
182
|
-
const probeTimeOf = (value) => {
|
|
183
|
-
if (Number.isFinite(value)) return value;
|
|
184
|
-
const parsed = typeof value === 'string' ? Date.parse(value) : NaN;
|
|
185
|
-
return Number.isFinite(parsed) ? parsed : null;
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
// State written by another version starts over. State for another config
|
|
189
|
-
// (the operator switched the seat) starts over on the new primary and keeps
|
|
190
|
-
// its history; the operator command records its own switch.
|
|
191
|
-
const stateFor = (plan, state) => {
|
|
192
|
-
if (!isPlainObject(state) || state.v !== 1) return initialState(plan);
|
|
193
|
-
if (state.configKey !== plan.key) {
|
|
194
|
-
return { ...initialState(plan), history: Array.isArray(state.history) ? state.history.slice(-HISTORY_LIMIT) : [] };
|
|
195
|
-
}
|
|
196
|
-
const active = Number.isInteger(state.active) && state.active >= 0 && state.active < plan.chain.length ? state.active : 0;
|
|
197
|
-
return {
|
|
198
|
-
...initialState(plan), ...state, active, nextProbeAt: probeTimeOf(state.nextProbeAt),
|
|
199
|
-
history: Array.isArray(state.history) ? state.history : [],
|
|
200
|
-
};
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* The entry indices this turn tries, in order. From the active entry down;
|
|
205
|
-
* from the primary when a fallback is active and its probe is due.
|
|
206
|
-
*/
|
|
207
|
-
export const turnOrder = (plan, state, now) => {
|
|
208
|
-
const current = stateFor(plan, state);
|
|
209
|
-
const probing = current.active > 0 && (current.nextProbeAt === null || now >= current.nextProbeAt);
|
|
210
|
-
const start = probing ? 0 : current.active;
|
|
211
|
-
return { probing, order: plan.chain.map((_, index) => index).slice(start) };
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
const iso = (ms) => new Date(ms).toISOString();
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Run one turn on the plan.
|
|
218
|
-
*
|
|
219
|
-
* @param {object} args
|
|
220
|
-
* @param {ReturnType<typeof validateRunConfig>} args.plan
|
|
221
|
-
* @param {ReturnType<typeof validatePolicy>} [args.policy]
|
|
222
|
-
* @param {object} [args.state] what the last turn returned; absent for a first run
|
|
223
|
-
* @param {number} args.now ms since the epoch
|
|
224
|
-
* @param {(entry, info: {index, fresh, probing, switch}) => Promise<any>} args.attempt
|
|
225
|
-
* runs the turn on one entry. `fresh` is true when the entry is not the one
|
|
226
|
-
* the seat was on, so the caller starts a new session for it. `switch` is
|
|
227
|
-
* then `{from, to, reason}` (reason in SWITCH_REASONS) for the caller to
|
|
228
|
-
* write into that attempt's trace as `harness.switched`; null otherwise.
|
|
229
|
-
* @param {(entry, {limit}) => Promise<{release?: Function}|{unavailable: string}>} [args.acquire]
|
|
230
|
-
* asked before each attempt; `{unavailable}` skips the entry for this turn
|
|
231
|
-
* (its cap is full, the host holds it). `release` runs when the attempt
|
|
232
|
-
* settles. Default: every entry is available.
|
|
233
|
-
* @param {(error) => string|null} [args.classify] the failure kind; default failureKindOf
|
|
234
|
-
* @param {(skip: {entry, reason, blocked}) => void} [args.onSkip] per skipped entry
|
|
235
|
-
* @returns {Promise<{result, entry, index, state, attempts}>}
|
|
236
|
-
* On failure it rejects with the failure that ended the turn, carrying
|
|
237
|
-
* `harnessState` (persist it as on success) and `harnessAttempts`.
|
|
238
|
-
*/
|
|
239
|
-
export const runTurnWithFallback = async ({
|
|
240
|
-
plan, policy, state, now, attempt, acquire = async () => ({}), classify = failureKindOf, onSkip = () => {},
|
|
241
|
-
}) => {
|
|
242
|
-
if (!plan?.chain?.length) fail('runTurnWithFallback needs a validated plan');
|
|
243
|
-
if (!Number.isFinite(now)) fail('runTurnWithFallback needs now');
|
|
244
|
-
const current = stateFor(plan, state);
|
|
245
|
-
const { probing, order } = turnOrder(plan, current, now);
|
|
246
|
-
const was = plan.chain[current.active];
|
|
247
|
-
const attempts = [];
|
|
248
|
-
let lastKind = null;
|
|
249
|
-
let probeKind = null;
|
|
250
|
-
let lastError = null;
|
|
251
|
-
const skip = (entry, reason, blocked) => {
|
|
252
|
-
attempts.push({ entry: entryLabel(entry), outcome: blocked ? 'blocked' : 'unavailable', reason });
|
|
253
|
-
try { onSkip({ entry, reason, blocked }); } catch { /* a listener never fails a turn */ }
|
|
254
|
-
};
|
|
255
|
-
|
|
256
|
-
const cooldownOf = (kind) => PROBE_COOLDOWN_MS[kind] ?? PROBE_COOLDOWN_MS.quota;
|
|
257
|
-
const failWith = (error, next) => {
|
|
258
|
-
error.harnessState = next;
|
|
259
|
-
error.harnessAttempts = attempts;
|
|
260
|
-
return error;
|
|
261
|
-
};
|
|
262
|
-
|
|
263
|
-
for (const index of order) {
|
|
264
|
-
const entry = plan.chain[index];
|
|
265
|
-
const rule = blockedBy(entry, policy);
|
|
266
|
-
if (rule) { skip(entry, rule.reason, true); continue; }
|
|
267
|
-
// eslint-disable-next-line no-await-in-loop
|
|
268
|
-
const slot = await acquire(entry, { limit: limitFor(entry, policy) });
|
|
269
|
-
if (typeof slot?.unavailable === 'string') { skip(entry, slot.unavailable, false); continue; }
|
|
270
|
-
const fresh = index !== current.active;
|
|
271
|
-
// Why this attempt is not on the seat's entry: what failed above it, a
|
|
272
|
-
// probe moving up, or the entries above it skipped.
|
|
273
|
-
let reason = null;
|
|
274
|
-
if (fresh) reason = index < current.active ? 'probe' : (lastKind || 'blocked');
|
|
275
|
-
try {
|
|
276
|
-
// eslint-disable-next-line no-await-in-loop
|
|
277
|
-
const result = await attempt(entry, {
|
|
278
|
-
index, fresh, probing, switch: fresh ? { from: was, to: entry, reason } : null,
|
|
279
|
-
});
|
|
280
|
-
attempts.push({ entry: entryLabel(entry), outcome: 'ok' });
|
|
281
|
-
const next = { ...current, active: index };
|
|
282
|
-
if (fresh) {
|
|
283
|
-
const why = index < current.active ? 'recovered' : reason;
|
|
284
|
-
next.since = iso(now);
|
|
285
|
-
next.reason = why;
|
|
286
|
-
next.history = [...current.history, { at: iso(now), from: entryLabel(was), to: entryLabel(entry), reason: why }]
|
|
287
|
-
.slice(-HISTORY_LIMIT);
|
|
288
|
-
}
|
|
289
|
-
// Back on the primary there is nothing to probe. On a fallback, the
|
|
290
|
-
// next probe waits out the cooldown of what failed above it this turn;
|
|
291
|
-
// a turn that simply stayed put keeps the probe time it had.
|
|
292
|
-
if (index === 0) next.nextProbeAt = null;
|
|
293
|
-
else if (probeKind || fresh) next.nextProbeAt = now + cooldownOf(probeKind || lastKind);
|
|
294
|
-
return { result, entry, index, attempts, state: next };
|
|
295
|
-
} catch (error) {
|
|
296
|
-
const kind = classify(error);
|
|
297
|
-
attempts.push({ entry: entryLabel(entry), outcome: 'failed', kind: kind || null });
|
|
298
|
-
const probeFailed = probing && index < current.active;
|
|
299
|
-
if (probeFailed && probeKind === null) probeKind = kind || 'unnamed';
|
|
300
|
-
// Not a reason to change harness: the turn fails as it would have with
|
|
301
|
-
// no fallback configured, and the seat stays where it was. A probe that
|
|
302
|
-
// failed only for want of an answer (PROBE_CARRY_KINDS) carries on to
|
|
303
|
-
// the seat's own entry; any other probe failure ends the turn too.
|
|
304
|
-
const carries = probeFailed ? PROBE_CARRY_KINDS.includes(kind) : FALLBACK_KINDS.includes(kind);
|
|
305
|
-
if (!carries) {
|
|
306
|
-
// A probe failed this turn, so the primary rests again whatever ended
|
|
307
|
-
// the turn; otherwise the next turn would probe it straight away.
|
|
308
|
-
const next = probeKind ? { ...current, nextProbeAt: now + cooldownOf(probeKind) } : current;
|
|
309
|
-
throw failWith(error, next);
|
|
310
|
-
}
|
|
311
|
-
// Only a failure at or below the seat's entry is a reason to move down
|
|
312
|
-
// from it; a probe's failures above it are the probe's own (probeKind).
|
|
313
|
-
if (FALLBACK_KINDS.includes(kind) && index >= current.active) lastKind = kind;
|
|
314
|
-
lastError = error;
|
|
315
|
-
} finally {
|
|
316
|
-
try { slot?.release?.(); } catch { /* releasing never fails a turn */ }
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
// Nothing served. Stay on the entry the seat was on and push the probe out,
|
|
320
|
-
// so the next turn does not go straight back to an entry that just failed.
|
|
321
|
-
const next = { ...current };
|
|
322
|
-
if (current.active > 0 && (lastKind || probeKind)) next.nextProbeAt = now + cooldownOf(probeKind || lastKind);
|
|
323
|
-
const summary = attempts.map((a) => (a.outcome === 'failed' ? `${a.entry} ${a.kind || 'failed'}` : `${a.entry} ${a.outcome} (${a.reason})`)).join(', ');
|
|
324
|
-
if (lastError) throw failWith(lastError, next);
|
|
325
|
-
// Every entry was skipped: by policy alone it cannot run at all; with any
|
|
326
|
-
// entry merely unavailable it may run once a slot frees, and is retryable.
|
|
327
|
-
const onlyBlocked = attempts.every((a) => a.outcome === 'blocked');
|
|
328
|
-
throw failWith(new RunFailure(onlyBlocked ? 'unsupported' : 'capacity', `no entry in the run config could run: ${summary}`), next);
|
|
329
|
-
};
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
export { createRegistry, providerKeyEnvNames } from './contract.js';
|
|
2
|
-
export { requireSeatModel } from './seat-model.js';
|
|
3
|
-
export { controlsFromArguments, observeSpawn } from './runtime-observation.js';
|
|
4
|
-
export {
|
|
5
|
-
LEADS_PROCESS_GROUP, OWN_PROCESS_GROUP, spawnInOwnGroup, wantsOwnProcessGroup, leadsOwnGroup, killProcessTree,
|
|
6
|
-
trackSupervisedGroup, killSupervisedGroups, armSupervisedGroupDeadline,
|
|
7
|
-
readProcessTable, readProcess, descendantsOf, killDescendants,
|
|
8
|
-
} from './process-group.js';
|
|
9
|
-
export { adapterFailure, spawnCredentials } from './upstream-refusal.js';
|
|
10
|
-
export { default as stub } from './adapters/stub.js';
|
|
11
|
-
export { createHermesAdapter } from './adapters/hermes.js';
|
|
12
|
-
export { RunFailure, runFailure, failureKindOf, FAILURE_KINDS } from './failures.js';
|
|
13
|
-
export { EVENT_TYPES, isEventType, traceOf, NULL_TRACE } from './trace/events.js';
|
|
14
|
-
export { createTraceStore } from './trace/store.js';
|
|
15
|
-
export { validateGrant, matchToolCall, TOOL_KINDS, CREDENTIAL_SOURCES } from './valet/grant.js';
|
|
16
|
-
export { prepareRunEnv, BASE_ENV_ALLOWLIST } from './valet/env.js';
|
|
17
|
-
export { createPermissionPolicy, DEFAULT_APPROVE_TIMEOUT_MS } from './valet/permissions.js';
|
|
18
|
-
export {
|
|
19
|
-
RECORD_VERSION, RECORD_SCHEMA, KINDS, canonicalLine, appendEntry, verifyRecord, readRecord, hashLine, toolSurfaceOf, argsDigestOf,
|
|
20
|
-
} from './record/format.js';
|
|
21
|
-
export { toOtlpJson } from './record/otel.js';
|
|
22
|
-
export { superviseRun, SUPERVISOR_DEFAULTS, DEFAULT_RETRY_ON } from './supervisor.js';
|
|
@@ -1,269 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Process groups for supervised children.
|
|
3
|
-
*
|
|
4
|
-
* An agent CLI spawns its own children: MCP servers through `npx`, a native
|
|
5
|
-
* binary behind a node launcher, shells for tools. Killing only the CLI leaves
|
|
6
|
-
* those running, holding the run's work and keys on a shared host. A child the
|
|
7
|
-
* supervisor will kill is therefore started as the leader of its own process
|
|
8
|
-
* group, and killed as a group.
|
|
9
|
-
*
|
|
10
|
-
* Leading a group is never inferred. The spawner that passed `detached` marks
|
|
11
|
-
* the child, and only a marked child that is still alive is ever signalled by
|
|
12
|
-
* negative pid: signalling `-pid` for a process that does not lead its group
|
|
13
|
-
* can reach an unrelated group.
|
|
14
|
-
*
|
|
15
|
-
* A group kill misses a descendant that moved to a group of its own (codex
|
|
16
|
-
* starts every MCP server and tool command that way; claude's Bash tool does
|
|
17
|
-
* too). So before any signal, the kill also snapshots the process table and
|
|
18
|
-
* walks the child's descendants, deepest first. Each pid is re-read right
|
|
19
|
-
* before it is signalled and killed only if its parent and start time still
|
|
20
|
-
* match the snapshot: a pid that exited and was reused by another process on
|
|
21
|
-
* this host is left alone. Leaves go first so a pid's parent is still alive,
|
|
22
|
-
* and its ppid still meaningful, when it is checked.
|
|
23
|
-
*
|
|
24
|
-
* What the walk does not catch, by design, because each case would need the
|
|
25
|
-
* ppid match loosened:
|
|
26
|
-
* (a) a descendant reparented to 1 (its parent died) between the snapshot
|
|
27
|
-
* and its re-read is skipped: its ppid no longer matches;
|
|
28
|
-
* (b) a process started after the snapshot is not in it, and one that calls
|
|
29
|
-
* setsid after the snapshot leaves the group the group kill signals;
|
|
30
|
-
* either is missed.
|
|
31
|
-
*
|
|
32
|
-
* A child in its own group is outside the wrapper's group, so launchd's kill
|
|
33
|
-
* of the seat's group on exit no longer reaches it. `trackSupervisedGroup`
|
|
34
|
-
* covers that: while any tracked group is live, the wrapper kills them all
|
|
35
|
-
* when it exits, and on SIGTERM, SIGINT or SIGHUP when nothing else handles
|
|
36
|
-
* the signal. A host that handles the signal itself (`agent run` finishes the
|
|
37
|
-
* in-flight turn on SIGTERM) owns the timing: it calls
|
|
38
|
-
* `armSupervisedGroupDeadline` so the groups die before launchd's SIGKILL,
|
|
39
|
-
* which no handler can catch, and the exit hook covers the clean case.
|
|
40
|
-
*/
|
|
41
|
-
|
|
42
|
-
import { spawnSync } from 'node:child_process';
|
|
43
|
-
|
|
44
|
-
/** Set on a child by the code that spawned it with `detached: true`. */
|
|
45
|
-
export const LEADS_PROCESS_GROUP = Symbol.for('@commonlyai/seats.leadsProcessGroup');
|
|
46
|
-
|
|
47
|
-
/** Whether a child can lead its own group here (POSIX only). */
|
|
48
|
-
export const PROCESS_GROUPS_SUPPORTED = process.platform !== 'win32';
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* The ctx key the supervisor sets to tell an adapter it will kill the child's
|
|
52
|
-
* group, so the adapter may start the child in one. Unset, an adapter spawns
|
|
53
|
-
* exactly as it would unsupervised.
|
|
54
|
-
*/
|
|
55
|
-
export const OWN_PROCESS_GROUP = 'ownProcessGroup';
|
|
56
|
-
|
|
57
|
-
export const wantsOwnProcessGroup = (ctx) => ctx?.[OWN_PROCESS_GROUP] === true && PROCESS_GROUPS_SUPPORTED;
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Spawn through `spawnImpl`. With `ownGroup`, the child is started detached
|
|
61
|
-
* (it calls setsid, so it leads a new group whose id is its pid) and marked.
|
|
62
|
-
* Without it, `options` reach `spawnImpl` untouched.
|
|
63
|
-
*/
|
|
64
|
-
export const spawnInOwnGroup = (spawnImpl, command, args, options, { ownGroup = false } = {}) => {
|
|
65
|
-
if (!ownGroup || !PROCESS_GROUPS_SUPPORTED) return spawnImpl(command, args, options);
|
|
66
|
-
const child = spawnImpl(command, args, { ...options, detached: true });
|
|
67
|
-
if (child && typeof child === 'object') child[LEADS_PROCESS_GROUP] = true;
|
|
68
|
-
return child;
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
const isAlive = (child) => child.exitCode === null && child.signalCode === null;
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* True only for a child its spawner marked, with a real pid, that has not been
|
|
75
|
-
* reaped. Node sets exitCode/signalCode in the same tick it reaps, so while
|
|
76
|
-
* this holds the pid (and so the group id) cannot have been reused.
|
|
77
|
-
*/
|
|
78
|
-
export const leadsOwnGroup = (child) => !!child
|
|
79
|
-
&& child[LEADS_PROCESS_GROUP] === true
|
|
80
|
-
&& Number.isInteger(child.pid) && child.pid > 0
|
|
81
|
-
&& isAlive(child);
|
|
82
|
-
|
|
83
|
-
// ── The process table ──────────────────────────────────────────────────────
|
|
84
|
-
|
|
85
|
-
// lstart is printed in local time with no offset, which repeats an hour when
|
|
86
|
-
// daylight saving ends. In UTC it never repeats; `startedMs` reads it as UTC.
|
|
87
|
-
const PS_ENV = { ...process.env, LC_ALL: 'C', TZ: 'UTC' };
|
|
88
|
-
|
|
89
|
-
const parseTable = (text) => {
|
|
90
|
-
const rows = [];
|
|
91
|
-
for (const line of String(text || '').split('\n')) {
|
|
92
|
-
const match = /^\s*(\d+)\s+(\d+)\s+(\S.*?)\s*$/.exec(line);
|
|
93
|
-
if (match) rows.push({ pid: Number(match[1]), ppid: Number(match[2]), started: match[3] });
|
|
94
|
-
}
|
|
95
|
-
return rows;
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
const ps = (args) => {
|
|
99
|
-
try {
|
|
100
|
-
const out = spawnSync('ps', args, { encoding: 'utf8', env: PS_ENV, timeout: 5000 });
|
|
101
|
-
return out.error ? null : parseTable(out.stdout);
|
|
102
|
-
} catch { return null; }
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
/** A row's `started` as epoch ms. */
|
|
106
|
-
export const startedMs = (started) => Date.parse(`${started} GMT`);
|
|
107
|
-
|
|
108
|
-
/** Every process: { pid, ppid, started }. null when ps is unavailable. */
|
|
109
|
-
export const readProcessTable = () => ps(['-A', '-o', 'pid=,ppid=,lstart=']);
|
|
110
|
-
|
|
111
|
-
/** One process as it is now, or null when it is gone. */
|
|
112
|
-
export const readProcess = (pid) => (ps(['-o', 'pid=,ppid=,lstart=', '-p', String(pid)]) || [])
|
|
113
|
-
.find((row) => row.pid === pid) || null;
|
|
114
|
-
|
|
115
|
-
/** Several processes as they are now, in one ps call; a gone pid has no row. null when ps is unavailable. */
|
|
116
|
-
export const readProcesses = (pids) => (pids.length ? ps(['-o', 'pid=,ppid=,lstart=', '-p', pids.join(',')]) : []);
|
|
117
|
-
|
|
118
|
-
/** The descendants of `root` in `table`, deepest first. The root is not included. */
|
|
119
|
-
export const descendantsOf = (table, root) => {
|
|
120
|
-
const byParent = new Map();
|
|
121
|
-
for (const row of table || []) {
|
|
122
|
-
if (row.pid === row.ppid) continue;
|
|
123
|
-
if (!byParent.has(row.ppid)) byParent.set(row.ppid, []);
|
|
124
|
-
byParent.get(row.ppid).push(row);
|
|
125
|
-
}
|
|
126
|
-
const out = [];
|
|
127
|
-
const seen = new Set([root]);
|
|
128
|
-
let level = [root];
|
|
129
|
-
for (let depth = 1; level.length; depth += 1) {
|
|
130
|
-
const next = [];
|
|
131
|
-
for (const parent of level) {
|
|
132
|
-
for (const row of byParent.get(parent) || []) {
|
|
133
|
-
if (seen.has(row.pid)) continue;
|
|
134
|
-
seen.add(row.pid);
|
|
135
|
-
out.push({ ...row, depth });
|
|
136
|
-
next.push(row.pid);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
level = next;
|
|
140
|
-
}
|
|
141
|
-
return out.sort((a, b) => b.depth - a.depth);
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
const DEFAULT_IO = Object.freeze({ table: readProcessTable, read: readProcess });
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Signal each snapshotted descendant, deepest first, re-reading it just
|
|
148
|
-
* before: it is signalled only while its ppid and start time still match.
|
|
149
|
-
* Returns the pids signalled.
|
|
150
|
-
*/
|
|
151
|
-
export const killDescendants = (descendants, signal = 'SIGKILL', { read = readProcess } = {}) => {
|
|
152
|
-
const killed = [];
|
|
153
|
-
for (const row of descendants) {
|
|
154
|
-
const now = read(row.pid);
|
|
155
|
-
if (!now || now.ppid !== row.ppid || now.started !== row.started) continue;
|
|
156
|
-
try { process.kill(row.pid, signal); killed.push(row.pid); } catch { /* gone */ }
|
|
157
|
-
}
|
|
158
|
-
return killed;
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
const liveRoot = (child) => !!child && Number.isInteger(child.pid) && child.pid > 0 && isAlive(child);
|
|
162
|
-
|
|
163
|
-
const killOne = (child, signal, table, io) => {
|
|
164
|
-
// The root must be alive, so unreaped, so its pid still names this child.
|
|
165
|
-
if (table && liveRoot(child)) killDescendants(descendantsOf(table, child.pid), signal, io);
|
|
166
|
-
if (leadsOwnGroup(child)) {
|
|
167
|
-
try { process.kill(-child.pid, signal); return true; } catch { /* fall back to the leader */ }
|
|
168
|
-
}
|
|
169
|
-
try { return child.kill(signal) !== false; } catch { return false; }
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Kill a child, its verified descendants, and its whole group when it leads
|
|
174
|
-
* one. The table is read before any signal. Falls back to `child.kill` when
|
|
175
|
-
* the child does not lead a group, or the group signal fails. Returns whether
|
|
176
|
-
* the child was signalled. `io` is a test seam ({ table, read }).
|
|
177
|
-
*/
|
|
178
|
-
export const killProcessTree = (child, signal = 'SIGKILL', io = {}) => {
|
|
179
|
-
const seam = { ...DEFAULT_IO, ...io };
|
|
180
|
-
return killOne(child, signal, liveRoot(child) ? seam.table() : null, seam);
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
// ── Wrapper-exit cleanup ────────────────────────────────────────────────────
|
|
184
|
-
|
|
185
|
-
const EXIT_SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP'];
|
|
186
|
-
const tracked = new Set();
|
|
187
|
-
const handlers = new Map();
|
|
188
|
-
let onExit = null;
|
|
189
|
-
let deadline = null;
|
|
190
|
-
|
|
191
|
-
/** SIGKILL every tracked group still led by a live child, with its verified descendants. Synchronous. */
|
|
192
|
-
export const killSupervisedGroups = (signal = 'SIGKILL', io = {}) => {
|
|
193
|
-
const live = [...tracked].filter(leadsOwnGroup);
|
|
194
|
-
if (live.length === 0) return 0;
|
|
195
|
-
const seam = { ...DEFAULT_IO, ...io };
|
|
196
|
-
const table = seam.table();
|
|
197
|
-
let killed = 0;
|
|
198
|
-
for (const child of live) if (killOne(child, signal, table, seam)) killed += 1;
|
|
199
|
-
return killed;
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Kill the tracked groups in `ms`, for a host that keeps running after a
|
|
204
|
-
* signal (to finish a turn) but will be SIGKILLed at a known deadline. The
|
|
205
|
-
* earliest deadline armed wins. The timer does not hold the process open.
|
|
206
|
-
* Returns a function that disarms it.
|
|
207
|
-
*/
|
|
208
|
-
export const armSupervisedGroupDeadline = (ms) => {
|
|
209
|
-
const wait = Math.max(0, Number(ms) || 0);
|
|
210
|
-
const at = Date.now() + wait;
|
|
211
|
-
if (deadline && deadline.at <= at) return deadline.cancel;
|
|
212
|
-
if (deadline) deadline.cancel();
|
|
213
|
-
const timer = setTimeout(() => { deadline = null; killSupervisedGroups(); }, wait);
|
|
214
|
-
timer.unref?.();
|
|
215
|
-
const entry = { at, cancel: () => { clearTimeout(timer); if (deadline === entry) deadline = null; } };
|
|
216
|
-
deadline = entry;
|
|
217
|
-
return entry.cancel;
|
|
218
|
-
};
|
|
219
|
-
|
|
220
|
-
const uninstall = () => {
|
|
221
|
-
for (const [signal, handler] of handlers) process.removeListener(signal, handler);
|
|
222
|
-
handlers.clear();
|
|
223
|
-
if (onExit) process.removeListener('exit', onExit);
|
|
224
|
-
onExit = null;
|
|
225
|
-
};
|
|
226
|
-
|
|
227
|
-
const install = () => {
|
|
228
|
-
if (onExit) return;
|
|
229
|
-
onExit = () => { killSupervisedGroups(); };
|
|
230
|
-
process.on('exit', onExit);
|
|
231
|
-
for (const signal of EXIT_SIGNALS) {
|
|
232
|
-
const handler = () => {
|
|
233
|
-
// A host that handles this signal has taken over shutdown: it may be
|
|
234
|
-
// finishing the turn these groups belong to, so they are left alone and
|
|
235
|
-
// the host's deadline or exit kills them.
|
|
236
|
-
if (process.listenerCount(signal) !== 1) return;
|
|
237
|
-
killSupervisedGroups();
|
|
238
|
-
// Listening for a signal replaces its default action. With nothing else
|
|
239
|
-
// handling it, restore that action and re-raise, so the wrapper still
|
|
240
|
-
// terminates exactly as it would have without this module.
|
|
241
|
-
uninstall();
|
|
242
|
-
process.kill(process.pid, signal);
|
|
243
|
-
};
|
|
244
|
-
handlers.set(signal, handler);
|
|
245
|
-
process.on(signal, handler);
|
|
246
|
-
}
|
|
247
|
-
};
|
|
248
|
-
|
|
249
|
-
const untrack = (child) => {
|
|
250
|
-
tracked.delete(child);
|
|
251
|
-
if (tracked.size === 0) uninstall();
|
|
252
|
-
};
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* Track a group-leading child until it exits. The handlers exist only while a
|
|
256
|
-
* tracked child is live, so a wrapper with no supervised group in flight keeps
|
|
257
|
-
* its signal behaviour unchanged. A child that does not lead a group is
|
|
258
|
-
* ignored: launchd's group kill already reaches it.
|
|
259
|
-
*/
|
|
260
|
-
export const trackSupervisedGroup = (child) => {
|
|
261
|
-
if (!leadsOwnGroup(child) || tracked.has(child)) return false;
|
|
262
|
-
tracked.add(child);
|
|
263
|
-
install();
|
|
264
|
-
child.once?.('exit', () => untrack(child));
|
|
265
|
-
return true;
|
|
266
|
-
};
|
|
267
|
-
|
|
268
|
-
/** For tests: how many groups are tracked. */
|
|
269
|
-
export const trackedGroupCount = () => tracked.size;
|