@indigoai-us/hq-cli 5.204.0 → 5.205.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 CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.205.0] — 2026-09-26
6
+
7
+ ### Added
8
+
9
+ - `hq lanes create` and `hq lanes resume` accept `--keep-alive`. When the
10
+ default-off `lanes.lead-keep-alive` flag is enabled, an opted-in lead lane
11
+ resumes after a `done` turn that leaves its story open. The first resume
12
+ waits at least five minutes; later attempts back off to an hour and stop
13
+ after 24 consecutive attempts.
14
+ `hq lanes show --json` reports the counter and next eligible time.
15
+ - Worker profiles accept boolean `keep_alive`. Add this key only after the
16
+ hq-cli release with this change is installed; older CLIs reject unknown
17
+ profile metadata.
18
+
19
+ ### Fixed
20
+
21
+ - Claude result text that says `Not logged in` or `Please run /login` now marks
22
+ a lane failed with `account_not_logged_in` when no envelope was written.
23
+
5
24
  ## [5.204.0] — 2026-09-26
6
25
 
7
26
  ### Added
@@ -7345,6 +7345,9 @@ export declare const COMMAND_CATALOG: readonly [{
7345
7345
  }, {
7346
7346
  readonly flags: "--hire-grant <n>";
7347
7347
  readonly description: "Maximum alive direct child lanes assigned to this lane; set by its senior";
7348
+ }, {
7349
+ readonly flags: "--keep-alive";
7350
+ readonly description: "Automatically resume done lead lanes while their story remains open";
7348
7351
  }, {
7349
7352
  readonly flags: "--timeout <secs>";
7350
7353
  readonly description: "Deadline in seconds (warns, does not kill)";
@@ -7878,6 +7881,9 @@ export declare const COMMAND_CATALOG: readonly [{
7878
7881
  }, {
7879
7882
  readonly flags: "--worker <profile>";
7880
7883
  readonly description: "Attach a personal worker profile for the resumed turn";
7884
+ }, {
7885
+ readonly flags: "--keep-alive";
7886
+ readonly description: "Automatically resume done lead lanes while their story remains open";
7881
7887
  }, {
7882
7888
  readonly flags: "--mesh-company <slug>";
7883
7889
  readonly description: "Attach Work Mesh attribution after verifying company membership";
@@ -9494,6 +9494,10 @@ export const COMMAND_CATALOG = [
9494
9494
  "flags": "--hire-grant <n>",
9495
9495
  "description": "Maximum alive direct child lanes assigned to this lane; set by its senior"
9496
9496
  },
9497
+ {
9498
+ "flags": "--keep-alive",
9499
+ "description": "Automatically resume done lead lanes while their story remains open"
9500
+ },
9497
9501
  {
9498
9502
  "flags": "--timeout <secs>",
9499
9503
  "description": "Deadline in seconds (warns, does not kill)"
@@ -10189,6 +10193,10 @@ export const COMMAND_CATALOG = [
10189
10193
  "flags": "--worker <profile>",
10190
10194
  "description": "Attach a personal worker profile for the resumed turn"
10191
10195
  },
10196
+ {
10197
+ "flags": "--keep-alive",
10198
+ "description": "Automatically resume done lead lanes while their story remains open"
10199
+ },
10192
10200
  {
10193
10201
  "flags": "--mesh-company <slug>",
10194
10202
  "description": "Attach Work Mesh attribution after verifying company membership"
@@ -34,7 +34,7 @@ import { openMeshTransport } from "../lib/mesh/client.js";
34
34
  import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshEventIdentity, } from "../lib/lanes/mesh-events.js";
35
35
  import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
36
36
  import { effectiveAccountHome } from "../lib/lanes/account-home.js";
37
- import { AccountResolutionError, compareLaneQueueOrder, coveredLaneIds, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountStatus, appendCappedHistory, canonicalizeHome, createLane, createQuestion, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, aliveDirectChildren, assignedChildHireGrants, LANE_HIRE_GRANTS_FLAG, planHireGrantBackfill, withHireGrantAdmission, laneStoryLedgerEnabled, validateProjectStories, buildLaneStatusSnapshot, formatLaneStatusHuman, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, laneRunsHere, MAX_LANE_DEPTH, MONITOR_ERROR_EXIT_CODE, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_UNCOVERED_EXIT_CODE, MAX_WATCHED_LANES, monitorReminderText, readLiveMonitorEvidence, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneRecord, parseLaneState, parseLivenessState, parseQuestionRecord, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, syncLaneRecordFromOwner, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile, runReconcilePass, wakeAnsweredLaneNow, runLaneWatch, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, hasActiveAdmissionReservation, hasActivePendingResume, hasRecordedSessionId, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, providerSupportsSessionResume, providerSessionPortable, sessionNotPortableMessage, withLaneAdmissionLock, withLaneHireLocks, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
37
+ import { AccountResolutionError, compareLaneQueueOrder, coveredLaneIds, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountStatus, appendCappedHistory, canonicalizeHome, createLane, createQuestion, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, aliveDirectChildren, assignedChildHireGrants, LANE_HIRE_GRANTS_FLAG, planHireGrantBackfill, terminalChildResumeHireGrantError, withHireGrantAdmission, laneStoryLedgerEnabled, validateProjectStories, buildLaneStatusSnapshot, formatLaneStatusHuman, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, laneRunsHere, MAX_LANE_DEPTH, MONITOR_ERROR_EXIT_CODE, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_UNCOVERED_EXIT_CODE, MAX_WATCHED_LANES, monitorReminderText, readLiveMonitorEvidence, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneRecord, parseLaneState, parseLivenessState, parseQuestionRecord, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, syncLaneRecordFromOwner, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile, runReconcilePass, wakeAnsweredLaneNow, runLaneWatch, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, hasActiveAdmissionReservation, hasActivePendingResume, hasRecordedSessionId, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, providerSupportsSessionResume, providerSessionPortable, sessionNotPortableMessage, withLaneAdmissionLock, withLaneHireLocks, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
38
38
  import { activeLaneStories, laneStories, laneStory, storyWarning, } from "../lib/lanes/stories.js";
39
39
  import { questionBodyAbsPath } from "../lib/lanes/questions.js";
40
40
  import { compactionAfterUsage, } from "../lib/lanes/compaction.js";
@@ -864,33 +864,15 @@ async function withResumeHireAdmission(ctx, lane, fn) {
864
864
  }
865
865
  if (!await ctx.laneHireGrantsEnabled(lane.company))
866
866
  return fn();
867
- return withHireGrantAdmission(hqRoot, lane.senior.id, ({ parent, children, parentGrant, availableChildGrant }) => {
867
+ return withHireGrantAdmission(hqRoot, lane.senior.id, (snapshot) => {
868
868
  const current = readLane(hqRoot, lane.lane_id);
869
869
  // A different resume already reactivated this lane; it occupies the slot
870
870
  // in the shared snapshot and is not a second hire.
871
871
  if (!isTerminalLaneState(current.state))
872
872
  return fn();
873
- if (isTerminalLaneState(parent.state)) {
874
- throw new LanesError("hire_grant_senior_inactive", `Senior lane ${parent.lane_id} is ${parent.state} and cannot resume child lanes`, { senior_lane_id: parent.lane_id, state: parent.state });
875
- }
876
- if (children.length >= (parent.hire_grant ?? 0)) {
877
- throw hireGrantCapacityError(parent, children);
878
- }
879
- const requestedGrant = current.hire_grant ?? 0;
880
- if (requestedGrant > availableChildGrant) {
881
- throw new LanesError("hire_grant_carve_exceeded", `Senior lane ${parent.lane_id} has ${availableChildGrant} unassigned hire grant remaining, but terminal child ${current.lane_id} needs ${requestedGrant} to resume. Ask its senior for more: write an ask envelope with the desired total grant and why; the senior runs hq lanes grant ${parent.lane_id} --hire <n>.`, {
882
- senior_lane_id: parent.lane_id,
883
- parent_grant: parentGrant,
884
- alive_children: children.map((child) => child.lane_id),
885
- assigned_child_grants: assignedChildHireGrants(children),
886
- requested_hire_grant: requestedGrant,
887
- available_hire_grant: availableChildGrant,
888
- ask_guidance: {
889
- envelope: "decision=ask; include the desired total grant and the reason",
890
- senior_command: `hq lanes grant ${parent.lane_id} --hire <n>`,
891
- },
892
- });
893
- }
873
+ const refusal = terminalChildResumeHireGrantError(current, snapshot);
874
+ if (refusal !== null)
875
+ throw refusal;
894
876
  return fn();
895
877
  });
896
878
  }
@@ -2469,6 +2451,11 @@ async function runCreate(ctx, opts) {
2469
2451
  const profileEffort = profileDefaultsApply ? profile?.meta.effort : undefined;
2470
2452
  const profileAccount = profileDefaultsApply ? profile?.meta.account : undefined;
2471
2453
  const profileAccounts = profileDefaultsApply ? profile?.meta.accounts : undefined;
2454
+ const keepAlive = opts.keepAlive === true ||
2455
+ (profileDefaultsApply && profile?.meta.keep_alive === true);
2456
+ if (keepAlive && !providerSupportsSessionResume(provider)) {
2457
+ throw new LanesError("keep_alive_provider_unsupported", `Provider ${provider} cannot resume recorded sessions, so keep-alive is unavailable`, { provider });
2458
+ }
2472
2459
  const briefAbs = resolveCallerPath(ctx.callerCwd(), briefFile);
2473
2460
  if (!ctx.existsSync(briefAbs)) {
2474
2461
  throw new LanesError("brief_not_found", `Brief file not found: ${briefAbs}`, {
@@ -2927,6 +2914,7 @@ async function runCreate(ctx, opts) {
2927
2914
  worker_profile_ignored: ignoredWorkerProfileFields(provider),
2928
2915
  }
2929
2916
  : {}),
2917
+ keep_alive: keepAlive,
2930
2918
  provider,
2931
2919
  account: pinnedAccount,
2932
2920
  ...(accountSelection !== undefined ? { account_selection: accountSelection } : {}),
@@ -3382,6 +3370,7 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options) {
3382
3370
  model: options.model,
3383
3371
  effort: options.effort,
3384
3372
  worker: options.worker,
3373
+ keepAlive: options.keepAlive,
3385
3374
  hire: options.hire,
3386
3375
  grantContext,
3387
3376
  compact: options.compact,
@@ -4508,6 +4497,13 @@ async function runShow(ctx, laneId) {
4508
4497
  effort_source: lane.effort_source ?? null,
4509
4498
  terminal_reason: lane.terminal_reason ?? null,
4510
4499
  terminal_details: lane.terminal_details ?? null,
4500
+ keep_alive_state: {
4501
+ enabled: lane.keep_alive === true,
4502
+ consecutive_auto_resumes: lane.keep_alive_resume_count ?? 0,
4503
+ last_auto_resume_at: lane.keep_alive_last_resume_at ?? null,
4504
+ next_eligible_at: lane.keep_alive_next_eligible_at ?? null,
4505
+ exhausted_at: lane.keep_alive_exhausted_at ?? null,
4506
+ },
4511
4507
  last_message_at: lane.last_message_at ?? null,
4512
4508
  last_compaction: lane.last_compaction
4513
4509
  ? { ...lane.last_compaction, after: compactionAfterUsage(storedLane) }
@@ -4922,6 +4918,9 @@ function applyResumeProfileAndEffort(current, next, input) {
4922
4918
  next.worker_profile_hash = profile.contentHash;
4923
4919
  next.worker_profile_ignored = ignoredWorkerProfileFields(current.provider);
4924
4920
  next.worker_policy_delivery = undefined;
4921
+ if (profile.meta.keep_alive !== undefined) {
4922
+ next.keep_alive = profile.meta.keep_alive;
4923
+ }
4925
4924
  if (next.model_source !== "cli") {
4926
4925
  if (profile.meta.provider === current.provider && profile.meta.model !== undefined) {
4927
4926
  next.model = profile.meta.model;
@@ -4937,6 +4936,8 @@ function applyResumeProfileAndEffort(current, next, input) {
4937
4936
  next.effort = input.effort.effort;
4938
4937
  next.effort_source = input.effort.source;
4939
4938
  }
4939
+ if (input.keepAlive !== undefined)
4940
+ next.keep_alive = input.keepAlive;
4940
4941
  }
4941
4942
  function recordResumeSetup(ctx, lane, input) {
4942
4943
  if (input.cliModel !== undefined) {
@@ -4958,6 +4959,7 @@ function recordResumeSetup(ctx, lane, input) {
4958
4959
  }
4959
4960
  if (input.profile === undefined &&
4960
4961
  input.effort === undefined &&
4962
+ input.keepAlive === undefined &&
4961
4963
  input.cliModel === undefined &&
4962
4964
  input.meshCompany === undefined)
4963
4965
  return lane;
@@ -4980,6 +4982,23 @@ function recordResumeSetup(ctx, lane, input) {
4980
4982
  return next;
4981
4983
  });
4982
4984
  }
4985
+ function hasKeepAliveProgress(lane) {
4986
+ return (lane.keep_alive_resume_count ?? 0) !== 0 ||
4987
+ lane.keep_alive_last_resume_at !== undefined ||
4988
+ lane.keep_alive_next_eligible_at !== undefined ||
4989
+ lane.keep_alive_exhausted_at !== undefined;
4990
+ }
4991
+ function resetKeepAliveProgress(lane) {
4992
+ if (!hasKeepAliveProgress(lane))
4993
+ return lane;
4994
+ return {
4995
+ ...lane,
4996
+ keep_alive_resume_count: 0,
4997
+ keep_alive_last_resume_at: undefined,
4998
+ keep_alive_next_eligible_at: undefined,
4999
+ keep_alive_exhausted_at: undefined,
5000
+ };
5001
+ }
4983
5002
  /** Resolve an explicit resume override or newly attached profile like create does. */
4984
5003
  function resolveResumeEffort(ctx, lane, input) {
4985
5004
  if (input.cliEffort === undefined && input.profile === undefined)
@@ -5575,7 +5594,7 @@ async function runMessage(ctx, laneId, opts) {
5575
5594
  }
5576
5595
  // Every accepted message moves the compaction reference forward.
5577
5596
  let next = casUpdateLane(hqRoot, laneId, (current) => ({
5578
- ...current,
5597
+ ...resetKeepAliveProgress(current),
5579
5598
  last_message_at: at,
5580
5599
  }));
5581
5600
  if (projectId !== lane.project_id || storyId !== lane.story_id) {
@@ -5913,6 +5932,9 @@ async function runResume(ctx, laneId, opts) {
5913
5932
  const resumeProfile = opts.worker !== undefined
5914
5933
  ? resolveResumeProfile(ctx, lane, opts.worker)
5915
5934
  : undefined;
5935
+ const keepAlive = opts.keepAlive === true
5936
+ ? true
5937
+ : resumeProfile?.profile.meta.keep_alive;
5916
5938
  const keepCurrentAccount = opts.account !== undefined && accountRequestKeepsCurrentLane(lane, opts.account);
5917
5939
  if (keepCurrentAccount)
5918
5940
  assertCurrentResumeAccountAvailable(ctx, lane);
@@ -5970,6 +5992,7 @@ async function runResume(ctx, laneId, opts) {
5970
5992
  lane = recordResumeSetup(ctx, lane, {
5971
5993
  ...(move === null && resumeProfile !== undefined ? { profile: resumeProfile } : {}),
5972
5994
  ...(move === null && resumeEffort !== undefined ? { effort: resumeEffort } : {}),
5995
+ ...(keepAlive !== undefined ? { keepAlive } : {}),
5973
5996
  ...(move === null && keepCurrentAccount && opts.model !== undefined ? { cliModel: opts.model } : {}),
5974
5997
  ...(requestedMeshCompany !== undefined
5975
5998
  ? { meshCompany: requestedMeshCompany, threadId: meshThreadId }
@@ -6065,7 +6088,7 @@ async function runResume(ctx, laneId, opts) {
6065
6088
  if (text !== undefined) {
6066
6089
  // An operator text moves the compaction reference forward, as a message does.
6067
6090
  next = casUpdateLane(hqRoot, laneId, (current) => ({
6068
- ...current,
6091
+ ...resetKeepAliveProgress(current),
6069
6092
  last_message_at: at,
6070
6093
  }));
6071
6094
  }
@@ -6100,6 +6123,9 @@ async function runResume(ctx, laneId, opts) {
6100
6123
  };
6101
6124
  });
6102
6125
  }
6126
+ if (text === undefined && hasKeepAliveProgress(next)) {
6127
+ next = casUpdateLane(hqRoot, laneId, (current) => resetKeepAliveProgress(current));
6128
+ }
6103
6129
  printJson({
6104
6130
  ok: true,
6105
6131
  action: "resume",
@@ -8309,6 +8335,7 @@ export function registerLanesCommand(parent, deps = {}) {
8309
8335
  .option("--effort <level>", "Effort level; claude: low|medium|high|xhigh|max, codex: minimal|low|medium|high|xhigh|max|ultra, grok: low|medium|high|xhigh")
8310
8336
  .option("--senior <ref>", "session:<id> | lane:<id> | user")
8311
8337
  .option("--hire-grant <n>", "Maximum alive direct child lanes assigned to this lane; set by its senior")
8338
+ .option("--keep-alive", "Automatically resume done lead lanes while their story remains open")
8312
8339
  .option("--timeout <secs>", "Deadline in seconds (warns, does not kill)")
8313
8340
  .option("--priority <n>", "Queue priority; higher runs first", "1")
8314
8341
  .option("--outpost <id>", "Run on this Outpost instead of the local host")
@@ -8546,6 +8573,7 @@ export function registerLanesCommand(parent, deps = {}) {
8546
8573
  .option("--model <id>", "Model for the moved lane (with --account only)")
8547
8574
  .option("--effort <level>", "Reasoning effort for the resumed provider turn")
8548
8575
  .option("--worker <profile>", "Attach a personal worker profile for the resumed turn")
8576
+ .option("--keep-alive", "Automatically resume done lead lanes while their story remains open")
8549
8577
  .option("--mesh-company <slug>", "Attach Work Mesh attribution after verifying company membership")
8550
8578
  .option("--no-compact", "Skip the resume-time compaction check for this resume")
8551
8579
  .action(wrap(async (laneId, opts) => {
@@ -137,6 +137,12 @@ export function hasActivePendingResume(lane, nowMs) {
137
137
  return false;
138
138
  if (lane.state === "queued" && pending.trigger === "answered_questions")
139
139
  return false;
140
+ if (lane.state === "queued" &&
141
+ lane.keep_alive === true &&
142
+ pending.trigger === "resume" &&
143
+ pending.resumed_from === "done" &&
144
+ lane.keep_alive_last_resume_at === pending.at)
145
+ return false;
140
146
  const reservedAt = Date.parse(pending.at);
141
147
  const ageMs = nowMs - reservedAt;
142
148
  return Number.isFinite(reservedAt) && ageMs >= 0 && ageMs < ADMISSION_RESERVATION_GRACE_MS;
@@ -1,4 +1,5 @@
1
1
  import { type LaneRecord } from "./types.js";
2
+ import { LanesError } from "./errors.js";
2
3
  export declare const LANE_HIRE_GRANTS_FLAG = "lanes.hire-grants";
3
4
  export type LaneHireGrantRow = Pick<LaneRecord, "lane_id" | "state" | "senior" | "hire_grant">;
4
5
  export declare function aliveDirectChildren<T extends LaneHireGrantRow>(lanes: readonly T[], parentLaneId: string): T[];
@@ -9,6 +10,9 @@ export interface HireGrantAdmissionSnapshot {
9
10
  parentGrant: number;
10
11
  availableChildGrant: number;
11
12
  }
13
+ /** Shared refusal logic for resuming a terminal lane under an active senior. */
14
+ export declare function terminalChildResumeHireGrantError(current: LaneRecord, snapshot: HireGrantAdmissionSnapshot): LanesError | null;
15
+ export declare function hireGrantCapacityError(parent: LaneRecord, children: readonly LaneRecord[]): LanesError;
12
16
  /** Keep the occupancy snapshot and the resulting lane write in one parent-lock scope. */
13
17
  export declare function withHireGrantAdmission<T>(hqRoot: string, parentLaneId: string, fn: (snapshot: HireGrantAdmissionSnapshot) => T): T;
14
18
  export interface HireGrantBackfillRow {
@@ -10,6 +10,68 @@ export function aliveDirectChildren(lanes, parentLaneId) {
10
10
  export function assignedChildHireGrants(children, exceptLaneId) {
11
11
  return children.reduce((sum, child) => child.lane_id === exceptLaneId ? sum : sum + (child.hire_grant ?? 0), 0);
12
12
  }
13
+ /** Shared refusal logic for resuming a terminal lane under an active senior. */
14
+ export function terminalChildResumeHireGrantError(current, snapshot) {
15
+ if (!isTerminalLaneState(current.state))
16
+ return null;
17
+ const { parent, children, parentGrant, availableChildGrant } = snapshot;
18
+ if (isTerminalLaneState(parent.state)) {
19
+ return new LanesError("hire_grant_senior_inactive", `Senior lane ${parent.lane_id} is ${parent.state} and cannot resume child lanes`, { senior_lane_id: parent.lane_id, state: parent.state });
20
+ }
21
+ if (children.length >= parentGrant)
22
+ return hireGrantCapacityError(parent, children);
23
+ const requestedGrant = current.hire_grant ?? 0;
24
+ if (requestedGrant > availableChildGrant) {
25
+ return new LanesError("hire_grant_carve_exceeded", `Senior lane ${parent.lane_id} has ${availableChildGrant} unassigned hire grant remaining, but terminal child ${current.lane_id} needs ${requestedGrant} to resume. Ask its senior for more: write an ask envelope with the desired total grant and why; the senior runs hq lanes grant ${parent.lane_id} --hire <n>.`, {
26
+ senior_lane_id: parent.lane_id,
27
+ parent_grant: parentGrant,
28
+ alive_children: children.map((child) => child.lane_id),
29
+ assigned_child_grants: assignedChildHireGrants(children),
30
+ requested_hire_grant: requestedGrant,
31
+ available_hire_grant: availableChildGrant,
32
+ ask_guidance: {
33
+ envelope: "decision=ask; include the desired total grant and the reason",
34
+ senior_command: `hq lanes grant ${parent.lane_id} --hire <n>`,
35
+ },
36
+ });
37
+ }
38
+ return null;
39
+ }
40
+ export function hireGrantCapacityError(parent, children) {
41
+ const grant = parent.hire_grant ?? 0;
42
+ const grantor = (parent.senior.kind === "lane" || parent.senior.kind === "session") &&
43
+ parent.senior.id
44
+ ? { kind: parent.senior.kind, id: parent.senior.id }
45
+ : null;
46
+ const grantorReason = grantor
47
+ ? undefined
48
+ : parent.senior.kind === "user"
49
+ ? "senior_is_user"
50
+ : "parent_senior_missing_id";
51
+ const capacity = children.length === grant
52
+ ? `is at its hire grant (${children.length} of ${grant} alive children)`
53
+ : `has exhausted its hire grant (${children.length} alive children; grant ${grant})`;
54
+ const grantCommand = `hq lanes grant ${parent.lane_id} --hire <n>`;
55
+ const authorityGuidance = grantor
56
+ ? `Its senior ${grantor.kind} ${grantor.id} can raise it with: ${grantCommand}`
57
+ : parent.senior.kind === "user"
58
+ ? `Its senior is the user and can raise it with: ${grantCommand}`
59
+ : `No senior is recorded, so an operator must resolve ownership before running: ${grantCommand}`;
60
+ const message = `Lane ${parent.lane_id} ${capacity}. ${authorityGuidance}`;
61
+ return new LanesError("hire_grant_exhausted", message, {
62
+ senior_lane_id: parent.lane_id,
63
+ parent_grant: grant,
64
+ alive_children: children.map((child) => child.lane_id),
65
+ alive_child_count: children.length,
66
+ grantor,
67
+ ...(grantorReason ? { grantor_reason: grantorReason } : {}),
68
+ message,
69
+ ask_guidance: {
70
+ envelope: "decision=ask; include the desired total grant and the reason",
71
+ senior_command: grantCommand,
72
+ },
73
+ });
74
+ }
13
75
  /** Keep the occupancy snapshot and the resulting lane write in one parent-lock scope. */
14
76
  export function withHireGrantAdmission(hqRoot, parentLaneId, fn) {
15
77
  return withLaneHireLocks(hqRoot, [parentLaneId], () => {
@@ -3,7 +3,7 @@
3
3
  */
4
4
  export { AccountResolutionError, LaneNotFoundError, LaneSchemaError, LaneVersionConflict, LanesConfigError, LanesError, WorkerResolutionError, isLaneVersionConflict, isLanesError, } from "./errors.js";
5
5
  export { MAX_LANE_DEPTH } from "./constants.js";
6
- export { assignedChildHireGrants, aliveDirectChildren, LANE_HIRE_GRANTS_FLAG, planHireGrantBackfill, withHireGrantAdmission } from "./hire-grants.js";
6
+ export { assignedChildHireGrants, aliveDirectChildren, LANE_HIRE_GRANTS_FLAG, planHireGrantBackfill, terminalChildResumeHireGrantError, withHireGrantAdmission, } from "./hire-grants.js";
7
7
  export type { HireGrantBackfillRow } from "./hire-grants.js";
8
8
  export { compareLaneQueueOrder, hasActiveAdmissionReservation, hasActivePendingResume, hasEarlierEligibleQueuedLane, laneOccupiesRunningSlot, laneAdmissionCapReason, laneRunsOnHost, withAllLaneAdmissionLocks, withLaneAdmissionLock, } from "./admission.js";
9
9
  export type { AdmissionCapReason, AdmissionHost } from "./admission.js";
@@ -3,7 +3,7 @@
3
3
  */
4
4
  export { AccountResolutionError, LaneNotFoundError, LaneSchemaError, LaneVersionConflict, LanesConfigError, LanesError, WorkerResolutionError, isLaneVersionConflict, isLanesError, } from "./errors.js";
5
5
  export { MAX_LANE_DEPTH } from "./constants.js";
6
- export { assignedChildHireGrants, aliveDirectChildren, LANE_HIRE_GRANTS_FLAG, planHireGrantBackfill, withHireGrantAdmission } from "./hire-grants.js";
6
+ export { assignedChildHireGrants, aliveDirectChildren, LANE_HIRE_GRANTS_FLAG, planHireGrantBackfill, terminalChildResumeHireGrantError, withHireGrantAdmission, } from "./hire-grants.js";
7
7
  export { compareLaneQueueOrder, hasActiveAdmissionReservation, hasActivePendingResume, hasEarlierEligibleQueuedLane, laneOccupiesRunningSlot, laneAdmissionCapReason, laneRunsOnHost, withAllLaneAdmissionLocks, withLaneAdmissionLock, } from "./admission.js";
8
8
  export { buildHostCapacitySnapshot, chooseHostForLane, parseHostCapacitySnapshot, } from "./host-capacity.js";
9
9
  export { CONFIG_SCHEMA_VERSION, ENVELOPE_DECISIONS, ENVELOPE_SCHEMA_VERSION, HISTORY_CAP, HOST_KINDS, LANE_PROVIDERS, LANE_SCHEMA_VERSION, LANE_STORY_STATUSES, LANE_STATES, LIVENESS_STATES, QUESTION_BODY_STORES, QUESTION_SCHEMA_VERSION, QUESTION_STATUSES, QUEUED_REASONS, RAISED_VIA, ROUTING_REASONS, SENIOR_KINDS, STORY_STATUSES, TERMINAL_LANE_STATES, isEnvelopeDecision, isHostKind, isLaneProvider, isLaneState, isLivenessState, isQuestionStatus, isSeniorKind, isTerminalLaneState, parseLaneEnvelope, parseLaneProvider, parseLaneState, parseLivenessState, parseMajorSchema, parseQuestionStatus, parseSeniorKind, } from "./types.js";
@@ -0,0 +1,8 @@
1
+ export declare const LEAD_LANE_KEEP_ALIVE_FLAG = "lanes.lead-keep-alive";
2
+ export declare const LEAD_LANE_KEEP_ALIVE_DEFAULT_ENABLED = false;
3
+ export declare const LEAD_LANE_KEEP_ALIVE_MAX_RESUMES = 24;
4
+ export declare const LEAD_LANE_KEEP_ALIVE_BASE_BACKOFF_MS: number;
5
+ export declare const LEAD_LANE_KEEP_ALIVE_MAX_BACKOFF_MS: number;
6
+ export declare const LEAD_LANE_KEEP_ALIVE_RESUME_TEXT = "Automatic keep-alive resume: your last turn ended with decision done and story_complete false. Continue the open work from where you stopped. End with done only when story_complete is true; if you are waiting, use hq lanes ask or stay in the loop.";
7
+ export declare function leadLaneKeepAliveBackoffMs(attempt: number): number;
8
+ //# sourceMappingURL=keep-alive.d.ts.map
@@ -0,0 +1,14 @@
1
+ export const LEAD_LANE_KEEP_ALIVE_FLAG = "lanes.lead-keep-alive";
2
+ export const LEAD_LANE_KEEP_ALIVE_DEFAULT_ENABLED = false;
3
+ export const LEAD_LANE_KEEP_ALIVE_MAX_RESUMES = 24;
4
+ export const LEAD_LANE_KEEP_ALIVE_BASE_BACKOFF_MS = 5 * 60 * 1000;
5
+ export const LEAD_LANE_KEEP_ALIVE_MAX_BACKOFF_MS = 60 * 60 * 1000;
6
+ export const LEAD_LANE_KEEP_ALIVE_RESUME_TEXT = "Automatic keep-alive resume: your last turn ended with decision done and story_complete false. Continue the open work from where you stopped. End with done only when story_complete is true; if you are waiting, use hq lanes ask or stay in the loop.";
7
+ export function leadLaneKeepAliveBackoffMs(attempt) {
8
+ if (!Number.isSafeInteger(attempt) || attempt < 1) {
9
+ throw new RangeError("keep-alive attempt must be a positive safe integer");
10
+ }
11
+ const exponential = LEAD_LANE_KEEP_ALIVE_BASE_BACKOFF_MS * 2 ** (attempt - 1);
12
+ return Math.min(exponential, LEAD_LANE_KEEP_ALIVE_MAX_BACKOFF_MS);
13
+ }
14
+ //# sourceMappingURL=keep-alive.js.map
@@ -37,6 +37,7 @@ export interface OutpostLaneCommand {
37
37
  model?: string;
38
38
  effort?: string;
39
39
  worker?: string;
40
+ keepAlive?: boolean;
40
41
  hire?: string;
41
42
  grantContext?: string;
42
43
  compact?: boolean;
@@ -154,6 +154,8 @@ export function buildOutpostLaneCommand(input) {
154
154
  if (value !== undefined)
155
155
  args.push(flag, value);
156
156
  }
157
+ if (input.action === "resume" && input.keepAlive === true)
158
+ args.push("--keep-alive");
157
159
  if (input.compact === false)
158
160
  args.push("--no-compact");
159
161
  return withRemoteHqDir(joinCommandParts(args));
@@ -17,6 +17,7 @@ export interface WorkerProfileMeta {
17
17
  max_concurrent?: number;
18
18
  source?: string;
19
19
  accounts?: string[];
20
+ keep_alive?: boolean;
20
21
  }
21
22
  export interface WorkerProfile {
22
23
  id: string;
@@ -28,6 +28,7 @@ const PROFILE_META_KEYS = new Set([
28
28
  "max_concurrent",
29
29
  "source",
30
30
  "accounts",
31
+ "keep_alive",
31
32
  ]);
32
33
  function isPlainObject(value) {
33
34
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -164,6 +165,12 @@ function parseMeta(id, dir, raw) {
164
165
  value: raw.fast_mode,
165
166
  });
166
167
  }
168
+ if (raw.keep_alive !== undefined && typeof raw.keep_alive !== "boolean") {
169
+ throw profileError(id, dir, "meta.yaml keep_alive must be a boolean when present", {
170
+ field: "keep_alive",
171
+ value: raw.keep_alive,
172
+ });
173
+ }
167
174
  let version;
168
175
  if (raw.version !== undefined) {
169
176
  if (typeof raw.version !== "string" || raw.version.trim().length === 0) {
@@ -211,6 +218,7 @@ function parseMeta(id, dir, raw) {
211
218
  ...(account !== undefined ? { account } : {}),
212
219
  ...(effort !== undefined ? { effort } : {}),
213
220
  ...(raw.fast_mode !== undefined ? { fast_mode: raw.fast_mode } : {}),
221
+ ...(raw.keep_alive !== undefined ? { keep_alive: raw.keep_alive } : {}),
214
222
  ...(maxConcurrent !== undefined ? { max_concurrent: maxConcurrent } : {}),
215
223
  ...(source !== undefined ? { source } : {}),
216
224
  ...(accounts !== undefined ? { accounts } : {}),
@@ -35,7 +35,7 @@ export interface ProviderRejection {
35
35
  /** Derived from the status: a transient class (408, 425, 5xx) is retryable; a 4xx is not. */
36
36
  retryable: boolean;
37
37
  /** Which event on the stream carried the failure. */
38
- event: "codex.turn.failed" | "claude.result.is_error" | "grok.error";
38
+ event: "codex.turn.failed" | "claude.result.is_error" | "claude.result.login_failure" | "grok.error";
39
39
  }
40
40
  export declare function isProviderLoginFailure(failure: ProviderRejection | null): boolean;
41
41
  export declare function capMessage(message: string): string;
@@ -28,7 +28,7 @@ export const QUOTA_STATUS = 429;
28
28
  function isProviderLoginMessage(provider, message) {
29
29
  switch (provider) {
30
30
  case "claude":
31
- return /\bnot logged in\b/i.test(message);
31
+ return /(?:\bnot logged in\b|\bplease run\s+\/login\b)/i.test(message);
32
32
  case "codex":
33
33
  return /\bnot logged in\b/i.test(message) ||
34
34
  /\bmissing bearer or basic authentication in header\b/i.test(message);
@@ -40,6 +40,11 @@ function isProviderLoginMessage(provider, message) {
40
40
  }
41
41
  }
42
42
  }
43
+ /** Successful Claude results can contain worker prose, so only accept its short CLI login banner. */
44
+ function isClaudeSuccessLoginMessage(message) {
45
+ const trimmed = message.trim();
46
+ return trimmed.length <= 80 && /^not logged in\b.{0,40}please run \/login\.?$/i.test(trimmed);
47
+ }
43
48
  export function isProviderLoginFailure(failure) {
44
49
  return failure !== null && isProviderLoginMessage(failure.provider, failure.message);
45
50
  }
@@ -163,11 +168,14 @@ export function parseClaudeResultRejection(text) {
163
168
  const found = scanJsonLinesBackward(text, (rec) => {
164
169
  if (rec.type !== "result")
165
170
  return null;
166
- if (rec.is_error !== true)
171
+ const message = typeof rec.result === "string" ? rec.result : "";
172
+ const loginFailure = rec.is_error === true
173
+ ? isProviderLoginMessage("claude", message)
174
+ : isClaudeSuccessLoginMessage(message);
175
+ if (rec.is_error !== true && !loginFailure)
167
176
  return NOT_REJECTED;
168
177
  const status = statusOf(rec.api_error_status);
169
- const message = typeof rec.result === "string" ? rec.result : "";
170
- if (status === null && !isProviderLoginMessage("claude", message))
178
+ if (status === null && !loginFailure)
171
179
  return NOT_REJECTED;
172
180
  return {
173
181
  reason: PROVIDER_REJECTED_REASON,
@@ -176,7 +184,9 @@ export function parseClaudeResultRejection(text) {
176
184
  status,
177
185
  error_type: typeof rec.subtype === "string" ? rec.subtype : null,
178
186
  retryable: retryableFromStatus(status),
179
- event: "claude.result.is_error",
187
+ event: loginFailure && rec.is_error !== true
188
+ ? "claude.result.login_failure"
189
+ : "claude.result.is_error",
180
190
  };
181
191
  });
182
192
  return found === NOT_REJECTED ? null : found;
@@ -8,6 +8,7 @@
8
8
  import { type LivenessDeps } from "./liveness.js";
9
9
  import { type EnvelopeRepairSpawnInput, type SpawnLaneDeps } from "./spawn.js";
10
10
  import { type ThrottleDeps } from "./throttle.js";
11
+ import { readLanesRegistryFlag } from "./registry-flag.js";
11
12
  import type { LaneRecord, LivenessState, QueuedReason, QuestionRecord, SeniorRef } from "./types.js";
12
13
  import { type LaneMesh } from "./mesh-events.js";
13
14
  /** Spec §9: unanswered at the current hop for 60 minutes → one hop. */
@@ -71,6 +72,8 @@ export interface ReconcileDeps {
71
72
  readSessionSenior?: (sessionId: string) => SeniorRef;
72
73
  presenceConnected?: boolean;
73
74
  env?: NodeJS.ProcessEnv;
75
+ /** Flag reader seam used to assert rollout reads happen before admission locks. */
76
+ readRegistryFlag?: typeof readLanesRegistryFlag;
74
77
  /** Detached mesh seam for dead-watcher recovery. */
75
78
  mesh?: LaneMesh | null;
76
79
  /** Bound on one terminal mesh emit during dead-host recovery. Default 10 s. */
@@ -14,18 +14,22 @@ import { effectiveAccountHome } from "./account-home.js";
14
14
  import { loadLanesConfig, providerQuotaDefaultHoldMs } from "./config.js";
15
15
  import { MAX_LANE_DEPTH } from "./constants.js";
16
16
  import { AccountResolutionError, LanesError, isLaneVersionConflict } from "./errors.js";
17
+ import { LANE_HIRE_GRANTS_FLAG, terminalChildResumeHireGrantError, withHireGrantAdmission, } from "./hire-grants.js";
17
18
  import { loadWorkerProfile } from "./profile.js";
18
19
  import { classifySessionLiveness, defaultLivenessDeps, loadLivenessIndex, readDaemonPresenceConnected, unindexedClaudeSessionLiveness, } from "./liveness.js";
19
20
  import { ANSWERED_QUESTIONS_FILE, armLaneWatcher, envelopeRepairMode, isPidRecordAlive, laneRunDir, readPidRecord, readSpawnManifest, markSpawnFailure, spawnEnvelopeRepair, spawnLaneWorker, retryLaneOnAlternateAccount, } from "./spawn.js";
20
21
  import { appendCappedHistory, lanesStoreRoot, parseLaneRecord, parseQuestionRecord, readLane, readQuestion, updateLane, updateQuestion, } from "./store.js";
21
22
  import { applyThrottleDecision, decideAdmission, defaultThrottleDeps, emptyThrottleState, evaluateThrottle, isCpuCheckEnabled, readAvailableMemMb, readThrottleState, resolveCpuBusyFraction, throttleStatePath, writeThrottleState, } from "./throttle.js";
23
+ import { readLanesRegistryFlag } from "./registry-flag.js";
24
+ import { LEAD_LANE_KEEP_ALIVE_FLAG, LEAD_LANE_KEEP_ALIVE_MAX_RESUMES, LEAD_LANE_KEEP_ALIVE_RESUME_TEXT, leadLaneKeepAliveBackoffMs, } from "./keep-alive.js";
25
+ import { providerSupportsSessionResume } from "./delivery.js";
22
26
  import { applyDefaultQuotaHold, readQuotaSignal, recordAccountQuota } from "./quota.js";
23
27
  import { answeredQuestionEntries, raiseEnvelopeQuestions } from "./questions.js";
24
- import { SILENCE_MS, classifyLaneExit, isPidAlive, missingExpectedEnvelopeFile, readLatestEnvelopeFile, readLatestEnvelopeRecord, } from "./watcher.js";
28
+ import { SILENCE_MS, classifyLaneExit, isPidAlive, missingExpectedEnvelopeFile, readLatestEnvelopeFile, readLatestEnvelopeRecord, watcherEventsPath, writeWatcherDecision, } from "./watcher.js";
25
29
  import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshCompany, } from "./mesh-events.js";
26
30
  import { laneStories, storyStatusForEnvelope } from "./stories.js";
27
31
  import { decideEnvelopeRepair, envelopeQuestionIds, routeProviderFailure } from "./watcher.js";
28
- import { providerRejectionDetails, readProviderRejection, } from "./provider-rejection.js";
32
+ import { PROVIDER_REJECTED_REASON, providerRejectionDetails, readProviderRejection, } from "./provider-rejection.js";
29
33
  /** Spec §9: unanswered at the current hop for 60 minutes → one hop. */
30
34
  export const QUESTION_AGE_MS = 60 * 60 * 1000;
31
35
  /** Spec §6.6 + §9: a depth-3 worker has two hops to age toward the user. */
@@ -792,11 +796,51 @@ export async function runPassBody(hqRoot, deps) {
792
796
  });
793
797
  }
794
798
  }
799
+ const readRegistryFlag = deps.readRegistryFlag ?? readLanesRegistryFlag;
800
+ const keepAliveFlagCompanies = new Map();
801
+ const keepAliveHireGrantFlagCompanies = new Map();
802
+ for (const lane of lanes) {
803
+ if (lane.state !== "done" ||
804
+ lane.keep_alive !== true ||
805
+ !laneRunsHere(lane, deps.host) ||
806
+ lane.terminal_reason === PROVIDER_REJECTED_REASON ||
807
+ lane.terminal_reason === "account_not_logged_in" ||
808
+ !providerSupportsSessionResume(lane.provider) ||
809
+ !(lane.provider_session_id ?? lane.last_resume?.session_id))
810
+ continue;
811
+ const envelope = readLatestEnvelopeRecord(hqRoot, lane.lane_id)?.envelope;
812
+ if (!envelope || envelope.decision !== "done" || envelope.story_complete === true)
813
+ continue;
814
+ const key = keepAliveCompanyKey(lane.company);
815
+ keepAliveFlagCompanies.set(key, lane.company);
816
+ if (lane.senior.kind === "lane" && lane.senior.id) {
817
+ keepAliveHireGrantFlagCompanies.set(key, lane.company);
818
+ }
819
+ }
820
+ const keepAliveFlagByCompany = new Map();
821
+ const keepAliveHireGrantFlagByCompany = new Map();
822
+ const env = deps.env ?? process.env;
823
+ await Promise.all([
824
+ ...[...keepAliveFlagCompanies.entries()].map(async ([key, company]) => {
825
+ keepAliveFlagByCompany.set(key, await readRegistryFlag(LEAD_LANE_KEEP_ALIVE_FLAG, {
826
+ company: company ?? undefined,
827
+ env,
828
+ }));
829
+ }),
830
+ ...[...keepAliveHireGrantFlagCompanies.entries()].map(async ([key, company]) => {
831
+ keepAliveHireGrantFlagByCompany.set(key, await readRegistryFlag(LANE_HIRE_GRANTS_FLAG, {
832
+ company: company ?? undefined,
833
+ env,
834
+ }));
835
+ }),
836
+ ]);
795
837
  await withAllLaneAdmissionLocks(hqRoot, async () => {
796
838
  const freshLanes = loadLaneRecords(hqRoot, summary);
839
+ resumeDoneKeepAlive(hqRoot, freshLanes, summary, deps, now, keepAliveFlagByCompany, keepAliveHireGrantFlagByCompany);
840
+ const drainLanes = loadLaneRecords(hqRoot, summary);
797
841
  const freshQuestions = loadQuestionRecords(hqRoot, summary);
798
- await admitQueued(hqRoot, freshLanes, summary, deps, now);
799
- await resumeAwaiting(hqRoot, freshLanes, freshQuestions, summary, deps);
842
+ await admitQueued(hqRoot, drainLanes, summary, deps, now);
843
+ await resumeAwaiting(hqRoot, drainLanes, freshQuestions, summary, deps);
800
844
  });
801
845
  if (!deps.dryRun && summary.errors.length === 0) {
802
846
  try {
@@ -1322,6 +1366,231 @@ async function admitQueued(hqRoot, lanes, summary, deps, now) {
1322
1366
  writeThrottleState(filePath, applyThrottleDecision(base, applied, nowMs), throttleDeps);
1323
1367
  }
1324
1368
  }
1369
+ function keepAliveCompanyKey(company) {
1370
+ return JSON.stringify([company?.uid ?? null, company?.slug ?? null]);
1371
+ }
1372
+ function hasKeepAliveProgress(lane) {
1373
+ return (lane.keep_alive_resume_count ?? 0) !== 0 ||
1374
+ lane.keep_alive_last_resume_at !== undefined ||
1375
+ lane.keep_alive_next_eligible_at !== undefined ||
1376
+ lane.keep_alive_exhausted_at !== undefined;
1377
+ }
1378
+ function clearedKeepAliveProgress(lane) {
1379
+ return {
1380
+ ...lane,
1381
+ keep_alive_resume_count: 0,
1382
+ keep_alive_last_resume_at: undefined,
1383
+ keep_alive_next_eligible_at: undefined,
1384
+ keep_alive_exhausted_at: undefined,
1385
+ };
1386
+ }
1387
+ function keepAliveProgressResetByHuman(lane) {
1388
+ const count = lane.keep_alive_resume_count ?? 0;
1389
+ const lastAutoMs = Date.parse(lane.keep_alive_last_resume_at ?? "");
1390
+ if (count > 0 && !Number.isFinite(lastAutoMs))
1391
+ return true;
1392
+ const messageMs = Date.parse(lane.last_message_at ?? "");
1393
+ const resumeMs = lane.last_resume?.text === LEAD_LANE_KEEP_ALIVE_RESUME_TEXT
1394
+ ? Number.NaN
1395
+ : Date.parse(lane.last_resume?.at ?? "");
1396
+ return Number.isFinite(lastAutoMs) &&
1397
+ ((Number.isFinite(messageMs) && messageMs > lastAutoMs) ||
1398
+ (Number.isFinite(resumeMs) && resumeMs > lastAutoMs));
1399
+ }
1400
+ function recordKeepAliveEvent(hqRoot, laneId, summary, event) {
1401
+ try {
1402
+ writeWatcherDecision(watcherEventsPath(hqRoot, laneId), {
1403
+ lane_id: laneId,
1404
+ ...event,
1405
+ });
1406
+ }
1407
+ catch (err) {
1408
+ markDirty(summary, {
1409
+ kind: "lane",
1410
+ id: laneId,
1411
+ why: `keep-alive event write failed: ${err instanceof Error ? err.message : String(err)}`,
1412
+ });
1413
+ }
1414
+ }
1415
+ function resumeDoneKeepAlive(hqRoot, lanes, summary, deps, now, flagByCompany, hireGrantFlagByCompany) {
1416
+ const nowMs = now.getTime();
1417
+ const nowIso = now.toISOString();
1418
+ const config = loadLanesConfig(hqRoot);
1419
+ for (const lane of lanes) {
1420
+ if (lane.state !== "done" ||
1421
+ lane.keep_alive !== true ||
1422
+ !laneRunsHere(lane, deps.host))
1423
+ continue;
1424
+ const latest = readLatestEnvelopeRecord(hqRoot, lane.lane_id);
1425
+ const envelope = latest?.envelope;
1426
+ if (!envelope || envelope.decision !== "done")
1427
+ continue;
1428
+ if (envelope.story_complete === true) {
1429
+ if (hasKeepAliveProgress(lane)) {
1430
+ casLaneIf(hqRoot, lane.lane_id, (current) => current.version === lane.version &&
1431
+ current.state === "done" &&
1432
+ current.keep_alive === true, (current) => clearedKeepAliveProgress(current), deps.dryRun);
1433
+ }
1434
+ continue;
1435
+ }
1436
+ if (lane.terminal_reason === PROVIDER_REJECTED_REASON ||
1437
+ lane.terminal_reason === "account_not_logged_in" ||
1438
+ !providerSupportsSessionResume(lane.provider) ||
1439
+ !(lane.provider_session_id ?? lane.last_resume?.session_id))
1440
+ continue;
1441
+ if (flagByCompany.get(keepAliveCompanyKey(lane.company)) !== true)
1442
+ continue;
1443
+ const current = readLane(hqRoot, lane.lane_id);
1444
+ if (current.version !== lane.version ||
1445
+ current.state !== "done" ||
1446
+ current.keep_alive !== true ||
1447
+ !laneRunsHere(current, deps.host))
1448
+ continue;
1449
+ const currentEnvelope = readLatestEnvelopeRecord(hqRoot, current.lane_id)?.envelope;
1450
+ if (!currentEnvelope || currentEnvelope.decision !== "done")
1451
+ continue;
1452
+ if (currentEnvelope.story_complete === true) {
1453
+ if (hasKeepAliveProgress(current)) {
1454
+ casLaneIf(hqRoot, current.lane_id, (latestLane) => latestLane.version === current.version &&
1455
+ latestLane.state === "done" &&
1456
+ latestLane.keep_alive === true, (latestLane) => clearedKeepAliveProgress(latestLane), deps.dryRun);
1457
+ }
1458
+ continue;
1459
+ }
1460
+ const providerSessionId = current.provider_session_id ?? current.last_resume?.session_id;
1461
+ if (current.terminal_reason === PROVIDER_REJECTED_REASON ||
1462
+ current.terminal_reason === "account_not_logged_in" ||
1463
+ typeof providerSessionId !== "string" ||
1464
+ providerSessionId.trim().length === 0)
1465
+ continue;
1466
+ const resetByHuman = keepAliveProgressResetByHuman(current);
1467
+ const attempts = resetByHuman ? 0 : current.keep_alive_resume_count ?? 0;
1468
+ if (attempts >= LEAD_LANE_KEEP_ALIVE_MAX_RESUMES) {
1469
+ if (current.keep_alive_exhausted_at !== undefined)
1470
+ continue;
1471
+ const exhausted = casLaneIf(hqRoot, current.lane_id, (latestLane) => latestLane.version === current.version &&
1472
+ latestLane.state === "done" &&
1473
+ latestLane.keep_alive === true &&
1474
+ (latestLane.keep_alive_resume_count ?? 0) >= LEAD_LANE_KEEP_ALIVE_MAX_RESUMES &&
1475
+ latestLane.keep_alive_exhausted_at === undefined, (latestLane) => ({ ...latestLane, keep_alive_exhausted_at: nowIso }), deps.dryRun);
1476
+ if (exhausted.changed && !deps.dryRun) {
1477
+ recordKeepAliveEvent(hqRoot, current.lane_id, summary, {
1478
+ event: "keep_alive_exhausted",
1479
+ at: nowIso,
1480
+ attempt: attempts,
1481
+ });
1482
+ }
1483
+ continue;
1484
+ }
1485
+ const attempt = attempts + 1;
1486
+ const doneAtMs = Date.parse(current.updated_at);
1487
+ const attemptEligibleMs = doneAtMs + leadLaneKeepAliveBackoffMs(attempt);
1488
+ const storedNextMs = resetByHuman
1489
+ ? Number.NaN
1490
+ : Date.parse(current.keep_alive_next_eligible_at ?? "");
1491
+ const nextEligibleMs = Number.isFinite(storedNextMs)
1492
+ ? Math.max(attemptEligibleMs, storedNextMs)
1493
+ : attemptEligibleMs;
1494
+ if (nowMs < nextEligibleMs) {
1495
+ if (resetByHuman && hasKeepAliveProgress(current)) {
1496
+ casLaneIf(hqRoot, current.lane_id, (latestLane) => latestLane.version === current.version &&
1497
+ latestLane.state === "done" &&
1498
+ latestLane.keep_alive === true, (latestLane) => clearedKeepAliveProgress(latestLane), deps.dryRun);
1499
+ }
1500
+ continue;
1501
+ }
1502
+ const nextEligibleAt = new Date(nowMs + leadLaneKeepAliveBackoffMs(attempt + 1)).toISOString();
1503
+ const queuedCount = loadLaneRecords(hqRoot, summary).filter((queuedLane) => queuedLane.state === "queued" &&
1504
+ queuedLane.provider === current.provider &&
1505
+ laneRunsHere(queuedLane, deps.host) &&
1506
+ !hasFreshAdmissionReservation(queuedLane, nowMs)).length;
1507
+ const maxQueued = config.providers[current.provider].max_queued;
1508
+ if (queuedCount >= maxQueued) {
1509
+ summary.skipped.push({
1510
+ kind: "lane",
1511
+ id: current.lane_id,
1512
+ why: `lane_queue_full: ${current.provider} queue has ${queuedCount}/${maxQueued} lanes`,
1513
+ });
1514
+ continue;
1515
+ }
1516
+ const queue = (expectedSeniorId) => casLaneIf(hqRoot, current.lane_id, (latestLane) => latestLane.version === current.version &&
1517
+ latestLane.state === "done" &&
1518
+ latestLane.keep_alive === true &&
1519
+ (expectedSeniorId === undefined ||
1520
+ (latestLane.senior.kind === "lane" && latestLane.senior.id === expectedSeniorId)), (latestLane) => ({
1521
+ ...latestLane,
1522
+ state: "queued",
1523
+ queued_reason: undefined,
1524
+ queued_at: nowIso,
1525
+ admission_reserved_at: undefined,
1526
+ terminal_reason: undefined,
1527
+ terminal_details: undefined,
1528
+ pending_resume: {
1529
+ session_id: providerSessionId,
1530
+ trigger: "resume",
1531
+ resumed_from: "done",
1532
+ text: LEAD_LANE_KEEP_ALIVE_RESUME_TEXT,
1533
+ compact: true,
1534
+ at: nowIso,
1535
+ },
1536
+ keep_alive_resume_count: attempt,
1537
+ keep_alive_last_resume_at: nowIso,
1538
+ keep_alive_next_eligible_at: nextEligibleAt,
1539
+ keep_alive_exhausted_at: undefined,
1540
+ }), deps.dryRun);
1541
+ const seniorId = current.senior.kind === "lane" ? current.senior.id : undefined;
1542
+ let queued;
1543
+ if (seniorId &&
1544
+ hireGrantFlagByCompany.get(keepAliveCompanyKey(current.company)) === true) {
1545
+ try {
1546
+ queued = withHireGrantAdmission(hqRoot, seniorId, (snapshot) => {
1547
+ const latestLane = readLane(hqRoot, current.lane_id);
1548
+ if (latestLane.version !== current.version ||
1549
+ latestLane.senior.kind !== "lane" ||
1550
+ latestLane.senior.id !== seniorId)
1551
+ return { changed: false, record: latestLane };
1552
+ const refusal = terminalChildResumeHireGrantError(latestLane, snapshot);
1553
+ if (refusal !== null) {
1554
+ summary.skipped.push({
1555
+ kind: "lane",
1556
+ id: current.lane_id,
1557
+ why: `keep_alive_hire_admission: ${refusal.error}: ${refusal.message}`,
1558
+ });
1559
+ return { changed: false, record: latestLane };
1560
+ }
1561
+ return queue(seniorId);
1562
+ });
1563
+ }
1564
+ catch (err) {
1565
+ markDirty(summary, {
1566
+ kind: "lane",
1567
+ id: current.lane_id,
1568
+ why: `keep-alive hire admission failed: ${err instanceof Error ? err.message : String(err)}`,
1569
+ });
1570
+ continue;
1571
+ }
1572
+ }
1573
+ else {
1574
+ queued = queue(seniorId);
1575
+ }
1576
+ if (!queued.changed)
1577
+ continue;
1578
+ summary.resumed.push({
1579
+ id: current.lane_id,
1580
+ from: "done",
1581
+ to: "queued",
1582
+ reason: "keep_alive",
1583
+ });
1584
+ if (!deps.dryRun) {
1585
+ recordKeepAliveEvent(hqRoot, current.lane_id, summary, {
1586
+ event: "keep_alive_resume",
1587
+ at: nowIso,
1588
+ attempt,
1589
+ next_eligible_at: nextEligibleAt,
1590
+ });
1591
+ }
1592
+ }
1593
+ }
1325
1594
  async function resumeAwaiting(hqRoot, lanes, questions, summary, deps, onlyLaneIds) {
1326
1595
  const config = loadLanesConfig(hqRoot);
1327
1596
  const awaiting = lanes.filter((lane) => lane.state === "awaiting_input" &&
@@ -402,6 +402,31 @@ export function parseLaneRecord(raw, filePath) {
402
402
  value: raw.hire_grant,
403
403
  });
404
404
  }
405
+ if (raw.keep_alive !== undefined && typeof raw.keep_alive !== "boolean") {
406
+ throw new LaneSchemaError("keep_alive must be a boolean", {
407
+ path: filePath,
408
+ field: "keep_alive",
409
+ value: raw.keep_alive,
410
+ });
411
+ }
412
+ if (raw.keep_alive_resume_count !== undefined &&
413
+ (typeof raw.keep_alive_resume_count !== "number" ||
414
+ !Number.isSafeInteger(raw.keep_alive_resume_count) ||
415
+ raw.keep_alive_resume_count < 0 || raw.keep_alive_resume_count > 24)) {
416
+ throw new LaneSchemaError("keep_alive_resume_count must be an integer from 0 through 24", {
417
+ path: filePath,
418
+ field: "keep_alive_resume_count",
419
+ value: raw.keep_alive_resume_count,
420
+ });
421
+ }
422
+ for (const field of [
423
+ "keep_alive_last_resume_at",
424
+ "keep_alive_next_eligible_at",
425
+ "keep_alive_exhausted_at",
426
+ ]) {
427
+ if (raw[field] !== undefined)
428
+ requireTimestamp(raw[field], field, filePath);
429
+ }
405
430
  requireString(raw.project_id, "project_id");
406
431
  if (typeof raw.story_id !== "string" ||
407
432
  (raw.story_id.trim() === "" && raw.no_story_reason === undefined)) {
@@ -354,6 +354,16 @@ export interface LaneRecord {
354
354
  worker_pack?: string;
355
355
  /** Personal worker profile id applied to every spawn. */
356
356
  worker_profile?: string;
357
+ /** Whether this lead lane should resume completed turns while its story is open. */
358
+ keep_alive?: boolean;
359
+ /** Consecutive automatic keep-alive resumes since the last human reset. */
360
+ keep_alive_resume_count?: number;
361
+ /** Time the most recent automatic keep-alive resume was queued. */
362
+ keep_alive_last_resume_at?: string;
363
+ /** Earliest time the next consecutive automatic resume may be queued. */
364
+ keep_alive_next_eligible_at?: string;
365
+ /** Time the automatic resume cap was reached for this run of the story. */
366
+ keep_alive_exhausted_at?: string;
357
367
  /** Maximum number of direct child lanes this lane may keep alive; old records default to 0. */
358
368
  hire_grant?: number;
359
369
  /** Content hash of the profile last applied to this lane. */
@@ -224,6 +224,7 @@ export const LANE_FIELD_WRITERS = {
224
224
  pickup_validation_pending: ["creator", "command", "spawn", "reconcile"],
225
225
  pickup_validation_pending_since: ["creator", "command", "spawn", "reconcile"],
226
226
  priority: ["command"], worker: ["creator", "command"], worker_path: ["creator", "command"], worker_pack: ["creator", "command"], worker_profile: ["creator", "command"], worker_profile_hash: ["creator", "command", "spawn"], worker_profile_ignored: ["creator", "command", "spawn"], worker_policy_delivery: ["command", "spawn"],
227
+ keep_alive: ["creator", "command"], keep_alive_resume_count: ["command", "reconcile"], keep_alive_last_resume_at: ["command", "reconcile"], keep_alive_next_eligible_at: ["command", "reconcile"], keep_alive_exhausted_at: ["command", "reconcile"],
227
228
  provider: ["creator"], account: ["creator", "spawn", "command", "watcher", "reconcile"], account_selection: ["creator", "command"], admission_accounts: ["creator", "command"], account_history: ["command", "spawn", "watcher", "reconcile"], pending_resume: ["command", "spawn", "watcher", "reconcile"], placement_default: ["creator"], host: ["creator", "command"], origin_host: ["creator"], origin_host_source: ["creator"],
228
229
  version: ["creator"], created_at: ["creator"], updated_at: ["creator"], attribution_history: ["command"],
229
230
  last_envelope: ["watcher", "reconcile"], model: ["creator", "spawn", "command"], model_source: ["creator", "spawn", "command"], effort: ["creator", "spawn", "command"], effort_source: ["creator", "spawn", "command"], fast_mode: ["spawn"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.204.0",
3
+ "version": "5.205.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {