@indigoai-us/hq-cli 5.274.0 → 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.
- package/CHANGELOG.md +52 -0
- package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +74 -18
- package/dist/command-catalog.generated.d.ts +34 -1
- package/dist/command-catalog.generated.js +43 -1
- package/dist/commands/cloud.js +22 -9
- package/dist/commands/company.js +66 -3
- package/dist/commands/core-checkpoint-gate-setting.d.ts +69 -0
- package/dist/commands/core-checkpoint-gate-setting.js +163 -0
- package/dist/commands/core.js +2 -0
- package/dist/commands/daemon.js +7 -2
- package/dist/commands/lanes-message.js +40 -7
- package/dist/commands/lanes-shared.d.ts +4 -0
- package/dist/commands/lanes-shared.js +5 -0
- package/dist/commands/lanes.js +41 -17
- package/dist/lib/daemon/catalog.d.ts +7 -0
- package/dist/lib/daemon/catalog.js +6 -0
- package/dist/lib/daemon/flag.d.ts +23 -0
- package/dist/lib/daemon/flag.js +57 -0
- package/dist/lib/daemon/install.js +6 -1
- package/dist/lib/daemon/remote-control.d.ts +6 -0
- package/dist/lib/daemon/remote-control.js +12 -0
- package/dist/lib/daemon/run.js +115 -2
- package/dist/lib/daemon/succession.d.ts +116 -0
- package/dist/lib/daemon/succession.js +262 -0
- package/dist/lib/daemon/supervisor.d.ts +34 -1
- package/dist/lib/daemon/supervisor.js +190 -23
- package/dist/lib/lanes/hire-grant-dispatch.d.ts +2 -1
- package/dist/lib/lanes/hire-grant-dispatch.js +37 -2
- package/dist/lib/lanes/host-capacity.js +7 -1
- package/dist/lib/lanes/keep-alive.js +2 -2
- package/dist/lib/lanes/models.d.ts +2 -0
- package/dist/lib/lanes/models.js +6 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
|
89
|
-
slot.killTimer
|
|
90
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
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
|
|
@@ -16,9 +16,10 @@ export interface OutpostHireGrantContext {
|
|
|
16
16
|
expected_senior: SeniorRef;
|
|
17
17
|
hierarchy: HireGrantLaneSnapshot[];
|
|
18
18
|
superseded_message_caller_id?: string;
|
|
19
|
+
superseded_message_session_id?: string;
|
|
19
20
|
}
|
|
20
21
|
/** Encode only the owner-relevant hierarchy from the primary's complete store. */
|
|
21
|
-
export declare function createOutpostHireGrantContext(target: LaneRecord, lanes: readonly LaneRecord[], supersededMessageCallerId?: string): string;
|
|
22
|
+
export declare function createOutpostHireGrantContext(target: LaneRecord, lanes: readonly LaneRecord[], supersededMessageCallerId?: string, supersededMessageSessionId?: string): string;
|
|
22
23
|
/** Decode and validate an owner grant context, binding it to the target lane. */
|
|
23
24
|
export declare function parseOutpostHireGrantContext(encoded: string, targetLaneId: string): OutpostHireGrantContext;
|
|
24
25
|
export declare function sameHireGrantSenior(a: SeniorRef, b: SeniorRef): boolean;
|
|
@@ -30,11 +30,18 @@ function snapshot(lane) {
|
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
/** Encode only the owner-relevant hierarchy from the primary's complete store. */
|
|
33
|
-
export function createOutpostHireGrantContext(target, lanes, supersededMessageCallerId) {
|
|
33
|
+
export function createOutpostHireGrantContext(target, lanes, supersededMessageCallerId, supersededMessageSessionId) {
|
|
34
34
|
const current = lanes.find((lane) => lane.lane_id === target.lane_id);
|
|
35
35
|
if (!current || !sameSenior(current.senior, target.senior)) {
|
|
36
36
|
throw new LanesError("hire_grant_hierarchy_unavailable", `Cannot build an authoritative hire-grant hierarchy for lane ${target.lane_id}`, { lane_id: target.lane_id });
|
|
37
37
|
}
|
|
38
|
+
if (supersededMessageCallerId !== undefined && supersededMessageSessionId !== undefined) {
|
|
39
|
+
throw contextError("Outpost grant context cannot contain both lane and session message callers");
|
|
40
|
+
}
|
|
41
|
+
if (supersededMessageSessionId !== undefined &&
|
|
42
|
+
(current.senior.kind !== "session" || current.senior.id !== supersededMessageSessionId)) {
|
|
43
|
+
throw new LanesError("hire_grant_message_caller_invalid", `Cannot include an unverified session message caller in lane ${target.lane_id}'s Outpost context`, { lane_id: target.lane_id });
|
|
44
|
+
}
|
|
38
45
|
const relevant = new Map();
|
|
39
46
|
relevant.set(current.lane_id, current);
|
|
40
47
|
if (current.senior.kind === "lane") {
|
|
@@ -65,6 +72,18 @@ export function createOutpostHireGrantContext(target, lanes, supersededMessageCa
|
|
|
65
72
|
}
|
|
66
73
|
}
|
|
67
74
|
}
|
|
75
|
+
if (current.senior.kind === "session") {
|
|
76
|
+
const companyUid = current.company?.uid;
|
|
77
|
+
const projectId = current.project_id;
|
|
78
|
+
if (companyUid) {
|
|
79
|
+
for (const candidate of lanes) {
|
|
80
|
+
if (candidate.company?.uid === companyUid && ((current.worker_profile !== undefined && candidate.worker_profile === current.worker_profile) ||
|
|
81
|
+
(projectId !== undefined && candidate.project_id === projectId && (candidate.hire_grant ?? 0) > 0))) {
|
|
82
|
+
relevant.set(candidate.lane_id, candidate);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
68
87
|
const hierarchy = [...relevant.values()]
|
|
69
88
|
.map(snapshot)
|
|
70
89
|
.sort((a, b) => a.lane_id.localeCompare(b.lane_id));
|
|
@@ -79,6 +98,9 @@ export function createOutpostHireGrantContext(target, lanes, supersededMessageCa
|
|
|
79
98
|
...(supersededMessageCallerId === undefined
|
|
80
99
|
? {}
|
|
81
100
|
: { superseded_message_caller_id: supersededMessageCallerId }),
|
|
101
|
+
...(supersededMessageSessionId === undefined
|
|
102
|
+
? {}
|
|
103
|
+
: { superseded_message_session_id: supersededMessageSessionId }),
|
|
82
104
|
};
|
|
83
105
|
const encoded = Buffer.from(JSON.stringify(context), "utf8").toString("base64url");
|
|
84
106
|
if (Buffer.byteLength(encoded, "utf8") > MAX_CONTEXT_BYTES) {
|
|
@@ -183,7 +205,7 @@ export function parseOutpostHireGrantContext(encoded, targetLaneId) {
|
|
|
183
205
|
throw contextError("Outpost grant context must be an object");
|
|
184
206
|
}
|
|
185
207
|
const record = raw;
|
|
186
|
-
if (Object.keys(record).some((key) => !["schema_version", "enforcement_enabled", "expected_senior", "hierarchy", "superseded_message_caller_id"].includes(key))) {
|
|
208
|
+
if (Object.keys(record).some((key) => !["schema_version", "enforcement_enabled", "expected_senior", "hierarchy", "superseded_message_caller_id", "superseded_message_session_id"].includes(key))) {
|
|
187
209
|
throw contextError("Outpost grant context contains unknown fields");
|
|
188
210
|
}
|
|
189
211
|
if (record.schema_version !== HIRE_GRANT_CONTEXT_SCHEMA ||
|
|
@@ -194,10 +216,18 @@ export function parseOutpostHireGrantContext(encoded, targetLaneId) {
|
|
|
194
216
|
}
|
|
195
217
|
const expectedSenior = parseSenior(record.expected_senior);
|
|
196
218
|
const messageCallerId = record.superseded_message_caller_id;
|
|
219
|
+
const messageSessionId = record.superseded_message_session_id;
|
|
197
220
|
if (messageCallerId !== undefined &&
|
|
198
221
|
(typeof messageCallerId !== "string" || !messageCallerId.trim() || messageCallerId.trim() !== messageCallerId)) {
|
|
199
222
|
throw contextError("Outpost grant context contains an invalid superseded-message caller");
|
|
200
223
|
}
|
|
224
|
+
if (messageSessionId !== undefined &&
|
|
225
|
+
(typeof messageSessionId !== "string" || !messageSessionId.trim() || messageSessionId.trim() !== messageSessionId)) {
|
|
226
|
+
throw contextError("Outpost grant context contains an invalid superseded-message session");
|
|
227
|
+
}
|
|
228
|
+
if (messageCallerId !== undefined && messageSessionId !== undefined) {
|
|
229
|
+
throw contextError("Outpost grant context cannot contain both lane and session message callers");
|
|
230
|
+
}
|
|
201
231
|
const hierarchy = record.hierarchy.map(parseLaneSnapshot);
|
|
202
232
|
if (!expectedSenior || hierarchy.some((lane) => lane === null)) {
|
|
203
233
|
throw contextError("Outpost grant context contains an invalid senior or lane snapshot");
|
|
@@ -211,6 +241,10 @@ export function parseOutpostHireGrantContext(encoded, targetLaneId) {
|
|
|
211
241
|
if (!target || !sameSenior(target.senior, expectedSenior)) {
|
|
212
242
|
throw contextError("Outpost grant context does not match the target's expected senior");
|
|
213
243
|
}
|
|
244
|
+
if (messageSessionId !== undefined &&
|
|
245
|
+
(expectedSenior.kind !== "session" || expectedSenior.id !== messageSessionId)) {
|
|
246
|
+
throw contextError("Outpost grant context session caller does not match the target's session senior");
|
|
247
|
+
}
|
|
214
248
|
if (expectedSenior.kind === "lane" &&
|
|
215
249
|
(!expectedSenior.id || !byId.has(expectedSenior.id))) {
|
|
216
250
|
throw contextError("Outpost grant context is missing the target's senior lane");
|
|
@@ -232,6 +266,7 @@ export function parseOutpostHireGrantContext(encoded, targetLaneId) {
|
|
|
232
266
|
expected_senior: expectedSenior,
|
|
233
267
|
hierarchy: snapshots,
|
|
234
268
|
...(messageCallerId === undefined ? {} : { superseded_message_caller_id: messageCallerId }),
|
|
269
|
+
...(messageSessionId === undefined ? {} : { superseded_message_session_id: messageSessionId }),
|
|
235
270
|
};
|
|
236
271
|
}
|
|
237
272
|
export function sameHireGrantSenior(a, b) {
|
|
@@ -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
|
|
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];
|
|
@@ -234,8 +234,8 @@ export function keepAliveSupersededBy(lane, lanes, lineageAware = LANE_SUPERSESS
|
|
|
234
234
|
// Any descendant is part of this lead's work tree, not a replacement lead.
|
|
235
235
|
if (laneAncestorsFrom(candidate.senior, lanesById, sessionOwners).has(lane.lane_id))
|
|
236
236
|
return false;
|
|
237
|
-
const sameLaneSenior = lane.senior.kind === "lane" &&
|
|
238
|
-
candidate.senior.kind ===
|
|
237
|
+
const sameLaneSenior = (lane.senior.kind === "lane" || lane.senior.kind === "session") &&
|
|
238
|
+
candidate.senior.kind === lane.senior.kind &&
|
|
239
239
|
candidate.senior.id === lane.senior.id;
|
|
240
240
|
const sameProfile = lane.worker_profile !== undefined &&
|
|
241
241
|
candidate.worker_profile === lane.worker_profile &&
|
|
@@ -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;
|
package/dist/lib/lanes/models.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|