@indigoai-us/hq-cli 5.162.0 → 5.163.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/CHANGELOG.md +13 -0
- package/dist/command-catalog.generated.d.ts +188 -0
- package/dist/command-catalog.generated.js +235 -0
- package/dist/command-registration-plan.d.ts +7 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/agent-enroll.d.ts +6 -1
- package/dist/commands/agent-enroll.js +30 -3
- package/dist/commands/agents.js +15 -22
- package/dist/commands/bot.js +56 -6
- package/dist/commands/daemon.d.ts +53 -0
- package/dist/commands/daemon.js +554 -0
- package/dist/commands/lanes.js +7 -0
- package/dist/commands/meetings.js +18 -16
- package/dist/commands/mesh.js +9 -0
- package/dist/lib/daemon/catalog.d.ts +85 -0
- package/dist/lib/daemon/catalog.js +274 -0
- package/dist/lib/daemon/config.d.ts +26 -0
- package/dist/lib/daemon/config.js +85 -0
- package/dist/lib/daemon/handoff.d.ts +53 -0
- package/dist/lib/daemon/handoff.js +136 -0
- package/dist/lib/daemon/install.d.ts +76 -0
- package/dist/lib/daemon/install.js +299 -0
- package/dist/lib/daemon/legacy.d.ts +35 -0
- package/dist/lib/daemon/legacy.js +120 -0
- package/dist/lib/daemon/lock.d.ts +47 -0
- package/dist/lib/daemon/lock.js +138 -0
- package/dist/lib/daemon/machine-type.d.ts +48 -0
- package/dist/lib/daemon/machine-type.js +139 -0
- package/dist/lib/daemon/migrate.d.ts +61 -0
- package/dist/lib/daemon/migrate.js +238 -0
- package/dist/lib/daemon/paths.d.ts +22 -0
- package/dist/lib/daemon/paths.js +42 -0
- package/dist/lib/daemon/remote-control.d.ts +35 -0
- package/dist/lib/daemon/remote-control.js +47 -0
- package/dist/lib/daemon/requests.d.ts +24 -0
- package/dist/lib/daemon/requests.js +63 -0
- package/dist/lib/daemon/run.d.ts +41 -0
- package/dist/lib/daemon/run.js +319 -0
- package/dist/lib/daemon/supervisor.d.ts +87 -0
- package/dist/lib/daemon/supervisor.js +523 -0
- package/dist/lib/daemon/updater.d.ts +39 -0
- package/dist/lib/daemon/updater.js +103 -0
- package/dist/lib/flag-registry.d.ts +7 -1
- package/dist/lib/flag-registry.js +8 -0
- package/dist/lib/mesh/live/backoff.d.ts +2 -2
- package/dist/lib/mesh/live/backoff.js +16 -3
- package/dist/lib/mesh/live/daemon/run.d.ts +2 -2
- package/dist/lib/mesh/live/daemon/run.js +5 -2
- package/dist/lib/mesh/live/flush.d.ts +3 -2
- package/dist/lib/mesh/live/flush.js +34 -7
- package/dist/lib/mesh/live/session-events-client.d.ts +2 -0
- package/dist/lib/mesh/live/session-events-client.js +25 -0
- package/dist/lib/service-manager/systemd.d.ts +2 -2
- package/dist/lib/service-manager/systemd.js +1 -1
- package/dist/lib/service-manager/types.d.ts +2 -0
- package/dist/main.d.ts +6 -0
- package/dist/main.js +53 -13
- package/dist/utils/cognito-session.d.ts +4 -0
- package/dist/utils/cognito-session.js +11 -10
- package/dist/utils/plan-gate-error.d.ts +27 -13
- package/dist/utils/plan-gate-error.js +133 -58
- package/dist/utils/self-update.d.ts +9 -0
- package/dist/utils/self-update.js +15 -2
- package/package.json +1 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `hq daemon run` supervisor. It keeps the planned units running:
|
|
3
|
+
*
|
|
4
|
+
* - long-running units are restarted after they exit, with per-unit backoff
|
|
5
|
+
* (1 s doubling to 60 s, reset once a unit has stayed up for a minute);
|
|
6
|
+
* `on-failure` units stay down after a clean exit or a SIGTERM/SIGINT from
|
|
7
|
+
* outside, and any unit stays down after a stop request (`hq bot stop`),
|
|
8
|
+
* until a restart request;
|
|
9
|
+
* - scheduled units run after their initial delay and then on their interval,
|
|
10
|
+
* never overlapping themselves;
|
|
11
|
+
* - the plan is re-read on a timer, so disabled services stop, new bots start,
|
|
12
|
+
* and units whose requirement appears or disappears follow on their own;
|
|
13
|
+
* - restart/trigger/stop requests from the CLI are polled and applied; a
|
|
14
|
+
* request for a unit that is not planned yet re-reads the plan first;
|
|
15
|
+
* - stop() sends SIGTERM to every child, escalates to SIGKILL after
|
|
16
|
+
* STOP_ESCALATION_MS, and resolves once every child has exited.
|
|
17
|
+
*
|
|
18
|
+
* Processes, time, and state output are injected; `run.ts` wires the real ones.
|
|
19
|
+
*/
|
|
20
|
+
import type { UnitPlan } from "./catalog.js";
|
|
21
|
+
import type { MachineType } from "./machine-type.js";
|
|
22
|
+
import type { ControlRequest } from "./requests.js";
|
|
23
|
+
export declare const BACKOFF_BASE_MS = 1000;
|
|
24
|
+
export declare const BACKOFF_MAX_MS = 60000;
|
|
25
|
+
export declare const HEALTHY_RUN_MS = 60000;
|
|
26
|
+
export declare const STOP_ESCALATION_MS = 10000;
|
|
27
|
+
export interface SupervisedChild {
|
|
28
|
+
pid?: number;
|
|
29
|
+
on(event: "exit", handler: (code: number | null, signal: string | null) => void): unknown;
|
|
30
|
+
on(event: "error", handler: (err: Error) => void): unknown;
|
|
31
|
+
kill(signal?: NodeJS.Signals | number): boolean;
|
|
32
|
+
}
|
|
33
|
+
export type UnitStatus = "running" | "waiting" | "unavailable" | "backoff" | "exited" | "idle" | "stopped";
|
|
34
|
+
export interface UnitState {
|
|
35
|
+
id: string;
|
|
36
|
+
service: string;
|
|
37
|
+
kind: UnitPlan["kind"];
|
|
38
|
+
description: string;
|
|
39
|
+
status: UnitStatus;
|
|
40
|
+
reason?: string;
|
|
41
|
+
pid?: number;
|
|
42
|
+
startedAt?: number;
|
|
43
|
+
restarts: number;
|
|
44
|
+
lastExit?: {
|
|
45
|
+
code: number | null;
|
|
46
|
+
signal: string | null;
|
|
47
|
+
at: number;
|
|
48
|
+
};
|
|
49
|
+
lastRunAt?: number;
|
|
50
|
+
nextRunAt?: number;
|
|
51
|
+
lastResult?: "ok" | "failed";
|
|
52
|
+
}
|
|
53
|
+
export interface DaemonState {
|
|
54
|
+
version: 1;
|
|
55
|
+
pid: number;
|
|
56
|
+
startedAt: number;
|
|
57
|
+
updatedAt: number;
|
|
58
|
+
machineType: MachineType;
|
|
59
|
+
units: UnitState[];
|
|
60
|
+
}
|
|
61
|
+
export interface SupervisorDeps {
|
|
62
|
+
machineType: MachineType;
|
|
63
|
+
pid: number;
|
|
64
|
+
plan: () => UnitPlan[];
|
|
65
|
+
spawn: (unit: UnitPlan) => SupervisedChild;
|
|
66
|
+
runInternal: (task: string) => Promise<boolean>;
|
|
67
|
+
takeRequests: () => ControlRequest[];
|
|
68
|
+
/** Restart the whole daemon process (a `restart-daemon` request). */
|
|
69
|
+
restartDaemon?: () => void;
|
|
70
|
+
writeState: (state: DaemonState) => void;
|
|
71
|
+
log: (level: "info" | "warn" | "error", msg: string) => void;
|
|
72
|
+
now?: () => number;
|
|
73
|
+
setTimeout?: (fn: () => void, ms: number) => unknown;
|
|
74
|
+
clearTimeout?: (handle: unknown) => void;
|
|
75
|
+
reconcileMs?: number;
|
|
76
|
+
requestPollMs?: number;
|
|
77
|
+
}
|
|
78
|
+
export interface Supervisor {
|
|
79
|
+
start(): void;
|
|
80
|
+
/** Resolves once every child process has exited. */
|
|
81
|
+
stop(): Promise<void>;
|
|
82
|
+
reconcile(): void;
|
|
83
|
+
state(): DaemonState;
|
|
84
|
+
}
|
|
85
|
+
export declare function backoffMs(attempt: number): number;
|
|
86
|
+
export declare function createSupervisor(deps: SupervisorDeps): Supervisor;
|
|
87
|
+
//# sourceMappingURL=supervisor.d.ts.map
|
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `hq daemon run` supervisor. It keeps the planned units running:
|
|
3
|
+
*
|
|
4
|
+
* - long-running units are restarted after they exit, with per-unit backoff
|
|
5
|
+
* (1 s doubling to 60 s, reset once a unit has stayed up for a minute);
|
|
6
|
+
* `on-failure` units stay down after a clean exit or a SIGTERM/SIGINT from
|
|
7
|
+
* outside, and any unit stays down after a stop request (`hq bot stop`),
|
|
8
|
+
* until a restart request;
|
|
9
|
+
* - scheduled units run after their initial delay and then on their interval,
|
|
10
|
+
* never overlapping themselves;
|
|
11
|
+
* - the plan is re-read on a timer, so disabled services stop, new bots start,
|
|
12
|
+
* and units whose requirement appears or disappears follow on their own;
|
|
13
|
+
* - restart/trigger/stop requests from the CLI are polled and applied; a
|
|
14
|
+
* request for a unit that is not planned yet re-reads the plan first;
|
|
15
|
+
* - stop() sends SIGTERM to every child, escalates to SIGKILL after
|
|
16
|
+
* STOP_ESCALATION_MS, and resolves once every child has exited.
|
|
17
|
+
*
|
|
18
|
+
* Processes, time, and state output are injected; `run.ts` wires the real ones.
|
|
19
|
+
*/
|
|
20
|
+
export const BACKOFF_BASE_MS = 1_000;
|
|
21
|
+
export const BACKOFF_MAX_MS = 60_000;
|
|
22
|
+
export const HEALTHY_RUN_MS = 60_000;
|
|
23
|
+
export const STOP_ESCALATION_MS = 10_000;
|
|
24
|
+
export function backoffMs(attempt) {
|
|
25
|
+
return Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** Math.max(0, attempt));
|
|
26
|
+
}
|
|
27
|
+
function planKey(plan) {
|
|
28
|
+
return JSON.stringify([plan.kind, plan.restart, plan.intervalSec, plan.initialDelaySec, plan.command, plan.env]);
|
|
29
|
+
}
|
|
30
|
+
function errorMessage(err) {
|
|
31
|
+
return err instanceof Error ? err.message : String(err);
|
|
32
|
+
}
|
|
33
|
+
export function createSupervisor(deps) {
|
|
34
|
+
const now = deps.now ?? Date.now;
|
|
35
|
+
const setT = deps.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
|
|
36
|
+
const clearT = deps.clearTimeout ?? ((h) => clearTimeout(h));
|
|
37
|
+
const reconcileMs = deps.reconcileMs ?? 15_000;
|
|
38
|
+
const requestPollMs = deps.requestPollMs ?? 1_000;
|
|
39
|
+
const startedAt = now();
|
|
40
|
+
let slots = new Map();
|
|
41
|
+
let stopped = false;
|
|
42
|
+
let started = false;
|
|
43
|
+
let reconcileTimer;
|
|
44
|
+
let requestTimer;
|
|
45
|
+
/** Every child process not yet known to have exited, including ones the slots have let go of. */
|
|
46
|
+
const live = new Set();
|
|
47
|
+
let drained = null;
|
|
48
|
+
let onDrained = null;
|
|
49
|
+
const snapshot = () => ({
|
|
50
|
+
version: 1,
|
|
51
|
+
pid: deps.pid,
|
|
52
|
+
startedAt,
|
|
53
|
+
updatedAt: now(),
|
|
54
|
+
machineType: deps.machineType,
|
|
55
|
+
units: [...slots.values()].map((s) => ({ ...s.state })),
|
|
56
|
+
});
|
|
57
|
+
const emit = () => {
|
|
58
|
+
try {
|
|
59
|
+
deps.writeState(snapshot());
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
deps.log("error", `could not write daemon state: ${errorMessage(err)}`);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const clearTimer = (slot) => {
|
|
66
|
+
if (slot.timer !== undefined)
|
|
67
|
+
clearT(slot.timer);
|
|
68
|
+
slot.timer = undefined;
|
|
69
|
+
};
|
|
70
|
+
const setBlocked = (slot) => {
|
|
71
|
+
const block = slot.plan.blocked;
|
|
72
|
+
slot.state.status = block.state;
|
|
73
|
+
slot.state.reason = block.reason;
|
|
74
|
+
delete slot.state.pid;
|
|
75
|
+
delete slot.state.nextRunAt;
|
|
76
|
+
};
|
|
77
|
+
/** Stop the slot's child (if any), then run `then` once it has exited. */
|
|
78
|
+
const terminate = (slot, then) => {
|
|
79
|
+
clearTimer(slot);
|
|
80
|
+
const child = slot.child;
|
|
81
|
+
if (!child) {
|
|
82
|
+
then?.();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
slot.expectingExit = true;
|
|
86
|
+
slot.afterExit = then;
|
|
87
|
+
slot.killTimer = setT(() => {
|
|
88
|
+
slot.killTimer = undefined;
|
|
89
|
+
if (slot.child === child) {
|
|
90
|
+
deps.log("warn", `${slot.plan.id} ignored SIGTERM; sending SIGKILL`);
|
|
91
|
+
try {
|
|
92
|
+
child.kill("SIGKILL");
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
deps.log("error", `could not SIGKILL ${slot.plan.id}: ${errorMessage(err)}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}, STOP_ESCALATION_MS);
|
|
99
|
+
try {
|
|
100
|
+
child.kill("SIGTERM");
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
deps.log("error", `could not stop ${slot.plan.id}: ${errorMessage(err)}`);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const scheduleRestart = (slot) => {
|
|
107
|
+
if (stopped)
|
|
108
|
+
return;
|
|
109
|
+
const delay = backoffMs(slot.attempts);
|
|
110
|
+
slot.attempts += 1;
|
|
111
|
+
slot.state.restarts += 1;
|
|
112
|
+
slot.state.status = "backoff";
|
|
113
|
+
slot.state.nextRunAt = now() + delay;
|
|
114
|
+
deps.log("info", `restarting ${slot.plan.id} in ${delay}ms (restart ${slot.state.restarts})`);
|
|
115
|
+
slot.timer = setT(() => {
|
|
116
|
+
slot.timer = undefined;
|
|
117
|
+
startLong(slot);
|
|
118
|
+
emit();
|
|
119
|
+
}, delay);
|
|
120
|
+
};
|
|
121
|
+
const watch = (slot, child) => {
|
|
122
|
+
live.add(child);
|
|
123
|
+
let ended = false;
|
|
124
|
+
const onEnd = (code, signal) => {
|
|
125
|
+
if (ended)
|
|
126
|
+
return;
|
|
127
|
+
ended = true;
|
|
128
|
+
live.delete(child);
|
|
129
|
+
if (live.size === 0)
|
|
130
|
+
onDrained?.();
|
|
131
|
+
if (slot.child !== child)
|
|
132
|
+
return;
|
|
133
|
+
slot.child = undefined;
|
|
134
|
+
if (slot.killTimer !== undefined) {
|
|
135
|
+
clearT(slot.killTimer);
|
|
136
|
+
slot.killTimer = undefined;
|
|
137
|
+
}
|
|
138
|
+
const ranFor = now() - (slot.childStartedAt ?? now());
|
|
139
|
+
slot.state.lastExit = { code, signal, at: now() };
|
|
140
|
+
delete slot.state.pid;
|
|
141
|
+
if (slot.expectingExit) {
|
|
142
|
+
slot.expectingExit = false;
|
|
143
|
+
slot.busy = false;
|
|
144
|
+
const then = slot.afterExit;
|
|
145
|
+
slot.afterExit = undefined;
|
|
146
|
+
then?.();
|
|
147
|
+
emit();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (stopped)
|
|
151
|
+
return;
|
|
152
|
+
if (slot.plan.kind === "scheduled") {
|
|
153
|
+
slot.busy = false;
|
|
154
|
+
slot.state.lastResult = code === 0 ? "ok" : "failed";
|
|
155
|
+
slot.state.status = "idle";
|
|
156
|
+
if (code !== 0)
|
|
157
|
+
deps.log("warn", `${slot.plan.id} run failed (code=${code ?? "null"} signal=${signal ?? "none"})`);
|
|
158
|
+
emit();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
deps.log("warn", `${slot.plan.id} exited (code=${code ?? "null"} signal=${signal ?? "none"})`);
|
|
162
|
+
if (ranFor >= HEALTHY_RUN_MS)
|
|
163
|
+
slot.attempts = 0;
|
|
164
|
+
const stoppedFromOutside = signal === "SIGTERM" || signal === "SIGINT";
|
|
165
|
+
if (slot.plan.restart === "on-failure" && (code === 0 || stoppedFromOutside)) {
|
|
166
|
+
slot.state.status = "exited";
|
|
167
|
+
if (stoppedFromOutside) {
|
|
168
|
+
slot.state.reason = `stopped outside the daemon; \`hq daemon restart ${slot.plan.id}\` starts it again`;
|
|
169
|
+
}
|
|
170
|
+
delete slot.state.nextRunAt;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
scheduleRestart(slot);
|
|
174
|
+
}
|
|
175
|
+
emit();
|
|
176
|
+
};
|
|
177
|
+
child.on("exit", onEnd);
|
|
178
|
+
child.on("error", (err) => {
|
|
179
|
+
deps.log("error", `${slot.plan.id}: ${errorMessage(err)}`);
|
|
180
|
+
// A child that never got a pid failed to launch and may never emit
|
|
181
|
+
// "exit". Any other error (for example a failed kill) leaves it running.
|
|
182
|
+
if (child.pid === undefined)
|
|
183
|
+
onEnd(null, null);
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
const startLong = (slot) => {
|
|
187
|
+
if (stopped)
|
|
188
|
+
return;
|
|
189
|
+
let child;
|
|
190
|
+
try {
|
|
191
|
+
child = deps.spawn(slot.plan);
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
slot.state.reason = `failed to start: ${errorMessage(err)}`;
|
|
195
|
+
deps.log("error", `${slot.plan.id} ${slot.state.reason}`);
|
|
196
|
+
scheduleRestart(slot);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
slot.child = child;
|
|
200
|
+
slot.childStartedAt = now();
|
|
201
|
+
slot.state.status = "running";
|
|
202
|
+
slot.state.startedAt = slot.childStartedAt;
|
|
203
|
+
delete slot.state.reason;
|
|
204
|
+
delete slot.state.nextRunAt;
|
|
205
|
+
if (child.pid !== undefined)
|
|
206
|
+
slot.state.pid = child.pid;
|
|
207
|
+
else
|
|
208
|
+
delete slot.state.pid;
|
|
209
|
+
watch(slot, child);
|
|
210
|
+
};
|
|
211
|
+
const scheduleNextRun = (slot, delayMs) => {
|
|
212
|
+
clearTimer(slot);
|
|
213
|
+
slot.state.nextRunAt = now() + delayMs;
|
|
214
|
+
slot.timer = setT(() => {
|
|
215
|
+
slot.timer = undefined;
|
|
216
|
+
runScheduled(slot);
|
|
217
|
+
}, delayMs);
|
|
218
|
+
};
|
|
219
|
+
const runScheduled = (slot, manual = false) => {
|
|
220
|
+
if (stopped)
|
|
221
|
+
return;
|
|
222
|
+
if (!manual)
|
|
223
|
+
scheduleNextRun(slot, (slot.plan.intervalSec ?? 60) * 1000);
|
|
224
|
+
if (slot.busy) {
|
|
225
|
+
deps.log("info", `${slot.plan.id} is still running; skipping this run`);
|
|
226
|
+
emit();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
slot.busy = true;
|
|
230
|
+
slot.state.lastRunAt = now();
|
|
231
|
+
const command = slot.plan.command;
|
|
232
|
+
if (command?.type === "internal") {
|
|
233
|
+
slot.state.status = "running";
|
|
234
|
+
deps
|
|
235
|
+
.runInternal(command.task)
|
|
236
|
+
.then((ok) => {
|
|
237
|
+
slot.state.lastResult = ok ? "ok" : "failed";
|
|
238
|
+
}, (err) => {
|
|
239
|
+
deps.log("error", `${slot.plan.id} failed: ${errorMessage(err)}`);
|
|
240
|
+
slot.state.lastResult = "failed";
|
|
241
|
+
})
|
|
242
|
+
.finally(() => {
|
|
243
|
+
slot.busy = false;
|
|
244
|
+
if (!stopped && !slot.plan.blocked)
|
|
245
|
+
slot.state.status = "idle";
|
|
246
|
+
emit();
|
|
247
|
+
});
|
|
248
|
+
emit();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
let child;
|
|
252
|
+
try {
|
|
253
|
+
child = deps.spawn(slot.plan);
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
slot.busy = false;
|
|
257
|
+
slot.state.lastResult = "failed";
|
|
258
|
+
slot.state.status = "idle";
|
|
259
|
+
slot.state.reason = `failed to start: ${errorMessage(err)}`;
|
|
260
|
+
deps.log("error", `${slot.plan.id} ${slot.state.reason}`);
|
|
261
|
+
emit();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
slot.child = child;
|
|
265
|
+
slot.childStartedAt = now();
|
|
266
|
+
slot.state.status = "running";
|
|
267
|
+
delete slot.state.reason;
|
|
268
|
+
if (child.pid !== undefined)
|
|
269
|
+
slot.state.pid = child.pid;
|
|
270
|
+
watch(slot, child);
|
|
271
|
+
emit();
|
|
272
|
+
};
|
|
273
|
+
const activate = (slot) => {
|
|
274
|
+
if (slot.plan.blocked) {
|
|
275
|
+
setBlocked(slot);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (slot.plan.kind === "scheduled") {
|
|
279
|
+
slot.state.status = "idle";
|
|
280
|
+
delete slot.state.reason;
|
|
281
|
+
const delay = (slot.plan.initialDelaySec ?? 0) * 1000;
|
|
282
|
+
if (delay === 0)
|
|
283
|
+
runScheduled(slot);
|
|
284
|
+
else
|
|
285
|
+
scheduleNextRun(slot, delay);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
startLong(slot);
|
|
289
|
+
};
|
|
290
|
+
const newSlot = (plan) => ({
|
|
291
|
+
plan,
|
|
292
|
+
key: planKey(plan),
|
|
293
|
+
attempts: 0,
|
|
294
|
+
expectingExit: false,
|
|
295
|
+
busy: false,
|
|
296
|
+
state: {
|
|
297
|
+
id: plan.id,
|
|
298
|
+
service: plan.service,
|
|
299
|
+
kind: plan.kind,
|
|
300
|
+
description: plan.description,
|
|
301
|
+
status: "idle",
|
|
302
|
+
restarts: 0,
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
const reconcile = () => {
|
|
306
|
+
if (stopped)
|
|
307
|
+
return;
|
|
308
|
+
let plans;
|
|
309
|
+
try {
|
|
310
|
+
const result = deps.plan();
|
|
311
|
+
if (!Array.isArray(result))
|
|
312
|
+
throw new Error("plan did not return a list of units");
|
|
313
|
+
plans = result;
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
deps.log("error", `could not plan units; keeping current ones: ${errorMessage(err)}`);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const wanted = new Set(plans.map((p) => p.id));
|
|
320
|
+
for (const [id, slot] of slots) {
|
|
321
|
+
if (!wanted.has(id)) {
|
|
322
|
+
deps.log("info", `stopping ${id}: no longer enabled`);
|
|
323
|
+
terminate(slot);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const next = new Map();
|
|
327
|
+
for (const plan of plans) {
|
|
328
|
+
const existing = slots.get(plan.id);
|
|
329
|
+
if (!existing) {
|
|
330
|
+
const slot = newSlot(plan);
|
|
331
|
+
next.set(plan.id, slot);
|
|
332
|
+
activate(slot);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
next.set(plan.id, existing);
|
|
336
|
+
const key = planKey(plan);
|
|
337
|
+
const wasBlocked = existing.plan.blocked !== undefined;
|
|
338
|
+
const changed = key !== existing.key;
|
|
339
|
+
existing.plan = plan;
|
|
340
|
+
existing.key = key;
|
|
341
|
+
existing.state.description = plan.description;
|
|
342
|
+
existing.state.kind = plan.kind;
|
|
343
|
+
if (plan.blocked) {
|
|
344
|
+
if (!wasBlocked) {
|
|
345
|
+
deps.log("info", `pausing ${plan.id}: ${plan.blocked.reason}`);
|
|
346
|
+
terminate(existing);
|
|
347
|
+
}
|
|
348
|
+
setBlocked(existing);
|
|
349
|
+
}
|
|
350
|
+
else if (wasBlocked) {
|
|
351
|
+
deps.log("info", `starting ${plan.id}: its requirement is now met`);
|
|
352
|
+
existing.attempts = 0;
|
|
353
|
+
// The copy stopped when it was paused may still be exiting; start the
|
|
354
|
+
// new one only after it is gone, so two never run side by side.
|
|
355
|
+
if (existing.child)
|
|
356
|
+
existing.afterExit = () => activate(existing);
|
|
357
|
+
else
|
|
358
|
+
activate(existing);
|
|
359
|
+
}
|
|
360
|
+
else if (changed) {
|
|
361
|
+
deps.log("info", `restarting ${plan.id}: its command changed`);
|
|
362
|
+
existing.attempts = 0;
|
|
363
|
+
existing.state.restarts = 0;
|
|
364
|
+
terminate(existing, () => activate(existing));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
slots = next;
|
|
368
|
+
emit();
|
|
369
|
+
};
|
|
370
|
+
const handleRequest = (req) => {
|
|
371
|
+
if (req.action === "restart-daemon") {
|
|
372
|
+
deps.log("info", "restarting the daemon (requested)");
|
|
373
|
+
if (deps.restartDaemon)
|
|
374
|
+
deps.restartDaemon();
|
|
375
|
+
else
|
|
376
|
+
deps.log("warn", "this daemon cannot restart itself; restart it to load the new version");
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
// A bot created since the last plan read is asked for before the timer
|
|
380
|
+
// would pick it up; read the plan now rather than dropping the request.
|
|
381
|
+
const unplanned = req.unit !== undefined && !slots.has(req.unit);
|
|
382
|
+
if (unplanned)
|
|
383
|
+
reconcile();
|
|
384
|
+
// The re-read already started a new long-running unit; restarting it would only bounce it.
|
|
385
|
+
if (unplanned && req.action === "restart" && slots.get(req.unit)?.child) {
|
|
386
|
+
emit();
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const targets = req.unit === undefined
|
|
390
|
+
? [...slots.values()].filter((s) => s.plan.kind === "long-running" && !s.plan.blocked)
|
|
391
|
+
: [slots.get(req.unit)].filter((s) => s !== undefined);
|
|
392
|
+
if (targets.length === 0) {
|
|
393
|
+
deps.log("warn", `ignoring ${req.action} request for unknown unit ${req.unit ?? "(all)"}`);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
for (const slot of targets) {
|
|
397
|
+
if (slot.plan.blocked) {
|
|
398
|
+
deps.log("warn", `ignoring ${req.action} for ${slot.plan.id}: ${slot.plan.blocked.reason}`);
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (req.action === "stop") {
|
|
402
|
+
deps.log("info", `stopping ${slot.plan.id} (requested)`);
|
|
403
|
+
// An expected exit is never restarted, including one that needed SIGKILL.
|
|
404
|
+
terminate(slot, () => {
|
|
405
|
+
slot.state.status = "exited";
|
|
406
|
+
slot.state.reason = `stopped on request; \`hq daemon restart ${slot.plan.id}\` starts it again`;
|
|
407
|
+
delete slot.state.nextRunAt;
|
|
408
|
+
});
|
|
409
|
+
if (!slot.child) {
|
|
410
|
+
slot.state.status = "exited";
|
|
411
|
+
slot.state.reason = `stopped on request; \`hq daemon restart ${slot.plan.id}\` starts it again`;
|
|
412
|
+
delete slot.state.nextRunAt;
|
|
413
|
+
}
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
if (req.action === "trigger") {
|
|
417
|
+
if (slot.plan.kind !== "scheduled") {
|
|
418
|
+
deps.log("warn", `ignoring trigger for ${slot.plan.id}: only scheduled services can be triggered`);
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
deps.log("info", `running ${slot.plan.id} now (requested)`);
|
|
422
|
+
runScheduled(slot, true);
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
deps.log("info", `restarting ${slot.plan.id} (requested)`);
|
|
426
|
+
slot.attempts = 0;
|
|
427
|
+
if (slot.plan.kind === "scheduled")
|
|
428
|
+
continue;
|
|
429
|
+
if (slot.child)
|
|
430
|
+
terminate(slot, () => startLong(slot));
|
|
431
|
+
else {
|
|
432
|
+
clearTimer(slot);
|
|
433
|
+
startLong(slot);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
emit();
|
|
437
|
+
};
|
|
438
|
+
const pollRequests = () => {
|
|
439
|
+
requestTimer = undefined;
|
|
440
|
+
if (stopped)
|
|
441
|
+
return;
|
|
442
|
+
try {
|
|
443
|
+
for (const req of deps.takeRequests())
|
|
444
|
+
handleRequest(req);
|
|
445
|
+
}
|
|
446
|
+
catch (err) {
|
|
447
|
+
deps.log("error", `could not read control requests: ${errorMessage(err)}`);
|
|
448
|
+
}
|
|
449
|
+
requestTimer = setT(pollRequests, requestPollMs);
|
|
450
|
+
};
|
|
451
|
+
const reconcileLoop = () => {
|
|
452
|
+
reconcileTimer = undefined;
|
|
453
|
+
if (stopped)
|
|
454
|
+
return;
|
|
455
|
+
reconcile();
|
|
456
|
+
reconcileTimer = setT(reconcileLoop, reconcileMs);
|
|
457
|
+
};
|
|
458
|
+
return {
|
|
459
|
+
start() {
|
|
460
|
+
if (started)
|
|
461
|
+
return;
|
|
462
|
+
started = true;
|
|
463
|
+
reconcile();
|
|
464
|
+
reconcileTimer = setT(reconcileLoop, reconcileMs);
|
|
465
|
+
requestTimer = setT(pollRequests, requestPollMs);
|
|
466
|
+
},
|
|
467
|
+
stop() {
|
|
468
|
+
if (drained)
|
|
469
|
+
return drained;
|
|
470
|
+
stopped = true;
|
|
471
|
+
if (reconcileTimer !== undefined)
|
|
472
|
+
clearT(reconcileTimer);
|
|
473
|
+
if (requestTimer !== undefined)
|
|
474
|
+
clearT(requestTimer);
|
|
475
|
+
for (const slot of slots.values()) {
|
|
476
|
+
clearTimer(slot);
|
|
477
|
+
slot.child = undefined;
|
|
478
|
+
if (slot.killTimer !== undefined)
|
|
479
|
+
clearT(slot.killTimer);
|
|
480
|
+
slot.killTimer = undefined;
|
|
481
|
+
slot.state.status = "stopped";
|
|
482
|
+
delete slot.state.pid;
|
|
483
|
+
delete slot.state.nextRunAt;
|
|
484
|
+
}
|
|
485
|
+
const children = [...live];
|
|
486
|
+
for (const child of children) {
|
|
487
|
+
try {
|
|
488
|
+
child.kill("SIGTERM");
|
|
489
|
+
}
|
|
490
|
+
catch (err) {
|
|
491
|
+
deps.log("error", `could not stop pid ${child.pid ?? "?"}: ${errorMessage(err)}`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
emit();
|
|
495
|
+
drained = new Promise((resolve) => {
|
|
496
|
+
if (live.size === 0) {
|
|
497
|
+
resolve();
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const escalation = setT(() => {
|
|
501
|
+
for (const child of live) {
|
|
502
|
+
deps.log("warn", `pid ${child.pid ?? "?"} ignored SIGTERM; sending SIGKILL`);
|
|
503
|
+
try {
|
|
504
|
+
child.kill("SIGKILL");
|
|
505
|
+
}
|
|
506
|
+
catch (err) {
|
|
507
|
+
deps.log("error", `could not SIGKILL pid ${child.pid ?? "?"}: ${errorMessage(err)}`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}, STOP_ESCALATION_MS);
|
|
511
|
+
onDrained = () => {
|
|
512
|
+
onDrained = null;
|
|
513
|
+
clearT(escalation);
|
|
514
|
+
resolve();
|
|
515
|
+
};
|
|
516
|
+
});
|
|
517
|
+
return drained;
|
|
518
|
+
},
|
|
519
|
+
reconcile,
|
|
520
|
+
state: snapshot,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
//# sourceMappingURL=supervisor.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One run of the `updater` service (`hq daemon service self-update`), which
|
|
3
|
+
* replaces the boxes' `hq-cli-update.timer` and `*-codex-update.timer`:
|
|
4
|
+
*
|
|
5
|
+
* 1. Update the hq CLI through the shared self-update path (same install
|
|
6
|
+
* routing, lock and convergence check as every other CLI update).
|
|
7
|
+
* 2. Update Codex when it is installed as a global npm package and behind.
|
|
8
|
+
* 3. After a CLI update, ask the daemon to restart itself so the supervisor and
|
|
9
|
+
* every service run the new version.
|
|
10
|
+
*/
|
|
11
|
+
import type { SelfUpdateOutcome } from "../../utils/self-update.js";
|
|
12
|
+
export declare const CODEX_PACKAGE = "@openai/codex";
|
|
13
|
+
export interface CodexUpdater {
|
|
14
|
+
/** Version of the global npm install, or null when Codex is not installed that way. */
|
|
15
|
+
installedVersion: () => string | null;
|
|
16
|
+
latestVersion: () => string | null;
|
|
17
|
+
install: () => {
|
|
18
|
+
ok: boolean;
|
|
19
|
+
detail?: string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export interface UpdaterDeps {
|
|
23
|
+
updateCli: () => Promise<Pick<SelfUpdateOutcome, "action" | "latest">>;
|
|
24
|
+
codex: CodexUpdater;
|
|
25
|
+
requestDaemonRestart: () => void;
|
|
26
|
+
log: (line: string) => void;
|
|
27
|
+
}
|
|
28
|
+
export declare function runDaemonUpdater(deps: UpdaterDeps): Promise<{
|
|
29
|
+
ok: boolean;
|
|
30
|
+
}>;
|
|
31
|
+
/**
|
|
32
|
+
* The variables for the updater's own CLI update. Every supervised service
|
|
33
|
+
* gets HQ_NO_UPDATE_CHECK=1 so it never updates itself; the updater is the one
|
|
34
|
+
* process that must, so it drops that opt-out.
|
|
35
|
+
*/
|
|
36
|
+
export declare function updaterEnv(vars: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
37
|
+
/** Codex through the global npm install, the way the boxes install it. */
|
|
38
|
+
export declare function npmCodexUpdater(): CodexUpdater;
|
|
39
|
+
//# sourceMappingURL=updater.d.ts.map
|