@indigoai-us/hq-cli 5.264.0 → 5.265.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 +17 -0
- package/dist/command-catalog.generated.d.ts +16 -0
- package/dist/command-catalog.generated.js +21 -0
- package/dist/commands/lanes-kpis.d.ts +11 -0
- package/dist/commands/lanes-kpis.js +34 -0
- package/dist/commands/lanes-watch.js +2 -1
- package/dist/commands/lanes.js +12 -0
- package/dist/lib/lanes/config.d.ts +2 -0
- package/dist/lib/lanes/config.js +4 -0
- package/dist/lib/lanes/keep-alive.d.ts +2 -2
- package/dist/lib/lanes/keep-alive.js +9 -6
- package/dist/lib/lanes/kr-freshness.d.ts +76 -0
- package/dist/lib/lanes/kr-freshness.js +412 -0
- package/dist/lib/lanes/lane-company.d.ts +9 -0
- package/dist/lib/lanes/lane-company.js +24 -0
- package/dist/lib/lanes/reconcile.js +57 -6
- package/dist/lib/lanes/types.d.ts +20 -1
- package/dist/lib/lanes/types.js +1 -1
- package/dist/lib/lanes/watcher.d.ts +2 -0
- package/dist/lib/lanes/watcher.js +70 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.265.0] — 2026-09-28
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- The `lanes.kr-freshness` flag tracks each manager KPI in a runtime-owned
|
|
10
|
+
ledger and queues one corrective resume for each stalled KPI set. The
|
|
11
|
+
freshness window defaults to six hours and can be set with
|
|
12
|
+
`kr_freshness_hours` in the lanes config.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Reconcile resolves a missing lane company for keep-alive and supersession
|
|
17
|
+
flag reads, and watch applies the same resolution to keep-alive checks.
|
|
18
|
+
Unusable project ownership remains unresolved; reconcile reports
|
|
19
|
+
`keep_alive_company_unresolved` or `keep_alive_flag_off` instead of silently
|
|
20
|
+
skipping those candidates.
|
|
21
|
+
|
|
5
22
|
## [5.264.0] — 2026-09-28
|
|
6
23
|
|
|
7
24
|
### Removed
|
|
@@ -7537,6 +7537,22 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
7537
7537
|
}];
|
|
7538
7538
|
readonly subcommands: readonly [];
|
|
7539
7539
|
}];
|
|
7540
|
+
}, {
|
|
7541
|
+
readonly name: "kpis";
|
|
7542
|
+
readonly description: "Show runtime freshness for a manager lane's key results";
|
|
7543
|
+
readonly aliases: readonly [];
|
|
7544
|
+
readonly hidden: false;
|
|
7545
|
+
readonly usage: "[options] <lane>";
|
|
7546
|
+
readonly arguments: readonly [{
|
|
7547
|
+
readonly name: "lane";
|
|
7548
|
+
readonly required: true;
|
|
7549
|
+
readonly variadic: false;
|
|
7550
|
+
}];
|
|
7551
|
+
readonly options: readonly [{
|
|
7552
|
+
readonly flags: "--json";
|
|
7553
|
+
readonly description: "Print the KR freshness report as JSON";
|
|
7554
|
+
}];
|
|
7555
|
+
readonly subcommands: readonly [];
|
|
7540
7556
|
}, {
|
|
7541
7557
|
readonly name: "create";
|
|
7542
7558
|
readonly description: "Register a lane, run admission, and return immediately";
|
|
@@ -9742,6 +9742,27 @@ export const COMMAND_CATALOG = [
|
|
|
9742
9742
|
}
|
|
9743
9743
|
]
|
|
9744
9744
|
},
|
|
9745
|
+
{
|
|
9746
|
+
"name": "kpis",
|
|
9747
|
+
"description": "Show runtime freshness for a manager lane's key results",
|
|
9748
|
+
"aliases": [],
|
|
9749
|
+
"hidden": false,
|
|
9750
|
+
"usage": "[options] <lane>",
|
|
9751
|
+
"arguments": [
|
|
9752
|
+
{
|
|
9753
|
+
"name": "lane",
|
|
9754
|
+
"required": true,
|
|
9755
|
+
"variadic": false
|
|
9756
|
+
}
|
|
9757
|
+
],
|
|
9758
|
+
"options": [
|
|
9759
|
+
{
|
|
9760
|
+
"flags": "--json",
|
|
9761
|
+
"description": "Print the KR freshness report as JSON"
|
|
9762
|
+
}
|
|
9763
|
+
],
|
|
9764
|
+
"subcommands": []
|
|
9765
|
+
},
|
|
9745
9766
|
{
|
|
9746
9767
|
"name": "create",
|
|
9747
9768
|
"description": "Register a lane, run admission, and return immediately",
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
export interface LaneKpisCommandDeps {
|
|
3
|
+
hqRoot: () => string;
|
|
4
|
+
now: () => Date;
|
|
5
|
+
wrap: WrapHandler;
|
|
6
|
+
}
|
|
7
|
+
type WrapHandler = <Args extends unknown[]>(action: (...args: Args) => Promise<void>) => (...args: Args) => Promise<void>;
|
|
8
|
+
/** Register the operator report command without depending on the large lanes registrar. */
|
|
9
|
+
export declare function registerLaneKpisCommand(parent: Command, deps: LaneKpisCommandDeps): void;
|
|
10
|
+
export {};
|
|
11
|
+
//# sourceMappingURL=lanes-kpis.d.ts.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readKrFreshnessReport } from "../lib/lanes/kr-freshness.js";
|
|
2
|
+
/** Register the operator report command without depending on the large lanes registrar. */
|
|
3
|
+
export function registerLaneKpisCommand(parent, deps) {
|
|
4
|
+
parent
|
|
5
|
+
.command("kpis")
|
|
6
|
+
.description("Show runtime freshness for a manager lane's key results")
|
|
7
|
+
.argument("<lane>")
|
|
8
|
+
.option("--json", "Print the KR freshness report as JSON")
|
|
9
|
+
.action(deps.wrap(async (laneId, options) => {
|
|
10
|
+
const report = readKrFreshnessReport(deps.hqRoot(), laneId, deps.now().getTime());
|
|
11
|
+
const result = {
|
|
12
|
+
ok: true,
|
|
13
|
+
action: "kpis",
|
|
14
|
+
lane_id: laneId,
|
|
15
|
+
ledger_available: report.available,
|
|
16
|
+
ledger_path: report.ledger_path,
|
|
17
|
+
kr_freshness_hours: report.kr_freshness_hours,
|
|
18
|
+
kpis: report.kpis,
|
|
19
|
+
};
|
|
20
|
+
if (options.json) {
|
|
21
|
+
console.log(JSON.stringify(result));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (!report.available) {
|
|
25
|
+
console.log(`No KR freshness ledger exists for ${laneId}. Expected ${report.ledger_path}.`);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
console.log(`KR freshness for ${laneId} (window ${report.kr_freshness_hours}h):`);
|
|
29
|
+
for (const kpi of report.kpis) {
|
|
30
|
+
console.log(`${kpi.name}: ${JSON.stringify(kpi.value)} — ${kpi.verdict}; measured ${kpi.measured_at}; value changed ${kpi.value_changed_at}`);
|
|
31
|
+
}
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=lanes-kpis.js.map
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { accountStatus, coveredLaneIds, loadLanesConfig, MAX_WATCHED_LANES, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_UNCOVERED_EXIT_CODE, monitorReminderText, runLaneWatch, } from "../lib/lanes/index.js";
|
|
2
2
|
import { LEAD_LANE_KEEP_ALIVE_FLAG } from "../lib/lanes/keep-alive.js";
|
|
3
|
+
import { resolveLaneCompany } from "../lib/lanes/lane-company.js";
|
|
3
4
|
import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
|
|
4
5
|
import { isLanesError, LanesError } from "../lib/lanes/errors.js";
|
|
5
6
|
import { LANE_WAIT_INVALID_INPUT_EXIT_CODE, LANE_WAIT_TIMEOUT_EXIT_CODE, runLaneWait, } from "../lib/lanes/wait.js";
|
|
@@ -46,7 +47,7 @@ export function createLaneWatchHandlers(deps) {
|
|
|
46
47
|
intervalMs: rawInterval,
|
|
47
48
|
sleep: ctx.sleep,
|
|
48
49
|
keepAliveEnabled: (lane) => readLanesRegistryFlag(LEAD_LANE_KEEP_ALIVE_FLAG, {
|
|
49
|
-
company: lane.company,
|
|
50
|
+
company: resolveLaneCompany(hqRoot, lane).company ?? undefined,
|
|
50
51
|
env: ctx.env,
|
|
51
52
|
}),
|
|
52
53
|
readAccountStatuses: async (lanes) => {
|
package/dist/commands/lanes.js
CHANGED
|
@@ -18,6 +18,7 @@ import { createLaneResumeHandlers } from "./lanes-resume.js";
|
|
|
18
18
|
import { createLaneWatchHandlers } from "./lanes-watch.js";
|
|
19
19
|
export { resolveAnswerChoice } from "./lanes-questions.js";
|
|
20
20
|
import { registerOutpostPrunerCommand } from "./outpost-pruner.js";
|
|
21
|
+
import { registerLaneKpisCommand } from "./lanes-kpis.js";
|
|
21
22
|
import { registerLaneChecksCommand } from "./lanes-checks.js";
|
|
22
23
|
import { registerLaneReportCommands } from "./lanes-report.js";
|
|
23
24
|
import { registerLaneTrackerImportCommand } from "./lanes-import.js";
|
|
@@ -50,6 +51,7 @@ import { effectiveAccountHome } from "../lib/lanes/account-home.js";
|
|
|
50
51
|
import { AccountResolutionError, compareLaneQueueOrder, LANE_PROVIDERS, LANE_STATES, QUESTION_STATUSES, SENIOR_KINDS, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, aliveDirectChildren, assignedChildHireGrants, hireGrantCapacityError, planHireGrantBackfill, terminalChildResumeHireGrantError, withHireGrantAdmission, laneStoryLedgerEnabled, validateProjectStories, buildLaneStatusSnapshot, formatLaneStatusHuman, listLaneIds, listQuestionIds, loadLanesConfig, laneRunsHere, MAX_LANE_DEPTH, MONITOR_ERROR_EXIT_CODE, readLiveMonitorEvidence, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneRecord, parseLaneState, parseQuestionRecord, parseSeniorKind, readLane, retryUpdateLane, syncLaneRecordFromOwner, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile, loadWorkerProfileWithRegistry, registryWorkerRecord, runReconcilePass, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, dropboxDir, laneDeliveryMechanism, hasActiveAdmissionReservation, hasActivePendingResume, hasRecordedSessionId, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, providerSupportsSessionResume, providerSessionPortable, sessionNotPortableMessage, withLaneAdmissionLock, withAllLaneAdmissionLocks, withLaneHireLocks, raiseLaneQuestion, sha256Text, } from "../lib/lanes/index.js";
|
|
51
52
|
import { activeLaneStories, laneStories, laneStory, storyWarning, } from "../lib/lanes/stories.js";
|
|
52
53
|
import { resolveLaneKeepAlive } from "../lib/lanes/profile.js";
|
|
54
|
+
import { resolveLaneCompany } from "../lib/lanes/lane-company.js";
|
|
53
55
|
import { doneKeepAliveVerdict, hasPendingKeepAliveWork, keepAliveExclusion, LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, loadRetiredLaneIds, } from "../lib/lanes/keep-alive.js";
|
|
54
56
|
import { questionBodyAbsPath, resolveQuestionCompanyWithFallback, resolveProjectQuestionCompany, } from "../lib/lanes/questions.js";
|
|
55
57
|
import { assertQuestionBodySecretSafe } from "../lib/lanes/questions.js";
|
|
@@ -60,6 +62,7 @@ import { loadQuotaState, quotaBlockFor } from "../lib/lanes/quota.js";
|
|
|
60
62
|
import { SESSION_MOVE_REASON } from "../lib/lanes/move-account.js";
|
|
61
63
|
import { managerStatusGateEnabledForLane, resolveManagerStatusGateForLane, } from "../lib/lanes/manager-status-gate.js";
|
|
62
64
|
import { blockerFreshnessFingerprint, resolveBlockerFreshnessForLane, } from "../lib/lanes/blocker-freshness.js";
|
|
65
|
+
import { resolveKrFreshnessForShow } from "../lib/lanes/kr-freshness.js";
|
|
63
66
|
import { ensureSessionHome } from "../lib/lanes/session-relink.js";
|
|
64
67
|
import { defaultProbe, validateProfileLaneModel, validateLaneModel, } from "../lib/lanes/models.js";
|
|
65
68
|
import { parseCliModel, refuseOutpostSelfTarget, resolveLaneModel, FOREGROUND_ONLY_RULE, STANDING_AUTHORIZATION, spawnLaneWorker, spawnEnvelopeRepairRequest, laneRunDir, readSpawnManifest, stopLaneProcesses, } from "../lib/lanes/spawn.js";
|
|
@@ -4524,6 +4527,7 @@ async function runShow(ctx, laneId) {
|
|
|
4524
4527
|
: storedLane;
|
|
4525
4528
|
const askDeliveryFailed = readJsonFile(askDeliveryFailedPath(hqRoot, laneId));
|
|
4526
4529
|
const keepAliveState = resolveLaneKeepAlive(hqRoot, storedLane);
|
|
4530
|
+
const keepAliveCompany = resolveLaneCompany(hqRoot, storedLane);
|
|
4527
4531
|
const doneStateVerdict = storedLane.state === "done" && storedLane.keep_alive === true
|
|
4528
4532
|
? doneKeepAliveVerdict(storedLane, { decision: storedLane.last_envelope?.decision, story_complete: storedLane.story_complete }, storedLane.keep_alive_standing === true && hasPendingKeepAliveWork(hqRoot, storedLane))
|
|
4529
4533
|
: null;
|
|
@@ -4533,6 +4537,10 @@ async function runShow(ctx, laneId) {
|
|
|
4533
4537
|
env: ctx.env,
|
|
4534
4538
|
now: ctx.now().getTime(),
|
|
4535
4539
|
});
|
|
4540
|
+
const krFreshness = await resolveKrFreshnessForShow(hqRoot, storedLane, {
|
|
4541
|
+
env: ctx.env,
|
|
4542
|
+
now: ctx.now().getTime(),
|
|
4543
|
+
});
|
|
4536
4544
|
printJson({
|
|
4537
4545
|
ok: true,
|
|
4538
4546
|
action: "show",
|
|
@@ -4562,6 +4570,8 @@ async function runShow(ctx, laneId) {
|
|
|
4562
4570
|
keep_alive_state: {
|
|
4563
4571
|
enabled: keepAliveState.enabled,
|
|
4564
4572
|
source: keepAliveState.source,
|
|
4573
|
+
company_source: keepAliveCompany.source,
|
|
4574
|
+
company_slug: keepAliveCompany.company?.slug ?? null,
|
|
4565
4575
|
standing: storedLane.keep_alive_standing === true,
|
|
4566
4576
|
done_state_verdict: doneStateVerdict,
|
|
4567
4577
|
opted_in_at: storedLane.keep_alive_opted_in_at ?? null,
|
|
@@ -4597,6 +4607,7 @@ async function runShow(ctx, laneId) {
|
|
|
4597
4607
|
blockerFreshnessFingerprint(blockerFreshness.stale),
|
|
4598
4608
|
}
|
|
4599
4609
|
: null,
|
|
4610
|
+
kr_freshness: krFreshness,
|
|
4600
4611
|
last_message_at: lane.last_message_at ?? null,
|
|
4601
4612
|
last_compaction: lane.last_compaction
|
|
4602
4613
|
? { ...lane.last_compaction, after: compactionAfterUsage(storedLane) }
|
|
@@ -6918,6 +6929,7 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
6918
6929
|
env: ctx.env,
|
|
6919
6930
|
}, wrap);
|
|
6920
6931
|
registerOutpostPrunerCommand(lanes);
|
|
6932
|
+
registerLaneKpisCommand(lanes, { hqRoot: ctx.hqRoot, now: ctx.now, wrap });
|
|
6921
6933
|
const noStoryOption = new Option("--no-story <reason>", "Create a lane without a story and record why");
|
|
6922
6934
|
noStoryOption.attributeName = () => "noStory";
|
|
6923
6935
|
lanes
|
|
@@ -41,6 +41,8 @@ export declare const DEFAULT_SILENT_LOST_MS: number;
|
|
|
41
41
|
export declare const DEFAULT_SESSION_QUESTION_TIMEOUT_SECS: number;
|
|
42
42
|
/** Open manager blockers are re-verified every four hours by default. */
|
|
43
43
|
export declare const DEFAULT_BLOCKER_FRESHNESS_HOURS = 4;
|
|
44
|
+
/** Manager key results must be re-measured within six hours by default. */
|
|
45
|
+
export declare const DEFAULT_KR_FRESHNESS_HOURS = 6;
|
|
44
46
|
export declare const DEFAULT_TRACKER_CHECKS: TrackerChecksConfig;
|
|
45
47
|
export declare const DEFAULT_LIVENESS_WINDOWS: LivenessWindows;
|
|
46
48
|
export declare const DEFAULT_PROVIDER_MAX_RUNNING: Record<LaneProvider, number>;
|
package/dist/lib/lanes/config.js
CHANGED
|
@@ -45,6 +45,8 @@ export const DEFAULT_SILENT_LOST_MS = 12 * 60 * 60 * 1000;
|
|
|
45
45
|
export const DEFAULT_SESSION_QUESTION_TIMEOUT_SECS = 10 * 60;
|
|
46
46
|
/** Open manager blockers are re-verified every four hours by default. */
|
|
47
47
|
export const DEFAULT_BLOCKER_FRESHNESS_HOURS = 4;
|
|
48
|
+
/** Manager key results must be re-measured within six hours by default. */
|
|
49
|
+
export const DEFAULT_KR_FRESHNESS_HOURS = 6;
|
|
48
50
|
export const DEFAULT_TRACKER_CHECKS = {
|
|
49
51
|
owner_question_surface_ms: 10 * 60 * 1000,
|
|
50
52
|
open_question_age_ms: 2 * 60 * 60 * 1000,
|
|
@@ -425,6 +427,7 @@ export function documentedDefaultConfig(deps = defaultDeps()) {
|
|
|
425
427
|
tracker: { checks: { ...DEFAULT_TRACKER_CHECKS } },
|
|
426
428
|
session_question_timeout_secs: DEFAULT_SESSION_QUESTION_TIMEOUT_SECS,
|
|
427
429
|
blocker_freshness_hours: DEFAULT_BLOCKER_FRESHNESS_HOURS,
|
|
430
|
+
kr_freshness_hours: DEFAULT_KR_FRESHNESS_HOURS,
|
|
428
431
|
};
|
|
429
432
|
}
|
|
430
433
|
export function parseLanesConfig(raw, deps = defaultDeps()) {
|
|
@@ -468,6 +471,7 @@ export function parseLanesConfig(raw, deps = defaultDeps()) {
|
|
|
468
471
|
tracker: { checks: parseTrackerChecks(raw.tracker) },
|
|
469
472
|
session_question_timeout_secs: positiveInt(raw.session_question_timeout_secs, "session_question_timeout_secs", DEFAULT_SESSION_QUESTION_TIMEOUT_SECS),
|
|
470
473
|
blocker_freshness_hours: positiveNumber(raw.blocker_freshness_hours, "blocker_freshness_hours", DEFAULT_BLOCKER_FRESHNESS_HOURS),
|
|
474
|
+
kr_freshness_hours: positiveNumber(raw.kr_freshness_hours, "kr_freshness_hours", DEFAULT_KR_FRESHNESS_HOURS),
|
|
471
475
|
};
|
|
472
476
|
}
|
|
473
477
|
export function lanesConfigPath(hqRoot) {
|
|
@@ -40,7 +40,7 @@ export declare function retiredLaneRegistryPath(hqRoot: string): string;
|
|
|
40
40
|
export declare function loadRetiredLaneIds(hqRoot: string): ReadonlySet<string>;
|
|
41
41
|
/** IDs may be stored bare or with an owner-host suffix after an underscore. */
|
|
42
42
|
export declare function isRetiredLaneId(laneId: string, retiredLaneIds: ReadonlySet<string>): boolean;
|
|
43
|
-
export declare function keepAliveSupersededBy(lane: LaneRecord, lanes: readonly LaneRecord[], lineageAware?: boolean): LaneRecord | undefined;
|
|
44
|
-
export declare function keepAliveExclusion(lane: LaneRecord, lanes: readonly LaneRecord[], retiredLaneIds: ReadonlySet<string>, lineageAware?: boolean): "retired" | "superseded" | undefined;
|
|
43
|
+
export declare function keepAliveSupersededBy(lane: LaneRecord, lanes: readonly LaneRecord[], lineageAware?: boolean, companyForLane?: (lane: LaneRecord) => LaneRecord["company"]): LaneRecord | undefined;
|
|
44
|
+
export declare function keepAliveExclusion(lane: LaneRecord, lanes: readonly LaneRecord[], retiredLaneIds: ReadonlySet<string>, lineageAware?: boolean, companyForLane?: (lane: LaneRecord) => LaneRecord["company"]): "retired" | "superseded" | undefined;
|
|
45
45
|
export declare function leadLaneKeepAliveBackoffMs(attempt: number): number;
|
|
46
46
|
//# sourceMappingURL=keep-alive.d.ts.map
|
|
@@ -199,10 +199,12 @@ export function isRetiredLaneId(laneId, retiredLaneIds) {
|
|
|
199
199
|
}
|
|
200
200
|
return false;
|
|
201
201
|
}
|
|
202
|
-
export function keepAliveSupersededBy(lane, lanes, lineageAware = LANE_SUPERSESSION_LINEAGE_AWARE_DEFAULT_ENABLED) {
|
|
202
|
+
export function keepAliveSupersededBy(lane, lanes, lineageAware = LANE_SUPERSESSION_LINEAGE_AWARE_DEFAULT_ENABLED, companyForLane) {
|
|
203
203
|
const createdAt = Date.parse(lane.created_at);
|
|
204
204
|
if (!Number.isFinite(createdAt))
|
|
205
205
|
return undefined;
|
|
206
|
+
const companyOf = companyForLane ?? ((candidate) => candidate.company);
|
|
207
|
+
const laneCompany = companyOf(lane);
|
|
206
208
|
const lanesById = new Map(lanes.map((candidate) => [candidate.lane_id, candidate]));
|
|
207
209
|
const targetAncestorIds = new Set();
|
|
208
210
|
if (lineageAware) {
|
|
@@ -218,9 +220,10 @@ export function keepAliveSupersededBy(lane, lanes, lineageAware = LANE_SUPERSESS
|
|
|
218
220
|
return lanes.find((candidate) => {
|
|
219
221
|
if (candidate.lane_id === lane.lane_id || Date.parse(candidate.created_at) <= createdAt)
|
|
220
222
|
return false;
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
223
|
+
const candidateCompany = companyOf(candidate);
|
|
224
|
+
const sameCompany = laneCompany === null
|
|
225
|
+
? candidateCompany === null
|
|
226
|
+
: candidateCompany?.uid === laneCompany.uid;
|
|
224
227
|
if (!sameCompany)
|
|
225
228
|
return false;
|
|
226
229
|
if (lineageAware && targetAncestorIds.has(candidate.lane_id))
|
|
@@ -249,10 +252,10 @@ export function keepAliveSupersededBy(lane, lanes, lineageAware = LANE_SUPERSESS
|
|
|
249
252
|
return sameProfile || projectLead;
|
|
250
253
|
});
|
|
251
254
|
}
|
|
252
|
-
export function keepAliveExclusion(lane, lanes, retiredLaneIds, lineageAware = LANE_SUPERSESSION_LINEAGE_AWARE_DEFAULT_ENABLED) {
|
|
255
|
+
export function keepAliveExclusion(lane, lanes, retiredLaneIds, lineageAware = LANE_SUPERSESSION_LINEAGE_AWARE_DEFAULT_ENABLED, companyForLane) {
|
|
253
256
|
if (isRetiredLaneId(lane.lane_id, retiredLaneIds))
|
|
254
257
|
return "retired";
|
|
255
|
-
return keepAliveSupersededBy(lane, lanes, lineageAware) === undefined ? undefined : "superseded";
|
|
258
|
+
return keepAliveSupersededBy(lane, lanes, lineageAware, companyForLane) === undefined ? undefined : "superseded";
|
|
256
259
|
}
|
|
257
260
|
export function leadLaneKeepAliveBackoffMs(attempt) {
|
|
258
261
|
if (!Number.isSafeInteger(attempt) || attempt < 1) {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { type ManagerStatusTarget } from "./manager-status-gate.js";
|
|
2
|
+
import { readLanesRegistryFlag } from "./registry-flag.js";
|
|
3
|
+
import type { LaneRecord } from "./types.js";
|
|
4
|
+
export declare const KR_FRESHNESS_FLAG = "lanes.kr-freshness";
|
|
5
|
+
export declare const KR_FRESHNESS_DEFAULT_HOURS = 6;
|
|
6
|
+
export declare const KR_FRESHNESS_LEDGER_REL: string;
|
|
7
|
+
export declare const KR_FRESHNESS_LEDGER_SCHEMA = "kr-freshness-ledger.v1";
|
|
8
|
+
export declare const KR_FRESHNESS_HISTORY_LIMIT = 50;
|
|
9
|
+
export type KrFreshnessVerdict = "moving" | "flat" | "stalled";
|
|
10
|
+
export interface KrFreshnessKpi {
|
|
11
|
+
name: string;
|
|
12
|
+
value: number | string;
|
|
13
|
+
entry_measured_at: string | null;
|
|
14
|
+
first_seen_at: string;
|
|
15
|
+
measured_at: string;
|
|
16
|
+
value_changed_at: string;
|
|
17
|
+
removed_at?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface KrFreshnessLedger {
|
|
20
|
+
schema_version: typeof KR_FRESHNESS_LEDGER_SCHEMA;
|
|
21
|
+
lane_id: string;
|
|
22
|
+
updated_at: string;
|
|
23
|
+
kpis: Record<string, KrFreshnessKpi>;
|
|
24
|
+
}
|
|
25
|
+
export interface KrFreshnessView {
|
|
26
|
+
name: string;
|
|
27
|
+
value: number | string;
|
|
28
|
+
verdict: KrFreshnessVerdict;
|
|
29
|
+
measured_at: string;
|
|
30
|
+
value_changed_at: string;
|
|
31
|
+
}
|
|
32
|
+
export interface KrFreshnessResolution {
|
|
33
|
+
flagEnabled: boolean;
|
|
34
|
+
enabled: boolean;
|
|
35
|
+
target?: ManagerStatusTarget;
|
|
36
|
+
statusPath?: string;
|
|
37
|
+
windowHours: number;
|
|
38
|
+
kpis: KrFreshnessView[];
|
|
39
|
+
stalledKeys: string[];
|
|
40
|
+
error?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface KrFreshnessResolutionOptions {
|
|
43
|
+
env?: NodeJS.ProcessEnv;
|
|
44
|
+
now?: number;
|
|
45
|
+
readFlag?: typeof readLanesRegistryFlag;
|
|
46
|
+
onError?: (error: unknown) => void;
|
|
47
|
+
}
|
|
48
|
+
export interface KrFreshnessStatusKpi {
|
|
49
|
+
key: string;
|
|
50
|
+
fullName: string;
|
|
51
|
+
name: string;
|
|
52
|
+
value: number | string;
|
|
53
|
+
entryMeasuredAt: string | null;
|
|
54
|
+
}
|
|
55
|
+
export declare function readKrFreshnessStatus(statusPath: string): KrFreshnessStatusKpi[];
|
|
56
|
+
export declare function krFreshnessLedgerPath(root: string, laneId: string): string;
|
|
57
|
+
export declare function readKrFreshnessLedger(root: string, laneId: string): KrFreshnessLedger | null;
|
|
58
|
+
export declare function writeKrFreshnessLedger(root: string, ledger: KrFreshnessLedger): void;
|
|
59
|
+
export declare function updateKrFreshnessLedger(laneId: string, prior: KrFreshnessLedger | null, statusKpis: readonly KrFreshnessStatusKpi[], now: number): KrFreshnessLedger;
|
|
60
|
+
export declare function krFreshnessVerdict(kpi: Pick<KrFreshnessKpi, "measured_at" | "value_changed_at">, now: number, windowHours: number): KrFreshnessVerdict;
|
|
61
|
+
export declare function krFreshnessFingerprint(keys: readonly string[]): string;
|
|
62
|
+
/** Read the flag before manager mapping, status, configuration or ledger files. */
|
|
63
|
+
export declare function resolveKrFreshnessForLane(root: string, lane: LaneRecord, options?: KrFreshnessResolutionOptions): Promise<KrFreshnessResolution>;
|
|
64
|
+
/** Read a previously written ledger for `lanes show`; this never reads status. */
|
|
65
|
+
export declare function resolveKrFreshnessForShow(root: string, lane: LaneRecord, options?: KrFreshnessResolutionOptions): Promise<{
|
|
66
|
+
enabled: true;
|
|
67
|
+
kpis: KrFreshnessView[];
|
|
68
|
+
} | null>;
|
|
69
|
+
/** Operator report path: the ledger remains readable while runtime enforcement is off. */
|
|
70
|
+
export declare function readKrFreshnessReport(root: string, laneId: string, now: number): {
|
|
71
|
+
ledger_path: string;
|
|
72
|
+
available: boolean;
|
|
73
|
+
kr_freshness_hours: number;
|
|
74
|
+
kpis: KrFreshnessView[];
|
|
75
|
+
};
|
|
76
|
+
//# sourceMappingURL=kr-freshness.d.ts.map
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { DEFAULT_KR_FRESHNESS_HOURS, loadLanesConfig } from "./config.js";
|
|
5
|
+
import { LanesError } from "./errors.js";
|
|
6
|
+
import { resolveManagerStatusTarget } from "./manager-status-gate.js";
|
|
7
|
+
import { readLanesRegistryFlag } from "./registry-flag.js";
|
|
8
|
+
import { assertNoSecretText } from "./text-scan.js";
|
|
9
|
+
export const KR_FRESHNESS_FLAG = "lanes.kr-freshness";
|
|
10
|
+
export const KR_FRESHNESS_DEFAULT_HOURS = DEFAULT_KR_FRESHNESS_HOURS;
|
|
11
|
+
export const KR_FRESHNESS_LEDGER_REL = path.join("workspace", "lanes", "kpi-ledger");
|
|
12
|
+
export const KR_FRESHNESS_LEDGER_SCHEMA = "kr-freshness-ledger.v1";
|
|
13
|
+
export const KR_FRESHNESS_HISTORY_LIMIT = 50;
|
|
14
|
+
const MAX_STATUS_BYTES = 1024 * 1024;
|
|
15
|
+
const MAX_LEDGER_BYTES = 1024 * 1024;
|
|
16
|
+
const MAX_KPIS = 1000;
|
|
17
|
+
const REMOVED_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
18
|
+
function isObject(value) {
|
|
19
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
function fail(code, message, details = {}) {
|
|
22
|
+
throw new LanesError(code, message, details);
|
|
23
|
+
}
|
|
24
|
+
function errorCode(error) {
|
|
25
|
+
return error instanceof LanesError ? error.error : "kr_freshness_unavailable";
|
|
26
|
+
}
|
|
27
|
+
function sha256(value) {
|
|
28
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
29
|
+
}
|
|
30
|
+
function displayName(name) {
|
|
31
|
+
const chars = Array.from(name);
|
|
32
|
+
return chars.length <= 120 ? name : `${chars.slice(0, 119).join("")}…`;
|
|
33
|
+
}
|
|
34
|
+
function safeLaneId(laneId) {
|
|
35
|
+
return /^[A-Za-z0-9._-]{1,200}$/.test(laneId) && laneId !== "." && laneId !== "..";
|
|
36
|
+
}
|
|
37
|
+
function ensureLedgerDirectory(root, create) {
|
|
38
|
+
let current = path.resolve(root);
|
|
39
|
+
const parts = [current];
|
|
40
|
+
for (const segment of ["workspace", "lanes", "kpi-ledger"]) {
|
|
41
|
+
current = path.join(current, segment);
|
|
42
|
+
parts.push(current);
|
|
43
|
+
}
|
|
44
|
+
for (const [index, directory] of parts.entries()) {
|
|
45
|
+
if (create && index > 0) {
|
|
46
|
+
try {
|
|
47
|
+
fs.mkdirSync(directory, { mode: 0o700 });
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error.code !== "EEXIST") {
|
|
51
|
+
fail("kr_freshness_ledger_write_failed", `Cannot create KR freshness ledger directory: ${directory}`, { path: directory });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
let stat;
|
|
56
|
+
try {
|
|
57
|
+
stat = fs.lstatSync(directory);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (!create && error.code === "ENOENT")
|
|
61
|
+
return false;
|
|
62
|
+
fail("kr_freshness_ledger_unavailable", `Cannot inspect KR freshness ledger directory: ${directory}`, { path: directory });
|
|
63
|
+
}
|
|
64
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
65
|
+
fail("kr_freshness_ledger_invalid", `KR freshness ledger path must not be a symlink: ${directory}`, { path: directory });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
function ledgerFilePath(root, laneId) {
|
|
71
|
+
if (!safeLaneId(laneId))
|
|
72
|
+
fail("kr_freshness_lane_id_invalid", "Lane id cannot be used as a KR freshness ledger filename");
|
|
73
|
+
return path.join(root, KR_FRESHNESS_LEDGER_REL, `${laneId}.json`);
|
|
74
|
+
}
|
|
75
|
+
function validDate(value) {
|
|
76
|
+
return typeof value === "string" && Number.isFinite(Date.parse(value));
|
|
77
|
+
}
|
|
78
|
+
function validateLedger(value, laneId, filePath) {
|
|
79
|
+
if (!isObject(value) || value.schema_version !== KR_FRESHNESS_LEDGER_SCHEMA || value.lane_id !== laneId ||
|
|
80
|
+
!validDate(value.updated_at) || !isObject(value.kpis)) {
|
|
81
|
+
fail("kr_freshness_ledger_invalid", `KR freshness ledger has an invalid shape: ${filePath}`, { path: filePath });
|
|
82
|
+
}
|
|
83
|
+
const keys = Object.keys(value.kpis);
|
|
84
|
+
if (keys.length > MAX_KPIS)
|
|
85
|
+
fail("kr_freshness_ledger_invalid", `KR freshness ledger exceeds ${MAX_KPIS} entries`, { path: filePath });
|
|
86
|
+
const kpis = {};
|
|
87
|
+
for (const key of keys) {
|
|
88
|
+
const row = value.kpis[key];
|
|
89
|
+
if (!/^[a-f0-9]{64}$/.test(key) || !isObject(row) || typeof row.name !== "string" || row.name.trim() === "" ||
|
|
90
|
+
!(typeof row.value === "string" || (typeof row.value === "number" && Number.isFinite(row.value))) ||
|
|
91
|
+
!(row.entry_measured_at === null || typeof row.entry_measured_at === "string") ||
|
|
92
|
+
!validDate(row.first_seen_at) || !validDate(row.measured_at) || !validDate(row.value_changed_at) ||
|
|
93
|
+
(row.removed_at !== undefined && !validDate(row.removed_at))) {
|
|
94
|
+
fail("kr_freshness_ledger_invalid", `KR freshness ledger contains an invalid KPI record: ${filePath}`, { path: filePath });
|
|
95
|
+
}
|
|
96
|
+
assertNoSecretText("KR name", row.name);
|
|
97
|
+
if (typeof row.value === "string")
|
|
98
|
+
assertNoSecretText("KR value", row.value);
|
|
99
|
+
if (typeof row.entry_measured_at === "string")
|
|
100
|
+
assertNoSecretText("KR measured_at", row.entry_measured_at);
|
|
101
|
+
kpis[key] = {
|
|
102
|
+
name: row.name,
|
|
103
|
+
value: row.value,
|
|
104
|
+
entry_measured_at: row.entry_measured_at,
|
|
105
|
+
first_seen_at: row.first_seen_at,
|
|
106
|
+
measured_at: row.measured_at,
|
|
107
|
+
value_changed_at: row.value_changed_at,
|
|
108
|
+
...(typeof row.removed_at === "string" ? { removed_at: row.removed_at } : {}),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
schema_version: KR_FRESHNESS_LEDGER_SCHEMA,
|
|
113
|
+
lane_id: laneId,
|
|
114
|
+
updated_at: value.updated_at,
|
|
115
|
+
kpis,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
export function readKrFreshnessStatus(statusPath) {
|
|
119
|
+
let stat;
|
|
120
|
+
try {
|
|
121
|
+
stat = fs.lstatSync(statusPath);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
fail("kr_freshness_status_unreadable", `Cannot inspect manager status for KR freshness: ${statusPath}`, {
|
|
125
|
+
path: statusPath,
|
|
126
|
+
cause: error.code ?? "unknown",
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_STATUS_BYTES) {
|
|
130
|
+
fail("kr_freshness_status_invalid", `Manager status is not a bounded regular file: ${statusPath}`, { path: statusPath });
|
|
131
|
+
}
|
|
132
|
+
let text;
|
|
133
|
+
try {
|
|
134
|
+
text = fs.readFileSync(statusPath, "utf8");
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
fail("kr_freshness_status_unreadable", `Cannot read manager status for KR freshness: ${statusPath}`, {
|
|
138
|
+
path: statusPath,
|
|
139
|
+
cause: error.code ?? "unknown",
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
if (Buffer.byteLength(text, "utf8") > MAX_STATUS_BYTES) {
|
|
143
|
+
fail("kr_freshness_status_invalid", `Manager status exceeds the KR freshness size limit: ${statusPath}`, { path: statusPath });
|
|
144
|
+
}
|
|
145
|
+
let parsed;
|
|
146
|
+
try {
|
|
147
|
+
parsed = JSON.parse(text);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
fail("kr_freshness_status_invalid", `Manager status contains invalid JSON: ${statusPath}`, { path: statusPath });
|
|
151
|
+
}
|
|
152
|
+
if (!isObject(parsed) || !Array.isArray(parsed.kpis)) {
|
|
153
|
+
fail("kr_freshness_status_invalid", `Manager status must contain a top-level kpis array: ${statusPath}`, { path: statusPath });
|
|
154
|
+
}
|
|
155
|
+
if (parsed.kpis.length > MAX_KPIS) {
|
|
156
|
+
fail("kr_freshness_status_invalid", `Manager status exceeds ${MAX_KPIS} KPIs: ${statusPath}`, { path: statusPath });
|
|
157
|
+
}
|
|
158
|
+
const found = new Map();
|
|
159
|
+
for (const [index, candidate] of parsed.kpis.entries()) {
|
|
160
|
+
if (!isObject(candidate) || typeof candidate.name !== "string" || candidate.name.trim() === "")
|
|
161
|
+
continue;
|
|
162
|
+
const fullName = candidate.name.trim();
|
|
163
|
+
if (Array.from(fullName).some((char) => {
|
|
164
|
+
const codePoint = char.codePointAt(0) ?? 0;
|
|
165
|
+
return codePoint <= 0x1f || codePoint === 0x7f;
|
|
166
|
+
})) {
|
|
167
|
+
fail("kr_freshness_status_invalid", `Manager KPI ${index + 1} name contains control characters: ${statusPath}`, { path: statusPath, index });
|
|
168
|
+
}
|
|
169
|
+
if (!(typeof candidate.value === "string" || (typeof candidate.value === "number" && Number.isFinite(candidate.value)))) {
|
|
170
|
+
fail("kr_freshness_status_invalid", `Manager KPI ${index + 1} needs a string or finite number value: ${statusPath}`, { path: statusPath, index });
|
|
171
|
+
}
|
|
172
|
+
if (candidate.measured_at !== undefined && typeof candidate.measured_at !== "string") {
|
|
173
|
+
fail("kr_freshness_status_invalid", `Manager KPI ${index + 1} measured_at must be a string when present: ${statusPath}`, { path: statusPath, index });
|
|
174
|
+
}
|
|
175
|
+
assertNoSecretText("KR name", fullName);
|
|
176
|
+
if (typeof candidate.value === "string")
|
|
177
|
+
assertNoSecretText("KR value", candidate.value);
|
|
178
|
+
if (typeof candidate.measured_at === "string")
|
|
179
|
+
assertNoSecretText("KR measured_at", candidate.measured_at);
|
|
180
|
+
const key = sha256(fullName);
|
|
181
|
+
if (found.has(key))
|
|
182
|
+
fail("kr_freshness_status_invalid", `Manager status contains duplicate KPI names: ${statusPath}`, { path: statusPath, index });
|
|
183
|
+
found.set(key, {
|
|
184
|
+
key,
|
|
185
|
+
fullName,
|
|
186
|
+
name: displayName(fullName),
|
|
187
|
+
value: candidate.value,
|
|
188
|
+
entryMeasuredAt: typeof candidate.measured_at === "string" ? candidate.measured_at : null,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return [...found.values()];
|
|
192
|
+
}
|
|
193
|
+
export function krFreshnessLedgerPath(root, laneId) {
|
|
194
|
+
return ledgerFilePath(root, laneId);
|
|
195
|
+
}
|
|
196
|
+
export function readKrFreshnessLedger(root, laneId) {
|
|
197
|
+
const filePath = ledgerFilePath(root, laneId);
|
|
198
|
+
if (!ensureLedgerDirectory(root, false))
|
|
199
|
+
return null;
|
|
200
|
+
let stat;
|
|
201
|
+
try {
|
|
202
|
+
stat = fs.lstatSync(filePath);
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
if (error.code === "ENOENT")
|
|
206
|
+
return null;
|
|
207
|
+
fail("kr_freshness_ledger_unreadable", `Cannot inspect KR freshness ledger: ${filePath}`, { path: filePath });
|
|
208
|
+
}
|
|
209
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_LEDGER_BYTES) {
|
|
210
|
+
fail("kr_freshness_ledger_invalid", `KR freshness ledger is not a bounded regular file: ${filePath}`, { path: filePath });
|
|
211
|
+
}
|
|
212
|
+
let parsed;
|
|
213
|
+
try {
|
|
214
|
+
parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
fail("kr_freshness_ledger_invalid", `KR freshness ledger contains invalid JSON: ${filePath}`, { path: filePath });
|
|
218
|
+
}
|
|
219
|
+
return validateLedger(parsed, laneId, filePath);
|
|
220
|
+
}
|
|
221
|
+
export function writeKrFreshnessLedger(root, ledger) {
|
|
222
|
+
const filePath = ledgerFilePath(root, ledger.lane_id);
|
|
223
|
+
const directory = path.dirname(filePath);
|
|
224
|
+
let tempPath;
|
|
225
|
+
try {
|
|
226
|
+
ensureLedgerDirectory(root, true);
|
|
227
|
+
const data = `${JSON.stringify(ledger, null, 2)}\n`;
|
|
228
|
+
if (Buffer.byteLength(data, "utf8") > MAX_LEDGER_BYTES) {
|
|
229
|
+
fail("kr_freshness_ledger_invalid", `KR freshness ledger exceeds ${MAX_LEDGER_BYTES} bytes`, { path: filePath });
|
|
230
|
+
}
|
|
231
|
+
tempPath = path.join(directory, `.${ledger.lane_id}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
232
|
+
fs.writeFileSync(tempPath, data, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
233
|
+
fs.renameSync(tempPath, filePath);
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
if (tempPath !== undefined) {
|
|
237
|
+
try {
|
|
238
|
+
fs.unlinkSync(tempPath);
|
|
239
|
+
}
|
|
240
|
+
catch { /* preserve the original write failure */ }
|
|
241
|
+
}
|
|
242
|
+
if (error instanceof LanesError)
|
|
243
|
+
throw error;
|
|
244
|
+
fail("kr_freshness_ledger_write_failed", `Cannot write KR freshness ledger: ${filePath}`, { path: filePath });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
export function updateKrFreshnessLedger(laneId, prior, statusKpis, now) {
|
|
248
|
+
const at = new Date(now).toISOString();
|
|
249
|
+
const next = {};
|
|
250
|
+
const seen = new Set();
|
|
251
|
+
for (const item of statusKpis) {
|
|
252
|
+
const { key, fullName } = item;
|
|
253
|
+
seen.add(key);
|
|
254
|
+
const old = prior?.kpis[key];
|
|
255
|
+
const changed = old === undefined || JSON.stringify(old.value) !== JSON.stringify(item.value);
|
|
256
|
+
const remeasured = old === undefined || old.removed_at !== undefined || old.entry_measured_at !== item.entryMeasuredAt;
|
|
257
|
+
next[key] = {
|
|
258
|
+
name: item.name,
|
|
259
|
+
value: item.value,
|
|
260
|
+
entry_measured_at: item.entryMeasuredAt,
|
|
261
|
+
first_seen_at: old?.first_seen_at ?? at,
|
|
262
|
+
measured_at: changed || remeasured ? at : old.measured_at,
|
|
263
|
+
value_changed_at: changed ? at : old.value_changed_at,
|
|
264
|
+
};
|
|
265
|
+
assertNoSecretText("KR name", fullName);
|
|
266
|
+
}
|
|
267
|
+
for (const [key, old] of Object.entries(prior?.kpis ?? {})) {
|
|
268
|
+
if (seen.has(key))
|
|
269
|
+
continue;
|
|
270
|
+
const removedAt = old.removed_at ?? at;
|
|
271
|
+
if (now - Date.parse(removedAt) >= REMOVED_RETENTION_MS)
|
|
272
|
+
continue;
|
|
273
|
+
next[key] = { ...old, removed_at: removedAt };
|
|
274
|
+
}
|
|
275
|
+
if (Object.keys(next).length > MAX_KPIS) {
|
|
276
|
+
fail("kr_freshness_ledger_invalid", `KR freshness ledger exceeds ${MAX_KPIS} retained entries`);
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
schema_version: KR_FRESHNESS_LEDGER_SCHEMA,
|
|
280
|
+
lane_id: laneId,
|
|
281
|
+
updated_at: at,
|
|
282
|
+
kpis: next,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
export function krFreshnessVerdict(kpi, now, windowHours) {
|
|
286
|
+
const cutoff = now - windowHours * 60 * 60 * 1000;
|
|
287
|
+
if (Date.parse(kpi.value_changed_at) >= cutoff)
|
|
288
|
+
return "moving";
|
|
289
|
+
if (Date.parse(kpi.measured_at) >= cutoff)
|
|
290
|
+
return "flat";
|
|
291
|
+
return "stalled";
|
|
292
|
+
}
|
|
293
|
+
export function krFreshnessFingerprint(keys) {
|
|
294
|
+
return sha256(JSON.stringify([...keys].sort()));
|
|
295
|
+
}
|
|
296
|
+
function viewsFromLedger(ledger, now, windowHours) {
|
|
297
|
+
if (!ledger)
|
|
298
|
+
return [];
|
|
299
|
+
return Object.entries(ledger.kpis)
|
|
300
|
+
.filter(([, kpi]) => kpi.removed_at === undefined)
|
|
301
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
302
|
+
.map(([, kpi]) => ({
|
|
303
|
+
name: kpi.name,
|
|
304
|
+
value: kpi.value,
|
|
305
|
+
verdict: krFreshnessVerdict(kpi, now, windowHours),
|
|
306
|
+
measured_at: kpi.measured_at,
|
|
307
|
+
value_changed_at: kpi.value_changed_at,
|
|
308
|
+
}));
|
|
309
|
+
}
|
|
310
|
+
function reportFailure(lane, error) {
|
|
311
|
+
process.stderr.write(`hq lanes: KR freshness disabled for ${lane.lane_id}: ${errorCode(error)}\n`);
|
|
312
|
+
}
|
|
313
|
+
function failedResolution(error, onError) {
|
|
314
|
+
onError(error);
|
|
315
|
+
return {
|
|
316
|
+
flagEnabled: false,
|
|
317
|
+
enabled: false,
|
|
318
|
+
windowHours: KR_FRESHNESS_DEFAULT_HOURS,
|
|
319
|
+
kpis: [],
|
|
320
|
+
stalledKeys: [],
|
|
321
|
+
error: errorCode(error),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
/** Read the flag before manager mapping, status, configuration or ledger files. */
|
|
325
|
+
export async function resolveKrFreshnessForLane(root, lane, options = {}) {
|
|
326
|
+
const onError = options.onError ?? ((error) => reportFailure(lane, error));
|
|
327
|
+
let flagEnabled;
|
|
328
|
+
try {
|
|
329
|
+
flagEnabled = await (options.readFlag ?? readLanesRegistryFlag)(KR_FRESHNESS_FLAG, {
|
|
330
|
+
company: lane.company ?? lane.mesh_company ?? undefined,
|
|
331
|
+
env: options.env,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
return failedResolution(error, onError);
|
|
336
|
+
}
|
|
337
|
+
if (!flagEnabled) {
|
|
338
|
+
return { flagEnabled: false, enabled: false, windowHours: KR_FRESHNESS_DEFAULT_HOURS, kpis: [], stalledKeys: [] };
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
const target = resolveManagerStatusTarget(root, lane);
|
|
342
|
+
if (!target.manager || target.statusPath === undefined) {
|
|
343
|
+
return {
|
|
344
|
+
flagEnabled: true,
|
|
345
|
+
enabled: true,
|
|
346
|
+
target,
|
|
347
|
+
windowHours: KR_FRESHNESS_DEFAULT_HOURS,
|
|
348
|
+
kpis: [],
|
|
349
|
+
stalledKeys: [],
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
const windowHours = loadLanesConfig(root).kr_freshness_hours;
|
|
353
|
+
const statusKpis = readKrFreshnessStatus(target.statusPath);
|
|
354
|
+
const prior = readKrFreshnessLedger(root, lane.lane_id);
|
|
355
|
+
const ledger = updateKrFreshnessLedger(lane.lane_id, prior, statusKpis, options.now ?? Date.now());
|
|
356
|
+
writeKrFreshnessLedger(root, ledger);
|
|
357
|
+
const effectiveNow = options.now ?? Date.now();
|
|
358
|
+
return {
|
|
359
|
+
flagEnabled: true,
|
|
360
|
+
enabled: true,
|
|
361
|
+
target,
|
|
362
|
+
statusPath: target.statusPath,
|
|
363
|
+
windowHours,
|
|
364
|
+
kpis: viewsFromLedger(ledger, effectiveNow, windowHours),
|
|
365
|
+
stalledKeys: Object.entries(ledger.kpis)
|
|
366
|
+
.filter(([, kpi]) => kpi.removed_at === undefined && krFreshnessVerdict(kpi, effectiveNow, windowHours) === "stalled")
|
|
367
|
+
.map(([key]) => key)
|
|
368
|
+
.sort(),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
return failedResolution(error, onError);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/** Read a previously written ledger for `lanes show`; this never reads status. */
|
|
376
|
+
export async function resolveKrFreshnessForShow(root, lane, options = {}) {
|
|
377
|
+
const onError = options.onError ?? ((error) => reportFailure(lane, error));
|
|
378
|
+
let enabled;
|
|
379
|
+
try {
|
|
380
|
+
enabled = await (options.readFlag ?? readLanesRegistryFlag)(KR_FRESHNESS_FLAG, {
|
|
381
|
+
company: lane.company ?? lane.mesh_company ?? undefined,
|
|
382
|
+
env: options.env,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
onError(error);
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
if (!enabled)
|
|
390
|
+
return null;
|
|
391
|
+
try {
|
|
392
|
+
const windowHours = loadLanesConfig(root).kr_freshness_hours;
|
|
393
|
+
const ledger = readKrFreshnessLedger(root, lane.lane_id);
|
|
394
|
+
return { enabled: true, kpis: viewsFromLedger(ledger, options.now ?? Date.now(), windowHours) };
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
onError(error);
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/** Operator report path: the ledger remains readable while runtime enforcement is off. */
|
|
402
|
+
export function readKrFreshnessReport(root, laneId, now) {
|
|
403
|
+
const ledger = readKrFreshnessLedger(root, laneId);
|
|
404
|
+
const windowHours = loadLanesConfig(root).kr_freshness_hours;
|
|
405
|
+
return {
|
|
406
|
+
ledger_path: krFreshnessLedgerPath(root, laneId),
|
|
407
|
+
available: ledger !== null,
|
|
408
|
+
kr_freshness_hours: windowHours,
|
|
409
|
+
kpis: viewsFromLedger(ledger, now, windowHours),
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
//# sourceMappingURL=kr-freshness.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { LaneCompanyRef, LaneRecord } from "./types.js";
|
|
2
|
+
export type LaneCompanySource = "lane" | "project" | "unresolved";
|
|
3
|
+
export interface LaneCompanyResolution {
|
|
4
|
+
company: LaneCompanyRef | null;
|
|
5
|
+
source: LaneCompanySource;
|
|
6
|
+
}
|
|
7
|
+
/** Resolve a lane's company without inferring ownership from its worker or caller. */
|
|
8
|
+
export declare function resolveLaneCompany(hqRoot: string, lane: Pick<LaneRecord, "lane_id" | "company" | "project_id">): LaneCompanyResolution;
|
|
9
|
+
//# sourceMappingURL=lane-company.d.ts.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { resolveProjectQuestionCompany } from "./questions.js";
|
|
2
|
+
import { LanesError } from "./errors.js";
|
|
3
|
+
/** Resolve a lane's company without inferring ownership from its worker or caller. */
|
|
4
|
+
export function resolveLaneCompany(hqRoot, lane) {
|
|
5
|
+
const slug = typeof lane.company?.slug === "string" ? lane.company.slug.trim() : "";
|
|
6
|
+
const uid = typeof lane.company?.uid === "string" ? lane.company.uid.trim() : "";
|
|
7
|
+
if (slug && uid) {
|
|
8
|
+
return { company: { slug, uid }, source: "lane" };
|
|
9
|
+
}
|
|
10
|
+
let projectCompany;
|
|
11
|
+
try {
|
|
12
|
+
projectCompany = resolveProjectQuestionCompany(hqRoot, lane);
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
if (err instanceof LanesError && err.error === "question_company_unavailable") {
|
|
16
|
+
return { company: null, source: "unresolved" };
|
|
17
|
+
}
|
|
18
|
+
throw err;
|
|
19
|
+
}
|
|
20
|
+
return projectCompany
|
|
21
|
+
? { company: projectCompany, source: "project" }
|
|
22
|
+
: { company: null, source: "unresolved" };
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=lane-company.js.map
|
|
@@ -22,6 +22,7 @@ import { ANSWERED_QUESTIONS_FILE, armLaneWatcher, envelopeRepairMode, isPidRecor
|
|
|
22
22
|
import { appendCappedHistory, lanesStoreRoot, parseLaneRecord, parseQuestionRecord, readLane, readQuestion, updateLane, updateQuestion, } from "./store.js";
|
|
23
23
|
import { applyThrottleDecision, decideAdmission, defaultThrottleDeps, emptyThrottleState, evaluateThrottle, isCpuCheckEnabled, readAvailableMemMb, readThrottleState, resolveCpuBusyFraction, throttleStatePath, writeThrottleState, } from "./throttle.js";
|
|
24
24
|
import { readLanesRegistryFlag } from "./registry-flag.js";
|
|
25
|
+
import { resolveLaneCompany } from "./lane-company.js";
|
|
25
26
|
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, LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, doneKeepAliveVerdict, hasPendingKeepAliveWork, keepAliveExclusion, loadRetiredLaneIds, leadLaneKeepAliveBackoffMs, undeliveredAnsweredQuestions, } from "./keep-alive.js";
|
|
26
27
|
import { providerSupportsSessionResume } from "./delivery.js";
|
|
27
28
|
import { applyDefaultQuotaHold, readQuotaSignal, recordAccountQuota } from "./quota.js";
|
|
@@ -851,8 +852,11 @@ export async function runPassBody(hqRoot, deps) {
|
|
|
851
852
|
if (doneKeepAliveVerdict(lane, envelope, hasQueuedWork) !== "eligible")
|
|
852
853
|
continue;
|
|
853
854
|
}
|
|
854
|
-
const
|
|
855
|
-
|
|
855
|
+
const company = resolveLaneCompany(hqRoot, lane).company;
|
|
856
|
+
if (company === null)
|
|
857
|
+
continue;
|
|
858
|
+
const key = keepAliveCompanyKey(company);
|
|
859
|
+
keepAliveFlagCompanies.set(key, company);
|
|
856
860
|
}
|
|
857
861
|
const keepAliveFlagByCompany = new Map();
|
|
858
862
|
const supersessionLineageAwareByCompany = new Map();
|
|
@@ -1598,6 +1602,15 @@ function resumeKeepAlive(hqRoot, lanes, questions, summary, deps, now, flagByCom
|
|
|
1598
1602
|
const nowMs = now.getTime();
|
|
1599
1603
|
const nowIso = now.toISOString();
|
|
1600
1604
|
const config = loadLanesConfig(hqRoot);
|
|
1605
|
+
const resolvedCompanyByLaneVersion = new Map();
|
|
1606
|
+
const companyForLane = (candidate) => {
|
|
1607
|
+
const key = `${candidate.lane_id}\u0000${candidate.version}`;
|
|
1608
|
+
if (resolvedCompanyByLaneVersion.has(key))
|
|
1609
|
+
return resolvedCompanyByLaneVersion.get(key) ?? null;
|
|
1610
|
+
const company = resolveLaneCompany(hqRoot, candidate).company;
|
|
1611
|
+
resolvedCompanyByLaneVersion.set(key, company);
|
|
1612
|
+
return company;
|
|
1613
|
+
};
|
|
1601
1614
|
const keepAliveEnabled = [...flagByCompany.values()].some((enabled) => enabled);
|
|
1602
1615
|
let retiredLaneIds = null;
|
|
1603
1616
|
if (keepAliveEnabled) {
|
|
@@ -1615,7 +1628,7 @@ function resumeKeepAlive(hqRoot, lanes, questions, summary, deps, now, flagByCom
|
|
|
1615
1628
|
originalLane.state === "cancelled" ||
|
|
1616
1629
|
originalLane.state === "failed") {
|
|
1617
1630
|
if (originalLane.keep_alive === true &&
|
|
1618
|
-
flagByCompany.get(keepAliveCompanyKey(originalLane
|
|
1631
|
+
flagByCompany.get(keepAliveCompanyKey(companyForLane(originalLane))) === true)
|
|
1619
1632
|
recordKeepAliveSkip(hqRoot, originalLane, "terminal_state", summary, deps);
|
|
1620
1633
|
continue;
|
|
1621
1634
|
}
|
|
@@ -1641,8 +1654,28 @@ function resumeKeepAlive(hqRoot, lanes, questions, summary, deps, now, flagByCom
|
|
|
1641
1654
|
continue;
|
|
1642
1655
|
}
|
|
1643
1656
|
}
|
|
1644
|
-
|
|
1657
|
+
const company = companyForLane(lane);
|
|
1658
|
+
if (company === null) {
|
|
1659
|
+
if (lane.keep_alive === true) {
|
|
1660
|
+
summary.skipped.push({
|
|
1661
|
+
kind: "lane",
|
|
1662
|
+
id: lane.lane_id,
|
|
1663
|
+
why: "keep_alive_company_unresolved",
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1645
1666
|
continue;
|
|
1667
|
+
}
|
|
1668
|
+
const companyKey = keepAliveCompanyKey(company);
|
|
1669
|
+
if (flagByCompany.get(companyKey) !== true) {
|
|
1670
|
+
if (lane.keep_alive === true) {
|
|
1671
|
+
summary.skipped.push({
|
|
1672
|
+
kind: "lane",
|
|
1673
|
+
id: lane.lane_id,
|
|
1674
|
+
why: "keep_alive_flag_off",
|
|
1675
|
+
});
|
|
1676
|
+
}
|
|
1677
|
+
continue;
|
|
1678
|
+
}
|
|
1646
1679
|
// Persist a stable timestamp for legacy interrupted rows before a later
|
|
1647
1680
|
// keep-alive status write can advance their general updated_at clock. The
|
|
1648
1681
|
// watcher classification is the interruption time; updated_at is only an
|
|
@@ -1760,7 +1793,7 @@ function resumeKeepAlive(hqRoot, lanes, questions, summary, deps, now, flagByCom
|
|
|
1760
1793
|
recordKeepAliveSkip(hqRoot, lane, "retired_list_invalid", summary, deps);
|
|
1761
1794
|
continue;
|
|
1762
1795
|
}
|
|
1763
|
-
const exclusion = keepAliveExclusion(lane, lanes, retiredLaneIds, supersessionLineageAwareByCompany.get(
|
|
1796
|
+
const exclusion = keepAliveExclusion(lane, lanes, retiredLaneIds, supersessionLineageAwareByCompany.get(companyKey) === true, companyForLane);
|
|
1764
1797
|
if (exclusion !== undefined) {
|
|
1765
1798
|
recordKeepAliveSkip(hqRoot, lane, exclusion, summary, deps);
|
|
1766
1799
|
continue;
|
|
@@ -1851,7 +1884,25 @@ function resumeKeepAlive(hqRoot, lanes, questions, summary, deps, now, flagByCom
|
|
|
1851
1884
|
continue;
|
|
1852
1885
|
}
|
|
1853
1886
|
const currentLanes = loadLaneRecords(hqRoot, summary);
|
|
1854
|
-
const
|
|
1887
|
+
const currentCompany = companyForLane(current);
|
|
1888
|
+
if (currentCompany === null) {
|
|
1889
|
+
summary.skipped.push({
|
|
1890
|
+
kind: "lane",
|
|
1891
|
+
id: current.lane_id,
|
|
1892
|
+
why: "keep_alive_company_unresolved",
|
|
1893
|
+
});
|
|
1894
|
+
continue;
|
|
1895
|
+
}
|
|
1896
|
+
const currentCompanyKey = keepAliveCompanyKey(currentCompany);
|
|
1897
|
+
if (flagByCompany.get(currentCompanyKey) !== true) {
|
|
1898
|
+
summary.skipped.push({
|
|
1899
|
+
kind: "lane",
|
|
1900
|
+
id: current.lane_id,
|
|
1901
|
+
why: "keep_alive_flag_off",
|
|
1902
|
+
});
|
|
1903
|
+
continue;
|
|
1904
|
+
}
|
|
1905
|
+
const currentExclusion = keepAliveExclusion(current, currentLanes, retiredLaneIds, supersessionLineageAwareByCompany.get(currentCompanyKey) === true, companyForLane);
|
|
1855
1906
|
if (currentExclusion !== undefined) {
|
|
1856
1907
|
recordKeepAliveSkip(hqRoot, current, currentExclusion, summary, deps);
|
|
1857
1908
|
continue;
|
|
@@ -370,7 +370,22 @@ export interface BlockerFreshnessHistoryEntry {
|
|
|
370
370
|
resume_skipped_reason?: "open_question" | "blocked" | "miss_limit" | "no_provider_session";
|
|
371
371
|
delivery_failed?: true;
|
|
372
372
|
}
|
|
373
|
-
export
|
|
373
|
+
export interface KrFreshnessState {
|
|
374
|
+
/** Stable fingerprint of the last stalled KPI set, which is resumed once. */
|
|
375
|
+
stalled_fingerprint?: string;
|
|
376
|
+
}
|
|
377
|
+
export interface KrFreshnessHistoryEntry {
|
|
378
|
+
event: "kr_stalled";
|
|
379
|
+
turn_id: string;
|
|
380
|
+
path: string;
|
|
381
|
+
at: string;
|
|
382
|
+
names: string[];
|
|
383
|
+
count: number;
|
|
384
|
+
fingerprint: string;
|
|
385
|
+
resume_queued?: boolean;
|
|
386
|
+
resume_skipped_reason?: "open_question" | "blocked" | "miss_limit" | "no_provider_session";
|
|
387
|
+
}
|
|
388
|
+
export type LaneGateHistoryEntry = ManagerStatusGateHistoryEntry | BlockerFreshnessHistoryEntry | KrFreshnessHistoryEntry;
|
|
374
389
|
export interface LaneStory {
|
|
375
390
|
project_id: string;
|
|
376
391
|
story_id: string;
|
|
@@ -516,6 +531,8 @@ export interface LaneRecord {
|
|
|
516
531
|
status_gate_history?: LaneGateHistoryEntry[];
|
|
517
532
|
/** Last blocker-freshness state used to detect consecutive stale turns. */
|
|
518
533
|
blocker_freshness_state?: BlockerFreshnessState;
|
|
534
|
+
/** Last stalled KR set already offered one automatic resume. */
|
|
535
|
+
kr_freshness_state?: KrFreshnessState;
|
|
519
536
|
/** Last automatic compaction of a resumed session (compaction.ts). */
|
|
520
537
|
last_compaction?: LaneCompactionRecord;
|
|
521
538
|
/**
|
|
@@ -857,6 +874,8 @@ export interface LanesConfig {
|
|
|
857
874
|
session_question_timeout_secs: number;
|
|
858
875
|
/** Age in hours after which an open manager blocker must be re-verified. */
|
|
859
876
|
blocker_freshness_hours: number;
|
|
877
|
+
/** Age in hours after which a manager KPI is considered stalled. */
|
|
878
|
+
kr_freshness_hours: number;
|
|
860
879
|
}
|
|
861
880
|
export declare function parseMajorSchema(schemaVersion: string, expectedPrefix: string, expectedMajor: number): void;
|
|
862
881
|
export declare function parseLaneEnvelope(raw: unknown): LaneEnvelope;
|
package/dist/lib/lanes/types.js
CHANGED
|
@@ -228,7 +228,7 @@ export const LANE_FIELD_WRITERS = {
|
|
|
228
228
|
provider: ["creator"], account: ["creator", "spawn", "command", "watcher", "reconcile"], account_selection: ["creator", "command"], admission_accounts: ["creator", "command"], account_history: ["command", "spawn", "watcher", "reconcile"], failed_resume_history: ["command"], 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"],
|
|
231
|
-
last_message_at: ["command"], status_gate: ["spawn", "watcher"], status_gate_history: ["watcher"], blocker_freshness_state: ["watcher"], last_compaction: ["spawn"], story_complete: ["watcher", "reconcile"], story_complete_reason: ["watcher", "reconcile"], story_status: ["watcher", "reconcile"],
|
|
231
|
+
last_message_at: ["command"], status_gate: ["spawn", "watcher"], status_gate_history: ["watcher"], blocker_freshness_state: ["watcher"], kr_freshness_state: ["watcher"], 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
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"],
|
|
@@ -233,6 +233,8 @@ export interface WatcherLoopDeps {
|
|
|
233
233
|
readManagerStatusGateFlag?: typeof readLanesRegistryFlag;
|
|
234
234
|
/** Test seam; production reads the company-scoped hq-flags row. */
|
|
235
235
|
readBlockerFreshnessFlag?: typeof readLanesRegistryFlag;
|
|
236
|
+
/** Test seam; production reads the company-scoped hq-flags row. */
|
|
237
|
+
readKrFreshnessFlag?: typeof readLanesRegistryFlag;
|
|
236
238
|
}
|
|
237
239
|
/**
|
|
238
240
|
* Wrap one mesh call. A failed emit is logged as `mesh_event_failed` with
|
|
@@ -49,6 +49,7 @@ import { createLaneMesh, emitLaneDecisionEvents, emitLaneProgressEvent, emitLane
|
|
|
49
49
|
import { envelopeQuestionId } from "./questions.js";
|
|
50
50
|
import { readLanesRegistryFlag } from "./registry-flag.js";
|
|
51
51
|
import { blockerFreshnessFingerprint, resolveBlockerFreshnessForLane, } from "./blocker-freshness.js";
|
|
52
|
+
import { krFreshnessFingerprint, resolveKrFreshnessForLane, } from "./kr-freshness.js";
|
|
52
53
|
import { MANAGER_STATUS_GATE_FLAG, managerStatusWasWritten, readStatusSnapshot, resolveManagerStatusTarget, managerStatusGateEnabledForLane, } from "./manager-status-gate.js";
|
|
53
54
|
import { queueLaneTerminalResume, queueManagerStatusGateResume } from "./keep-alive.js";
|
|
54
55
|
import { monitorSessionDir } from "../monitor.js";
|
|
@@ -863,6 +864,72 @@ async function settleBlockerFreshness(hqRoot, lane, manifestTurnId, at, deps, me
|
|
|
863
864
|
}
|
|
864
865
|
return updated;
|
|
865
866
|
}
|
|
867
|
+
async function settleKrFreshness(hqRoot, lane, manifestTurnId, at, deps, logDecision) {
|
|
868
|
+
if (manifestTurnId !== undefined && lane.status_gate !== undefined && lane.status_gate.turn_id !== manifestTurnId) {
|
|
869
|
+
logDecision({ event: "kr_freshness_baseline_mismatch", at, turn_id: lane.status_gate.turn_id });
|
|
870
|
+
return lane;
|
|
871
|
+
}
|
|
872
|
+
let errorLogged = false;
|
|
873
|
+
const resolution = await resolveKrFreshnessForLane(hqRoot, lane, {
|
|
874
|
+
readFlag: deps.readKrFreshnessFlag ?? readLanesRegistryFlag,
|
|
875
|
+
now: Date.parse(at),
|
|
876
|
+
onError: (error) => {
|
|
877
|
+
if (errorLogged)
|
|
878
|
+
return;
|
|
879
|
+
errorLogged = true;
|
|
880
|
+
const code = error instanceof LanesError ? error.error : "kr_freshness_unavailable";
|
|
881
|
+
process.stderr.write(`hq lanes watcher: KR freshness disabled for ${lane.lane_id}: ${code}\n`);
|
|
882
|
+
logDecision({ event: "kr_freshness_disabled", at, error: code });
|
|
883
|
+
},
|
|
884
|
+
});
|
|
885
|
+
if (!resolution.enabled || resolution.statusPath === undefined || resolution.error !== undefined)
|
|
886
|
+
return lane;
|
|
887
|
+
const stalled = resolution.kpis.filter((kpi) => kpi.verdict === "stalled");
|
|
888
|
+
if (stalled.length === 0) {
|
|
889
|
+
if (lane.kr_freshness_state === undefined)
|
|
890
|
+
return lane;
|
|
891
|
+
return retryUpdateLane(hqRoot, lane.lane_id, "watcher", (current) => current.kr_freshness_state === undefined
|
|
892
|
+
? current
|
|
893
|
+
: { ...current, kr_freshness_state: undefined });
|
|
894
|
+
}
|
|
895
|
+
const fingerprint = krFreshnessFingerprint(resolution.stalledKeys);
|
|
896
|
+
const names = stalled.map((kpi) => kpi.name);
|
|
897
|
+
const turnId = manifestTurnId ?? lane.status_gate?.turn_id ?? `unknown-${at}`;
|
|
898
|
+
const resumeText = `These KPIs in ${resolution.statusPath} were not re-measured in the last ${resolution.windowHours}h: ${names.join(", ")}. Measure each one now, update value and measured_at, then end the turn.`;
|
|
899
|
+
return retryUpdateLane(hqRoot, lane.lane_id, "watcher", (current) => {
|
|
900
|
+
const history = current.status_gate_history ?? [];
|
|
901
|
+
if (history.some((entry) => entry.event === "kr_stalled" && entry.turn_id === turnId))
|
|
902
|
+
return current;
|
|
903
|
+
const sameStalledSet = current.kr_freshness_state?.stalled_fingerprint === fingerprint;
|
|
904
|
+
let nextLane = current;
|
|
905
|
+
let resumeQueued = false;
|
|
906
|
+
let resumeSkippedReason;
|
|
907
|
+
if (!sameStalledSet) {
|
|
908
|
+
const mergeExisting = current.pending_resume !== undefined &&
|
|
909
|
+
current.state !== "blocked" && current.state !== "awaiting_input";
|
|
910
|
+
const resume = queueLaneTerminalResume(current, at, resumeText, { mergeExisting });
|
|
911
|
+
nextLane = resume.lane;
|
|
912
|
+
resumeQueued = resume.resumeQueued;
|
|
913
|
+
resumeSkippedReason = resume.resumeSkippedReason;
|
|
914
|
+
}
|
|
915
|
+
const entry = {
|
|
916
|
+
event: "kr_stalled",
|
|
917
|
+
turn_id: turnId,
|
|
918
|
+
path: resolution.statusPath,
|
|
919
|
+
at,
|
|
920
|
+
names,
|
|
921
|
+
count: stalled.length,
|
|
922
|
+
fingerprint,
|
|
923
|
+
...(resumeQueued ? { resume_queued: true } : {}),
|
|
924
|
+
...(resumeSkippedReason !== undefined ? { resume_skipped_reason: resumeSkippedReason } : {}),
|
|
925
|
+
};
|
|
926
|
+
return {
|
|
927
|
+
...nextLane,
|
|
928
|
+
kr_freshness_state: { stalled_fingerprint: fingerprint },
|
|
929
|
+
status_gate_history: appendCappedHistory(history, entry),
|
|
930
|
+
};
|
|
931
|
+
});
|
|
932
|
+
}
|
|
866
933
|
function resolveBlockerFreshnessSessionInbox(hqRoot, sessionId) {
|
|
867
934
|
let candidates;
|
|
868
935
|
try {
|
|
@@ -1452,11 +1519,13 @@ async function runWatcherLoopOwned(hqRoot, laneId, deps) {
|
|
|
1452
1519
|
if (terminalWrite.changed) {
|
|
1453
1520
|
updated = await settleManagerStatusGate(hqRoot, updated, manifest.turn_id, tick.emit.at, deps, logDecision);
|
|
1454
1521
|
updated = await settleBlockerFreshness(hqRoot, updated, manifest.turn_id, tick.emit.at, deps, mesh, logDecision);
|
|
1522
|
+
updated = await settleKrFreshness(hqRoot, updated, manifest.turn_id, tick.emit.at, deps, logDecision);
|
|
1455
1523
|
}
|
|
1456
1524
|
const managerStatusFollowupQueued = manifest.turn_id !== undefined &&
|
|
1457
1525
|
updated.status_gate_history?.some((entry) => entry.turn_id === manifest.turn_id &&
|
|
1458
1526
|
((entry.event === "status_missed" && entry.resume_queued === true) ||
|
|
1459
|
-
(entry.event === "blocker_stale" && entry.resume_queued === true)
|
|
1527
|
+
(entry.event === "blocker_stale" && entry.resume_queued === true) ||
|
|
1528
|
+
(entry.event === "kr_stalled" && entry.resume_queued === true))) === true;
|
|
1460
1529
|
if (terminalWrite.changed && deps.recoverQueuedMessages && !managerStatusFollowupQueued) {
|
|
1461
1530
|
try {
|
|
1462
1531
|
if (dropboxInboxHasMessages(hqRoot, laneId)) {
|