@indigoai-us/hq-cli 5.214.0 → 5.216.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,10 +2,35 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.216.0] — 2026-09-26
6
+
7
+ ### Fixed
8
+
9
+ - Lane watchers keep valid questions from a malformed `ask` envelope when
10
+ repair is skipped, cannot start, or still produces invalid output. The lane
11
+ stores the validation errors, and recovered question context includes them.
12
+ Repair spawns ignore unknown profile metadata and log the field names.
13
+ `hq lanes create` and `hq lanes resume --worker` keep strict validation.
14
+
15
+ - `hq lanes` stops sending status updates for stories missing from the project
16
+ board. The watcher reports the refusal once per lane run and keeps appending
17
+ lane events.
18
+
19
+ ## [5.215.0] — 2026-09-26
20
+
21
+ ### Fixed
22
+
23
+ - hq-cli 5.211.0 could resume a retired lead from its worker profile's
24
+ `keep_alive` value. Reconcile now requires an explicit lane opt-in written
25
+ after the latest envelope, and skips retired or superseded leads.
26
+
5
27
  ## [5.214.0] — 2026-09-26
6
28
 
7
29
  ### Added
8
30
 
31
+ - `hq lanes keep-alive <lane-id> --on|--off` lets a lane's senior opt in on
32
+ the host that owns the lane.
33
+
9
34
  - `hq doctor` now checks the last hour of the sync runner log for stuck
10
35
  realtime lag, cloud permission refusals, runner restart loops, device-limit
11
36
  backoff, and repeated conflicts. Client health reports send these to Sentry
@@ -7399,6 +7399,28 @@ export declare const COMMAND_CATALOG: readonly [{
7399
7399
  readonly description: "JSON on stdout (always)";
7400
7400
  }];
7401
7401
  readonly subcommands: readonly [];
7402
+ }, {
7403
+ readonly name: "keep-alive";
7404
+ readonly description: "Opt a lane into or out of automatic lead-lane resumes";
7405
+ readonly aliases: readonly [];
7406
+ readonly hidden: false;
7407
+ readonly usage: "[options] <lane-id>";
7408
+ readonly arguments: readonly [{
7409
+ readonly name: "lane-id";
7410
+ readonly required: true;
7411
+ readonly variadic: false;
7412
+ }];
7413
+ readonly options: readonly [{
7414
+ readonly flags: "--on";
7415
+ readonly description: "Enable explicit keep-alive for this lane";
7416
+ }, {
7417
+ readonly flags: "--off";
7418
+ readonly description: "Disable keep-alive for this lane";
7419
+ }, {
7420
+ readonly flags: "--json";
7421
+ readonly description: "Emit JSON on stdout (default)";
7422
+ }];
7423
+ readonly subcommands: readonly [];
7402
7424
  }, {
7403
7425
  readonly name: "grant";
7404
7426
  readonly description: "Set a lane's alive child-lane grant as its senior";
@@ -9566,6 +9566,35 @@ export const COMMAND_CATALOG = [
9566
9566
  ],
9567
9567
  "subcommands": []
9568
9568
  },
9569
+ {
9570
+ "name": "keep-alive",
9571
+ "description": "Opt a lane into or out of automatic lead-lane resumes",
9572
+ "aliases": [],
9573
+ "hidden": false,
9574
+ "usage": "[options] <lane-id>",
9575
+ "arguments": [
9576
+ {
9577
+ "name": "lane-id",
9578
+ "required": true,
9579
+ "variadic": false
9580
+ }
9581
+ ],
9582
+ "options": [
9583
+ {
9584
+ "flags": "--on",
9585
+ "description": "Enable explicit keep-alive for this lane"
9586
+ },
9587
+ {
9588
+ "flags": "--off",
9589
+ "description": "Disable keep-alive for this lane"
9590
+ },
9591
+ {
9592
+ "flags": "--json",
9593
+ "description": "Emit JSON on stdout (default)"
9594
+ }
9595
+ ],
9596
+ "subcommands": []
9597
+ },
9569
9598
  {
9570
9599
  "name": "grant",
9571
9600
  "description": "Set a lane's alive child-lane grant as its senior",
@@ -36,9 +36,10 @@ import { CompanySelectionError } from "../utils/company-selection-error.js";
36
36
  import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshEventIdentity, } from "../lib/lanes/mesh-events.js";
37
37
  import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
