@indigoai-us/hq-cli 5.276.0 → 5.278.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 +99 -0
- package/assets/scaffold/core/scripts/hq-delegate-pickup.sh +99 -0
- package/assets/scaffold/core/scripts/hq-delegate-repo.sh +158 -0
- package/assets/scaffold/core/scripts/hq-delegate-resolve.sh +145 -0
- package/assets/scaffold/core/scripts/hq-delegate-secrets.sh +129 -0
- package/assets/scaffold/core/scripts/hq-delegate-transfer.sh +175 -0
- package/assets/scaffold/core/scripts/lib/secret-patterns.sh +58 -0
- package/dist/command-catalog.generated.d.ts +239 -1
- package/dist/command-catalog.generated.js +302 -1
- package/dist/commands/core-delegate.d.ts +6 -0
- package/dist/commands/core-delegate.js +987 -0
- package/dist/commands/core.js +10 -0
- package/dist/commands/daemon.d.ts +3 -0
- package/dist/commands/daemon.js +173 -1
- package/dist/commands/dm.d.ts +2 -0
- package/dist/commands/dm.js +11 -4
- package/dist/commands/lanes-accounts.js +44 -2
- package/dist/commands/lanes-message.js +69 -12
- package/dist/commands/lanes-resume.js +2 -0
- package/dist/commands/lanes-shared.d.ts +3 -0
- package/dist/commands/lanes-shared.js +12 -2
- package/dist/commands/lanes.d.ts +5 -1
- package/dist/commands/lanes.js +399 -47
- package/dist/commands/secrets.d.ts +16 -1
- package/dist/commands/secrets.js +77 -48
- package/dist/commands/sync-mode.d.ts +11 -0
- package/dist/commands/sync-mode.js +30 -0
- package/dist/fast-scaffold.d.ts +17 -0
- package/dist/fast-scaffold.js +91 -0
- package/dist/index.js +17 -1
- package/dist/lanes-drain-hook.js +154 -7
- package/dist/lib/agent-kit/run/inbox.js +3 -2
- package/dist/lib/checkpoint-ontology/apply.d.ts +6 -0
- package/dist/lib/checkpoint-ontology/apply.js +111 -3
- package/dist/lib/checkpoint-ontology/plan.d.ts +23 -0
- package/dist/lib/checkpoint-ontology/plan.js +23 -0
- package/dist/lib/checkpoint-ontology/tree.d.ts +2 -0
- package/dist/lib/checkpoint-ontology/tree.js +34 -0
- package/dist/lib/core-utils/jq-json-serializer.d.ts +5 -0
- package/dist/lib/core-utils/jq-json-serializer.js +10 -0
- package/dist/lib/core-utils/secret-patterns.d.ts +41 -0
- package/dist/lib/core-utils/secret-patterns.js +33 -0
- package/dist/lib/daemon/catalog.d.ts +18 -2
- package/dist/lib/daemon/catalog.js +18 -2
- package/dist/lib/daemon/config.d.ts +6 -0
- package/dist/lib/daemon/config.js +15 -2
- package/dist/lib/daemon/requests.d.ts +4 -0
- package/dist/lib/daemon/requests.js +2 -0
- package/dist/lib/daemon/run.js +94 -8
- package/dist/lib/daemon/supervisor.d.ts +8 -0
- package/dist/lib/daemon/supervisor.js +101 -1
- package/dist/lib/daemon/sync-runner.d.ts +58 -0
- package/dist/lib/daemon/sync-runner.js +277 -0
- package/dist/lib/doctor/__testing__/run-doctor.d.ts +8 -0
- package/dist/lib/doctor/__testing__/run-doctor.js +52 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/lanes/claude-balance.d.ts +6 -1
- package/dist/lib/lanes/claude-balance.js +46 -8
- package/dist/lib/lanes/config.d.ts +2 -1
- package/dist/lib/lanes/config.js +31 -0
- package/dist/lib/lanes/hire-grant-dispatch.d.ts +2 -0
- package/dist/lib/lanes/hire-grant-dispatch.js +41 -0
- package/dist/lib/lanes/index.d.ts +1 -1
- package/dist/lib/lanes/keep-alive.d.ts +3 -0
- package/dist/lib/lanes/keep-alive.js +3 -0
- package/dist/lib/lanes/outpost-dispatch.d.ts +4 -0
- package/dist/lib/lanes/outpost-dispatch.js +11 -2
- package/dist/lib/lanes/reconcile.d.ts +9 -0
- package/dist/lib/lanes/reconcile.js +127 -2
- package/dist/lib/lanes/runaway-tool-guard.d.ts +38 -0
- package/dist/lib/lanes/runaway-tool-guard.js +284 -0
- package/dist/lib/lanes/spawn.js +17 -10
- package/dist/lib/lanes/store.js +45 -1
- package/dist/lib/lanes/types.d.ts +37 -1
- package/dist/lib/lanes/types.js +2 -2
- package/dist/lib/lanes/watcher.d.ts +2 -0
- package/dist/lib/lanes/watcher.js +13 -2
- package/dist/secrets-env-fast-path.d.ts +5 -0
- package/dist/secrets-env-fast-path.js +16 -0
- package/dist/secrets-env-fast-route.d.ts +18 -0
- package/dist/secrets-env-fast-route.js +54 -0
- package/package.json +2 -2
package/dist/commands/lanes.js
CHANGED
|
@@ -17,8 +17,8 @@ import { createLaneMoveHandlers } from "./lanes-move.js";
|
|
|
17
17
|
import { createLaneQuestionsHandlers } from "./lanes-questions.js";
|
|
18
18
|
import { createLaneResumeHandlers } from "./lanes-resume.js";
|
|
19
19
|
import { createLaneWatchHandlers } from "./lanes-watch.js";
|
|
20
|
-
import { callerMatchesSessionSenior, assertPlacementEnabled, defaultLanePlacementEnabled, parsePlacementPolicy, processLanePlacementCompany, readLanePlacementEnabled, } from "./lanes-shared.js";
|
|
21
|
-
export { LANE_OUTPOST_LOAD_BALANCING_FLAG } from "./lanes-shared.js";
|
|
20
|
+
import { callerMatchesSessionSenior, assertPlacementEnabled, defaultLanePlacementEnabled, parsePlacementPolicy, processLanePlacementCompany, readClaudeAccountBalanceSignalsEnabled, readLanePlacementEnabled, } from "./lanes-shared.js";
|
|
21
|
+
export { CLAUDE_ACCOUNT_BALANCE_SIGNALS_FLAG, LANE_OUTPOST_LOAD_BALANCING_FLAG } from "./lanes-shared.js";
|
|
22
22
|
export { resolveAnswerChoice } from "./lanes-questions.js";
|
|
23
23
|
import { registerOutpostPrunerCommand } from "./outpost-pruner.js";
|
|
24
24
|
import { registerLaneKpisCommand } from "./lanes-kpis.js";
|
|
@@ -31,7 +31,7 @@ import { withCliFeatureUsed } from "../utils/cli-telemetry.js";
|
|
|
31
31
|
import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
|
|
32
32
|
import { isSsmRunCommandProcess } from "../lib/lanes/ssm-command-context.js";
|
|
33
33
|
import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
|
|
34
|
-
import { createOutpostHireGrantContext, parseOutpostHireGrantContext, sameHireGrantSenior, } from "../lib/lanes/hire-grant-dispatch.js";
|
|
34
|
+
import { createOutpostHireGrantContext, parseOutpostHireGrantContext, selectOutpostHireGrantHierarchy, sameHireGrantSenior, } from "../lib/lanes/hire-grant-dispatch.js";
|
|
35
35
|
import { createOutpostProfileRepinContext, parseOutpostProfileRepinContext, } from "../lib/lanes/outpost-profile-repin.js";
|
|
36
36
|
import { createOutpostRegistryWorkerApprovalContext, parseOutpostRegistryWorkerApprovalContext, } from "../lib/lanes/outpost-registry-worker-approval.js";
|
|
37
37
|
import { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity, resolveAdmissionHost, } from "../lib/lanes/admission-host.js";
|
|
@@ -51,7 +51,7 @@ import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, lan
|
|
|
51
51
|
import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
|
|
52
52
|
import { listLaneReviews } from "../lib/lanes/reviews.js";
|
|
53
53
|
import { effectiveAccountHome } from "../lib/lanes/account-home.js";
|
|
54
|
-
import { AccountResolutionError, compareLaneQueueOrder, LANE_PROVIDERS, LANE_STATES, QUESTION_STATUSES, SENIOR_KINDS, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, aliveDirectChildren, assignedChildHireGrants, hireGrantCapacityError, planHireGrantBackfill, terminalChildResumeHireGrantError, withHireGrantAdmission, laneStoryLedgerEnabled, validateProjectStories, buildLaneStatusSnapshot, formatLaneStatusHuman, listLaneIds, listQuestionIds, loadLanesConfig, laneRunsHere, MAX_LANE_DEPTH, MONITOR_ERROR_EXIT_CODE, readLiveMonitorEvidence, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneRecord, parseLaneState, parseQuestionRecord, parseSeniorKind, readLane, sanitizeHostComponent, retryUpdateLane, syncLaneRecordFromOwner, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile, loadWorkerProfileWithRegistry, registryWorkerRecord, runReconcilePass, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, dropboxDir, laneDeliveryMechanism, hasActiveAdmissionReservation, hasActivePendingResume, hasRecordedSessionId, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, providerSupportsSessionResume, providerSessionPortable, sessionNotPortableMessage, withLaneAdmissionLock, withAllLaneAdmissionLocks, withLaneHireLocks, raiseLaneQuestion, sha256Text, } from "../lib/lanes/index.js";
|
|
54
|
+
import { AccountResolutionError, compareLaneQueueOrder, LANE_PROVIDERS, LANE_STATES, QUESTION_STATUSES, SENIOR_KINDS, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, aliveDirectChildren, assignedChildHireGrants, hireGrantCapacityError, planHireGrantBackfill, terminalChildResumeHireGrantError, withHireGrantAdmission, laneStoryLedgerEnabled, validateProjectStories, buildLaneStatusSnapshot, formatLaneStatusHuman, listLaneIds, listQuestionIds, loadLanesConfig, laneRunsHere, MAX_LANE_DEPTH, MONITOR_ERROR_EXIT_CODE, readLiveMonitorEvidence, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneRecord, parseLaneState, parseQuestionRecord, parseSeniorKind, readLane, sanitizeHostComponent, retryUpdateLane, syncLaneRecordFromOwner, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile, loadWorkerProfileWithRegistry, registryWorkerRecord, runReconcilePass, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, dropboxDir, laneDeliveryMechanism, hasActiveAdmissionReservation, hasActivePendingResume, hasRecordedSessionId, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, providerSupportsSessionResume, providerSessionPortable, sessionNotPortableMessage, withLaneAdmissionLock, withAllLaneAdmissionLocks, readWorkerRunResult, resumeCommandRefusal, withLaneHireLocks, raiseLaneQuestion, sha256Text, } from "../lib/lanes/index.js";
|
|
55
55
|
import { activeLaneStories, laneStories, laneStory, storyWarning, } from "../lib/lanes/stories.js";
|
|
56
56
|
import { resolveLaneKeepAlive } from "../lib/lanes/profile.js";
|
|
57
57
|
import { resolveLaneCompany } from "../lib/lanes/lane-company.js";
|
|
@@ -451,14 +451,16 @@ function readQuestionBody(hqRoot, question) {
|
|
|
451
451
|
function loadAllLanes(hqRoot) {
|
|
452
452
|
return listLaneIds(hqRoot).map((id) => readLane(hqRoot, id));
|
|
453
453
|
}
|
|
454
|
-
function laneHireFields(lane, lanes) {
|
|
455
|
-
const
|
|
454
|
+
function laneHireFields(lane, lanes, aliveChildrenByParent) {
|
|
455
|
+
const aliveChildCount = aliveChildrenByParent
|
|
456
|
+
? aliveChildrenByParent.get(lane.lane_id) ?? 0
|
|
457
|
+
: aliveDirectChildren(lanes, lane.lane_id).length;
|
|
456
458
|
const hireGrant = lane.hire_grant ?? 0;
|
|
457
|
-
const hireSummary = `hire ${
|
|
459
|
+
const hireSummary = `hire ${aliveChildCount}/${hireGrant}`;
|
|
458
460
|
return {
|
|
459
461
|
hire_grant: hireGrant,
|
|
460
|
-
hire_alive_children:
|
|
461
|
-
hire: `${
|
|
462
|
+
hire_alive_children: aliveChildCount,
|
|
463
|
+
hire: `${aliveChildCount}/${hireGrant}`,
|
|
462
464
|
hire_summary: hireSummary,
|
|
463
465
|
};
|
|
464
466
|
}
|
|
@@ -676,6 +678,39 @@ function validateOwnerLaneHireSummary(input) {
|
|
|
676
678
|
hire_summary: `hire ${alive}/${grant}`,
|
|
677
679
|
};
|
|
678
680
|
}
|
|
681
|
+
function laneListQueryFlags(query) {
|
|
682
|
+
return [
|
|
683
|
+
...(query.laneIds?.length ? ["--lane"] : []),
|
|
684
|
+
...(query.active ? ["--active"] : []),
|
|
685
|
+
...(query.hierarchy !== undefined ? ["--hierarchy"] : []),
|
|
686
|
+
...(query.snapshot ? ["--snapshot"] : []),
|
|
687
|
+
];
|
|
688
|
+
}
|
|
689
|
+
function rejectedLaneListFlag(result, query) {
|
|
690
|
+
if (result.done === true && result.status === "Success" && result.exitCode === 0)
|
|
691
|
+
return undefined;
|
|
692
|
+
const output = `${result.stderr}\n${result.stdout}`;
|
|
693
|
+
const match = /unknown\s+(?:option|argument)\s+["']?(--[a-z-]+)/i.exec(output);
|
|
694
|
+
const rejected = match?.[1];
|
|
695
|
+
return rejected && laneListQueryFlags(query).includes(rejected) ? rejected : undefined;
|
|
696
|
+
}
|
|
697
|
+
async function dispatchOutpostLaneList(ctx, outpostId, query, timeoutSeconds, waitMs) {
|
|
698
|
+
const hqBinary = outpostCliOverride(ctx, outpostId);
|
|
699
|
+
const filtered = await ctx.dispatchOutpostExec({
|
|
700
|
+
outpostId,
|
|
701
|
+
command: buildOutpostLaneListCommand({ hqBinary, ...query }),
|
|
702
|
+
timeoutSeconds,
|
|
703
|
+
waitMs,
|
|
704
|
+
});
|
|
705
|
+
if (!rejectedLaneListFlag(filtered, query))
|
|
706
|
+
return filtered;
|
|
707
|
+
return ctx.dispatchOutpostExec({
|
|
708
|
+
outpostId,
|
|
709
|
+
command: buildOutpostLaneListCommand({ hqBinary }),
|
|
710
|
+
timeoutSeconds,
|
|
711
|
+
waitMs,
|
|
712
|
+
});
|
|
713
|
+
}
|
|
679
714
|
async function fetchOutpostListHireSummaries(ctx, outpostId, lanes) {
|
|
680
715
|
const fallback = (error) => new Map(lanes.map((lane) => [lane.lane_id, {
|
|
681
716
|
error: new LanesError(error.error, error.message, {
|
|
@@ -684,28 +719,15 @@ async function fetchOutpostListHireSummaries(ctx, outpostId, lanes) {
|
|
|
684
719
|
lane_id: lane.lane_id,
|
|
685
720
|
}),
|
|
686
721
|
}]));
|
|
687
|
-
let
|
|
722
|
+
let dispatched;
|
|
688
723
|
try {
|
|
689
|
-
|
|
724
|
+
dispatched = await dispatchOutpostLaneList(ctx, outpostId, { laneIds: lanes.map((lane) => lane.lane_id) }, OUTPOST_HIRE_SUMMARY_TIMEOUT_SECONDS, OUTPOST_HIRE_SUMMARY_WAIT_MS);
|
|
690
725
|
}
|
|
691
726
|
catch (err) {
|
|
692
727
|
if (isLanesError(err))
|
|
693
728
|
return fallback(err);
|
|
694
729
|
return fallback(new LanesError("outpost_lane_hire_summary_unavailable", `Could not read hire summaries from Outpost ${outpostId}: ${dispatchErrorMessage(err)}`, { outpost_id: outpostId }));
|
|
695
730
|
}
|
|
696
|
-
let dispatched;
|
|
697
|
-
try {
|
|
698
|
-
// No filters: the owner list must return every lane requested by this host group.
|
|
699
|
-
dispatched = await ctx.dispatchOutpostExec({
|
|
700
|
-
outpostId,
|
|
701
|
-
command: buildOutpostLaneListCommand({ hqBinary }),
|
|
702
|
-
timeoutSeconds: OUTPOST_HIRE_SUMMARY_TIMEOUT_SECONDS,
|
|
703
|
-
waitMs: OUTPOST_HIRE_SUMMARY_WAIT_MS,
|
|
704
|
-
});
|
|
705
|
-
}
|
|
706
|
-
catch (err) {
|
|
707
|
-
return fallback(new LanesError("outpost_lane_hire_summary_unavailable", `Could not read hire summaries from Outpost ${outpostId}: ${dispatchErrorMessage(err)}`, { outpost_id: outpostId }));
|
|
708
|
-
}
|
|
709
731
|
if (dispatched.done !== true || dispatched.status !== "Success" || dispatched.exitCode !== 0) {
|
|
710
732
|
return fallback(new LanesError("outpost_lane_hire_summary_unavailable", `Outpost ${outpostId} did not confirm its lane list for hire summaries`, { outpost_id: outpostId, command_id: dispatched.commandId }));
|
|
711
733
|
}
|
|
@@ -990,18 +1012,26 @@ async function loadHostCapacitySnapshots(ctx, hqRoot, config = loadLanesConfig(h
|
|
|
990
1012
|
skippedHosts: unavailable.map(({ outpost_id, error }) => ({ outpost_id, error })),
|
|
991
1013
|
};
|
|
992
1014
|
}
|
|
993
|
-
|
|
994
|
-
|
|
1015
|
+
const outpostLaneListMemo = new WeakMap();
|
|
1016
|
+
function normalizedLaneListQuery(query) {
|
|
1017
|
+
const laneIds = query.laneIds === undefined
|
|
1018
|
+
? undefined
|
|
1019
|
+
: [...new Set(query.laneIds)].sort((a, b) => a.localeCompare(b));
|
|
1020
|
+
return {
|
|
1021
|
+
...(laneIds === undefined ? {} : { laneIds }),
|
|
1022
|
+
...(query.active ? { active: true } : {}),
|
|
1023
|
+
...(query.hierarchy === undefined ? {} : { hierarchy: query.hierarchy }),
|
|
1024
|
+
...(query.snapshot ? { snapshot: true } : {}),
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
async function readOutpostLaneRecordsUncached(ctx, outpostId, query) {
|
|
995
1028
|
let result;
|
|
996
1029
|
try {
|
|
997
|
-
result = await ctx
|
|
998
|
-
outpostId,
|
|
999
|
-
command: buildOutpostLaneListCommand({ hqBinary }),
|
|
1000
|
-
timeoutSeconds: CAPACITY_PROBE_TIMEOUT_MS / 1000,
|
|
1001
|
-
waitMs: CAPACITY_PROBE_TIMEOUT_MS,
|
|
1002
|
-
});
|
|
1030
|
+
result = await dispatchOutpostLaneList(ctx, outpostId, query, CAPACITY_PROBE_TIMEOUT_MS / 1000, CAPACITY_PROBE_TIMEOUT_MS);
|
|
1003
1031
|
}
|
|
1004
1032
|
catch (err) {
|
|
1033
|
+
if (isLanesError(err))
|
|
1034
|
+
throw err;
|
|
1005
1035
|
throw new LanesError("outpost_lane_list_unavailable", `Could not read lanes from Outpost ${outpostId}: ${dispatchErrorMessage(err)}`, { outpost_id: outpostId });
|
|
1006
1036
|
}
|
|
1007
1037
|
if (result.done !== true || result.status !== "Success" || result.exitCode !== 0) {
|
|
@@ -1057,6 +1087,25 @@ async function readOutpostLaneRecords(ctx, outpostId) {
|
|
|
1057
1087
|
}
|
|
1058
1088
|
return lanes;
|
|
1059
1089
|
}
|
|
1090
|
+
async function readOutpostLaneRecords(ctx, outpostId, requestedQuery = {}) {
|
|
1091
|
+
const query = normalizedLaneListQuery(requestedQuery);
|
|
1092
|
+
const queryKey = JSON.stringify(query);
|
|
1093
|
+
let byOutpost = outpostLaneListMemo.get(ctx);
|
|
1094
|
+
if (!byOutpost) {
|
|
1095
|
+
byOutpost = new Map();
|
|
1096
|
+
outpostLaneListMemo.set(ctx, byOutpost);
|
|
1097
|
+
}
|
|
1098
|
+
const existing = byOutpost.get(outpostId);
|
|
1099
|
+
if (existing) {
|
|
1100
|
+
if (existing.queryKey !== queryKey) {
|
|
1101
|
+
throw new LanesError("outpost_lane_list_query_conflict", `Outpost ${outpostId} was already read with a different lane-list query in this invocation`, { outpost_id: outpostId });
|
|
1102
|
+
}
|
|
1103
|
+
return existing.promise;
|
|
1104
|
+
}
|
|
1105
|
+
const promise = readOutpostLaneRecordsUncached(ctx, outpostId, query);
|
|
1106
|
+
byOutpost.set(outpostId, { queryKey, promise });
|
|
1107
|
+
return promise;
|
|
1108
|
+
}
|
|
1060
1109
|
async function resolveCommandLaneTarget(ctx, laneId, options = {}) {
|
|
1061
1110
|
try {
|
|
1062
1111
|
return { lane: readLane(ctx.hqRoot(), laneId), remoteOnly: false };
|
|
@@ -1099,7 +1148,10 @@ async function resolveCommandLaneTarget(ctx, laneId, options = {}) {
|
|
|
1099
1148
|
for (const outpostId of lookupOutpostIds) {
|
|
1100
1149
|
let ownerLanes;
|
|
1101
1150
|
try {
|
|
1102
|
-
ownerLanes = await readOutpostLaneRecords(ctx, outpostId
|
|
1151
|
+
ownerLanes = await readOutpostLaneRecords(ctx, outpostId, {
|
|
1152
|
+
hierarchy: laneId,
|
|
1153
|
+
snapshot: true,
|
|
1154
|
+
});
|
|
1103
1155
|
}
|
|
1104
1156
|
catch (err) {
|
|
1105
1157
|
if (requestedOutpostId !== undefined)
|
|
@@ -1215,7 +1267,9 @@ async function loadClaudeBalanceHostSnapshots(ctx, hqRoot, config) {
|
|
|
1215
1267
|
let lanes = [];
|
|
1216
1268
|
let laneListAvailable = false;
|
|
1217
1269
|
try {
|
|
1218
|
-
|
|
1270
|
+
// Balance can move done and interrupted lanes, so the snapshot must keep
|
|
1271
|
+
// terminal rows; `--active` would drop them.
|
|
1272
|
+
lanes = await readOutpostLaneRecords(ctx, id, { snapshot: true });
|
|
1219
1273
|
laneListAvailable = true;
|
|
1220
1274
|
}
|
|
1221
1275
|
catch (err) {
|
|
@@ -2723,6 +2777,8 @@ async function runCreate(ctx, opts) {
|
|
|
2723
2777
|
hosts: balanceHostLoad.snapshots,
|
|
2724
2778
|
targetHost,
|
|
2725
2779
|
config,
|
|
2780
|
+
recentUsageEnabled: await readClaudeAccountBalanceSignalsEnabled(ctx, company ?? undefined),
|
|
2781
|
+
now: ctx.now(),
|
|
2726
2782
|
...(allowedAccounts !== undefined ? { allowedAccounts } : {}),
|
|
2727
2783
|
...(explicitBalanceAccount !== undefined ? { explicitAccount: explicitBalanceAccount } : {}),
|
|
2728
2784
|
unavailableHosts: balanceHostLoad.unavailableHosts,
|
|
@@ -2768,6 +2824,8 @@ async function runCreate(ctx, opts) {
|
|
|
2768
2824
|
hosts,
|
|
2769
2825
|
targetHost: balanceTargetHost,
|
|
2770
2826
|
config,
|
|
2827
|
+
recentUsageEnabled: await readClaudeAccountBalanceSignalsEnabled(ctx, company ?? undefined),
|
|
2828
|
+
now: ctx.now(),
|
|
2771
2829
|
...(allowedAccounts !== undefined ? { allowedAccounts } : {}),
|
|
2772
2830
|
unavailableHosts: balanceHostLoad.unavailableHosts,
|
|
2773
2831
|
});
|
|
@@ -3504,13 +3562,19 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options, behavior =
|
|
|
3504
3562
|
let grantContext;
|
|
3505
3563
|
let grantHierarchy;
|
|
3506
3564
|
if (action === "grant") {
|
|
3507
|
-
const hierarchy = mergeOwnerLaneRecords(loadAllLanes(ctx.hqRoot()), await readOutpostLaneRecords(ctx, outpostId
|
|
3565
|
+
const hierarchy = mergeOwnerLaneRecords(loadAllLanes(ctx.hqRoot()), await readOutpostLaneRecords(ctx, outpostId, {
|
|
3566
|
+
hierarchy: lane.lane_id,
|
|
3567
|
+
snapshot: true,
|
|
3568
|
+
}));
|
|
3508
3569
|
grantHierarchy = hierarchy;
|
|
3509
3570
|
grantContext = createOutpostHireGrantContext(lane, hierarchy);
|
|
3510
3571
|
}
|
|
3511
3572
|
else if ((action === "resume" && lane.senior.kind === "lane") ||
|
|
3512
3573
|
(action === "message" && (lane.senior.kind === "lane" || lane.senior.kind === "session"))) {
|
|
3513
|
-
const hierarchy = mergeOwnerLaneRecords(loadAllLanes(ctx.hqRoot()), await readOutpostLaneRecords(ctx, outpostId
|
|
3574
|
+
const hierarchy = mergeOwnerLaneRecords(loadAllLanes(ctx.hqRoot()), await readOutpostLaneRecords(ctx, outpostId, {
|
|
3575
|
+
hierarchy: lane.lane_id,
|
|
3576
|
+
snapshot: true,
|
|
3577
|
+
}));
|
|
3514
3578
|
grantContext = createOutpostHireGrantContext(lane, hierarchy, action === "message" ? options.supersededMessageCallerId : undefined, action === "message" ? options.supersededMessageSessionId : undefined);
|
|
3515
3579
|
}
|
|
3516
3580
|
let dispatched;
|
|
@@ -4050,6 +4114,21 @@ async function runList(ctx, opts) {
|
|
|
4050
4114
|
if (opts.json && opts.human) {
|
|
4051
4115
|
throw new LanesError("conflicting_output_format", "Use either --json or --human for hq lanes list", { json: true, human: true });
|
|
4052
4116
|
}
|
|
4117
|
+
const requestedLaneIds = opts.lane ?? [];
|
|
4118
|
+
if (!Array.isArray(requestedLaneIds) ||
|
|
4119
|
+
requestedLaneIds.some((laneId) => typeof laneId !== "string" || !laneId.trim() || laneId.trim() !== laneId) ||
|
|
4120
|
+
new Set(requestedLaneIds).size !== requestedLaneIds.length) {
|
|
4121
|
+
throw new LanesError("lane_list_filter_invalid", "--lane must be repeated with distinct, non-empty lane ids", { lane_ids: requestedLaneIds });
|
|
4122
|
+
}
|
|
4123
|
+
if (opts.hierarchy !== undefined && (!opts.hierarchy.trim() || opts.hierarchy.trim() !== opts.hierarchy)) {
|
|
4124
|
+
throw new LanesError("lane_list_hierarchy_invalid", "--hierarchy requires one non-empty lane id without surrounding whitespace", {});
|
|
4125
|
+
}
|
|
4126
|
+
if (opts.snapshot && (opts.human || opts.company || opts.project || opts.story || opts.state || opts.senior)) {
|
|
4127
|
+
throw new LanesError("lane_list_snapshot_invalid", "--snapshot only supports owner lane filters and JSON output", {});
|
|
4128
|
+
}
|
|
4129
|
+
if (opts.hierarchy !== undefined && !opts.snapshot) {
|
|
4130
|
+
throw new LanesError("lane_list_hierarchy_invalid", "--hierarchy requires --snapshot", {});
|
|
4131
|
+
}
|
|
4053
4132
|
const hqRoot = ctx.hqRoot();
|
|
4054
4133
|
const state = opts.state ? parseLaneState(opts.state) : undefined;
|
|
4055
4134
|
const senior = opts.senior ? parseSeniorRef(opts.senior) : undefined;
|
|
@@ -4057,11 +4136,25 @@ async function runList(ctx, opts) {
|
|
|
4057
4136
|
if (opts.company) {
|
|
4058
4137
|
company = await ctx.resolveCompany(opts.company);
|
|
4059
4138
|
}
|
|
4060
|
-
const
|
|
4061
|
-
const
|
|
4062
|
-
|
|
4063
|
-
const
|
|
4139
|
+
const requestedIds = new Set(requestedLaneIds);
|
|
4140
|
+
const canReadOnlyRequestedIds = opts.snapshot && requestedLaneIds.length > 0 &&
|
|
4141
|
+
!opts.active && opts.hierarchy === undefined;
|
|
4142
|
+
const laneSource = canReadOnlyRequestedIds
|
|
4143
|
+
? listLaneIds(hqRoot)
|
|
4144
|
+
.filter((laneId) => requestedIds.has(laneId))
|
|
4145
|
+
.map((laneId) => readLane(hqRoot, laneId))
|
|
4146
|
+
: loadAllLanes(hqRoot);
|
|
4147
|
+
const hierarchyIds = opts.hierarchy === undefined
|
|
4148
|
+
? undefined
|
|
4149
|
+
: new Set(selectOutpostHireGrantHierarchy(opts.hierarchy, laneSource).map((lane) => lane.lane_id));
|
|
4150
|
+
const selectedLanes = laneSource
|
|
4064
4151
|
.filter((lane) => {
|
|
4152
|
+
if (requestedLaneIds.length > 0 && !requestedIds.has(lane.lane_id))
|
|
4153
|
+
return false;
|
|
4154
|
+
if (opts.active && isTerminalLaneState(lane.state))
|
|
4155
|
+
return false;
|
|
4156
|
+
if (hierarchyIds && !hierarchyIds.has(lane.lane_id))
|
|
4157
|
+
return false;
|
|
4065
4158
|
if (state && lane.state !== state)
|
|
4066
4159
|
return false;
|
|
4067
4160
|
const carriesFilter = laneStories(lane).some((story) => (!opts.project || story.project_id === opts.project) &&
|
|
@@ -4079,6 +4172,19 @@ async function runList(ctx, opts) {
|
|
|
4079
4172
|
return true;
|
|
4080
4173
|
})
|
|
4081
4174
|
.sort(compareLaneQueueOrder);
|
|
4175
|
+
if (opts.snapshot) {
|
|
4176
|
+
printJson({ ok: true, action: "list", lanes: selectedLanes });
|
|
4177
|
+
return;
|
|
4178
|
+
}
|
|
4179
|
+
const now = ctx.now();
|
|
4180
|
+
const host = reconcileHostFromCtx(ctx);
|
|
4181
|
+
const allLanes = laneSource;
|
|
4182
|
+
const aliveChildrenByParent = new Map();
|
|
4183
|
+
for (const child of allLanes) {
|
|
4184
|
+
if (child.senior.kind !== "lane" || !child.senior.id || isTerminalLaneState(child.state))
|
|
4185
|
+
continue;
|
|
4186
|
+
aliveChildrenByParent.set(child.senior.id, (aliveChildrenByParent.get(child.senior.id) ?? 0) + 1);
|
|
4187
|
+
}
|
|
4082
4188
|
const ownerEligibleLaneIds = new Set();
|
|
4083
4189
|
for (const lane of selectedLanes) {
|
|
4084
4190
|
if (lane.host.kind !== "outpost" ||
|
|
@@ -4093,7 +4199,7 @@ async function runList(ctx, opts) {
|
|
|
4093
4199
|
for (const lane of selectedLanes) {
|
|
4094
4200
|
if (!ownerEligibleLaneIds.has(lane.lane_id)) {
|
|
4095
4201
|
hireByLane.set(lane.lane_id, {
|
|
4096
|
-
...laneHireFields(lane, allLanes),
|
|
4202
|
+
...laneHireFields(lane, allLanes, aliveChildrenByParent),
|
|
4097
4203
|
hire_source: "local",
|
|
4098
4204
|
});
|
|
4099
4205
|
continue;
|
|
@@ -4111,12 +4217,14 @@ async function runList(ctx, opts) {
|
|
|
4111
4217
|
? outcome.error
|
|
4112
4218
|
: new LanesError("outpost_lane_hire_summary_missing", `No owner hire summary was returned for lane ${lane.lane_id}`, { lane_id: lane.lane_id, outpost_id: lane.host.id });
|
|
4113
4219
|
hireByLane.set(lane.lane_id, {
|
|
4114
|
-
...laneHireFields(lane, allLanes),
|
|
4220
|
+
...laneHireFields(lane, allLanes, aliveChildrenByParent),
|
|
4115
4221
|
hire_source: "local",
|
|
4116
4222
|
hire_warning: `${error.error}: ${error.message}`,
|
|
4117
4223
|
});
|
|
4118
4224
|
}
|
|
4119
4225
|
}
|
|
4226
|
+
const livenessBySession = new Map();
|
|
4227
|
+
let livenessDeps;
|
|
4120
4228
|
const lanes = selectedLanes.map((lane) => {
|
|
4121
4229
|
const remoteQueued = lane.state === "queued" &&
|
|
4122
4230
|
lane.host.kind === "outpost" &&
|
|
@@ -4135,12 +4243,19 @@ async function runList(ctx, opts) {
|
|
|
4135
4243
|
// Listing is observational. Reconcile owns durable liveness transitions.
|
|
4136
4244
|
senior_lost: lane.state === "running" && lane.senior.kind === "session" && lane.senior.id
|
|
4137
4245
|
? (() => {
|
|
4138
|
-
const
|
|
4246
|
+
const sessionId = lane.senior.id;
|
|
4247
|
+
const cached = livenessBySession.get(sessionId);
|
|
4248
|
+
if (cached !== undefined)
|
|
4249
|
+
return cached;
|
|
4250
|
+
livenessDeps ??= livenessDepsFromCtx(ctx);
|
|
4251
|
+
const state = sessionLiveness(hqRoot, sessionId, {
|
|
4139
4252
|
nowMs: now.getTime(),
|
|
4140
|
-
deps:
|
|
4253
|
+
deps: livenessDeps,
|
|
4141
4254
|
presenceConnected: ctx.presenceConnected,
|
|
4142
4255
|
});
|
|
4143
|
-
|
|
4256
|
+
const lost = state === null || state === "ended" || state === "lost";
|
|
4257
|
+
livenessBySession.set(sessionId, lost);
|
|
4258
|
+
return lost;
|
|
4144
4259
|
})()
|
|
4145
4260
|
: false,
|
|
4146
4261
|
};
|
|
@@ -4414,6 +4529,7 @@ async function runShow(ctx, laneId) {
|
|
|
4414
4529
|
model: lane.model ?? null,
|
|
4415
4530
|
effort: lane.effort ?? null,
|
|
4416
4531
|
effort_source: lane.effort_source ?? null,
|
|
4532
|
+
settings_history: lane.settings_history ?? [],
|
|
4417
4533
|
terminal_reason: lane.terminal_reason ?? null,
|
|
4418
4534
|
terminal_details: lane.terminal_details ?? null,
|
|
4419
4535
|
keep_alive_state: {
|
|
@@ -4645,6 +4761,107 @@ function printGrantResult(hqRoot, updated, hierarchy) {
|
|
|
4645
4761
|
const fields = laneHireFields(updated, hierarchy ?? loadAllLanes(hqRoot));
|
|
4646
4762
|
printJson({ ok: true, action: "grant", lane_id: updated.lane_id, lane: updated, ...fields });
|
|
4647
4763
|
}
|
|
4764
|
+
/**
|
|
4765
|
+
* `hq lanes set`: change the model or effort a lane uses from its next
|
|
4766
|
+
* resume. The running turn keeps its values. Both are stored as explicit
|
|
4767
|
+
* (`cli`) choices, so keep-alive and `hq lanes resume` keep them until the
|
|
4768
|
+
* next `set` or an explicit resume `--effort`/`--worker`.
|
|
4769
|
+
*/
|
|
4770
|
+
async function runSet(ctx, laneId, opts) {
|
|
4771
|
+
if (opts.model === undefined && opts.effort === undefined) {
|
|
4772
|
+
throw new LanesError("lane_set_empty", "hq lanes set needs --model or --effort", { lane_id: laneId });
|
|
4773
|
+
}
|
|
4774
|
+
const reason = opts.reason?.trim();
|
|
4775
|
+
if (opts.reason !== undefined && !reason) {
|
|
4776
|
+
throw new LanesError("lane_set_reason_invalid", "Set reason must not be empty", { lane_id: laneId });
|
|
4777
|
+
}
|
|
4778
|
+
if (reason !== undefined)
|
|
4779
|
+
assertNoSecretText("set reason", reason);
|
|
4780
|
+
const hqRoot = ctx.hqRoot();
|
|
4781
|
+
const lane = readLane(hqRoot, laneId);
|
|
4782
|
+
const callerLaneId = ctx.env.HQ_LANE_ID?.trim() || undefined;
|
|
4783
|
+
const authorization = callerLaneId === laneId
|
|
4784
|
+
? { authorized: true }
|
|
4785
|
+
: callerSeniorAuthorization(ctx, lane.senior);
|
|
4786
|
+
if (!authorization.authorized) {
|
|
4787
|
+
throw new LanesError("lane_set_unauthorized", appendCallerSeniorReason(`Only lane ${laneId} or its senior ${seniorKey(lane.senior)} may change its model or effort`, authorization), { lane_id: laneId, senior: lane.senior, reason: authorization.reason });
|
|
4788
|
+
}
|
|
4789
|
+
if (lane.host.kind === "outpost" && !laneRunsHereForOperatorMutation(ctx, lane)) {
|
|
4790
|
+
throw new LanesError("set_remote_unsupported", `Lane ${laneId} runs on Outpost ${lane.host.id}; run hq lanes set on that Outpost`, { lane_id: laneId, outpost_id: lane.host.id });
|
|
4791
|
+
}
|
|
4792
|
+
assertLaneRunsHereForOperatorMutation(ctx, lane, "set");
|
|
4793
|
+
if (lane.state === "killed" || lane.state === "cancelled") {
|
|
4794
|
+
throw new LanesError("lane_not_resumable", `Lane ${laneId} is ${lane.state} and will not run again`, { lane_id: laneId, state: lane.state });
|
|
4795
|
+
}
|
|
4796
|
+
const model = opts.model?.trim();
|
|
4797
|
+
if (opts.model !== undefined && !model) {
|
|
4798
|
+
throw new LanesError("empty_model", "--model is empty or whitespace-only", { value: opts.model });
|
|
4799
|
+
}
|
|
4800
|
+
if (model !== undefined) {
|
|
4801
|
+
const config = loadLanesConfig(hqRoot);
|
|
4802
|
+
const account = lane.account === undefined
|
|
4803
|
+
? undefined
|
|
4804
|
+
: config.providers[lane.provider].accounts[lane.account];
|
|
4805
|
+
if (account === undefined) {
|
|
4806
|
+
throw new AccountResolutionError("unknown_account", `Lane ${laneId} records account ${JSON.stringify(lane.account)} which is not configured for ${lane.provider}`, { provider: lane.provider, account: lane.account ?? "", enabled: [] });
|
|
4807
|
+
}
|
|
4808
|
+
validateLaneModel(lane.provider, model, effectiveAccountHome(account, ctx.hostname()).home, {
|
|
4809
|
+
readFileSync: ctx.readFileSync,
|
|
4810
|
+
probe: ctx.probeProvider,
|
|
4811
|
+
env: ctx.env,
|
|
4812
|
+
});
|
|
4813
|
+
}
|
|
4814
|
+
const effort = parseCliEffort(lane.provider, opts.effort);
|
|
4815
|
+
const at = ctx.now().toISOString();
|
|
4816
|
+
const actor = callerLaneId !== undefined
|
|
4817
|
+
? `lane:${callerLaneId}`
|
|
4818
|
+
: `session:${sessionIdFromEnv(ctx.env) ?? "unknown"}`;
|
|
4819
|
+
const updated = casUpdateLane(hqRoot, laneId, (current) => {
|
|
4820
|
+
if (current.version !== lane.version) {
|
|
4821
|
+
throw new LanesError("lane_set_conflict", `Lane ${laneId} changed while the settings were checked; run hq lanes set again`, { lane_id: laneId, prepared_version: lane.version, version: current.version });
|
|
4822
|
+
}
|
|
4823
|
+
const entry = { at, actor, ...(reason !== undefined ? { reason } : {}) };
|
|
4824
|
+
const next = { ...current };
|
|
4825
|
+
const pending = { ...(current.pending_settings ?? {}) };
|
|
4826
|
+
// A running turn keeps the model and effort it launched with, because the
|
|
4827
|
+
// watcher classifies that turn's exit (including model quota) by them.
|
|
4828
|
+
const deferred = current.state === "running";
|
|
4829
|
+
if (model !== undefined) {
|
|
4830
|
+
entry.model = { from: pending.model ?? current.model ?? null, to: model };
|
|
4831
|
+
if (deferred) {
|
|
4832
|
+
pending.model = model;
|
|
4833
|
+
}
|
|
4834
|
+
else {
|
|
4835
|
+
next.model = model;
|
|
4836
|
+
next.model_source = "cli";
|
|
4837
|
+
delete pending.model;
|
|
4838
|
+
}
|
|
4839
|
+
}
|
|
4840
|
+
if (effort !== undefined) {
|
|
4841
|
+
entry.effort = { from: pending.effort ?? current.effort ?? null, to: effort };
|
|
4842
|
+
if (deferred) {
|
|
4843
|
+
pending.effort = effort;
|
|
4844
|
+
}
|
|
4845
|
+
else {
|
|
4846
|
+
next.effort = effort;
|
|
4847
|
+
next.effort_source = "cli";
|
|
4848
|
+
delete pending.effort;
|
|
4849
|
+
}
|
|
4850
|
+
}
|
|
4851
|
+
next.pending_settings = Object.keys(pending).length > 0 ? pending : undefined;
|
|
4852
|
+
next.settings_history = appendCappedHistory(current.settings_history ?? [], entry);
|
|
4853
|
+
return next;
|
|
4854
|
+
});
|
|
4855
|
+
printJson({
|
|
4856
|
+
ok: true,
|
|
4857
|
+
action: "set",
|
|
4858
|
+
lane: updated,
|
|
4859
|
+
model: updated.pending_settings?.model ?? updated.model ?? null,
|
|
4860
|
+
effort: updated.pending_settings?.effort ?? updated.effort ?? null,
|
|
4861
|
+
pending: updated.pending_settings !== undefined,
|
|
4862
|
+
applies: "next_resume",
|
|
4863
|
+
});
|
|
4864
|
+
}
|
|
4648
4865
|
async function runKeepAlive(ctx, laneId, opts) {
|
|
4649
4866
|
if (opts.standing && !opts.on) {
|
|
4650
4867
|
throw new LanesError("keep_alive_standing_requires_on", "Pass --standing together with --on", { lane_id: laneId });
|
|
@@ -5160,7 +5377,8 @@ function recordResumeSetup(ctx, lane, input) {
|
|
|
5160
5377
|
input.effort === undefined &&
|
|
5161
5378
|
input.keepAlive === undefined &&
|
|
5162
5379
|
input.cliModel === undefined &&
|
|
5163
|
-
input.meshCompany === undefined
|
|
5380
|
+
input.meshCompany === undefined &&
|
|
5381
|
+
lane.keep_alive_blocked_reason !== "terminal_state")
|
|
5164
5382
|
return lane;
|
|
5165
5383
|
return casUpdateLane(ctx.hqRoot(), lane.lane_id, (current) => {
|
|
5166
5384
|
if (current.version !== lane.version) {
|
|
@@ -5170,6 +5388,10 @@ function recordResumeSetup(ctx, lane, input) {
|
|
|
5170
5388
|
assertResumeProfileRepinStillAuthorized(ctx, current, input.profileRepin);
|
|
5171
5389
|
}
|
|
5172
5390
|
const next = { ...current };
|
|
5391
|
+
// An operator resume takes the lane out of the terminal state reconcile
|
|
5392
|
+
// recorded, so keep-alive may continue it again after this turn.
|
|
5393
|
+
if (next.keep_alive_blocked_reason === "terminal_state")
|
|
5394
|
+
next.keep_alive_blocked_reason = undefined;
|
|
5173
5395
|
applyResumeProfileAndEffort(current, next, { ...input, at: ctx.now().toISOString() });
|
|
5174
5396
|
if (input.cliModel !== undefined) {
|
|
5175
5397
|
next.model = input.cliModel.trim();
|
|
@@ -5450,6 +5672,8 @@ async function selectAutomaticClaudeResumeAccount(ctx, lane, workerProfile, opti
|
|
|
5450
5672
|
hosts: balanceHosts.snapshots,
|
|
5451
5673
|
targetHost: lane.host,
|
|
5452
5674
|
config,
|
|
5675
|
+
recentUsageEnabled: await readClaudeAccountBalanceSignalsEnabled(ctx, lane.company ?? undefined),
|
|
5676
|
+
now: ctx.now(),
|
|
5453
5677
|
...(!repairUnavailableAccount && allowedAccounts !== undefined ? { allowedAccounts } : {}),
|
|
5454
5678
|
...(model !== undefined ? { model } : {}),
|
|
5455
5679
|
...(recordedModelAccount !== undefined ? { recordedModelAccount } : {}),
|
|
@@ -6344,6 +6568,8 @@ async function dispatchOutpostLaneStop(ctx, lane, reason, quiet = false) {
|
|
|
6344
6568
|
ok: true,
|
|
6345
6569
|
action: "stop",
|
|
6346
6570
|
lane: updated,
|
|
6571
|
+
terminal: true,
|
|
6572
|
+
resumable: false,
|
|
6347
6573
|
cancelled_questions: cancelledQuestions,
|
|
6348
6574
|
outpost_id: outpostId,
|
|
6349
6575
|
outpost_command_id: commandId,
|
|
@@ -6365,6 +6591,9 @@ async function runStop(ctx, laneId, reason, quiet = false) {
|
|
|
6365
6591
|
assertNoSecretText("stop reason", operatorReason);
|
|
6366
6592
|
const hqRoot = ctx.hqRoot();
|
|
6367
6593
|
const lane = readLane(hqRoot, laneId);
|
|
6594
|
+
if (!quiet && lane.state !== "queued" && !isTerminalLaneState(lane.state)) {
|
|
6595
|
+
process.stderr.write(stopTerminalWarning(laneId));
|
|
6596
|
+
}
|
|
6368
6597
|
if (lane.host.kind === "outpost" && !laneRunsHereForOperatorMutation(ctx, lane)) {
|
|
6369
6598
|
await dispatchOutpostLaneStop(ctx, lane, operatorReason, quiet);
|
|
6370
6599
|
return;
|
|
@@ -6409,9 +6638,108 @@ async function runStop(ctx, laneId, reason, quiet = false) {
|
|
|
6409
6638
|
ok: true,
|
|
6410
6639
|
action: "stop",
|
|
6411
6640
|
lane: updated,
|
|
6641
|
+
terminal: true,
|
|
6642
|
+
resumable: false,
|
|
6412
6643
|
cancelled_questions: cancelled.map((q) => q.question_id),
|
|
6413
6644
|
});
|
|
6414
6645
|
}
|
|
6646
|
+
/** Stderr notice that `stop` ends a lane for good, naming the resumable path. */
|
|
6647
|
+
export function stopTerminalWarning(laneId) {
|
|
6648
|
+
return `hq lanes: warning: stop kills lane ${laneId} and it cannot be resumed. ` +
|
|
6649
|
+
`To pause it and keep it resumable, use hq lanes interrupt ${laneId}.\n`;
|
|
6650
|
+
}
|
|
6651
|
+
/**
|
|
6652
|
+
* Stop a running lane's worker and leave it `interrupted`, keeping the provider
|
|
6653
|
+
* session so `hq lanes resume` continues it. Keep-alive resumes only
|
|
6654
|
+
* `exited_with_partial_output` interruptions, so it leaves this lane alone.
|
|
6655
|
+
*/
|
|
6656
|
+
async function runInterrupt(ctx, laneId, reason) {
|
|
6657
|
+
const operatorReason = reason?.trim();
|
|
6658
|
+
if (reason !== undefined && !operatorReason) {
|
|
6659
|
+
throw new LanesError("lane_interrupt_reason_invalid", "Interrupt reason must not be empty", {});
|
|
6660
|
+
}
|
|
6661
|
+
if (operatorReason !== undefined)
|
|
6662
|
+
assertNoSecretText("interrupt reason", operatorReason);
|
|
6663
|
+
const hqRoot = ctx.hqRoot();
|
|
6664
|
+
const lane = readLane(hqRoot, laneId);
|
|
6665
|
+
if (lane.host.kind === "outpost" && !laneRunsHereForOperatorMutation(ctx, lane)) {
|
|
6666
|
+
throw new LanesError("interrupt_remote_unsupported", `Lane ${laneId} runs on Outpost ${lane.host.id}; run hq lanes interrupt on that Outpost`, { lane_id: laneId, outpost_id: lane.host.id });
|
|
6667
|
+
}
|
|
6668
|
+
assertLaneRunsHereForOperatorMutation(ctx, lane, "interrupt");
|
|
6669
|
+
const assertInterruptible = (current) => {
|
|
6670
|
+
if (current.state !== "running") {
|
|
6671
|
+
throw new LanesError("lane_not_running", current.state === "queued"
|
|
6672
|
+
? `Lane ${laneId} is queued and has no running turn; use hq lanes stop to cancel it`
|
|
6673
|
+
: `Lane ${laneId} is ${current.state}; only a running lane can be interrupted`, { lane_id: laneId, state: current.state });
|
|
6674
|
+
}
|
|
6675
|
+
};
|
|
6676
|
+
assertInterruptible(lane);
|
|
6677
|
+
// Hold the provider's admission lock across the state change, the process
|
|
6678
|
+
// stop and the session write, so a resume cannot spawn a second worker on
|
|
6679
|
+
// the same session while the old one is still exiting.
|
|
6680
|
+
const updated = await withLaneAdmissionLock(hqRoot, lane.provider, async () => {
|
|
6681
|
+
const at = ctx.now().toISOString();
|
|
6682
|
+
const interrupted = casUpdateLane(hqRoot, laneId, (current) => {
|
|
6683
|
+
assertLaneRunsHereForOperatorMutation(ctx, current, "interrupt");
|
|
6684
|
+
assertInterruptible(current);
|
|
6685
|
+
return {
|
|
6686
|
+
...current,
|
|
6687
|
+
state: "interrupted",
|
|
6688
|
+
queued_reason: undefined,
|
|
6689
|
+
interrupted_at: at,
|
|
6690
|
+
terminal_reason: "operator_interrupt",
|
|
6691
|
+
...(operatorReason === undefined ? {} : {
|
|
6692
|
+
stop_history: [
|
|
6693
|
+
...(current.stop_history ?? []),
|
|
6694
|
+
{ at, reason: operatorReason },
|
|
6695
|
+
],
|
|
6696
|
+
}),
|
|
6697
|
+
};
|
|
6698
|
+
});
|
|
6699
|
+
stopLaneProcesses(hqRoot, laneId, {
|
|
6700
|
+
killProcess: ctx.killProcess,
|
|
6701
|
+
isAlive: ctx.isAlive,
|
|
6702
|
+
sleepMs: ctx.stopSleepMs,
|
|
6703
|
+
});
|
|
6704
|
+
return persistInterruptedSession(hqRoot, interrupted);
|
|
6705
|
+
}, ctx.sleep);
|
|
6706
|
+
await emitLaneTerminalStateSoft(ctx, updated);
|
|
6707
|
+
const resumable = resumeCommandRefusal(laneWithRecoveredProviderSession(hqRoot, updated)) === null;
|
|
6708
|
+
if (!resumable)
|
|
6709
|
+
process.stderr.write(interruptNotResumableWarning(laneId));
|
|
6710
|
+
printJson({
|
|
6711
|
+
ok: true,
|
|
6712
|
+
action: "interrupt",
|
|
6713
|
+
lane: updated,
|
|
6714
|
+
terminal: false,
|
|
6715
|
+
resumable,
|
|
6716
|
+
});
|
|
6717
|
+
}
|
|
6718
|
+
/**
|
|
6719
|
+
* The watcher skips lanes that are already terminal, so it never records the
|
|
6720
|
+
* session id of a turn the operator interrupted. Read it from this run's
|
|
6721
|
+
* worker output here; a lane with no session id cannot be resumed.
|
|
6722
|
+
*/
|
|
6723
|
+
function persistInterruptedSession(hqRoot, lane) {
|
|
6724
|
+
const runDir = laneRunDir(hqRoot, lane.lane_id);
|
|
6725
|
+
let sessionId = null;
|
|
6726
|
+
try {
|
|
6727
|
+
sessionId = readWorkerRunResult(runDir, lane.provider, readSpawnManifest(runDir).worker_out_offset ?? 0)?.session_id ?? null;
|
|
6728
|
+
}
|
|
6729
|
+
catch (err) {
|
|
6730
|
+
process.stderr.write(`hq lanes: could not read the session id of interrupted lane ${lane.lane_id}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
6731
|
+
}
|
|
6732
|
+
if (sessionId === null || sessionId === lane.provider_session_id)
|
|
6733
|
+
return lane;
|
|
6734
|
+
return casUpdateLane(hqRoot, lane.lane_id, (current) => current.state !== "interrupted"
|
|
6735
|
+
? current
|
|
6736
|
+
: { ...current, provider_session_id: sessionId, provider_session_id_absent: undefined });
|
|
6737
|
+
}
|
|
6738
|
+
/** Stderr notice when an interrupted lane has no session to resume from. */
|
|
6739
|
+
export function interruptNotResumableWarning(laneId) {
|
|
6740
|
+
return `hq lanes: warning: lane ${laneId} was interrupted before its provider reported a session id, ` +
|
|
6741
|
+
`so hq lanes resume cannot continue it. Start a new lane for the remaining work.\n`;
|
|
6742
|
+
}
|
|
6415
6743
|
async function runAsk(ctx, opts) {
|
|
6416
6744
|
const hqRoot = ctx.hqRoot();
|
|
6417
6745
|
const laneId = ctx.env.HQ_LANE_ID?.trim();
|
|
@@ -6740,6 +7068,7 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
6740
7068
|
parseRemoteJson,
|
|
6741
7069
|
prepareAccountMove,
|
|
6742
7070
|
printJson,
|
|
7071
|
+
readClaudeAccountBalanceSignalsEnabled,
|
|
6743
7072
|
reconcileHostFromCtx,
|
|
6744
7073
|
resolveCallerPath,
|
|
6745
7074
|
});
|
|
@@ -6948,6 +7277,17 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
6948
7277
|
.action(wrap(async (laneId, opts) => {
|
|
6949
7278
|
await runKeepAlive(ctx, laneId, opts);
|
|
6950
7279
|
}));
|
|
7280
|
+
lanes
|
|
7281
|
+
.command("set")
|
|
7282
|
+
.description("Change the model or effort a lane uses from its next resume")
|
|
7283
|
+
.argument("<lane-id>", "Lane id")
|
|
7284
|
+
.option("--model <model>", "Model for the lane's next and later turns")
|
|
7285
|
+
.option("--effort <level>", "Effort for the lane's next and later turns")
|
|
7286
|
+
.option("--reason <text>", "Why the settings changed; recorded in settings_history")
|
|
7287
|
+
.option("--json", "Emit JSON on stdout (default)")
|
|
7288
|
+
.action(wrap(async (laneId, opts) => {
|
|
7289
|
+
await runSet(ctx, laneId, opts);
|
|
7290
|
+
}));
|
|
6951
7291
|
lanes
|
|
6952
7292
|
.command("grant")
|
|
6953
7293
|
.description("Set a lane's alive child-lane grant as its senior")
|
|
@@ -6973,6 +7313,10 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
6973
7313
|
.option("--story <id>", "Filter by story")
|
|
6974
7314
|
.option("--state <state>", `Filter by state (${LANE_STATES.join("|")})`)
|
|
6975
7315
|
.option("--senior <ref>", "Filter by senior ref")
|
|
7316
|
+
.option("--lane <id>", "Filter by lane id (repeatable)", (value, previous = []) => [...previous, value], [])
|
|
7317
|
+
.option("--active", "Include only lanes that are not terminal")
|
|
7318
|
+
.addOption(new Option("--hierarchy <lane-id>").hideHelp())
|
|
7319
|
+
.addOption(new Option("--snapshot").hideHelp())
|
|
6976
7320
|
.option("--json", "Emit JSON on stdout (default)")
|
|
6977
7321
|
.option("--human", "Emit human-readable output")
|
|
6978
7322
|
.action(wrap(async (opts) => {
|
|
@@ -7237,12 +7581,20 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
7237
7581
|
}));
|
|
7238
7582
|
lanes
|
|
7239
7583
|
.command("stop")
|
|
7240
|
-
.description("Cancel a queued lane or kill a running one")
|
|
7584
|
+
.description("Cancel a queued lane or kill a running one (terminal; use interrupt to keep it resumable)")
|
|
7241
7585
|
.argument("<lane-id>", "Lane id")
|
|
7242
7586
|
.option("--reason <text>", "Record the reason in the lane's terminal history")
|
|
7243
7587
|
.action(wrap(async (laneId, options) => {
|
|
7244
7588
|
await runStop(ctx, laneId, options.reason);
|
|
7245
7589
|
}));
|
|
7590
|
+
lanes
|
|
7591
|
+
.command("interrupt")
|
|
7592
|
+
.description("Stop a running lane's turn and keep it resumable with hq lanes resume")
|
|
7593
|
+
.argument("<lane-id>", "Lane id")
|
|
7594
|
+
.option("--reason <text>", "Record the reason in the lane's stop history")
|
|
7595
|
+
.action(wrap(async (laneId, options) => {
|
|
7596
|
+
await runInterrupt(ctx, laneId, options.reason);
|
|
7597
|
+
}));
|
|
7246
7598
|
lanes
|
|
7247
7599
|
.command("approve")
|
|
7248
7600
|
.description("Record senior approval for a registry worker checkpoint")
|