@indigoai-us/hq-cli 5.274.1 → 5.275.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.
@@ -13,7 +13,11 @@
13
13
  * - restart/trigger/stop requests from the CLI are polled and applied; a
14
14
  * request for a unit that is not planned yet re-reads the plan first;
15
15
  * - stop() sends SIGTERM to every child, escalates to SIGKILL after
16
- * STOP_ESCALATION_MS, and resolves once every child has exited.
16
+ * STOP_ESCALATION_MS, and resolves once every child has exited;
17
+ * - release() instead leaves every child running and reports the long-running
18
+ * ones, so the next daemon can adopt them (`adopt`). Adopted units whose
19
+ * command is unchanged keep running; changed ones are replaced one at a
20
+ * time, draining first when the unit has a drain signal.
17
21
  *
18
22
  * Processes, time, and state output are injected; `run.ts` wires the real ones.
19
23
  */
@@ -21,11 +25,24 @@ export const BACKOFF_BASE_MS = 1_000;
21
25
  export const BACKOFF_MAX_MS = 60_000;
22
26
  export const HEALTHY_RUN_MS = 60_000;
23
27
  export const STOP_ESCALATION_MS = 10_000;
28
+ /** After a handoff, how long a replaced unit must stay up before the next one is replaced. */
29
+ export const HANDOFF_STAGGER_MS = 15_000;
30
+ /** How long a draining unit may take to finish its work before it gets SIGTERM. */
31
+ export const HANDOFF_DRAIN_MAX_MS = 15 * 60_000;
24
32
  export function backoffMs(attempt) {
25
33
  return Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** Math.max(0, attempt));
26
34
  }