38
38
  import { effectiveAccountHome } from "../lib/lanes/account-home.js";
39
- 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";
39
+ 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, hireGrantCapacityError, 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, withAllLaneAdmissionLocks, withLaneHireLocks, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
40
40
  import { activeLaneStories, laneStories, laneStory, storyWarning, } from "../lib/lanes/stories.js";
41
41
  import { resolveLaneKeepAlive } from "../lib/lanes/profile.js";
42
+ import { keepAliveExclusion, loadRetiredLaneIds, } from "../lib/lanes/keep-alive.js";
42
43
  import { questionBodyAbsPath } from "../lib/lanes/questions.js";
43
44
  import { compactionAfterUsage, } from "../lib/lanes/compaction.js";
44
45
  import { assertUniqueAccountHomes, providerCompactAfterMs } from "../lib/lanes/config.js";
@@ -896,41 +897,6 @@ async function withResumeHireAdmission(ctx, lane, fn) {
896
897
  return fn();
897
898
  });
898
899
  }
899
- function hireGrantCapacityError(parent, children) {
900
- const grant = parent.hire_grant ?? 0;
901
- const grantor = (parent.senior.kind === "lane" || parent.senior.kind === "session") &&
902
- parent.senior.id
903
- ? { kind: parent.senior.kind, id: parent.senior.id }
904
- : null;
905
- const grantorReason = grantor
906
- ? undefined
907
- : parent.senior.kind === "user"
908
- ? "senior_is_user"
909
- : "parent_senior_missing_id";
910
- const capacity = children.length === grant
911
- ? `is at its hire grant (${children.length} of ${grant} alive children)`
912
- : `has exhausted its hire grant (${children.length} alive children; grant ${grant})`;
913
- const grantCommand = `hq lanes grant ${parent.lane_id} --hire <n>`;
914
- const authorityGuidance = grantor
915
- ? `Its senior ${grantor.kind} ${grantor.id} can raise it with: ${grantCommand}`
916
- : parent.senior.kind === "user"
917
- ? `Its senior is the user and can raise it with: ${grantCommand}`
918
- : `No senior is recorded, so an operator must resolve ownership before running: ${grantCommand}`;
919
- const message = `Lane ${parent.lane_id} ${capacity}. ${authorityGuidance}`;
920
- return new LanesError("hire_grant_exhausted", message, {
921
- senior_lane_id: parent.lane_id,
922
- parent_grant: grant,
923
- alive_children: children.map((child) => child.lane_id),
924
- alive_child_count: children.length,
925
- grantor,
926
- ...(grantorReason ? { grantor_reason: grantorReason } : {}),
927
- message,
928
- ask_guidance: {
929
- envelope: "decision=ask; include the desired total grant and the reason",
930
- senior_command: `hq lanes grant ${parent.lane_id} --hire <n>`,
931
- },
932
- });
933
- }
934
900
  const CAPACITY_PROBE_TIMEOUT_MS = 15_000;
935
901
  const MAX_PLACEMENT_OUTPOSTS = 16;
936
902
  function outpostIdentityUnknownError(message, expectedId, details = {}) {
@@ -2071,7 +2037,7 @@ async function emitLaneTerminalStateSoft(ctx, lane) {
2071
2037
  process.stderr.write(`hq lanes: terminal mesh update failed for ${lane.lane_id}: ${err instanceof Error ? err.message : String(err)}\n`);
2072
2038
  }
2073
2039
  }
