@indigoai-us/hq-cli 5.237.0 → 5.238.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,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.238.0] — 2026-09-27
6
+
7
+ ### Added: Lane orphan reporting
8
+
9
+ - `hq lanes checks` includes an orphaned-lane check. The company-scoped
10
+ `hq lanes orphans` report lists candidates without changing their state. Add
11
+ `--stop --reason <text>` to stop candidates that remain orphaned on recheck
12
+ and record the reason in lane history.
13
+
5
14
  ## [5.237.0] — 2026-09-27
6
15
 
7
16
  ### Added
@@ -7300,6 +7300,33 @@ export declare const COMMAND_CATALOG: readonly [{
7300
7300
  }, {
7301
7301
  readonly flags: "--project <slug>";
7302
7302
  readonly description: "Limit checks to one company project";
7303
+ }, {
7304
+ readonly flags: "--min-age <minutes>";
7305
+ readonly description: "Minimum orphan lane age in minutes (default: 30)";
7306
+ }, {
7307
+ readonly flags: "--json";
7308
+ readonly description: "JSON on stdout (always)";
7309
+ }];
7310
+ readonly subcommands: readonly [];
7311
+ }, {
7312
+ readonly name: "orphans";
7313
+ readonly description: "Report lanes whose lane or session senior has ended";
7314
+ readonly aliases: readonly [];
7315
+ readonly hidden: false;
7316
+ readonly usage: "[options]";
7317
+ readonly arguments: readonly [];
7318
+ readonly options: readonly [{
7319
+ readonly flags: "--company <slug>";
7320
+ readonly description: "Select the company by slug";
7321
+ }, {
7322
+ readonly flags: "--min-age <minutes>";
7323
+ readonly description: "Minimum orphan lane age in minutes (default: 30)";
7324
+ }, {
7325
+ readonly flags: "--stop";
7326
+ readonly description: "Stop the reported lanes through their normal owner path";
7327
+ }, {
7328
+ readonly flags: "--reason <text>";
7329
+ readonly description: "Required audit reason when --stop is set";
7303
7330
  }, {
7304
7331
  readonly flags: "--json";
7305
7332
  readonly description: "JSON on stdout (always)";
@@ -8159,6 +8186,9 @@ export declare const COMMAND_CATALOG: readonly [{
8159
8186
  readonly variadic: false;
8160
8187
  }];
8161
8188
  readonly options: readonly [{
8189
+ readonly flags: "--reason <text>";
8190
+ readonly description: "Record the reason in the lane's terminal history";
8191
+ }, {
8162
8192
  readonly flags: "--json";
8163
8193
  readonly description: "JSON on stdout (always)";
8164
8194
  }];
@@ -9436,6 +9436,41 @@ export const COMMAND_CATALOG = [
9436
9436
  "flags": "--project <slug>",
9437
9437
  "description": "Limit checks to one company project"
9438
9438
  },
9439
+ {
9440
+ "flags": "--min-age <minutes>",
9441
+ "description": "Minimum orphan lane age in minutes (default: 30)"
9442
+ },
9443
+ {
9444
+ "flags": "--json",
9445
+ "description": "JSON on stdout (always)"
9446
+ }
9447
+ ],
9448
+ "subcommands": []
9449
+ },
9450
+ {
9451
+ "name": "orphans",
9452
+ "description": "Report lanes whose lane or session senior has ended",
9453
+ "aliases": [],
9454
+ "hidden": false,
9455
+ "usage": "[options]",
9456
+ "arguments": [],
9457
+ "options": [
9458
+ {
9459
+ "flags": "--company <slug>",
9460
+ "description": "Select the company by slug"
9461
+ },
9462
+ {
9463
+ "flags": "--min-age <minutes>",
9464
+ "description": "Minimum orphan lane age in minutes (default: 30)"
9465
+ },
9466
+ {
9467
+ "flags": "--stop",
9468
+ "description": "Stop the reported lanes through their normal owner path"
9469
+ },
9470
+ {
9471
+ "flags": "--reason <text>",
9472
+ "description": "Required audit reason when --stop is set"
9473
+ },
9439
9474
  {
9440
9475
  "flags": "--json",
9441
9476
  "description": "JSON on stdout (always)"
@@ -10555,6 +10590,10 @@ export const COMMAND_CATALOG = [
10555
10590
  }
10556
10591
  ],
10557
10592
  "options": [
10593
+ {
10594
+ "flags": "--reason <text>",
10595
+ "description": "Record the reason in the lane's terminal history"
10596
+ },
10558
10597
  {
10559
10598
  "flags": "--json",
10560
10599
  "description": "JSON on stdout (always)"
@@ -6,6 +6,8 @@ export interface LaneChecksDeps {
6
6
  resolveCompany: (company?: string) => Promise<LaneCompanyRef>;
7
7
  trackerEnabled: (company: LaneCompanyRef) => boolean | Promise<boolean>;
8
8
  now: () => Date;
9
+ stopLane?: (laneId: string, reason: string) => Promise<void>;
10
+ sessionState?: (root: string, sessionId: string, now: Date) => string | null;
9
11
  }
10
12
  type WrapHandler = <Args extends unknown[]>(action: (...args: Args) => Promise<void>) => (...args: Args) => Promise<void>;
11
13
  export interface ProjectMapping {
@@ -6,6 +6,9 @@ import { LanesError, isLanesError } from "../lib/lanes/errors.js";
6
6
  import { evaluateTrackerChecks } from "../lib/lanes/tracker-checks.js";
7
7
  import { LANE_STORE_UPGRADE_COMMAND, isLaneStoreVersionNewer, laneRecordPath, listLaneIds, listQuestionIds, questionRecordPath, readLane, readQuestion } from "../lib/lanes/store.js";
8
8
  import { createLocalTrackerStore } from "../lib/lanes/tracker-store.js";
9
+ import { sessionLiveness } from "../lib/lanes/liveness.js";
10
+ import { findOrphanLanes, parseOrphanMinAge } from "../lib/lanes/orphans.js";
11
+ import { assertNoSecretText } from "../lib/lanes/text-scan.js";
9
12
  import { LANES_TRACKER_FLAG } from "./lanes-tracker.js";
10
13
  function fail(error, message, details = {}) {
11
14
  throw new LanesError(error, message, details);
@@ -320,6 +323,47 @@ function readScopedLanes(root, company, projects) {
320
323
  return [readLane(root, id)];
321
324
  });
322
325
  }
326
+ function readCompanyLanes(root, company, projectId) {
327
+ return listLaneIds(root).flatMap((id) => {
328
+ const file = laneRecordPath(root, id);
329
+ const metadata = readJsonObject(file);
330
+ if (metadata === null)
331
+ return fail("tracker_checks_input_unreadable", "Lane record could not be read while checking its scope", { path: file });
332
+ const laneCompany = isObject(metadata.company) ? metadata.company : null;
333
+ if (laneCompany?.slug !== company.slug || laneCompany.uid !== company.uid)
334
+ return [];
335
+ if (projectId !== undefined && !laneMetadataMatchesScope(metadata, company, new Set([projectId])))
336
+ return [];
337
+ return [readLane(root, id)];
338
+ });
339
+ }
340
+ function orphanSessionState(root, sessionId, now, deps) {
341
+ if (deps.sessionState)
342
+ return deps.sessionState(root, sessionId, now);
343
+ return sessionLiveness(root, sessionId, { nowMs: now.getTime() });
344
+ }
345
+ function orphanFindings(root, company, now, minAgeMinutes, deps, projectId) {
346
+ const candidates = readCompanyLanes(root, company, projectId);
347
+ const lanes = [...candidates];
348
+ const known = new Set(lanes.map((lane) => lane.lane_id));
349
+ for (const candidate of candidates) {
350
+ if (candidate.senior.kind !== "lane" || candidate.senior.id === undefined || known.has(candidate.senior.id))
351
+ continue;
352
+ const file = laneRecordPath(root, candidate.senior.id);
353
+ const metadata = readJsonObject(file, "empty");
354
+ const laneCompany = metadata && isObject(metadata.company) ? metadata.company : null;
355
+ if (laneCompany?.slug !== company.slug || laneCompany.uid !== company.uid)
356
+ continue;
357
+ lanes.push(readLane(root, candidate.senior.id));
358
+ known.add(candidate.senior.id);
359
+ }
360
+ return findOrphanLanes({
361
+ lanes,
362
+ now,
363
+ minAgeMinutes,
364
+ sessionState: (sessionId, at) => orphanSessionState(root, sessionId, at, deps),
365
+ }).filter((finding) => candidates.some((lane) => lane.lane_id === finding.lane_id));
366
+ }
323
367
  function sameCompanyLane(lane, company) {
324
368
  return lane.company?.slug === company.slug && lane.company.uid === company.uid;
325
369
  }
@@ -573,7 +617,7 @@ export async function resolveTrackerCompany(deps, requested) {
573
617
  }
574
618
  return company;
575
619
  }
576
- async function checkReport(root, company, projects, directiveProjects, projectFilter, now) {
620
+ async function checkReport(root, company, projects, directiveProjects, projectFilter, minAgeMinutes, now, deps) {
577
621
  const selectedProjects = [...projects.values()];
578
622
  const laneRecords = readScopedLanes(root, company, selectedProjects);
579
623
  const warnings = laneRecords.flatMap((lane) => {
@@ -602,39 +646,113 @@ async function checkReport(root, company, projects, directiveProjects, projectFi
602
646
  team: project.team,
603
647
  ...(project.leadLaneId === undefined ? {} : { lead_lane_id: project.leadLaneId }),
604
648
  }));
649
+ const checks = evaluateTrackerChecks({
650
+ now,
651
+ thresholds: loadLanesConfig(root).tracker.checks,
652
+ questions: questionRecords,
653
+ teams,
654
+ ...trackerRecords,
655
+ directives: trackerRecords.directives,
656
+ directive_teams: directiveTeams,
657
+ });
658
+ const orphanedLanes = orphanFindings(root, company, now, minAgeMinutes, deps, projectFilter);
659
+ checks.push({
660
+ id: "orphaned_lane",
661
+ result: orphanedLanes.length === 0 ? "ok" : "failing",
662
+ items: orphanedLanes.map((finding) => ({
663
+ id: finding.lane_id,
664
+ reason: finding.senior_state === "missing" ? "missing_senior" :
665
+ finding.senior_state === "ended" ? "ended_session_senior" : "terminal_senior",
666
+ senior: finding.senior,
667
+ senior_state: finding.senior_state,
668
+ age_minutes: finding.age_minutes,
669
+ account: finding.account,
670
+ host: finding.host,
671
+ })),
672
+ });
605
673
  return {
606
674
  ok: true,
607
675
  company: company.slug,
608
676
  generated_at: now.toISOString(),
609
677
  ...(warnings.length === 0 ? {} : { warnings }),
610
- checks: evaluateTrackerChecks({
611
- now,
612
- thresholds: loadLanesConfig(root).tracker.checks,
613
- questions: questionRecords,
614
- teams,
615
- ...trackerRecords,
616
- directives: trackerRecords.directives,
617
- directive_teams: directiveTeams,
618
- }),
678
+ checks,
619
679
  };
620
680
  }
681
+ async function orphanReport(root, company, now, minAgeMinutes, deps, options) {
682
+ const reason = options.reason?.trim();
683
+ if (options.stop && !reason) {
684
+ fail("orphan_stop_reason_required", "--stop requires a non-empty --reason", {});
685
+ }
686
+ if (!options.stop && options.reason !== undefined) {
687
+ fail("orphan_reason_requires_stop", "--reason can only be used with --stop", {});
688
+ }
689
+ if (reason !== undefined)
690
+ assertNoSecretText("stop reason", reason);
691
+ const orphans = orphanFindings(root, company, now, minAgeMinutes, deps);
692
+ if (!options.stop) {
693
+ console.log(JSON.stringify({
694
+ ok: true,
695
+ action: "orphans",
696
+ company: company.slug,
697
+ generated_at: now.toISOString(),
698
+ min_age_minutes: minAgeMinutes,
699
+ orphans,
700
+ }, null, 2));
701
+ return;
702
+ }
703
+ if (!deps.stopLane)
704
+ fail("orphan_stop_unavailable", "The normal lane stop path is unavailable", {});
705
+ const stopped = [];
706
+ for (const finding of orphans) {
707
+ const fresh = orphanFindings(root, company, deps.now(), minAgeMinutes, deps)
708
+ .find((candidate) => candidate.lane_id === finding.lane_id);
709
+ if (!fresh)
710
+ continue;
711
+ await deps.stopLane(finding.lane_id, reason);
712
+ stopped.push({ lane_id: finding.lane_id, state: readLane(root, finding.lane_id).state });
713
+ }
714
+ console.log(JSON.stringify({
715
+ ok: true,
716
+ action: "orphans.stop",
717
+ company: company.slug,
718
+ generated_at: now.toISOString(),
719
+ min_age_minutes: minAgeMinutes,
720
+ reason,
721
+ orphans,
722
+ stopped,
723
+ }, null, 2));
724
+ }
621
725
  export function registerLaneChecksCommand(parent, deps, wrap) {
622
726
  parent.command("checks")
623
727
  .description("Evaluate tracker and lane health checks")
624
728
  .option("--company <slug>", "Select the company by slug")
625
729
  .option("--project <slug>", "Limit checks to one company project")
730
+ .option("--min-age <minutes>", "Minimum orphan lane age in minutes (default: 30)")
626
731
  .action(wrap(async (options) => {
627
732
  const company = await resolveTrackerCompany(deps, options.company);
628
733
  const root = deps.hqRoot();
629
734
  const projectFilter = options.project === undefined ? undefined : validSegment(options.project, "project");
735
+ const minAgeMinutes = parseOrphanMinAge(options.minAge);
630
736
  const projects = loadProjectMappings(root, company.slug, projectFilter);
631
737
  const directiveProjects = projectFilter === undefined
632
738
  ? projects
633
739
  : loadProjectMappings(root, company.slug);
634
- const report = await checkReport(root, company, projects, directiveProjects, projectFilter, deps.now());
740
+ const report = await checkReport(root, company, projects, directiveProjects, projectFilter, minAgeMinutes, deps.now(), deps);
635
741
  console.log(JSON.stringify(report, null, 2));
636
742
  if (report.checks.some((item) => item.result === "failing"))
637
743
  process.exitCode = 2;
638
744
  }));
745
+ parent.command("orphans")
746
+ .description("Report lanes whose lane or session senior has ended")
747
+ .option("--company <slug>", "Select the company by slug")
748
+ .option("--min-age <minutes>", "Minimum orphan lane age in minutes (default: 30)")
749
+ .option("--stop", "Stop the reported lanes through their normal owner path")
750
+ .option("--reason <text>", "Required audit reason when --stop is set")
751
+ .action(wrap(async (options) => {
752
+ const company = await deps.resolveCompany(options.company);
753
+ const root = deps.hqRoot();
754
+ const minAgeMinutes = parseOrphanMinAge(options.minAge);
755
+ await orphanReport(root, company, deps.now(), minAgeMinutes, deps, options);
756
+ }));
639
757
  }
640
758
  //# sourceMappingURL=lanes-checks.js.map
@@ -3462,6 +3462,10 @@ function outpostRejectedFailedResumeOption(result) {
3462
3462
  return result.exitCode !== 0 &&
3463
3463
  /unknown option\s+(?:['"])?--(?:from-failed|failed-resume-context)(?:['"])?(?:\s|$)/i.test(`${result.stderr}\n${result.stdout}`);
3464
3464
  }
3465
+ function outpostRejectedStopReasonOption(result) {
3466
+ return result.exitCode !== 0 &&
3467
+ /unknown option\s+(?:['"])?--reason(?:['"])?(?:\s|$)/i.test(`${result.stderr}\n${result.stdout}`);
3468
+ }
3465
3469
  function verifyOutpostProfileContent(hqRoot, lane) {
3466
3470
  if (lane.host.kind !== "outpost" || lane.worker_profile === undefined)
3467
3471
  return;
@@ -7635,7 +7639,7 @@ async function readOutpostTerminalLane(ctx, laneId, outpostId, stopCommandId) {
7635
7639
  commandId,
7636
7640
  };
7637
7641
  }
7638
- async function dispatchOutpostLaneStop(ctx, lane) {
7642
+ async function dispatchOutpostLaneStop(ctx, lane, reason, quiet = false) {
7639
7643
  if (lane.host.kind !== "outpost") {
7640
7644
  throw new LanesError("outpost_dispatch_failed", `Lane ${lane.lane_id} has no Outpost host to dispatch stop to`, { lane_id: lane.lane_id });
7641
7645
  }
@@ -7644,7 +7648,7 @@ async function dispatchOutpostLaneStop(ctx, lane) {
7644
7648
  try {
7645
7649
  dispatched = await ctx.dispatchOutpostExec({
7646
7650
  outpostId,
7647
- command: buildOutpostLaneCommand({ action: "stop", laneId: lane.lane_id }),
7651
+ command: buildOutpostLaneCommand({ action: "stop", laneId: lane.lane_id, reason }),
7648
7652
  timeoutSeconds: 120,
7649
7653
  waitMs: 120_000,
7650
7654
  });
@@ -7652,6 +7656,13 @@ async function dispatchOutpostLaneStop(ctx, lane) {
7652
7656
  catch (err) {
7653
7657
  throw new LanesError("outpost_dispatch_failed", `Could not dispatch stop for lane ${lane.lane_id} to Outpost ${outpostId}: ${dispatchErrorMessage(err)}`, { lane_id: lane.lane_id, outpost_id: outpostId });
7654
7658
  }
7659
+ if (reason !== undefined && outpostRejectedStopReasonOption(dispatched)) {
7660
+ throw new LanesError("outpost_cli_upgrade_required", `Outpost ${outpostId} needs an hq-cli release containing stop reason support before it can stop lane ${lane.lane_id}`, {
7661
+ lane_id: lane.lane_id,
7662
+ outpost_id: outpostId,
7663
+ required_option: "--reason",
7664
+ });
7665
+ }
7655
7666
  const ownerError = ownerErrorFromOutput(dispatched.stdout);
7656
7667
  if (ownerError) {
7657
7668
  throw new LanesError("outpost_dispatch_failed", `Outpost ${outpostId} rejected the stop command: ${ownerError.message}`, {
@@ -7742,27 +7753,34 @@ async function dispatchOutpostLaneStop(ctx, lane) {
7742
7753
  cancelledQuestions = parseRemoteCancelledQuestionIds(remote.cancelled_questions, lane.lane_id, outpostId, commandId);
7743
7754
  }
7744
7755
  const updated = syncLaneRecordFromOwner(ctx.hqRoot(), lane.lane_id, outpostId, ownerLane);
7745
- printJson({
7746
- ok: true,
7747
- action: "stop",
7748
- lane: updated,
7749
- cancelled_questions: cancelledQuestions,
7750
- outpost_id: outpostId,
7751
- outpost_command_id: commandId,
7752
- ...(syncCommandId ? { outpost_sync_command_id: syncCommandId } : {}),
7753
- outpost_result: {
7754
- state,
7755
- already_terminal: alreadyTerminal,
7756
- status: dispatched.status,
7757
- exit_code: dispatched.exitCode,
7758
- },
7759
- });
7756
+ if (!quiet)
7757
+ printJson({
7758
+ ok: true,
7759
+ action: "stop",
7760
+ lane: updated,
7761
+ cancelled_questions: cancelledQuestions,
7762
+ outpost_id: outpostId,
7763
+ outpost_command_id: commandId,
7764
+ ...(syncCommandId ? { outpost_sync_command_id: syncCommandId } : {}),
7765
+ outpost_result: {
7766
+ state,
7767
+ already_terminal: alreadyTerminal,
7768
+ status: dispatched.status,
7769
+ exit_code: dispatched.exitCode,
7770
+ },
7771
+ });
7760
7772
  }
7761
- async function runStop(ctx, laneId) {
7773
+ async function runStop(ctx, laneId, reason, quiet = false) {
7774
+ const operatorReason = reason?.trim();
7775
+ if (reason !== undefined && !operatorReason) {
7776
+ throw new LanesError("lane_stop_reason_invalid", "Stop reason must not be empty", {});
7777
+ }
7778
+ if (operatorReason !== undefined)
7779
+ assertNoSecretText("stop reason", operatorReason);
7762
7780
  const hqRoot = ctx.hqRoot();
7763
7781
  const lane = readLane(hqRoot, laneId);
7764
7782
  if (lane.host.kind === "outpost" && !laneRunsHereForOperatorMutation(ctx, lane)) {
7765
- await dispatchOutpostLaneStop(ctx, lane);
7783
+ await dispatchOutpostLaneStop(ctx, lane, operatorReason, quiet);
7766
7784
  return;
7767
7785
  }
7768
7786
  assertLaneRunsHereForOperatorMutation(ctx, lane, "stop");
@@ -7779,6 +7797,16 @@ async function runStop(ctx, laneId) {
7779
7797
  ...current,
7780
7798
  state: stopCommittedState(current.state),
7781
7799
  queued_reason: undefined,
7800
+ ...(operatorReason === undefined ? {} : {
7801
+ stop_history: [
7802
+ ...(current.stop_history ?? []),
7803
+ { at, reason: operatorReason },
7804
+ ],
7805
+ terminal_details: {
7806
+ ...(current.terminal_details ?? {}),
7807
+ operator_reason: operatorReason,
7808
+ },
7809
+ }),
7782
7810
  };
7783
7811
  });
7784
7812
  if (updated.state === "killed") {
@@ -7790,12 +7818,13 @@ async function runStop(ctx, laneId) {
7790
7818
  }
7791
7819
  await emitLaneTerminalStateSoft(ctx, updated);
7792
7820
  const cancelled = cancelPendingQuestionsForLane(hqRoot, laneId, at);
7793
- printJson({
7794
- ok: true,
7795
- action: "stop",
7796
- lane: updated,
7797
- cancelled_questions: cancelled.map((q) => q.question_id),
7798
- });
7821
+ if (!quiet)
7822
+ printJson({
7823
+ ok: true,
7824
+ action: "stop",
7825
+ lane: updated,
7826
+ cancelled_questions: cancelled.map((q) => q.question_id),
7827
+ });
7799
7828
  }
7800
7829
  async function runAsk(ctx, opts) {
7801
7830
  const hqRoot = ctx.hqRoot();
@@ -9206,6 +9235,12 @@ export function registerLanesCommand(parent, deps = {}) {
9206
9235
  resolveCompany: ctx.resolveCompany,
9207
9236
  trackerEnabled: ctx.laneTrackerEnabled,
9208
9237
  now: ctx.now,
9238
+ stopLane: (laneId, reason) => runStop(ctx, laneId, reason, true),
9239
+ sessionState: (root, sessionId, now) => sessionLiveness(root, sessionId, {
9240
+ nowMs: now.getTime(),
9241
+ presenceConnected: ctx.presenceConnected ?? false,
9242
+ deps: ctx.livenessDeps,
9243
+ }),
9209
9244
  }, wrap);
9210
9245
  registerLaneReportCommands(lanes, {
9211
9246
  hqRoot: ctx.hqRoot,
@@ -9542,8 +9577,9 @@ export function registerLanesCommand(parent, deps = {}) {
9542
9577
  .command("stop")
9543
9578
  .description("Cancel a queued lane or kill a running one")
9544
9579
  .argument("<lane-id>", "Lane id")
9545
- .action(wrap(async (laneId) => {
9546
- await runStop(ctx, laneId);
9580
+ .option("--reason <text>", "Record the reason in the lane's terminal history")
9581
+ .action(wrap(async (laneId, options) => {
9582
+ await runStop(ctx, laneId, options.reason);
9547
9583
  }));
9548
9584
  lanes
9549
9585
  .command("ask")
@@ -0,0 +1,18 @@
1
+ import { type LaneRecord, type SeniorRef } from "./types.js";
2
+ export declare const DEFAULT_ORPHAN_MIN_AGE_MINUTES = 30;
3
+ export interface OrphanLaneFinding {
4
+ lane_id: string;
5
+ senior: SeniorRef;
6
+ senior_state: string;
7
+ age_minutes: number;
8
+ account: string | null;
9
+ host: LaneRecord["host"];
10
+ }
11
+ export declare function parseOrphanMinAge(value: string | undefined): number;
12
+ export declare function findOrphanLanes(input: {
13
+ lanes: readonly LaneRecord[];
14
+ now: Date;
15
+ minAgeMinutes: number;
16
+ sessionState: (sessionId: string, now: Date) => string | null;
17
+ }): OrphanLaneFinding[];
18
+ //# sourceMappingURL=orphans.d.ts.map
@@ -0,0 +1,67 @@
1
+ import { LanesError } from "./errors.js";
2
+ import { isTerminalLaneState } from "./types.js";
3
+ export const DEFAULT_ORPHAN_MIN_AGE_MINUTES = 30;
4
+ const LIVE_LANE_STATES = new Set(["running", "queued", "blocked", "awaiting_input"]);
5
+ export function parseOrphanMinAge(value) {
6
+ if (value === undefined)
7
+ return DEFAULT_ORPHAN_MIN_AGE_MINUTES;
8
+ if (!/^(0|[1-9]\d*)$/.test(value)) {
9
+ throw new LanesError("orphan_min_age_invalid", "Minimum orphan age must be a non-negative whole number of minutes", { value });
10
+ }
11
+ const minutes = Number(value);
12
+ if (!Number.isSafeInteger(minutes) || minutes > Math.floor(Number.MAX_SAFE_INTEGER / 60_000)) {
13
+ throw new LanesError("orphan_min_age_invalid", "Minimum orphan age is outside the supported range", { value });
14
+ }
15
+ return minutes;
16
+ }
17
+ export function findOrphanLanes(input) {
18
+ const nowMs = input.now.getTime();
19
+ if (!Number.isFinite(nowMs)) {
20
+ throw new LanesError("orphan_clock_invalid", "Current time for orphan detection is invalid", {});
21
+ }
22
+ const byId = new Map(input.lanes.map((lane) => [lane.lane_id, lane]));
23
+ const minAgeMs = input.minAgeMinutes * 60_000;
24
+ const findings = [];
25
+ for (const lane of input.lanes) {
26
+ if (!LIVE_LANE_STATES.has(lane.state))
27
+ continue;
28
+ if (lane.keep_alive_standing === true || lane.senior.kind === "user")
29
+ continue;
30
+ const createdAt = Date.parse(lane.created_at);
31
+ if (!Number.isFinite(createdAt)) {
32
+ throw new LanesError("orphan_lane_timestamp_invalid", `Lane ${lane.lane_id} has an invalid created_at timestamp`, { lane_id: lane.lane_id, field: "created_at" });
33
+ }
34
+ const ageMs = nowMs - createdAt;
35
+ if (ageMs < 0) {
36
+ throw new LanesError("orphan_lane_timestamp_future", `Lane ${lane.lane_id} was created after the current time`, { lane_id: lane.lane_id, created_at: lane.created_at });
37
+ }
38
+ if (ageMs < minAgeMs)
39
+ continue;
40
+ let seniorState;
41
+ if (lane.senior.kind === "lane") {
42
+ const senior = byId.get(lane.senior.id ?? "");
43
+ if (!senior) {
44
+ seniorState = "missing";
45
+ }
46
+ else if (isTerminalLaneState(senior.state)) {
47
+ seniorState = senior.state;
48
+ }
49
+ }
50
+ else if (lane.senior.kind === "session" && lane.senior.id !== undefined) {
51
+ if (input.sessionState(lane.senior.id, input.now) === "ended")
52
+ seniorState = "ended";
53
+ }
54
+ if (seniorState === undefined)
55
+ continue;
56
+ findings.push({
57
+ lane_id: lane.lane_id,
58
+ senior: { ...lane.senior },
59
+ senior_state: seniorState,
60
+ age_minutes: Math.floor(ageMs / 60_000),
61
+ account: lane.account ?? null,
62
+ host: { ...lane.host },
63
+ });
64
+ }
65
+ return findings.sort((a, b) => b.age_minutes - a.age_minutes || a.lane_id.localeCompare(b.lane_id));
66
+ }
67
+ //# sourceMappingURL=orphans.js.map
@@ -591,6 +591,24 @@ export function parseLaneRecord(raw, filePath) {
591
591
  path: filePath,
592
592
  });
593
593
  }
594
+ if (raw.stop_history !== undefined) {
595
+ if (!Array.isArray(raw.stop_history)) {
596
+ throw new LaneSchemaError("stop_history must be an array when present", {
597
+ path: filePath,
598
+ field: "stop_history",
599
+ });
600
+ }
601
+ for (const [index, entry] of raw.stop_history.entries()) {
602
+ const field = `stop_history[${index}]`;
603
+ if (!isPlainObject(entry) || Object.keys(entry).some((key) => !["at", "reason"].includes(key))) {
604
+ throw new LaneSchemaError(`${field} must be a stop history entry`, { path: filePath, field });
605
+ }
606
+ if (typeof entry.reason !== "string" || entry.reason.trim() === "") {
607
+ throw new LaneSchemaError(`${field}.reason must be a non-empty string`, { path: filePath, field });
608
+ }
609
+ requireTimestamp(entry.at, `${field}.at`, filePath);
610
+ }
611
+ }
594
612
  if (raw.worker_profile_repin_history !== undefined) {
595
613
  if (!Array.isArray(raw.worker_profile_repin_history)) {
596
614
  throw new LaneSchemaError("worker_profile_repin_history must be an array when present", {
@@ -907,6 +925,11 @@ export function mergeLaneRecords(live, sidecars) {
907
925
  failed_resume_history: unionHistory(all.map((record) => record.failed_resume_history ?? []), entryIdentity, base.failed_resume_history ?? []),
908
926
  }
909
927
  : {}),
928
+ ...(all.some((record) => record.stop_history !== undefined)
929
+ ? {
930
+ stop_history: unionHistory(all.map((record) => record.stop_history ?? []), entryIdentity, base.stop_history ?? []),
931
+ }
932
+ : {}),
910
933
  stories: mergeLaneStories(all, base),
911
934
  };
912
935
  }
@@ -473,12 +473,16 @@ function setLaneEntry(story, lane, laneState, laneStoryStatus, prs, pendingQuest
473
473
  item.terminal === true);
474
474
  const stateChanged = oldState !== laneState;
475
475
  if (stateChanged || (terminal && !alreadyRecorded)) {
476
+ const operatorReason = lane.terminal_details?.operator_reason;
476
477
  history.push({
477
478
  lane_id: lane.lane_id,
478
479
  state: laneState,
479
480
  at,
480
481
  prs,
481
482
  ...(terminal ? { terminal: true } : {}),
483
+ ...(terminal && typeof operatorReason === "string" && operatorReason.trim() !== ""
484
+ ? { reason: operatorReason }
485
+ : {}),
482
486
  });
483
487
  }
484
488
  const entry = {
@@ -69,7 +69,7 @@ export interface TrackerCheckFinding {
69
69
  [key: string]: unknown;
70
70
  }
71
71
  export interface TrackerCheckResult {
72
- id: "questions" | "lead_lanes" | "freshness" | "blockers" | "kpis" | "directives";
72
+ id: "questions" | "lead_lanes" | "freshness" | "blockers" | "kpis" | "directives" | "orphaned_lane";
73
73
  result: "ok" | "failing";
74
74
  items: TrackerCheckFinding[];
75
75
  }
@@ -283,6 +283,10 @@ export interface SeniorHistoryEntry {
283
283
  by: string;
284
284
  reason: string;
285
285
  }
286
+ export interface LaneStopHistoryEntry {
287
+ at: string;
288
+ reason: string;
289
+ }
286
290
  /** Evidence for a senior-authorized Outpost worker-profile content re-pin. */
287
291
  export interface WorkerProfileRepinHistoryEntry {
288
292
  old_hash: string | null;
@@ -469,6 +473,8 @@ export interface LaneRecord {
469
473
  /** Set when the reconcile pass force-admits under the starvation bound. */
470
474
  admitted_reason?: string;
471
475
  terminal_reason?: string;
476
+ /** Reasons supplied to explicit stop commands, retained independently of optional project ledgers. */
477
+ stop_history?: LaneStopHistoryEntry[];
472
478
  /** Classification time when this lane entered interrupted; stable across later metadata writes. */
473
479
  interrupted_at?: string;
474
480
  /**
@@ -230,7 +230,7 @@ export const LANE_FIELD_WRITERS = {
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"],
231
231
  last_message_at: ["command"], last_compaction: ["spawn"], story_complete: ["watcher", "reconcile"], story_complete_reason: ["watcher", "reconcile"], story_status: ["watcher", "reconcile"],
232
232
  overdue: ["reconcile"], overdue_at: ["reconcile"], last_host_report_at: ["watcher", "spawn", "reconcile"], stalled: ["watcher"], stalled_at: ["watcher"], stalled_reason: ["watcher"],
233
- admitted_reason: ["command", "reconcile"], terminal_reason: ["command", "watcher", "spawn", "reconcile"], interrupted_at: ["watcher", "reconcile"], terminal_details: ["command", "spawn", "watcher", "reconcile"],
233
+ admitted_reason: ["command", "reconcile"], terminal_reason: ["command", "watcher", "spawn", "reconcile"], stop_history: ["command"], interrupted_at: ["watcher", "reconcile"], terminal_details: ["command", "spawn", "watcher", "reconcile"],
234
234
  quota_reset_at: ["watcher", "reconcile"], model_quota_marker: ["watcher", "reconcile"], outpost_command_id: ["command"],
235
235
  provider_session_id: ["watcher", "command", "spawn"], provider_session_id_absent: ["watcher", "command"], provider_home_at_spawn: ["spawn"], last_resume: ["spawn"], last_run_usage: ["watcher"], envelope_repair: ["watcher", "reconcile"], envelope_validation_errors: ["watcher"], last_progress_event_at: ["watcher"],
236
236
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.237.0",
3
+ "version": "5.238.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {