@indigoai-us/hq-cli 5.236.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 +19 -0
- package/dist/command-catalog.generated.d.ts +30 -0
- package/dist/command-catalog.generated.js +39 -0
- package/dist/commands/lanes-checks.d.ts +2 -0
- package/dist/commands/lanes-checks.js +151 -12
- package/dist/commands/lanes.js +63 -27
- package/dist/lib/lanes/orphans.d.ts +18 -0
- package/dist/lib/lanes/orphans.js +67 -0
- package/dist/lib/lanes/store.d.ts +3 -0
- package/dist/lib/lanes/store.js +105 -3
- package/dist/lib/lanes/story-ledger.js +4 -0
- package/dist/lib/lanes/tracker-checks.d.ts +1 -1
- package/dist/lib/lanes/types.d.ts +10 -0
- package/dist/lib/lanes/types.js +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
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
|
+
|
|
14
|
+
## [5.237.0] — 2026-09-27
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- Lane-store creates and updates record their hq-cli version. User-invoked
|
|
19
|
+
writes from older CLIs are refused, while background writers preserve newer
|
|
20
|
+
stamps and record Outpost owner versions separately. `hq lanes checks` warns
|
|
21
|
+
about newer stamps, and `HQ_LANES_ALLOW_OLDER_WRITE=1` provides a one-command
|
|
22
|
+
override with a warning.
|
|
23
|
+
|
|
5
24
|
## [5.236.0] — 2026-09-27
|
|
6
25
|
|
|
7
26
|
### Changed
|
|
@@ -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 {
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import { CLI_VERSION } from "../cli-version.js";
|
|
3
4
|
import { loadLanesConfig } from "../lib/lanes/config.js";
|
|
4
5
|
import { LanesError, isLanesError } from "../lib/lanes/errors.js";
|
|
5
6
|
import { evaluateTrackerChecks } from "../lib/lanes/tracker-checks.js";
|
|
6
|
-
import { laneRecordPath, listLaneIds, listQuestionIds, questionRecordPath, readLane, readQuestion } from "../lib/lanes/store.js";
|
|
7
|
+
import { LANE_STORE_UPGRADE_COMMAND, isLaneStoreVersionNewer, laneRecordPath, listLaneIds, listQuestionIds, questionRecordPath, readLane, readQuestion } from "../lib/lanes/store.js";
|
|
7
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";
|
|
8
12
|
import { LANES_TRACKER_FLAG } from "./lanes-tracker.js";
|
|
9
13
|
function fail(error, message, details = {}) {
|
|
10
14
|
throw new LanesError(error, message, details);
|
|
@@ -319,6 +323,47 @@ function readScopedLanes(root, company, projects) {
|
|
|
319
323
|
return [readLane(root, id)];
|
|
320
324
|
});
|
|
321
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
|
+
}
|
|
322
367
|
function sameCompanyLane(lane, company) {
|
|
323
368
|
return lane.company?.slug === company.slug && lane.company.uid === company.uid;
|
|
324
369
|
}
|
|
@@ -572,9 +617,28 @@ export async function resolveTrackerCompany(deps, requested) {
|
|
|
572
617
|
}
|
|
573
618
|
return company;
|
|
574
619
|
}
|
|
575
|
-
async function checkReport(root, company, projects, directiveProjects, projectFilter, now) {
|
|
620
|
+
async function checkReport(root, company, projects, directiveProjects, projectFilter, minAgeMinutes, now, deps) {
|
|
576
621
|
const selectedProjects = [...projects.values()];
|
|
577
622
|
const laneRecords = readScopedLanes(root, company, selectedProjects);
|
|
623
|
+
const warnings = laneRecords.flatMap((lane) => {
|
|
624
|
+
const storedVersion = [lane.written_by_version, lane.owner_written_by_version]
|
|
625
|
+
.filter((version) => version !== undefined)
|
|
626
|
+
.reduce((newest, candidate) => {
|
|
627
|
+
if (newest === undefined || isLaneStoreVersionNewer(candidate, newest))
|
|
628
|
+
return candidate;
|
|
629
|
+
return newest;
|
|
630
|
+
}, undefined);
|
|
631
|
+
return storedVersion !== undefined && isLaneStoreVersionNewer(storedVersion, CLI_VERSION)
|
|
632
|
+
? [{
|
|
633
|
+
code: "lane_store_newer_cli",
|
|
634
|
+
lane_id: lane.lane_id,
|
|
635
|
+
stored_version: storedVersion,
|
|
636
|
+
running_version: CLI_VERSION,
|
|
637
|
+
upgrade_command: LANE_STORE_UPGRADE_COMMAND,
|
|
638
|
+
message: `Lane ${lane.lane_id} has a local or owner-host copy last written by hq-cli ${storedVersion}, newer than running hq-cli ${CLI_VERSION}. Upgrade before writing.`
|
|
639
|
+
}]
|
|
640
|
+
: [];
|
|
641
|
+
});
|
|
578
642
|
const teams = readTeamInputs(root, company, selectedProjects, laneRecords);
|
|
579
643
|
const questionRecords = readCompanyQuestions(root, company, projectFilter);
|
|
580
644
|
const trackerRecords = await readTrackerCheckRecords(root, company, selectedProjects);
|
|
@@ -582,38 +646,113 @@ async function checkReport(root, company, projects, directiveProjects, projectFi
|
|
|
582
646
|
team: project.team,
|
|
583
647
|
...(project.leadLaneId === undefined ? {} : { lead_lane_id: project.leadLaneId }),
|
|
584
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
|
+
});
|
|
585
673
|
return {
|
|
586
674
|
ok: true,
|
|
587
675
|
company: company.slug,
|
|
588
676
|
generated_at: now.toISOString(),
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
thresholds: loadLanesConfig(root).tracker.checks,
|
|
592
|
-
questions: questionRecords,
|
|
593
|
-
teams,
|
|
594
|
-
...trackerRecords,
|
|
595
|
-
directives: trackerRecords.directives,
|
|
596
|
-
directive_teams: directiveTeams,
|
|
597
|
-
}),
|
|
677
|
+
...(warnings.length === 0 ? {} : { warnings }),
|
|
678
|
+
checks,
|
|
598
679
|
};
|
|
599
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
|
+
}
|
|
600
725
|
export function registerLaneChecksCommand(parent, deps, wrap) {
|
|
601
726
|
parent.command("checks")
|
|
602
727
|
.description("Evaluate tracker and lane health checks")
|
|
603
728
|
.option("--company <slug>", "Select the company by slug")
|
|
604
729
|
.option("--project <slug>", "Limit checks to one company project")
|
|
730
|
+
.option("--min-age <minutes>", "Minimum orphan lane age in minutes (default: 30)")
|
|
605
731
|
.action(wrap(async (options) => {
|
|
606
732
|
const company = await resolveTrackerCompany(deps, options.company);
|
|
607
733
|
const root = deps.hqRoot();
|
|
608
734
|
const projectFilter = options.project === undefined ? undefined : validSegment(options.project, "project");
|
|
735
|
+
const minAgeMinutes = parseOrphanMinAge(options.minAge);
|
|
609
736
|
const projects = loadProjectMappings(root, company.slug, projectFilter);
|
|
610
737
|
const directiveProjects = projectFilter === undefined
|
|
611
738
|
? projects
|
|
612
739
|
: loadProjectMappings(root, company.slug);
|
|
613
|
-
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);
|
|
614
741
|
console.log(JSON.stringify(report, null, 2));
|
|
615
742
|
if (report.checks.some((item) => item.result === "failing"))
|
|
616
743
|
process.exitCode = 2;
|
|
617
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
|
+
}));
|
|
618
757
|
}
|
|
619
758
|
//# sourceMappingURL=lanes-checks.js.map
|
package/dist/commands/lanes.js
CHANGED
|
@@ -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
|
-
|
|
7746
|
-
|
|
7747
|
-
|
|
7748
|
-
|
|
7749
|
-
|
|
7750
|
-
|
|
7751
|
-
|
|
7752
|
-
|
|
7753
|
-
|
|
7754
|
-
|
|
7755
|
-
|
|
7756
|
-
|
|
7757
|
-
|
|
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
|
-
|
|
7794
|
-
|
|
7795
|
-
|
|
7796
|
-
|
|
7797
|
-
|
|
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
|
-
.
|
|
9546
|
-
|
|
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
|
|
@@ -52,6 +52,9 @@ export declare function tryReadLane(hqRoot: string, laneId: string, deps?: Store
|
|
|
52
52
|
export declare function readLane(hqRoot: string, laneId: string, deps?: StoreDeps): LaneRecord;
|
|
53
53
|
export declare function tryReadQuestion(hqRoot: string, questionId: string, deps?: StoreDeps): QuestionRecord | null;
|
|
54
54
|
export declare function readQuestion(hqRoot: string, questionId: string, deps?: StoreDeps): QuestionRecord;
|
|
55
|
+
/** Semver precedence includes prerelease identifiers (for example, rc.2 > rc.1). */
|
|
56
|
+
export declare function isLaneStoreVersionNewer(storedVersion: string, runningVersion: string): boolean;
|
|
57
|
+
export declare const LANE_STORE_UPGRADE_COMMAND = "npm install -g @indigoai-us/hq-cli@latest";
|
|
55
58
|
export declare function createLane(hqRoot: string, init: Omit<LaneRecord, "schema_version" | "lane_id" | "version" | "created_at" | "updated_at"> & Partial<Pick<LaneRecord, "schema_version" | "lane_id" | "version" | "created_at" | "updated_at">>, deps?: StoreDeps): LaneRecord;
|
|
56
59
|
/** Hierarchy lock: separate from record writes so callers can CAS-write while holding it. */
|
|
57
60
|
export declare function withLaneHireLocks<T>(hqRoot: string, laneIds: readonly string[], fn: () => T, deps?: StoreDeps): T;
|
package/dist/lib/lanes/store.js
CHANGED
|
@@ -10,6 +10,8 @@ import { randomBytes } from "node:crypto";
|
|
|
10
10
|
import * as fs from "node:fs";
|
|
11
11
|
import * as os from "node:os";
|
|
12
12
|
import * as path from "node:path";
|
|
13
|
+
import semver from "semver";
|
|
14
|
+
import { CLI_VERSION } from "../../cli-version.js";
|
|
13
15
|
import { generateUlid } from "../mesh/live/ulid.js";
|
|
14
16
|
import { resolveLanesHqRoot } from "./hq-root.js";
|
|
15
17
|
import { LaneNotFoundError, LaneSchemaError, LaneVersionConflict, LanesError, isLaneVersionConflict, } from "./errors.js";
|
|
@@ -391,6 +393,26 @@ export function parseLaneRecord(raw, filePath) {
|
|
|
391
393
|
const schema_version = requireString(raw.schema_version, "schema_version");
|
|
392
394
|
parseMajorSchema(schema_version, "hq-lane", 1);
|
|
393
395
|
requireString(raw.lane_id, "lane_id");
|
|
396
|
+
if (raw.written_by_version !== undefined) {
|
|
397
|
+
const writtenByVersion = requireString(raw.written_by_version, "written_by_version");
|
|
398
|
+
if (!semver.valid(writtenByVersion)) {
|
|
399
|
+
throw new LaneSchemaError("written_by_version must be a valid semver version", {
|
|
400
|
+
path: filePath,
|
|
401
|
+
field: "written_by_version",
|
|
402
|
+
value: writtenByVersion,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (raw.owner_written_by_version !== undefined) {
|
|
407
|
+
const ownerWrittenByVersion = requireString(raw.owner_written_by_version, "owner_written_by_version");
|
|
408
|
+
if (!semver.valid(ownerWrittenByVersion)) {
|
|
409
|
+
throw new LaneSchemaError("owner_written_by_version must be a valid semver version", {
|
|
410
|
+
path: filePath,
|
|
411
|
+
field: "owner_written_by_version",
|
|
412
|
+
value: ownerWrittenByVersion,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
394
416
|
requireInt(raw.version, "version");
|
|
395
417
|
requireInt(raw.depth, "depth");
|
|
396
418
|
requireInt(raw.priority, "priority");
|
|
@@ -569,6 +591,24 @@ export function parseLaneRecord(raw, filePath) {
|
|
|
569
591
|
path: filePath,
|
|
570
592
|
});
|
|
571
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
|
+
}
|
|
572
612
|
if (raw.worker_profile_repin_history !== undefined) {
|
|
573
613
|
if (!Array.isArray(raw.worker_profile_repin_history)) {
|
|
574
614
|
throw new LaneSchemaError("worker_profile_repin_history must be an array when present", {
|
|
@@ -885,6 +925,11 @@ export function mergeLaneRecords(live, sidecars) {
|
|
|
885
925
|
failed_resume_history: unionHistory(all.map((record) => record.failed_resume_history ?? []), entryIdentity, base.failed_resume_history ?? []),
|
|
886
926
|
}
|
|
887
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
|
+
: {}),
|
|
888
933
|
stories: mergeLaneStories(all, base),
|
|
889
934
|
};
|
|
890
935
|
}
|
|
@@ -950,6 +995,52 @@ export function readQuestion(hqRoot, questionId, deps = defaultStoreDeps()) {
|
|
|
950
995
|
throw new LaneNotFoundError("question", questionId);
|
|
951
996
|
return record;
|
|
952
997
|
}
|
|
998
|
+
/** Semver precedence includes prerelease identifiers (for example, rc.2 > rc.1). */
|
|
999
|
+
export function isLaneStoreVersionNewer(storedVersion, runningVersion) {
|
|
1000
|
+
if (!semver.valid(storedVersion)) {
|
|
1001
|
+
throw new LanesError("lane_store_version_invalid", "Lane store writer version is not valid semver", {
|
|
1002
|
+
stored_version: storedVersion,
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
if (!semver.valid(runningVersion)) {
|
|
1006
|
+
throw new LanesError("lane_store_version_invalid", "Running hq-cli version is not valid semver", {
|
|
1007
|
+
running_version: runningVersion,
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
return semver.gt(storedVersion, runningVersion);
|
|
1011
|
+
}
|
|
1012
|
+
const OLDER_LANE_STORE_WRITE_OVERRIDE = "HQ_LANES_ALLOW_OLDER_WRITE";
|
|
1013
|
+
export const LANE_STORE_UPGRADE_COMMAND = "npm install -g @indigoai-us/hq-cli@latest";
|
|
1014
|
+
const warnedBackgroundWriterLaneIds = new Set();
|
|
1015
|
+
function assertLaneStoreWriteAllowed(lane, laneId, writer) {
|
|
1016
|
+
const storedVersion = lane.written_by_version;
|
|
1017
|
+
if (storedVersion === undefined || !isLaneStoreVersionNewer(storedVersion, CLI_VERSION))
|
|
1018
|
+
return;
|
|
1019
|
+
const warning = `Lane ${laneId} was last written by hq-cli ${storedVersion}, newer than running hq-cli ${CLI_VERSION}. Upgrade with \`${LANE_STORE_UPGRADE_COMMAND}\` before writing.`;
|
|
1020
|
+
if (writer === "command" || writer === "creator") {
|
|
1021
|
+
if (process.env[OLDER_LANE_STORE_WRITE_OVERRIDE] === "1") {
|
|
1022
|
+
console.warn(`${warning} Proceeding because ${OLDER_LANE_STORE_WRITE_OVERRIDE}=1 was set for this command.`);
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
throw new LanesError("lane_store_newer_cli", `${warning} For a one-command override, set ${OLDER_LANE_STORE_WRITE_OVERRIDE}=1.`, {
|
|
1026
|
+
lane_id: laneId,
|
|
1027
|
+
stored_version: storedVersion,
|
|
1028
|
+
running_version: CLI_VERSION,
|
|
1029
|
+
upgrade_command: LANE_STORE_UPGRADE_COMMAND,
|
|
1030
|
+
override: `${OLDER_LANE_STORE_WRITE_OVERRIDE}=1`,
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
if (!warnedBackgroundWriterLaneIds.has(laneId)) {
|
|
1034
|
+
warnedBackgroundWriterLaneIds.add(laneId);
|
|
1035
|
+
console.warn(`${warning} Continuing background ${writer} writes and preserving the newer stamp.`);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
function newestLaneStoreVersion(storedVersion) {
|
|
1039
|
+
if (storedVersion !== undefined && isLaneStoreVersionNewer(storedVersion, CLI_VERSION)) {
|
|
1040
|
+
return storedVersion;
|
|
1041
|
+
}
|
|
1042
|
+
return CLI_VERSION;
|
|
1043
|
+
}
|
|
953
1044
|
function recordsEquivalentAsideFromClock(current, updated) {
|
|
954
1045
|
if (updated === current)
|
|
955
1046
|
return true;
|
|
@@ -961,7 +1052,7 @@ function recordsEquivalentAsideFromClock(current, updated) {
|
|
|
961
1052
|
};
|
|
962
1053
|
return JSON.stringify(strip(current)) === JSON.stringify(strip(updated));
|
|
963
1054
|
}
|
|
964
|
-
function casWrite(filePath, expectedVersion, parse, merge, updater, deps) {
|
|
1055
|
+
function casWrite(filePath, expectedVersion, parse, merge, updater, deps, options = {}) {
|
|
965
1056
|
return withRecordLock(filePath, deps, () => {
|
|
966
1057
|
const current = readMergedRecord(filePath, parse, merge, deps);
|
|
967
1058
|
if (current === null) {
|
|
@@ -980,9 +1071,11 @@ function casWrite(filePath, expectedVersion, parse, merge, updater, deps) {
|
|
|
980
1071
|
if (recordsEquivalentAsideFromClock(current, updated)) {
|
|
981
1072
|
return current;
|
|
982
1073
|
}
|
|
1074
|
+
options.beforeWrite?.(current);
|
|
983
1075
|
const next = {
|
|
984
1076
|
...current,
|
|
985
1077
|
...updated,
|
|
1078
|
+
...options.stamp?.(current),
|
|
986
1079
|
version: current.version + 1,
|
|
987
1080
|
updated_at: deps.now().toISOString(),
|
|
988
1081
|
};
|
|
@@ -996,6 +1089,7 @@ export function createLane(hqRoot, init, deps = defaultStoreDeps()) {
|
|
|
996
1089
|
const filePath = laneRecordPath(hqRoot, lane_id);
|
|
997
1090
|
const record = {
|
|
998
1091
|
...init,
|
|
1092
|
+
written_by_version: CLI_VERSION,
|
|
999
1093
|
hire_grant: init.hire_grant ?? 0,
|
|
1000
1094
|
schema_version: init.schema_version ?? LANE_SCHEMA_VERSION,
|
|
1001
1095
|
lane_id,
|
|
@@ -1060,7 +1154,10 @@ export function updateLane(hqRoot, laneId, expectedVersion, writer, updater, dep
|
|
|
1060
1154
|
}
|
|
1061
1155
|
}
|
|
1062
1156
|
return updated;
|
|
1063
|
-
}, deps
|
|
1157
|
+
}, deps, {
|
|
1158
|
+
beforeWrite: (current) => assertLaneStoreWriteAllowed(current, laneId, writer),
|
|
1159
|
+
stamp: (current) => ({ written_by_version: newestLaneStoreVersion(current.written_by_version) }),
|
|
1160
|
+
});
|
|
1064
1161
|
if (laneStoryLedgerEnabled() && before && laneLedgerRelevantChange(before, updated)) {
|
|
1065
1162
|
return syncStoryLedgerForLane(hqRoot, updated, deps);
|
|
1066
1163
|
}
|
|
@@ -1182,7 +1279,12 @@ export function syncLaneRecordFromOwner(hqRoot, laneId, outpostId, rawOwnerLane,
|
|
|
1182
1279
|
current.outpost_command_id !== undefined
|
|
1183
1280
|
? { ...ownerLane, outpost_command_id: current.outpost_command_id }
|
|
1184
1281
|
: ownerLane;
|
|
1185
|
-
const
|
|
1282
|
+
const merged = mergeLaneRecords(ownerSnapshot, [current]);
|
|
1283
|
+
const synced = {
|
|
1284
|
+
...merged,
|
|
1285
|
+
written_by_version: newestLaneStoreVersion(current.written_by_version),
|
|
1286
|
+
owner_written_by_version: ownerLane.written_by_version,
|
|
1287
|
+
};
|
|
1186
1288
|
parseLaneRecord(synced, filePath);
|
|
1187
1289
|
atomicWriteJson(filePath, synced, deps);
|
|
1188
1290
|
return synced;
|
|
@@ -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;
|
|
@@ -318,6 +322,10 @@ export interface LaneStory {
|
|
|
318
322
|
export interface LaneRecord {
|
|
319
323
|
schema_version: typeof LANE_SCHEMA_VERSION | string;
|
|
320
324
|
lane_id: string;
|
|
325
|
+
/** CLI release that most recently wrote this lane record. Absent on legacy rows. */
|
|
326
|
+
written_by_version?: string;
|
|
327
|
+
/** CLI release that most recently wrote the owner-host copy synced to primary. */
|
|
328
|
+
owner_written_by_version?: string;
|
|
321
329
|
/** `null` is a core/personal lane (spec §6.2 / §10). Not an empty slug. */
|
|
322
330
|
company: LaneCompanyRef | null;
|
|
323
331
|
/** Explicit Work Mesh attribution for a personal lane; never inferred from cwd. */
|
|
@@ -465,6 +473,8 @@ export interface LaneRecord {
|
|
|
465
473
|
/** Set when the reconcile pass force-admits under the starvation bound. */
|
|
466
474
|
admitted_reason?: string;
|
|
467
475
|
terminal_reason?: string;
|
|
476
|
+
/** Reasons supplied to explicit stop commands, retained independently of optional project ledgers. */
|
|
477
|
+
stop_history?: LaneStopHistoryEntry[];
|
|
468
478
|
/** Classification time when this lane entered interrupted; stable across later metadata writes. */
|
|
469
479
|
interrupted_at?: string;
|
|
470
480
|
/**
|
package/dist/lib/lanes/types.js
CHANGED
|
@@ -216,7 +216,7 @@ export const REQUIRED_LANE_RECORD_FIELDS = [
|
|
|
216
216
|
export const LANE_WRITERS = ["creator", "command", "watcher", "reconcile", "spawn", "story-ledger"];
|
|
217
217
|
/** Field ownership at the durable-record mutation boundary (spec §10.1). */
|
|
218
218
|
export const LANE_FIELD_WRITERS = {
|
|
219
|
-
schema_version: ["creator"], lane_id: ["creator"], company: ["creator"],
|
|
219
|
+
schema_version: ["creator"], lane_id: ["creator"], written_by_version: [], owner_written_by_version: [], company: ["creator"],
|
|
220
220
|
project_id: ["command"], story_id: ["command"], no_story_reason: ["creator", "command"], ledger_sync_error: ["story-ledger"], pending_question_ids: ["story-ledger"], stories: ["creator", "command", "watcher", "reconcile", "spawn"], thread_id: ["creator", "command", "spawn"], mesh_company: ["creator", "command"],
|
|
221
221
|
depth: ["command"], senior: ["command"], senior_history: ["command"],
|
|
222
222
|
hire_grant: ["creator", "command"],
|
|
@@ -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
|
};
|