2074
- async function emitLaneStartedForStories(ctx, company, lane, threadId) {
2040
+ async function emitLaneStartedForStories(ctx, company, lane, threadId, knownAbsentStories = new Set()) {
2075
2041
  let ready = lane;
2076
2042
  if (ctx.mesh.ensureLaneThread) {
2077
2043
  try {
@@ -2107,6 +2073,8 @@ async function emitLaneStartedForStories(ctx, company, lane, threadId) {
2107
2073
  failures.push(err);
2108
2074
  continue;
2109
2075
  }
2076
+ if (knownAbsentStories.has(storyBoardStoryKey(story.project_id, story.story_id)))
2077
+ continue;
2110
2078
  try {
2111
2079
  await ctx.mesh.patchStoryStatus(company, story.project_id, story.story_id, "in_progress");
2112
2080
  }
@@ -2134,20 +2102,30 @@ function storyNotOnBoardWarning(err) {
2134
2102
  ? err.message
2135
2103
  : undefined;
2136
2104
  }
2105
+ function storyBoardStoryKey(projectId, storyId) {
2106
+ return JSON.stringify([projectId, storyId]);
2107
+ }
2137
2108
  function combinedStoryWarning(lane, additional = []) {
2138
2109
  const warnings = [storyWarning(lane), ...additional]
2139
2110
  .filter((warning) => warning !== undefined && warning.trim().length > 0);
2140
2111
  return [...new Set(warnings)].join(" ") || null;
2141
2112
  }
2142
- async function storyBoardWarning(ctx, company, projectId, storyIds) {
2113
+ async function storyBoardCheck(ctx, company, projectId, storyIds) {
2143
2114
  try {
2144
2115
  await assertStoriesOnBoard(ctx, company, projectId, storyIds);
2145
- return undefined;
2116
+ return { absentStoryIds: new Set() };
2146
2117
  }
2147
2118
  catch (err) {
2148
2119
  const warning = storyNotOnBoardWarning(err);
2149
- if (warning !== undefined)
2150
- return warning;
2120
+ if (warning !== undefined) {
2121
+ const storyId = isLanesError(err) && typeof err.details.story_id === "string"
2122
+ ? err.details.story_id
2123
+ : undefined;
2124
+ return {
2125
+ warning,
2126
+ absentStoryIds: new Set(storyId ? [storyId] : []),
2127
+ };
2128
+ }
2151
2129
  throw err;
2152
2130
  }
2153
2131
  }
@@ -2450,10 +2428,14 @@ async function runCreate(ctx, opts) {
2450
2428
  const seniorCompany = companyOfSenior(hqRoot, senior, ctx);
2451
2429
  assertSameTenant(company, senior, seniorCompany);
2452
2430
  const createStoryWarnings = [];
2431
+ const knownAbsentStories = new Set();
2453
2432
  if (meshCompany && storyIds.length > 0) {
2454
- const warning = await storyBoardWarning(ctx, meshCompany, project, storyIds);
2455
- if (warning !== undefined)
2456
- createStoryWarnings.push(warning);
2433
+ const boardCheck = await storyBoardCheck(ctx, meshCompany, project, storyIds);
2434
+ if (boardCheck.warning !== undefined)
2435
+ createStoryWarnings.push(boardCheck.warning);
2436
+ for (const storyId of boardCheck.absentStoryIds) {
2437
+ knownAbsentStories.add(storyBoardStoryKey(project, storyId));
2438
+ }
2457
2439
  }
2458
2440
  let resolvedWorker;
2459
2441
  if (opts.worker) {
@@ -2935,6 +2917,7 @@ async function runCreate(ctx, opts) {
2935
2917
  }
2936
2918
  : {}),
2937
2919
  keep_alive: keepAlive,
2920
+ ...(keepAlive ? { keep_alive_opted_in_at: createdAt } : {}),
2938
2921
  provider,
2939
2922
  account: pinnedAccount,
2940
2923
  ...(accountSelection !== undefined ? { account_selection: accountSelection } : {}),
@@ -3065,7 +3048,7 @@ async function runCreate(ctx, opts) {
3065
3048
  locateTranscript: ctx.locateTranscript,
3066
3049
  });
3067
3050
  if (meshCompany) {
3068
- createStoryWarnings.push(...await emitLaneStartedForStories(ctx, meshCompany, started, started.thread_id));
3051
+ createStoryWarnings.push(...await emitLaneStartedForStories(ctx, meshCompany, started, started.thread_id, knownAbsentStories));
3069
3052
  }
3070
3053
  }
3071
3054
  else if (outpostId) {
@@ -4493,15 +4476,7 @@ async function runShow(ctx, laneId) {
4493
4476
  ? { ...storedLane, state: "remote", remote_state: storedLane.state, queued_reason: undefined }
4494
4477
  : storedLane;
4495
4478
  const askDeliveryFailed = readJsonFile(askDeliveryFailedPath(hqRoot, laneId));
4496
- let keepAliveState;
4497
- try {
4498
- keepAliveState = resolveLaneKeepAlive(hqRoot, storedLane);
4499
- }
4500
- catch (err) {
4501
- const message = err instanceof Error ? err.message : String(err);
4502
- process.stderr.write(`hq lanes show: keep-alive profile could not be loaded for ${laneId}: ${message}\n`);
4503
- keepAliveState = { enabled: false, source: "none" };
4504
- }
4479
+ const keepAliveState = resolveLaneKeepAlive(hqRoot, storedLane);
4505
4480
  printJson({
4506
4481
  ok: true,
4507
4482
  action: "show",
@@ -4516,6 +4491,7 @@ async function runShow(ctx, laneId) {
4516
4491
  provider_session_id: lane.provider_session_id ?? null,
4517
4492
  provider_session_id_absent: lane.provider_session_id_absent ?? null,
4518
4493
  envelope_repair_mode: lane.envelope_repair?.mode ?? null,
4494
+ envelope_validation_errors: lane.envelope_validation_errors ?? [],
4519
4495
  last_resume: lane.last_resume ?? null,
4520
4496
  account: lane.account ?? null,
4521
4497
  account_history: lane.account_history ?? [],
@@ -4529,6 +4505,9 @@ async function runShow(ctx, laneId) {
4529
4505
  keep_alive_state: {
4530
4506
  enabled: keepAliveState.enabled,
4531
4507
  source: keepAliveState.source,
4508
+ opted_in_at: storedLane.keep_alive_opted_in_at ?? null,
4509
+ blocked_reason: storedLane.keep_alive_blocked_reason ??
4510
+ (storedLane.keep_alive === true ? null : "not_opted_in"),
4532
4511
  consecutive_auto_resumes: lane.keep_alive_resume_count ?? 0,
4533
4512
  last_auto_resume_at: lane.keep_alive_last_resume_at ?? null,
4534
4513
  next_eligible_at: lane.keep_alive_next_eligible_at ?? null,
@@ -4658,6 +4637,45 @@ function printGrantResult(hqRoot, updated, hierarchy) {
4658
4637
  const fields = laneHireFields(updated, hierarchy ?? loadAllLanes(hqRoot));
4659
4638
  printJson({ ok: true, action: "grant", lane_id: updated.lane_id, lane: updated, ...fields });
4660
4639
  }
4640
+ async function runKeepAlive(ctx, laneId, opts) {
4641
+ if (Boolean(opts.on) === Boolean(opts.off)) {
4642
+ throw new LanesError("keep_alive_mode_invalid", "Pass exactly one of --on or --off", { lane_id: laneId });
4643
+ }
4644
+ const hqRoot = ctx.hqRoot();
4645
+ const updated = await withAllLaneAdmissionLocks(hqRoot, async () => {
4646
+ const lane = readLane(hqRoot, laneId);
4647
+ if (!callerMatchesSenior(ctx, lane.senior)) {
4648
+ throw new LanesError("keep_alive_unauthorized", `Only senior ${seniorKey(lane.senior)} may change lane ${laneId}'s keep-alive setting`, { lane_id: laneId, senior: lane.senior });
4649
+ }
4650
+ if (!laneRunsHere(lane, reconcileHostFromCtx(ctx))) {
4651
+ throw new LanesError("keep_alive_owner_host_required", `Run hq lanes keep-alive ${laneId} on its owner host ${lane.host.kind}:${lane.host.id}`, { lane_id: laneId, host: lane.host });
4652
+ }
4653
+ if (lane.state === "killed" || lane.state === "cancelled" || lane.state === "failed") {
4654
+ throw new LanesError("keep_alive_terminal_lane", `Cannot change keep-alive for lane ${laneId} in terminal state ${lane.state}`, { lane_id: laneId, state: lane.state });
4655
+ }
4656
+ let optedInAt;
4657
+ if (opts.on) {
4658
+ const retiredLaneIds = loadRetiredLaneIds(hqRoot);
4659
+ const exclusion = keepAliveExclusion(lane, loadAllLanes(hqRoot), retiredLaneIds);
4660
+ if (exclusion !== undefined) {
4661
+ throw new LanesError("keep_alive_lane_excluded", `Cannot opt in lane ${laneId}: it is ${exclusion}`, { lane_id: laneId, blocked_reason: exclusion });
4662
+ }
4663
+ optedInAt = ctx.now().toISOString();
4664
+ }
4665
+ return updateLane(hqRoot, laneId, lane.version, "command", (current) => ({
4666
+ ...current,
4667
+ keep_alive: Boolean(opts.on),
4668
+ keep_alive_opted_in_at: optedInAt,
4669
+ keep_alive_blocked_reason: undefined,
4670
+ }));
4671
+ });
4672
+ printJson({
4673
+ ok: true,
4674
+ action: "keep-alive",
4675
+ lane_id: updated.lane_id,
4676
+ lane: updated,
4677
+ });
4678
+ }
4661
4679
  async function runGrant(ctx, laneId, opts) {
4662
4680
  if (opts.hire === undefined) {
4663
4681
  throw new LanesError("missing_hire_grant", "--hire is required", {});
@@ -4950,6 +4968,10 @@ function applyResumeProfileAndEffort(current, next, input) {
4950
4968
  next.worker_policy_delivery = undefined;
4951
4969
  if (profile.meta.keep_alive !== undefined) {
4952
4970
  next.keep_alive = profile.meta.keep_alive;
4971
+ next.keep_alive_opted_in_at = profile.meta.keep_alive
4972
+ ? input.at ?? new Date().toISOString()
4973
+ : undefined;
4974
+ next.keep_alive_blocked_reason = undefined;
4953
4975
  }
4954
4976
  if (next.model_source !== "cli") {
4955
4977
  if (profile.meta.provider === current.provider && profile.meta.model !== undefined) {
@@ -4966,8 +4988,13 @@ function applyResumeProfileAndEffort(current, next, input) {
4966
4988
  next.effort = input.effort.effort;
4967
4989
  next.effort_source = input.effort.source;
4968
4990
  }
4969
- if (input.keepAlive !== undefined)
4991
+ if (input.keepAlive !== undefined) {
4970
4992
  next.keep_alive = input.keepAlive;
4993
+ next.keep_alive_opted_in_at = input.keepAlive
4994
+ ? input.at ?? new Date().toISOString()
4995
+ : undefined;
4996
+ next.keep_alive_blocked_reason = undefined;
4997
+ }
4971
4998
  }
4972
4999
  function recordResumeSetup(ctx, lane, input) {
4973
5000
  if (input.cliModel !== undefined) {
@@ -4998,7 +5025,7 @@ function recordResumeSetup(ctx, lane, input) {
4998
5025
  throw new LanesError("lane_resume_setup_conflict", `Lane ${lane.lane_id} changed while resume settings were prepared; run the resume again`, { lane_id: lane.lane_id, prepared_version: lane.version, version: current.version });
4999
5026
  }
5000
5027
  const next = { ...current };
5001
- applyResumeProfileAndEffort(current, next, input);
5028
+ applyResumeProfileAndEffort(current, next, { ...input, at: ctx.now().toISOString() });
5002
5029
  if (input.cliModel !== undefined) {
5003
5030
  next.model = input.cliModel.trim();
5004
5031
  next.model_source = "cli";
@@ -5237,6 +5264,7 @@ async function prepareAccountMove(ctx, lane, input) {
5237
5264
  applyResumeProfileAndEffort(current, next, {
5238
5265
  ...(input.workerProfile !== undefined ? { profile: input.workerProfile } : {}),
5239
5266
  ...(input.effort !== undefined ? { effort: input.effort } : {}),
5267
+ at,
5240
5268
  });
5241
5269
  if (input.deferTurnStart) {
5242
5270
  next.state = current.state;
@@ -6281,7 +6309,7 @@ async function ensureLaneThreadRecord(ctx, lane, projectId = lane.project_id, st
6281
6309
  }
6282
6310
  return updated;
6283
6311
  }
6284
- async function emitStoryTransition(ctx, lane, projectId, storyId, event, status, payload = {}) {
6312
+ async function emitStoryTransition(ctx, lane, projectId, storyId, event, status, payload = {}, skipStoryStatusPatch = false) {
6285
6313
  const company = lane.mesh_company ?? lane.company;
6286
6314
  if (!company)
6287
6315
  return;
@@ -6295,7 +6323,9 @@ async function emitStoryTransition(ctx, lane, projectId, storyId, event, status,
6295
6323
  ...payload,
6296
6324
  lane_status: lane.state,
6297
6325
  });
6298
- await ctx.mesh.patchStoryStatus(company, projectId, storyId, status === "blocked" ? "in_progress" : status);
6326
+ if (!skipStoryStatusPatch) {
6327
+ await ctx.mesh.patchStoryStatus(company, projectId, storyId, status === "blocked" ? "in_progress" : status);
6328
+ }
6299
6329
  }
6300
6330
  function sameCommittedStory(current, expected, projectId, storyId) {
6301
6331
  const currentStory = laneStory(current, projectId, storyId);
@@ -6304,9 +6334,9 @@ function sameCommittedStory(current, expected, projectId, storyId) {
6304
6334
  current.story_id === expected.story_id &&
6305
6335
  JSON.stringify(currentStory) === JSON.stringify(expectedStory));
6306
6336
  }
6307
- async function commitStoryTransition(ctx, before, next, projectId, storyId, event, status, payload = {}) {
6337
+ async function commitStoryTransition(ctx, before, next, projectId, storyId, event, status, payload = {}, skipStoryStatusPatch = false) {
6308
6338
  try {
6309
- await emitStoryTransition(ctx, next, projectId, storyId, event, status, payload);
6339
+ await emitStoryTransition(ctx, next, projectId, storyId, event, status, payload, skipStoryStatusPatch);
6310
6340
  return undefined;
6311
6341
  }
6312
6342
  catch (err) {
@@ -6362,10 +6392,12 @@ async function runStoryAdd(ctx, laneId, storyId, opts) {
6362
6392
  }
6363
6393
  const company = lane.mesh_company ?? lane.company;
6364
6394
  const warnings = [];
6395
+ let storyKnownAbsent = false;
6365
6396
  if (company) {
6366
- const warning = await storyBoardWarning(ctx, company, projectId, [normalizedStoryId]);
6367
- if (warning !== undefined)
6368
- warnings.push(warning);
6397
+ const boardCheck = await storyBoardCheck(ctx, company, projectId, [normalizedStoryId]);
6398
+ if (boardCheck.warning !== undefined)
6399
+ warnings.push(boardCheck.warning);
6400
+ storyKnownAbsent = boardCheck.absentStoryIds.has(normalizedStoryId);
6369
6401
  }
6370
6402
  const at = ctx.now().toISOString();
6371
6403
  const thread = await ensureStoryThreadForTransition(ctx, lane, projectId, normalizedStoryId);
@@ -6381,7 +6413,7 @@ async function runStoryAdd(ctx, laneId, storyId, opts) {
6381
6413
  no_story_reason: undefined,
6382
6414
  };
6383
6415
  });
6384
- const transitionWarning = await commitStoryTransition(ctx, before, next, projectId, normalizedStoryId, "lane_started", "in_progress");
6416
+ const transitionWarning = await commitStoryTransition(ctx, before, next, projectId, normalizedStoryId, "lane_started", "in_progress", {}, storyKnownAbsent);
6385
6417
  if (transitionWarning !== undefined)
6386
6418
  warnings.push(transitionWarning);
6387
6419
  const finalLane = readLane(hqRoot, laneId);
@@ -8388,6 +8420,16 @@ export function registerLanesCommand(parent, deps = {}) {
8388
8420
  .action(wrap(async (laneId, opts) => {
8389
8421
  await runGrantOwnerTarget(ctx, laneId, opts);
8390
8422
  }));
8423
+ lanes
8424
+ .command("keep-alive")
8425
+ .description("Opt a lane into or out of automatic lead-lane resumes")
8426
+ .argument("<lane-id>", "Lane id")
8427
+ .option("--on", "Enable explicit keep-alive for this lane")
8428
+ .option("--off", "Disable keep-alive for this lane")
8429
+ .option("--json", "Emit JSON on stdout (default)")
8430
+ .action(wrap(async (laneId, opts) => {
8431
+ await runKeepAlive(ctx, laneId, opts);
8432
+ }));
8391
8433
  lanes
8392
8434
  .command("grant")
8393
8435
  .description("Set a lane's alive child-lane grant as its senior")
@@ -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, terminalChildResumeHireGrantError, withHireGrantAdmission, } from "./hire-grants.js";
6
+ export { assignedChildHireGrants, aliveDirectChildren, hireGrantCapacityError, 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, terminalChildResumeHireGrantError, withHireGrantAdmission, } from "./hire-grants.js";
6
+ export { assignedChildHireGrants, aliveDirectChildren, hireGrantCapacityError, 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";
@@ -1,8 +1,17 @@
1
+ import type { LaneRecord } from "./types.js";
1
2
  export declare const LEAD_LANE_KEEP_ALIVE_FLAG = "lanes.lead-keep-alive";
2
3
  export declare const LEAD_LANE_KEEP_ALIVE_DEFAULT_ENABLED = false;
3
4
  export declare const LEAD_LANE_KEEP_ALIVE_MAX_RESUMES = 24;
4
5
  export declare const LEAD_LANE_KEEP_ALIVE_BASE_BACKOFF_MS: number;
5
6
  export declare const LEAD_LANE_KEEP_ALIVE_MAX_BACKOFF_MS: number;
6
7
  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.";
8
+ export type KeepAliveBlockedReason = NonNullable<LaneRecord["keep_alive_blocked_reason"]>;
9
+ export declare function retiredLaneRegistryPath(hqRoot: string): string;
10
+ /** Missing registry means no retired lanes; every present invalid file fails closed. */
11
+ export declare function loadRetiredLaneIds(hqRoot: string): ReadonlySet<string>;
12
+ /** IDs may be stored bare or with an owner-host suffix after an underscore. */
13
+ export declare function isRetiredLaneId(laneId: string, retiredLaneIds: ReadonlySet<string>): boolean;
14
+ export declare function keepAliveSupersededBy(lane: LaneRecord, lanes: readonly LaneRecord[]): LaneRecord | undefined;
15
+ export declare function keepAliveExclusion(lane: LaneRecord, lanes: readonly LaneRecord[], retiredLaneIds: ReadonlySet<string>): "retired" | "superseded" | undefined;
7
16
  export declare function leadLaneKeepAliveBackoffMs(attempt: number): number;
8
17
  //# sourceMappingURL=keep-alive.d.ts.map
@@ -1,9 +1,110 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { LanesError } from "./errors.js";
1
4
  export const LEAD_LANE_KEEP_ALIVE_FLAG = "lanes.lead-keep-alive";
2
5
  export const LEAD_LANE_KEEP_ALIVE_DEFAULT_ENABLED = false;
3
6
  export const LEAD_LANE_KEEP_ALIVE_MAX_RESUMES = 24;
4
7
  export const LEAD_LANE_KEEP_ALIVE_BASE_BACKOFF_MS = 5 * 60 * 1000;
5
8
  export const LEAD_LANE_KEEP_ALIVE_MAX_BACKOFF_MS = 60 * 60 * 1000;
6
9
  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.";
10
+ export function retiredLaneRegistryPath(hqRoot) {
11
+ return path.join(hqRoot, "workspace", "lane-briefs", "juniors.json");
12
+ }
13
+ /** Missing registry means no retired lanes; every present invalid file fails closed. */
14
+ export function loadRetiredLaneIds(hqRoot) {
15
+ const filePath = retiredLaneRegistryPath(hqRoot);
16
+ let text;
17
+ try {
18
+ const stat = fs.lstatSync(filePath);
19
+ if (!stat.isFile() || stat.isSymbolicLink()) {
20
+ throw new Error("expected a regular file");
21
+ }
22
+ text = fs.readFileSync(filePath, "utf8");
23
+ }
24
+ catch (err) {
25
+ if (err.code === "ENOENT")
26
+ return new Set();
27
+ throw new LanesError("keep_alive_retired_list_invalid", `Cannot read retired lane list ${filePath}: ${err instanceof Error ? err.message : String(err)}`, { path: filePath });
28
+ }
29
+ let parsed;
30
+ try {
31
+ parsed = JSON.parse(text);
32
+ }
33
+ catch (err) {
34
+ throw new LanesError("keep_alive_retired_list_invalid", `Cannot parse retired lane list ${filePath}: ${err instanceof Error ? err.message : String(err)}`, { path: filePath });
35
+ }
36
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
37
+ throw invalidRetiredList(filePath, "root must be an object");
38
+ }
39
+ const retired = parsed.retired;
40
+ if (retired === undefined)
41
+ return new Set();
42
+ if (!Array.isArray(retired))
43
+ throw invalidRetiredList(filePath, "retired must be an array");
44
+ const laneIds = new Set();
45
+ for (const [index, entry] of retired.entries()) {
46
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
47
+ throw invalidRetiredList(filePath, `retired[${index}] must be an object with a lane id`);
48
+ }
49
+ const laneId = entry.lane;
50
+ if (typeof laneId !== "string" || laneId.trim() === "") {
51
+ throw invalidRetiredList(filePath, `retired[${index}].lane must be a non-empty string`);
52
+ }
53
+ laneIds.add(laneId.trim());
54
+ }
55
+ return laneIds;
56
+ }
57
+ function invalidRetiredList(filePath, message) {
58
+ return new LanesError("keep_alive_retired_list_invalid", `Invalid retired lane list ${filePath}: ${message}`, { path: filePath });
59
+ }
60
+ /** IDs may be stored bare or with an owner-host suffix after an underscore. */
61
+ export function isRetiredLaneId(laneId, retiredLaneIds) {
62
+ for (const retiredId of retiredLaneIds) {
63
+ if (retiredId === laneId ||
64
+ retiredId.startsWith(`${laneId}_`) ||
65
+ laneId.startsWith(`${retiredId}_`))
66
+ return true;
67
+ }
68
+ return false;
69
+ }
70
+ export function keepAliveSupersededBy(lane, lanes) {
71
+ const createdAt = Date.parse(lane.created_at);
72
+ if (!Number.isFinite(createdAt))
73
+ return undefined;
74
+ const lanesById = new Map(lanes.map((candidate) => [candidate.lane_id, candidate]));
75
+ return lanes.find((candidate) => {
76
+ if (candidate.lane_id === lane.lane_id || Date.parse(candidate.created_at) <= createdAt)
77
+ return false;
78
+ const sameCompany = lane.company === null
79
+ ? candidate.company === null
80
+ : candidate.company?.uid === lane.company.uid;
81
+ if (!sameCompany)
82
+ return false;
83
+ // Any descendant is part of this lead's work tree, not a replacement lead.
84
+ let parentLaneId = candidate.senior.kind === "lane" ? candidate.senior.id : undefined;
85
+ const visitedLaneIds = new Set();
86
+ while (parentLaneId) {
87
+ if (parentLaneId === lane.lane_id)
88
+ return false;
89
+ if (visitedLaneIds.has(parentLaneId))
90
+ break;
91
+ visitedLaneIds.add(parentLaneId);
92
+ const parent = lanesById.get(parentLaneId);
93
+ parentLaneId = parent?.senior.kind === "lane" ? parent.senior.id : undefined;
94
+ }
95
+ const sameProfile = lane.worker_profile !== undefined &&
96
+ candidate.worker_profile === lane.worker_profile;
97
+ const projectLead = lane.project_id !== undefined &&
98
+ candidate.project_id === lane.project_id &&
99
+ (candidate.hire_grant ?? 0) > 0;
100
+ return sameProfile || projectLead;
101
+ });
102
+ }
103
+ export function keepAliveExclusion(lane, lanes, retiredLaneIds) {
104
+ if (isRetiredLaneId(lane.lane_id, retiredLaneIds))
105
+ return "retired";
106
+ return keepAliveSupersededBy(lane, lanes) === undefined ? undefined : "superseded";
107
+ }
7
108
  export function leadLaneKeepAliveBackoffMs(attempt) {
8
109
  if (!Number.isSafeInteger(attempt) || attempt < 1) {
9
110
  throw new RangeError("keep-alive attempt must be a positive safe integer");
@@ -92,6 +92,7 @@ export interface CreateLaneMeshOptions {
92
92
  }) => Promise<void>;
93
93
  }
94
94
  export declare function storyNotOnBoardError(company: LaneCompanyRef, projectId: string, storyId: string): LanesError;
95
+ export declare function laneStoryStatusSuppressionKey(company: LaneCompanyRef, projectId: string, storyId: string): string;
95
96
  export declare function createLaneMesh(opts?: CreateLaneMeshOptions): LaneMesh;
96
97
  /** Resolve the explicit mesh attribution before falling back to the lane tenant. */
97
98
  export declare function laneMeshCompany(lane: Pick<LaneRecord, "company" | "mesh_company">): LaneCompanyRef | undefined;
@@ -106,6 +107,8 @@ export declare function laneMeshEventIdentity(lane: LaneRecord): Record<string,
106
107
  export declare function emitLaneDecisionEvents(mesh: LaneMesh, lane: LaneRecord, envelope: Pick<LaneEnvelope, "decision" | "summary" | "blocked_reason">, opts?: {
107
108
  questionIds?: string[];
108
109
  signal?: AbortSignal;
110
+ /** Per-watcher-run cache for story_not_on_board refusals. */
111
+ storyStatusSuppressions?: Set<string>;
109
112
  }): Promise<void>;
110
113
  /** Emit an abnormal terminal state even when the worker produced no envelope. */
111
114
  export declare function emitLaneTerminalStateEvent(mesh: LaneMesh, lane: LaneRecord, state: Exclude<TerminalLaneState, "done">, summary?: string, signal?: AbortSignal): Promise<void>;