27
- function planKey(plan) {
28
- return JSON.stringify([plan.kind, plan.restart, plan.intervalSec, plan.initialDelaySec, plan.command, plan.env]);
35
+ /**
36
+ * What a unit's process was started from; a unit whose key changes is
37
+ * restarted. The CLI version counts only for `hq` commands, so a CLI update
38
+ * leaves hq-cloud runners, other programs, and units marked
39
+ * `keepAcrossCliUpdates` running.
40
+ */
41
+ export function planKey(plan, cliVersion) {
42
+ const fields = [plan.kind, plan.restart, plan.intervalSec, plan.initialDelaySec, plan.command, plan.env];
43
+ if (cliVersion !== undefined && plan.command?.type === "hq" && !plan.keepAcrossCliUpdates)
44
+ fields.push(cliVersion);
45
+ return JSON.stringify(fields);
29
46
  }
30
47
  function errorMessage(err) {
31
48
  return err instanceof Error ? err.message : String(err);
@@ -42,6 +59,10 @@ export function createSupervisor(deps) {
42
59
  let started = false;
43
60
  let reconcileTimer;
44
61
  let requestTimer;
62
+ /** Adopted units whose command changed, replaced one at a time. */
63
+ const handoffQueue = [];
64
+ let handoffActive = false;
65
+ let handoffTimer;
45
66
  /** Every child process not yet known to have exited, including ones the slots have let go of. */
46
67
  const live = new Set();
47
68
  let drained = null;
@@ -75,8 +96,20 @@ export function createSupervisor(deps) {
75
96
  delete slot.state.pid;
76
97
  delete slot.state.nextRunAt;
77
98
  };
78
- /** Stop the slot's child (if any), then run `then` once it has exited. */
79
- const terminate = (slot, then) => {
99
+ const signalChild = (slot, child, signal) => {
100
+ try {
101
+ child.kill(signal);
102
+ }
103
+ catch (err) {
104
+ deps.log("error", `could not send ${signal} to ${slot.plan.id}: ${errorMessage(err)}`);
105
+ }
106
+ };
107
+ /**
108
+ * Stop the slot's child (if any), then run `then` once it has exited. With
109
+ * `drain`, a unit that has a drain signal gets it first and SIGTERM only
110
+ * after HANDOFF_DRAIN_MAX_MS; SIGKILL follows SIGTERM after STOP_ESCALATION_MS.
111
+ */
112
+ const terminate = (slot, then, drain = false) => {
80
113
  clearTimer(slot);
81
114
  const child = slot.child;
82
115
  if (!child) {
@@ -85,24 +118,32 @@ export function createSupervisor(deps) {
85
118
  }
86
119
  slot.expectingExit = true;
87
120
  slot.afterExit = then;
88
- slot.killTimer = setT(() => {
89
- slot.killTimer = undefined;
90
- if (slot.child === child) {
121
+ if (slot.killTimer !== undefined)
122
+ clearT(slot.killTimer);
123
+ const escalateToKill = () => {
124
+ slot.killTimer = setT(() => {
125
+ slot.killTimer = undefined;
126
+ if (slot.child !== child)
127
+ return;
91
128
  deps.log("warn", `${slot.plan.id} ignored SIGTERM; sending SIGKILL`);
92
- try {
93
- child.kill("SIGKILL");
94
- }
95
- catch (err) {
96
- deps.log("error", `could not SIGKILL ${slot.plan.id}: ${errorMessage(err)}`);
97
- }
98
- }
99
- }, STOP_ESCALATION_MS);
100
- try {
101
- child.kill("SIGTERM");
102
- }
103
- catch (err) {
104
- deps.log("error", `could not stop ${slot.plan.id}: ${errorMessage(err)}`);
129
+ signalChild(slot, child, "SIGKILL");
130
+ }, STOP_ESCALATION_MS);
131
+ };
132
+ const drainSignal = drain ? slot.plan.drainSignal : undefined;
133
+ if (drainSignal) {
134
+ slot.killTimer = setT(() => {
135
+ slot.killTimer = undefined;
136
+ if (slot.child !== child)
137
+ return;
138
+ deps.log("warn", `${slot.plan.id} did not finish draining; sending SIGTERM`);
139
+ escalateToKill();
140
+ signalChild(slot, child, "SIGTERM");
141
+ }, HANDOFF_DRAIN_MAX_MS);
142
+ signalChild(slot, child, drainSignal);
143
+ return;
105
144
  }
145
+ escalateToKill();
146
+ signalChild(slot, child, "SIGTERM");
106
147
  };
107
148
  const scheduleRestart = (slot) => {
108
149
  if (stopped)
@@ -198,6 +239,7 @@ export function createSupervisor(deps) {
198
239
  return;
199
240
  }
200
241
  slot.child = child;
242
+ slot.childKey = slot.key;
201
243
  slot.childStartedAt = now();
202
244
  slot.state.status = "running";
203
245
  slot.state.startedAt = slot.childStartedAt;
@@ -263,6 +305,7 @@ export function createSupervisor(deps) {
263
305
  return;
264
306
  }
265
307
  slot.child = child;
308
+ slot.childKey = slot.key;
266
309
  slot.childStartedAt = now();
267
310
  slot.state.status = "running";
268
311
  delete slot.state.reason;
@@ -290,7 +333,7 @@ export function createSupervisor(deps) {
290
333
  };
291
334
  const newSlot = (plan) => ({
292
335
  plan,
293
- key: planKey(plan),
336
+ key: planKey(plan, deps.cliVersion),
294
337
  attempts: 0,
295
338
  expectingExit: false,
296
339
  busy: false,
@@ -334,7 +377,9 @@ export function createSupervisor(deps) {
334
377
  continue;
335
378
  }
336
379
  next.set(plan.id, existing);
337
- const key = planKey(plan);
380
+ const adopted = existing.adopted === true;
381
+ existing.adopted = false;
382
+ const key = planKey(plan, deps.cliVersion);
338
383
  const wasBlocked = existing.plan.blocked !== undefined;
339
384
  const changed = key !== existing.key;
340
385
  existing.plan = plan;
@@ -358,6 +403,10 @@ export function createSupervisor(deps) {
358
403
  else
359
404
  activate(existing);
360
405
  }
406
+ else if (changed && adopted && existing.child) {
407
+ deps.log("info", `will replace ${plan.id}: it runs an older CLI or command`);
408
+ handoffQueue.push(existing);
409
+ }
361
410
  else if (changed) {
362
411
  deps.log("info", `restarting ${plan.id}: its command changed`);
363
412
  existing.attempts = 0;
@@ -367,6 +416,93 @@ export function createSupervisor(deps) {
367
416
  }
368
417
  slots = next;
369
418
  emit();
419
+ pumpHandoff();
420
+ };
421
+ /** Replace the next queued adopted unit once the previous replacement has settled. */
422
+ const pumpHandoff = () => {
423
+ if (stopped || handoffActive)
424
+ return;
425
+ let slot;
426
+ while ((slot = handoffQueue.shift())) {
427
+ // Skip units that left the plan, were paused, or already restarted on their own.
428
+ if (slots.get(slot.plan.id) === slot && !slot.plan.blocked && slot.child && slot.childKey !== slot.key)
429
+ break;
430
+ }
431
+ if (!slot)
432
+ return;
433
+ const target = slot;
434
+ handoffActive = true;
435
+ deps.log("info", `replacing ${target.plan.id}${target.plan.drainSignal ? " once it finishes its current work" : ""}`);
436
+ target.attempts = 0;
437
+ target.state.restarts = 0;
438
+ terminate(target, () => activate(target), true);
439
+ const settle = (delay) => {
440
+ handoffTimer = setT(() => {
441
+ handoffTimer = undefined;
442
+ if (stopped)
443
+ return;
444
+ const gone = slots.get(target.plan.id) !== target || target.plan.blocked !== undefined;
445
+ if (!gone && target.expectingExit)
446
+ return settle(HANDOFF_STAGGER_MS);
447
+ if (!gone && target.child) {
448
+ const ranFor = now() - (target.childStartedAt ?? now());
449
+ if (ranFor < HANDOFF_STAGGER_MS)
450
+ return settle(HANDOFF_STAGGER_MS - ranFor);
451
+ }
452
+ else if (!gone && target.state.status === "backoff") {
453
+ return settle(HANDOFF_STAGGER_MS);
454
+ }
455
+ handoffActive = false;
456
+ pumpHandoff();
457
+ }, delay);
458
+ };
459
+ settle(HANDOFF_STAGGER_MS);
460
+ };
461
+ /** Stop a process the plan has no place for. */
462
+ const stopStray = (id, child) => {
463
+ const slot = newSlot({ id, service: id, description: id, kind: "long-running", restart: "always", command: null, env: {} });
464
+ slot.child = child;
465
+ watch(slot, child);
466
+ terminate(slot);
467
+ };
468
+ /** Take over the previous daemon's services before the first plan read starts anything. */
469
+ const adoptPrevious = () => {
470
+ const adopt = deps.adopt ?? [];
471
+ if (adopt.length === 0)
472
+ return;
473
+ let plans = [];
474
+ try {
475
+ const result = deps.plan();
476
+ if (!Array.isArray(result))
477
+ throw new Error("plan did not return a list of units");
478
+ plans = result;
479
+ }
480
+ catch (err) {
481
+ deps.log("error", `could not plan units; stopping the services left by the previous daemon: ${errorMessage(err)}`);
482
+ }
483
+ const byId = new Map(plans.map((p) => [p.id, p]));
484
+ for (const unit of adopt) {
485
+ const plan = byId.get(unit.id);
486
+ if (!plan || plan.kind !== "long-running" || slots.has(unit.id)) {
487
+ deps.log("info", `stopping ${unit.id} left by the previous daemon: no longer planned as a service`);
488
+ stopStray(unit.id, unit.child);
489
+ continue;
490
+ }
491
+ // Recorded as runnable, so the first plan read pauses it when it is now blocked.
492
+ const slot = newSlot({ ...plan, blocked: undefined });
493
+ slot.key = unit.key;
494
+ slot.adopted = true;
495
+ slot.child = unit.child;
496
+ slot.childKey = unit.key;
497
+ slot.childStartedAt = now();
498
+ slot.state.status = "running";
499
+ slot.state.startedAt = slot.childStartedAt;
500
+ if (unit.child.pid !== undefined)
501
+ slot.state.pid = unit.child.pid;
502
+ watch(slot, unit.child);
503
+ slots.set(unit.id, slot);
504
+ deps.log("info", `adopted ${unit.id} (pid ${unit.child.pid ?? "?"}) from the previous daemon`);
505
+ }
370
506
  };
371
507
  const handleRequest = (req) => {
372
508
  if (req.action === "restart-daemon") {
@@ -461,6 +597,7 @@ export function createSupervisor(deps) {
461
597
  if (started)
462
598
  return;
463
599
  started = true;
600
+ adoptPrevious();
464
601
  reconcile();
465
602
  reconcileTimer = setT(reconcileLoop, reconcileMs);
466
603
  requestTimer = setT(pollRequests, requestPollMs);
@@ -473,6 +610,8 @@ export function createSupervisor(deps) {
473
610
  clearT(reconcileTimer);
474
611
  if (requestTimer !== undefined)
475
612
  clearT(requestTimer);
613
+ if (handoffTimer !== undefined)
614
+ clearT(handoffTimer);
476
615
  for (const slot of slots.values()) {
477
616
  clearTimer(slot);
478
617
  slot.child = undefined;
@@ -519,6 +658,34 @@ export function createSupervisor(deps) {
519
658
  },
520
659
  reconcile,
521
660
  state: snapshot,
661
+ release() {
662
+ const released = [];
663
+ for (const slot of slots.values()) {
664
+ const pid = slot.child?.pid;
665
+ if (slot.plan.kind === "long-running" && pid !== undefined && slot.childKey !== undefined) {
666
+ released.push({ id: slot.plan.id, pid, key: slot.childKey });
667
+ }
668
+ }
669
+ stopped = true;
670
+ if (reconcileTimer !== undefined)
671
+ clearT(reconcileTimer);
672
+ if (requestTimer !== undefined)
673
+ clearT(requestTimer);
674
+ if (handoffTimer !== undefined)
675
+ clearT(handoffTimer);
676
+ for (const slot of slots.values()) {
677
+ clearTimer(slot);
678
+ if (slot.killTimer !== undefined)
679
+ clearT(slot.killTimer);
680
+ slot.killTimer = undefined;
681
+ slot.child = undefined;
682
+ slot.state.status = "stopped";
683
+ delete slot.state.pid;
684
+ delete slot.state.nextRunAt;
685
+ }
686
+ emit();
687
+ return released;
688
+ },
522
689
  };
523
690
  }
524
691
  //# sourceMappingURL=supervisor.js.map
@@ -10,7 +10,13 @@ function admissionHost(host) {
10
10
  /** Snapshot the same per-host running-slot predicate used by lane admission. */
11
11
  export function buildHostCapacitySnapshot(input) {
12
12
  const at = input.now.getTime();
13
- const onHost = input.lanes.filter((lane) => laneRunsOnHost(lane, admissionHost(input.host)));
13
+ const localHostname = input.hostname?.trim();
14
+ const onHost = input.lanes.filter((lane) => laneRunsOnHost(lane, admissionHost(input.host)) ||
15
+ (input.host.kind === "outpost" &&
16
+ localHostname !== undefined &&
17
+ localHostname !== "" &&
18
+ lane.host.kind === "local" &&
19
+ lane.host.id === localHostname));
14
20
  const providers = {};
15
21
  for (const provider of ["claude", "codex", "grok"]) {
16
22
  const providerConfig = input.config.providers[provider];
@@ -67,6 +67,8 @@ export declare function parseClaudeHelpModels(text: string): {
67
67
  aliases: string[];
68
68
  prefixes: string[];
69
69
  };
70
+ /** Prefix of every Claude model full name, e.g. `claude-sonnet-5-5`. */
71
+ export declare const CLAUDE_FULL_NAME_PREFIX = "claude-";
70
72
  export declare function claudeKnownModels(probe?: ProbeCommand, env?: NodeJS.ProcessEnv): KnownModels | null;
71
73
  export declare function knownModels(provider: LaneProvider, home: string, deps?: KnownModelsDeps): KnownModels | null;
72
74
  export declare function modelIsKnown(known: KnownModels, model: string): boolean;
@@ -154,6 +154,8 @@ export function parseClaudeHelpModels(text) {
154
154
  }
155
155
  return { aliases, prefixes: [...prefixes] };
156
156
  }
157
+ /** Prefix of every Claude model full name, e.g. `claude-sonnet-5-5`. */
158
+ export const CLAUDE_FULL_NAME_PREFIX = "claude-";
157
159
  export function claudeKnownModels(probe = defaultProbe, env = process.env) {
158
160
  const source = "claude --help";
159
161
  const result = probe("claude", ["--help"], env);
@@ -166,7 +168,10 @@ export function claudeKnownModels(probe = defaultProbe, env = process.env) {
166
168
  process.stderr.write(`hq lanes: \`${source}\` named no --model aliases (exit ${result.status ?? "spawn error"}); --model is not validated for claude\n`);
167
169
  return null;
168
170
  }
169
- return { provider: "claude", ids: parsed.aliases, prefixes: parsed.prefixes, source };
171
+ // Every Claude model's full name starts with `claude-`. Accept it even when
172
+ // this help text carries no full-name example, as some releases' help does.
173
+ const prefixes = [...new Set([CLAUDE_FULL_NAME_PREFIX, ...parsed.prefixes])];
174
+ return { provider: "claude", ids: parsed.aliases, prefixes, source };
170
175
  }
171
176
  export function knownModels(provider, home, deps = {}) {
172
177
  switch (provider) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.274.1",
3
+ "version": "5.275.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {