@indigoai-us/hq-cli 5.229.1 → 5.230.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,26 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.230.0] — 2026-09-27
6
+
7
+ ### Added
8
+
9
+ - Behind the off-by-default `checkpoint.ontology.no-link-aliases` flag, a
10
+ checkpoint ontology candidate that links to an existing entity no longer adds
11
+ aliases to that entity's file. The plan records `link_aliases: false` and
12
+ apply reports the link without writing. New entities keep their aliases.
13
+ Most aliases written through links were other names the text mentioned next
14
+ to the entity, such as `Publisher` on `indigo`.
15
+
16
+ ## [5.229.2] — 2026-09-27
17
+
18
+ ### Fixed
19
+
20
+ - Standing keep-alive leads now wait after done turns for drop-box messages,
21
+ undelivered answers, or pending resumes; queued answers are included in the
22
+ next turn.
23
+ - An unreadable question record no longer aborts reconciliation of other lanes.
24
+
5
25
  ## [5.229.1] — 2026-09-27
6
26
 
7
27
  ### Fixed
@@ -7541,6 +7541,9 @@ export declare const COMMAND_CATALOG: readonly [{
7541
7541
  }, {
7542
7542
  readonly flags: "--off";
7543
7543
  readonly description: "Disable keep-alive for this lane";
7544
+ }, {
7545
+ readonly flags: "--standing";
7546
+ readonly description: "Wait for queued work after every done turn; requires --on";
7544
7547
  }, {
7545
7548
  readonly flags: "--json";
7546
7549
  readonly description: "Emit JSON on stdout (default)";
@@ -9750,6 +9750,10 @@ export const COMMAND_CATALOG = [
9750
9750
  "flags": "--off",
9751
9751
  "description": "Disable keep-alive for this lane"
9752
9752
  },
9753
+ {
9754
+ "flags": "--standing",
9755
+ "description": "Wait for queued work after every done turn; requires --on"
9756
+ },
9753
9757
  {
9754
9758
  "flags": "--json",
9755
9759
  "description": "Emit JSON on stdout (default)"
@@ -4975,6 +4975,7 @@ async function runShow(ctx, laneId) {
4975
4975
  keep_alive_state: {
4976
4976
  enabled: keepAliveState.enabled,
4977
4977
  source: keepAliveState.source,
4978
+ standing: storedLane.keep_alive_standing === true,
4978
4979
  opted_in_at: storedLane.keep_alive_opted_in_at ?? null,
4979
4980
  blocked_reason: storedLane.keep_alive_blocked_reason ??
4980
4981
  (storedLane.keep_alive === true ? null : "not_opted_in"),
@@ -5104,6 +5105,9 @@ function printGrantResult(hqRoot, updated, hierarchy) {
5104
5105
  printJson({ ok: true, action: "grant", lane_id: updated.lane_id, lane: updated, ...fields });
5105
5106
  }
5106
5107
  async function runKeepAlive(ctx, laneId, opts) {
5108
+ if (opts.standing && !opts.on) {
5109
+ throw new LanesError("keep_alive_standing_requires_on", "Pass --standing together with --on", { lane_id: laneId });
5110
+ }
5107
5111
  if (Boolean(opts.on) === Boolean(opts.off)) {
5108
5112
  throw new LanesError("keep_alive_mode_invalid", "Pass exactly one of --on or --off", { lane_id: laneId });
5109
5113
  }
@@ -5131,6 +5135,7 @@ async function runKeepAlive(ctx, laneId, opts) {
5131
5135
  return updateLane(hqRoot, laneId, lane.version, "command", (current) => ({
5132
5136
  ...current,
5133
5137
  keep_alive: Boolean(opts.on),
5138
+ keep_alive_standing: Boolean(opts.on && opts.standing),
5134
5139
  keep_alive_opted_in_at: optedInAt,
5135
5140
  keep_alive_blocked_reason: undefined,
5136
5141
  }));
@@ -9329,6 +9334,7 @@ export function registerLanesCommand(parent, deps = {}) {
9329
9334
  .argument("<lane-id>", "Lane id")
9330
9335
  .option("--on", "Enable explicit keep-alive for this lane")
9331
9336
  .option("--off", "Disable keep-alive for this lane")
9337
+ .option("--standing", "Wait for queued work after every done turn; requires --on")
9332
9338
  .option("--json", "Emit JSON on stdout (default)")
9333
9339
  .action(wrap(async (laneId, opts) => {
9334
9340
  await runKeepAlive(ctx, laneId, opts);
@@ -286,6 +286,9 @@ export function applyOntologyRun(options) {
286
286
  report.suspect_duplicates.push({ name: canonical, type: candidate.type, paths: [resolved.target, ...resolved.duplicates] });
287
287
  continue;
288
288
  }
289
+ // checkpoint.ontology.no-link-aliases: the link is reported and the entity file is left as it is.
290
+ if (plan.link_aliases === false)
291
+ continue;
289
292
  const pending = updates.get(resolved.target);
290
293
  if (!pending && updates.size >= CAPS.updates) {
291
294
  note(report.deferred, "cap_updates");
@@ -39,6 +39,15 @@ export declare const MULTI_WRITER_FLAG = "checkpoint.ontology.multi-writer";
39
39
  * runs were model errors, 5 of them duplicates of an existing entity.
40
40
  */
41
41
  export declare const ENTITY_HINTS_FLAG = "checkpoint.ontology.entity-hints";
42
+ /**
43
+ * A candidate that links to an existing entity adds no aliases to it; the plan
44
+ * carries `link_aliases: false` and apply honours it. CO-42: most aliases
45
+ * written through links on the CO-41 runs were other things the text named
46
+ * next to the entity (`Publisher` on indigo, `Stripe Connect` on Stripe), and
47
+ * entity-hints multiplied them (22 aliases on a flag-off run, 45 and 125 on two
48
+ * hinted runs).
49
+ */
50
+ export declare const NO_LINK_ALIASES_FLAG = "checkpoint.ontology.no-link-aliases";
42
51
  export declare const PLAN_FILE = "ontology-plan.json";
43
52
  export declare const CANDIDATES_FILE = "ontology-candidates.json";
44
53
  export declare const REPORT_FILE = "ontology-report.json";
@@ -105,6 +114,8 @@ export interface OntologyPlan {
105
114
  };
106
115
  /** Present only when `checkpoint.ontology.multi-writer` was on for this plan. */
107
116
  multi_writer?: MultiWriterStart;
117
+ /** Present, as false, only when `checkpoint.ontology.no-link-aliases` was on for this plan. */
118
+ link_aliases?: false;
108
119
  inputs: PlanInput[];
109
120
  /** The session's summary, decisions and learnings, for the `session` input. */
110
121
  session_text: string | null;
@@ -43,6 +43,15 @@ export const MULTI_WRITER_FLAG = "checkpoint.ontology.multi-writer";
43
43
  * runs were model errors, 5 of them duplicates of an existing entity.
44
44
  */
45
45
  export const ENTITY_HINTS_FLAG = "checkpoint.ontology.entity-hints";
46
+ /**
47
+ * A candidate that links to an existing entity adds no aliases to it; the plan
48
+ * carries `link_aliases: false` and apply honours it. CO-42: most aliases
49
+ * written through links on the CO-41 runs were other things the text named
50
+ * next to the entity (`Publisher` on indigo, `Stripe Connect` on Stripe), and
51
+ * entity-hints multiplied them (22 aliases on a flag-off run, 45 and 125 on two
52
+ * hinted runs).
53
+ */
54
+ export const NO_LINK_ALIASES_FLAG = "checkpoint.ontology.no-link-aliases";
46
55
  export const PLAN_FILE = "ontology-plan.json";
47
56
  export const CANDIDATES_FILE = "ontology-candidates.json";
48
57
  export const REPORT_FILE = "ontology-report.json";
@@ -219,6 +228,7 @@ export async function planOntologyRun(options) {
219
228
  recordBackendPreference(hqRoot, company, await options.readFlag(GROK_BACKEND_FLAG, company));
220
229
  const multiWriterOn = await options.readFlag(MULTI_WRITER_FLAG, company);
221
230
  const hintsOn = await options.readFlag(ENTITY_HINTS_FLAG, company);
231
+ const noLinkAliases = await options.readFlag(NO_LINK_ALIASES_FLAG, company);
222
232
  const touched = sessionTouched(hqRoot, company, payload.files);
223
233
  // A first run takes its boundary from the entity tree, so the ledger is
224
234
  // refreshed before merge-back moves any `last_updated` forward.
@@ -331,6 +341,7 @@ export async function planOntologyRun(options) {
331
341
  }
332
342
  : {}),
333
343
  ...(multiWriter ? { multi_writer: multiWriter } : {}),
344
+ ...(noLinkAliases ? { link_aliases: false } : {}),
334
345
  inputs,
335
346
  session_text: sessionText,
336
347
  pending_after_run: remaining,
@@ -1,4 +1,4 @@
1
- import type { LaneRecord } from "./types.js";
1
+ import type { LaneRecord, QuestionRecord } from "./types.js";
2
2
  export declare const LEAD_LANE_KEEP_ALIVE_FLAG = "lanes.lead-keep-alive";
3
3
  export declare const LEAD_LANE_KEEP_ALIVE_DEFAULT_ENABLED = false;
4
4
  export declare const LEAD_LANE_KEEP_ALIVE_MAX_RESUMES = 24;
@@ -7,6 +7,21 @@ export declare const LEAD_LANE_KEEP_ALIVE_MAX_BACKOFF_MS: number;
7
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
8
  export declare const LEAD_LANE_KEEP_ALIVE_INTERRUPTED_RESUME_TEXT = "Automatic keep-alive resume: your previous turn was interrupted by a provider process exit. Check your last state before continuing. 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.";
9
9
  export type KeepAliveBlockedReason = NonNullable<LaneRecord["keep_alive_blocked_reason"]>;
10
+ /** The latest terminal envelope fields used by both watch and reconcile. */
11
+ export interface KeepAliveDoneEnvelope {
12
+ decision?: string;
13
+ story_complete?: boolean;
14
+ }
15
+ /**
16
+ * Shared done-state policy: ordinary opted-in lanes resume open stories;
17
+ * standing leads wait for queued work, even when each turn marks the story
18
+ * complete. Watch and reconcile both use this decision.
19
+ */
20
+ export declare function canResumeDoneKeepAliveLane(lane: Pick<LaneRecord, "keep_alive_standing">, envelope: KeepAliveDoneEnvelope, hasQueuedWork: boolean): boolean;
21
+ /** Work signals already persisted for a lane that ended its turn. */
22
+ export declare function hasPendingKeepAliveWork(hqRoot: string, lane: Pick<LaneRecord, "lane_id" | "company" | "pending_resume">, questionRecords?: readonly QuestionRecord[]): boolean;
23
+ /** Undelivered answers that belong to this lane and company. */
24
+ export declare function undeliveredAnsweredQuestions(lane: Pick<LaneRecord, "lane_id" | "company">, questions: readonly QuestionRecord[]): QuestionRecord[];
10
25
  export declare function retiredLaneRegistryPath(hqRoot: string): string;
11
26
  /** Missing registry means no retired lanes; every present invalid file fails closed. */
12
27
  export declare function loadRetiredLaneIds(hqRoot: string): ReadonlySet<string>;
@@ -1,6 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { LanesError } from "./errors.js";
4
+ import { dropboxInboxHasMessages } from "./dropbox.js";
5
+ import { listQuestionIds, readQuestion } from "./store.js";
4
6
  export const LEAD_LANE_KEEP_ALIVE_FLAG = "lanes.lead-keep-alive";
5
7
  export const LEAD_LANE_KEEP_ALIVE_DEFAULT_ENABLED = false;
6
8
  export const LEAD_LANE_KEEP_ALIVE_MAX_RESUMES = 24;
@@ -8,6 +10,55 @@ export const LEAD_LANE_KEEP_ALIVE_BASE_BACKOFF_MS = 5 * 60 * 1000;
8
10
  export const LEAD_LANE_KEEP_ALIVE_MAX_BACKOFF_MS = 60 * 60 * 1000;
9
11
  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
12
  export const LEAD_LANE_KEEP_ALIVE_INTERRUPTED_RESUME_TEXT = "Automatic keep-alive resume: your previous turn was interrupted by a provider process exit. Check your last state before continuing. 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.";
13
+ /**
14
+ * Shared done-state policy: ordinary opted-in lanes resume open stories;
15
+ * standing leads wait for queued work, even when each turn marks the story
16
+ * complete. Watch and reconcile both use this decision.
17
+ */
18
+ export function canResumeDoneKeepAliveLane(lane, envelope, hasQueuedWork) {
19
+ if (envelope.decision !== "done")
20
+ return false;
21
+ return lane.keep_alive_standing === true
22
+ ? hasQueuedWork
23
+ : envelope.story_complete !== true;
24
+ }
25
+ /** Work signals already persisted for a lane that ended its turn. */
26
+ export function hasPendingKeepAliveWork(hqRoot, lane, questionRecords) {
27
+ if (lane.pending_resume !== undefined)
28
+ return true;
29
+ if (dropboxInboxHasMessages(hqRoot, lane.lane_id))
30
+ return true;
31
+ const questions = questionRecords ?? readKeepAliveQuestionRecords(hqRoot);
32
+ return undeliveredAnsweredQuestions(lane, questions).length > 0;
33
+ }
34
+ /** Undelivered answers that belong to this lane and company. */
35
+ export function undeliveredAnsweredQuestions(lane, questions) {
36
+ return questions.filter((question) => {
37
+ const sameCompany = question.company === null
38
+ ? lane.company === null
39
+ : lane.company !== null &&
40
+ question.company.uid === lane.company.uid &&
41
+ question.company.slug === lane.company.slug;
42
+ return (question.lane_id === lane.lane_id &&
43
+ question.origin.id === lane.lane_id &&
44
+ sameCompany &&
45
+ question.status === "answered" &&
46
+ question.answer !== undefined &&
47
+ (question.delivered_at === null || question.delivered_at === undefined));
48
+ });
49
+ }
50
+ function readKeepAliveQuestionRecords(hqRoot) {
51
+ const questions = [];
52
+ for (const questionId of listQuestionIds(hqRoot)) {
53
+ try {
54
+ questions.push(readQuestion(hqRoot, questionId));
55
+ }
56
+ catch (err) {
57
+ process.stderr.write(`hq lanes keep-alive: skipped unreadable question ${questionId}: ${err instanceof Error ? err.message : String(err)}\n`);
58
+ }
59
+ }
60
+ return questions;
61
+ }
11
62
  export function retiredLaneRegistryPath(hqRoot) {
12
63
  return path.join(hqRoot, "workspace", "lane-briefs", "juniors.json");
13
64
  }
@@ -21,7 +21,7 @@ import { ANSWERED_QUESTIONS_FILE, armLaneWatcher, envelopeRepairMode, isPidRecor
21
21
  import { appendCappedHistory, lanesStoreRoot, parseLaneRecord, parseQuestionRecord, readLane, readQuestion, updateLane, updateQuestion, } from "./store.js";
22
22
  import { applyThrottleDecision, decideAdmission, defaultThrottleDeps, emptyThrottleState, evaluateThrottle, isCpuCheckEnabled, readAvailableMemMb, readThrottleState, resolveCpuBusyFraction, throttleStatePath, writeThrottleState, } from "./throttle.js";
23
23
  import { readLanesRegistryFlag } from "./registry-flag.js";
24
- import { LEAD_LANE_KEEP_ALIVE_FLAG, LEAD_LANE_KEEP_ALIVE_INTERRUPTED_RESUME_TEXT, LEAD_LANE_KEEP_ALIVE_MAX_RESUMES, LEAD_LANE_KEEP_ALIVE_RESUME_TEXT, keepAliveExclusion, loadRetiredLaneIds, leadLaneKeepAliveBackoffMs, } from "./keep-alive.js";
24
+ import { LEAD_LANE_KEEP_ALIVE_FLAG, LEAD_LANE_KEEP_ALIVE_INTERRUPTED_RESUME_TEXT, LEAD_LANE_KEEP_ALIVE_MAX_RESUMES, LEAD_LANE_KEEP_ALIVE_RESUME_TEXT, canResumeDoneKeepAliveLane, hasPendingKeepAliveWork, keepAliveExclusion, loadRetiredLaneIds, leadLaneKeepAliveBackoffMs, undeliveredAnsweredQuestions, } from "./keep-alive.js";
25
25
  import { providerSupportsSessionResume } from "./delivery.js";
26
26
  import { applyDefaultQuotaHold, readQuotaSignal, recordAccountQuota } from "./quota.js";
27
27
  import { answeredQuestionEntries, questionRecordRefusalCode, raiseEnvelopeQuestions, resolveQuestionCompanyWithFallback, } from "./questions.js";
@@ -818,7 +818,10 @@ export async function runPassBody(hqRoot, deps) {
818
818
  continue;
819
819
  if (lane.state === "done") {
820
820
  const envelope = readLatestEnvelopeRecord(hqRoot, lane.lane_id)?.envelope;
821
- if (!envelope || envelope.decision !== "done" || envelope.story_complete === true)
821
+ if (!envelope || envelope.decision !== "done")
822
+ continue;
823
+ const hasQueuedWork = lane.keep_alive_standing === true && hasPendingKeepAliveWork(hqRoot, lane, questions);
824
+ if (!canResumeDoneKeepAliveLane(lane, envelope, hasQueuedWork))
822
825
  continue;
823
826
  }
824
827
  const key = keepAliveCompanyKey(lane.company);
@@ -834,9 +837,9 @@ export async function runPassBody(hqRoot, deps) {
834
837
  }));
835
838
  await withAllLaneAdmissionLocks(hqRoot, async () => {
836
839
  const freshLanes = loadLaneRecords(hqRoot, summary);
837
- resumeKeepAlive(hqRoot, freshLanes, summary, deps, now, keepAliveFlagByCompany);
838
- const drainLanes = loadLaneRecords(hqRoot, summary);
839
840
  const freshQuestions = loadQuestionRecords(hqRoot, summary);
841
+ resumeKeepAlive(hqRoot, freshLanes, freshQuestions, summary, deps, now, keepAliveFlagByCompany);
842
+ const drainLanes = loadLaneRecords(hqRoot, summary);
840
843
  await admitQueued(hqRoot, drainLanes, summary, deps, now);
841
844
  await resumeAwaiting(hqRoot, drainLanes, freshQuestions, summary, deps);
842
845
  });
@@ -1532,7 +1535,7 @@ function latestEnvelopeMtimeMs(hqRoot, lane, ref) {
1532
1535
  throw new LanesError("keep_alive_envelope_timestamp_unavailable", `Cannot stat latest envelope for lane ${lane.lane_id}: ${err instanceof Error ? err.message : String(err)}`, { lane_id: lane.lane_id, path: filePath });
1533
1536
  }
1534
1537
  }
1535
- function resumeKeepAlive(hqRoot, lanes, summary, deps, now, flagByCompany) {
1538
+ function resumeKeepAlive(hqRoot, lanes, questions, summary, deps, now, flagByCompany) {
1536
1539
  const nowMs = now.getTime();
1537
1540
  const nowIso = now.toISOString();
1538
1541
  const config = loadLanesConfig(hqRoot);
@@ -1566,8 +1569,9 @@ function resumeKeepAlive(hqRoot, lanes, summary, deps, now, flagByCompany) {
1566
1569
  const envelope = readLatestEnvelopeRecord(hqRoot, lane.lane_id)?.envelope;
1567
1570
  if (!envelope || envelope.decision !== "done")
1568
1571
  continue;
1569
- if (envelope.story_complete === true) {
1570
- if (hasKeepAliveProgress(lane)) {
1572
+ const hasQueuedWork = lane.keep_alive_standing === true && hasPendingKeepAliveWork(hqRoot, lane, questions);
1573
+ if (!canResumeDoneKeepAliveLane(lane, envelope, hasQueuedWork)) {
1574
+ if (envelope.story_complete === true && lane.keep_alive_standing !== true && hasKeepAliveProgress(lane)) {
1571
1575
  casLaneIf(hqRoot, lane.lane_id, (current) => current.version === lane.version &&
1572
1576
  current.state === "done" &&
1573
1577
  current.keep_alive === lane.keep_alive, (current) => ({ ...clearedKeepAliveProgress(current), keep_alive_blocked_reason: undefined }), deps.dryRun);
@@ -1652,7 +1656,10 @@ function resumeKeepAlive(hqRoot, lanes, summary, deps, now, flagByCompany) {
1652
1656
  let eligibilityAtMs;
1653
1657
  if (initialState === "done") {
1654
1658
  const latest = readLatestEnvelopeRecord(hqRoot, lane.lane_id);
1655
- if (!latest || latest.envelope.decision !== "done" || latest.envelope.story_complete === true)
1659
+ if (!latest || latest.envelope.decision !== "done")
1660
+ continue;
1661
+ const hasQueuedWork = lane.keep_alive_standing === true && hasPendingKeepAliveWork(hqRoot, lane, questions);
1662
+ if (!canResumeDoneKeepAliveLane(lane, latest.envelope, hasQueuedWork))
1656
1663
  continue;
1657
1664
  try {
1658
1665
  eligibilityAtMs = latestEnvelopeMtimeMs(hqRoot, lane, latest.ref);
@@ -1726,8 +1733,9 @@ function resumeKeepAlive(hqRoot, lanes, summary, deps, now, flagByCompany) {
1726
1733
  const currentEnvelope = readLatestEnvelopeRecord(hqRoot, current.lane_id);
1727
1734
  if (!currentEnvelope || currentEnvelope.envelope.decision !== "done")
1728
1735
  continue;
1729
- if (currentEnvelope.envelope.story_complete === true) {
1730
- if (hasKeepAliveProgress(current)) {
1736
+ const hasQueuedWork = current.keep_alive_standing === true && hasPendingKeepAliveWork(hqRoot, current, questions);
1737
+ if (!canResumeDoneKeepAliveLane(current, currentEnvelope.envelope, hasQueuedWork)) {
1738
+ if (currentEnvelope.envelope.story_complete === true && current.keep_alive_standing !== true && hasKeepAliveProgress(current)) {
1731
1739
  casLaneIf(hqRoot, current.lane_id, (latestLane) => latestLane.version === current.version &&
1732
1740
  latestLane.state === "done" &&
1733
1741
  latestLane.keep_alive === true &&
@@ -1840,6 +1848,12 @@ function resumeKeepAlive(hqRoot, lanes, summary, deps, now, flagByCompany) {
1840
1848
  const resumeText = resumedFrom === "interrupted"
1841
1849
  ? LEAD_LANE_KEEP_ALIVE_INTERRUPTED_RESUME_TEXT
1842
1850
  : LEAD_LANE_KEEP_ALIVE_RESUME_TEXT;
1851
+ const carriesAnsweredQuestions = resumedFrom === "done" &&
1852
+ current.keep_alive_standing === true &&
1853
+ undeliveredAnsweredQuestions(current, questions).length > 0;
1854
+ if (carriesAnsweredQuestions && !deps.dryRun) {
1855
+ writeAnsweredQuestions(hqRoot, current, questions);
1856
+ }
1843
1857
  const queue = (expectedSeniorId) => casLaneIf(hqRoot, current.lane_id, (latestLane) => latestLane.version === current.version &&
1844
1858
  latestLane.state === current.state &&
1845
1859
  latestLane.keep_alive === true &&
@@ -1855,7 +1869,7 @@ function resumeKeepAlive(hqRoot, lanes, summary, deps, now, flagByCompany) {
1855
1869
  terminal_details: undefined,
1856
1870
  pending_resume: {
1857
1871
  session_id: currentSessionId,
1858
- trigger: "resume",
1872
+ trigger: carriesAnsweredQuestions ? "answered_questions" : "resume",
1859
1873
  resumed_from: resumedFrom,
1860
1874
  text: resumeText,
1861
1875
  compact: true,
@@ -409,6 +409,13 @@ export function parseLaneRecord(raw, filePath) {
409
409
  value: raw.keep_alive,
410
410
  });
411
411
  }
412
+ if (raw.keep_alive_standing !== undefined && typeof raw.keep_alive_standing !== "boolean") {
413
+ throw new LaneSchemaError("keep_alive_standing must be a boolean", {
414
+ path: filePath,
415
+ field: "keep_alive_standing",
416
+ value: raw.keep_alive_standing,
417
+ });
418
+ }
412
419
  const keepAliveBlockedReason = raw.keep_alive_blocked_reason;
413
420
  const validKeepAliveBlockedReasons = [
414
421
  "not_opted_in",
@@ -364,6 +364,8 @@ export interface LaneRecord {
364
364
  worker_profile?: string;
365
365
  /** Whether this lead lane should resume completed turns while its story is open. */
366
366
  keep_alive?: boolean;
367
+ /** Whether a keep-alive lead waits for queued work after every done turn. */
368
+ keep_alive_standing?: boolean;
367
369
  /** Time this lane most recently explicitly opted into keep-alive. */
368
370
  keep_alive_opted_in_at?: string;
369
371
  /** Why reconcile declined to resume this lane most recently. */
@@ -224,7 +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_repin_history: ["command"], worker_profile_ignored: ["creator", "command", "spawn"], worker_policy_delivery: ["command", "spawn"],
227
- keep_alive: ["creator", "command"], keep_alive_opted_in_at: ["creator", "command"], keep_alive_blocked_reason: ["command", "reconcile"], 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
+ keep_alive: ["creator", "command"], keep_alive_standing: ["command"], keep_alive_opted_in_at: ["creator", "command"], keep_alive_blocked_reason: ["command", "reconcile"], 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"],
228
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"],
229
229
  version: ["creator"], created_at: ["creator"], updated_at: ["creator"], attribution_history: ["command"],
230
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"],
@@ -1,7 +1,7 @@
1
1
  import { listQuestionIds, readLane, readQuestion, } from "./store.js";
2
2
  import { LaneNotFoundError, LanesError } from "./errors.js";
3
3
  import { providerSupportsSessionResume } from "./delivery.js";
4
- import { LEAD_LANE_KEEP_ALIVE_MAX_RESUMES } from "./keep-alive.js";
4
+ import { canResumeDoneKeepAliveLane, hasPendingKeepAliveWork, LEAD_LANE_KEEP_ALIVE_MAX_RESUMES, } from "./keep-alive.js";
5
5
  import { isTerminalLaneState, } from "./types.js";
6
6
  export const DEFAULT_LANE_WATCH_INTERVAL_MS = 5_000;
7
7
  export const MAX_LANE_WATCH_INTERVAL_MS = 30_000;
@@ -32,7 +32,7 @@ function validateLaneIds(laneIds) {
32
32
  }
33
33
  return unique;
34
34
  }
35
- function canResumeKeepAliveLane(lane) {
35
+ function canResumeKeepAliveLane(hqRoot, lane) {
36
36
  if (lane.keep_alive !== true ||
37
37
  lane.keep_alive_opted_in_at === undefined ||
38
38
  !Number.isFinite(Date.parse(lane.keep_alive_opted_in_at)) ||
@@ -49,8 +49,7 @@ function canResumeKeepAliveLane(lane) {
49
49
  return lane.terminal_reason === "exited_with_partial_output";
50
50
  }
51
51
  if (lane.state === "done") {
52
- return lane.last_envelope?.decision === "done" &&
53
- lane.story_complete !== true &&
52
+ return canResumeDoneKeepAliveLane(lane, { decision: lane.last_envelope?.decision, story_complete: lane.story_complete }, lane.keep_alive_standing === true && hasPendingKeepAliveWork(hqRoot, lane)) &&
54
53
  lane.terminal_reason !== "provider_rejected" &&
55
54
  lane.terminal_reason !== "account_not_logged_in";
56
55
  }
@@ -133,7 +132,7 @@ export async function runLaneWatch(hqRoot, laneIds, deps = {}) {
133
132
  if (deps.keepAliveEnabled !== undefined) {
134
133
  for (const record of records) {
135
134
  if (isTerminalLaneState(record.state) &&
136
- canResumeKeepAliveLane(record) &&
135
+ canResumeKeepAliveLane(hqRoot, record) &&
137
136
  await deps.keepAliveEnabled(record))
138
137
  keepAliveWaiting.add(record.lane_id);
139
138
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.229.1",
3
+ "version": "5.230.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {