@indigoai-us/hq-cli 5.197.0 → 5.198.1
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 +34 -0
- package/dist/command-catalog.generated.d.ts +390 -0
- package/dist/command-catalog.generated.js +506 -0
- package/dist/commands/index-cmd.d.ts +7 -1
- package/dist/commands/index-cmd.js +46 -21
- package/dist/commands/lanes-tracker.d.ts +20 -0
- package/dist/commands/lanes-tracker.js +420 -0
- package/dist/commands/lanes.d.ts +6 -0
- package/dist/commands/lanes.js +19 -1
- package/dist/lib/bot/inflight.d.ts +12 -0
- package/dist/lib/bot/run.js +21 -2
- package/dist/lib/bot/runtime/messages-stream.d.ts +21 -1
- package/dist/lib/bot/runtime/messages-stream.js +54 -2
- package/dist/lib/core-utils/qmd-reindex-after-sync.d.ts +3 -1
- package/dist/lib/core-utils/qmd-reindex-after-sync.js +69 -40
- package/dist/lib/lanes/config.d.ts +2 -0
- package/dist/lib/lanes/config.js +4 -0
- package/dist/lib/lanes/liveness.d.ts +12 -0
- package/dist/lib/lanes/liveness.js +65 -3
- package/dist/lib/lanes/reconcile.d.ts +1 -1
- package/dist/lib/lanes/reconcile.js +45 -17
- package/dist/lib/lanes/tracker-store.d.ts +27 -0
- package/dist/lib/lanes/tracker-store.js +277 -0
- package/dist/lib/lanes/types.d.ts +2 -0
- package/dist/lib/search-index/embed-lock.d.ts +5 -4
- package/dist/lib/search-index/embed-lock.js +5 -4
- package/dist/lib/search-index/reindex-single-flight-flag.d.ts +5 -0
- package/dist/lib/search-index/reindex-single-flight-flag.js +8 -0
- package/package.json +1 -1
|
@@ -15,12 +15,14 @@ export type QmdReindexOptions = UtilityIo & {
|
|
|
15
15
|
cwd: string;
|
|
16
16
|
}) => unknown;
|
|
17
17
|
sizeLimit?: (hqRoot: string) => unknown;
|
|
18
|
-
/** Test seams for the lock shared by every hq-cli
|
|
18
|
+
/** Test seams for the lock shared by every hq-cli QMD writer. */
|
|
19
19
|
embedLockDependencies?: EmbedLockDependencies;
|
|
20
20
|
/** Test seams for the host load gate consulted while the embed lock is held. */
|
|
21
21
|
loadGateDependencies?: LoadGateDependencies;
|
|
22
22
|
/** Test seam for the hq-flags-gated warning on qmd native startup failures. */
|
|
23
23
|
startupFailureWarningsEnabled?: () => boolean;
|
|
24
|
+
/** Test seam for the default-off shared lock around the full reindex pass. */
|
|
25
|
+
singleFlightEnabled?: () => boolean;
|
|
24
26
|
};
|
|
25
27
|
export declare function qmdReindexAfterSync(args?: string[], options?: QmdReindexOptions): number;
|
|
26
28
|
//# sourceMappingURL=qmd-reindex-after-sync.d.ts.map
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Contract preserved verbatim: a missing qmd or a non-HQ path is a silent
|
|
9
9
|
* success (a sync must never fail because indexing could not run), the lexical
|
|
10
|
-
* update
|
|
10
|
+
* update normally runs, and embeddings rebuild only on `--embed`. An enabled
|
|
11
|
+
* single-flight canary may skip a pass while another QMD writer owns the lock.
|
|
11
12
|
*/
|
|
12
13
|
import * as fs from "node:fs";
|
|
13
14
|
import * as path from "node:path";
|
|
@@ -16,6 +17,7 @@ import { applyIndexSizeLimit } from "../search-index/max-doc-bytes.js";
|
|
|
16
17
|
import { defaultEmbedLockDependencies, runWithEmbedLock, } from "../search-index/embed-lock.js";
|
|
17
18
|
import { LoadGateConfigurationError, checkEmbedLoad, defaultLoadGateDependencies, recordSuccessfulEmbed, } from "../search-index/load-gate.js";
|
|
18
19
|
import { resolveProcessFlagGate } from "../flag-registry.js";
|
|
20
|
+
import { qmdReindexSingleFlightEnabled } from "../search-index/reindex-single-flight-flag.js";
|
|
19
21
|
import { isQmdNativeBindingError, QMD_NATIVE_BINDING_REMEDY, } from "../../utils/qmd-native-binding-error.js";
|
|
20
22
|
import { ioFor, line } from "./common.js";
|
|
21
23
|
export const QMD_REINDEX_STARTUP_WARNING_FLAG = "qmd.reindex-startup-warning";
|
|
@@ -58,52 +60,59 @@ export function qmdReindexAfterSync(args = [], options = {}) {
|
|
|
58
60
|
startupFailureWarningShown = true;
|
|
59
61
|
line(stderr, `hq: search index was not updated after sync; ${QMD_NATIVE_BINDING_REMEDY}`);
|
|
60
62
|
};
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
63
|
+
const updateIndex = () => {
|
|
64
|
+
// Every qmd interaction is best-effort: the shell suffixed each with `|| true`.
|
|
65
|
+
try {
|
|
66
|
+
reconcile(hqRoot, { bin });
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
/* registration is advisory; an update still helps */
|
|
70
|
+
}
|
|
71
|
+
// The cap has to land before the update: qmd decides what to read
|
|
72
|
+
// at glob time, so an ignore entry written afterwards saves nothing on this
|
|
73
|
+
// pass. This is the post-sync entry point, which reaches `qmd update` without
|
|
74
|
+
// going through `hq index sync` at all.
|
|
75
|
+
try {
|
|
76
|
+
sizeLimit(hqRoot);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
/* an uncapped index is worse than none, but still better than a failed sync */
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
run(["update"], { bin, cwd: hqRoot });
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
warnOnNativeStartupFailure(error);
|
|
86
|
+
/* never fail a sync on index freshness */
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const runEmbed = (home, lockDependencies) => {
|
|
90
|
+
const loadGateDependencies = options.loadGateDependencies ?? defaultLoadGateDependencies(lockDependencies.env);
|
|
91
|
+
const decision = checkEmbedLoad(home, loadGateDependencies);
|
|
92
|
+
if (decision.action === "skip") {
|
|
93
|
+
line(stderr, decision.notice.trimEnd());
|
|
94
|
+
return "skipped";
|
|
95
|
+
}
|
|
96
|
+
if (decision.forced)
|
|
97
|
+
line(stderr, decision.notice.trimEnd());
|
|
98
|
+
run(["embed"], { bin, cwd: hqRoot });
|
|
99
|
+
recordSuccessfulEmbed(home, loadGateDependencies);
|
|
100
|
+
return undefined;
|
|
101
|
+
};
|
|
102
|
+
if ((options.singleFlightEnabled ?? qmdReindexSingleFlightEnabled)()) {
|
|
86
103
|
const lockDependencies = options.embedLockDependencies ?? defaultEmbedLockDependencies();
|
|
87
104
|
const home = resolveQmdHome(lockDependencies.env);
|
|
88
|
-
const loadGateDependencies = options.loadGateDependencies ?? defaultLoadGateDependencies(lockDependencies.env);
|
|
89
105
|
try {
|
|
90
106
|
const outcome = runWithEmbedLock(home, lockDependencies, () => {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
line(stderr, decision.notice.trimEnd());
|
|
94
|
-
return "skipped";
|
|
95
|
-
}
|
|
96
|
-
if (decision.forced)
|
|
97
|
-
line(stderr, decision.notice.trimEnd());
|
|
98
|
-
run(["embed"], { bin, cwd: hqRoot });
|
|
99
|
-
recordSuccessfulEmbed(home, loadGateDependencies);
|
|
100
|
-
return undefined;
|
|
107
|
+
updateIndex();
|
|
108
|
+
return embed ? runEmbed(home, lockDependencies) : undefined;
|
|
101
109
|
});
|
|
102
110
|
if (outcome === "busy") {
|
|
103
|
-
line(stderr, "hq: qmd
|
|
111
|
+
line(stderr, "hq: qmd reindex is already running; skipping this post-sync pass.");
|
|
112
|
+
}
|
|
113
|
+
if (outcome === "unavailable") {
|
|
114
|
+
line(stderr, "hq: cannot access the qmd reindex lock; skipping this post-sync pass.");
|
|
104
115
|
}
|
|
105
|
-
if (outcome === "unavailable")
|
|
106
|
-
line(stderr, "hq: cannot access the qmd embed lock; skipping this pass.");
|
|
107
116
|
}
|
|
108
117
|
catch (error) {
|
|
109
118
|
if (error instanceof LoadGateConfigurationError)
|
|
@@ -111,6 +120,26 @@ export function qmdReindexAfterSync(args = [], options = {}) {
|
|
|
111
120
|
warnOnNativeStartupFailure(error);
|
|
112
121
|
/* embeddings are deferred-cost and optional */
|
|
113
122
|
}
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
updateIndex();
|
|
126
|
+
if (!embed)
|
|
127
|
+
return 0;
|
|
128
|
+
const lockDependencies = options.embedLockDependencies ?? defaultEmbedLockDependencies();
|
|
129
|
+
const home = resolveQmdHome(lockDependencies.env);
|
|
130
|
+
try {
|
|
131
|
+
const outcome = runWithEmbedLock(home, lockDependencies, () => runEmbed(home, lockDependencies));
|
|
132
|
+
if (outcome === "busy") {
|
|
133
|
+
line(stderr, "hq: qmd embed is already running; skipping this pass.");
|
|
134
|
+
}
|
|
135
|
+
if (outcome === "unavailable")
|
|
136
|
+
line(stderr, "hq: cannot access the qmd embed lock; skipping this pass.");
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (error instanceof LoadGateConfigurationError)
|
|
140
|
+
line(stderr, `hq: ${error.message}`);
|
|
141
|
+
warnOnNativeStartupFailure(error);
|
|
142
|
+
/* embeddings are deferred-cost and optional */
|
|
114
143
|
}
|
|
115
144
|
return 0;
|
|
116
145
|
}
|
|
@@ -37,6 +37,8 @@ export declare const DEFAULT_LIVE_WITHIN_MS: number;
|
|
|
37
37
|
export declare const DEFAULT_TURN_START_LOST_MS: number;
|
|
38
38
|
/** Spec §9: no event for 12 hours (and no session_end) → lost. */
|
|
39
39
|
export declare const DEFAULT_SILENT_LOST_MS: number;
|
|
40
|
+
/** Unanswered session questions move to the next audience after 10 minutes. */
|
|
41
|
+
export declare const DEFAULT_SESSION_QUESTION_TIMEOUT_SECS: number;
|
|
40
42
|
export declare const DEFAULT_LIVENESS_WINDOWS: LivenessWindows;
|
|
41
43
|
export declare const DEFAULT_PROVIDER_MAX_RUNNING: Record<LaneProvider, number>;
|
|
42
44
|
export declare const DEFAULT_ACCOUNT_HOMES: Record<LaneProvider, string>;
|
package/dist/lib/lanes/config.js
CHANGED
|
@@ -41,6 +41,8 @@ export const DEFAULT_LIVE_WITHIN_MS = 10 * 60 * 1000;
|
|
|
41
41
|
export const DEFAULT_TURN_START_LOST_MS = 2 * 60 * 60 * 1000;
|
|
42
42
|
/** Spec §9: no event for 12 hours (and no session_end) → lost. */
|
|
43
43
|
export const DEFAULT_SILENT_LOST_MS = 12 * 60 * 60 * 1000;
|
|
44
|
+
/** Unanswered session questions move to the next audience after 10 minutes. */
|
|
45
|
+
export const DEFAULT_SESSION_QUESTION_TIMEOUT_SECS = 10 * 60;
|
|
44
46
|
export const DEFAULT_LIVENESS_WINDOWS = {
|
|
45
47
|
live_within_ms: DEFAULT_LIVE_WITHIN_MS,
|
|
46
48
|
turn_start_lost_ms: DEFAULT_TURN_START_LOST_MS,
|
|
@@ -340,6 +342,7 @@ export function documentedDefaultConfig(deps = defaultDeps()) {
|
|
|
340
342
|
min_available_mem_mb: DEFAULT_MIN_AVAILABLE_MEM_MB,
|
|
341
343
|
},
|
|
342
344
|
liveness: { ...DEFAULT_LIVENESS_WINDOWS },
|
|
345
|
+
session_question_timeout_secs: DEFAULT_SESSION_QUESTION_TIMEOUT_SECS,
|
|
343
346
|
};
|
|
344
347
|
}
|
|
345
348
|
export function parseLanesConfig(raw, deps = defaultDeps()) {
|
|
@@ -380,6 +383,7 @@ export function parseLanesConfig(raw, deps = defaultDeps()) {
|
|
|
380
383
|
providers,
|
|
381
384
|
throttle: parseThrottle(raw.throttle),
|
|
382
385
|
liveness: parseLiveness(raw.liveness),
|
|
386
|
+
session_question_timeout_secs: positiveInt(raw.session_question_timeout_secs, "session_question_timeout_secs", DEFAULT_SESSION_QUESTION_TIMEOUT_SECS),
|
|
383
387
|
};
|
|
384
388
|
}
|
|
385
389
|
export function lanesConfigPath(hqRoot) {
|
|
@@ -40,6 +40,10 @@ export interface LivenessDeps {
|
|
|
40
40
|
existsSync: (file: string) => boolean;
|
|
41
41
|
mkdirSync: typeof fs.mkdirSync;
|
|
42
42
|
statSync: (file: string) => fs.Stats;
|
|
43
|
+
lstatSync: (file: string) => fs.Stats;
|
|
44
|
+
readdirSync: (dir: string, options: {
|
|
45
|
+
withFileTypes: true;
|
|
46
|
+
}) => fs.Dirent[];
|
|
43
47
|
}
|
|
44
48
|
export declare function defaultLivenessDeps(): LivenessDeps;
|
|
45
49
|
export declare function defaultSpoolPath(deps?: LivenessDeps): string;
|
|
@@ -53,6 +57,14 @@ export declare function livenessIndexPath(hqRoot: string): string;
|
|
|
53
57
|
* `session_end` as the last event is `ended` even when recent (fast path).
|
|
54
58
|
*/
|
|
55
59
|
export declare function classifySessionLiveness(entry: SessionIndexEntry, nowMs: number, presenceConnected?: boolean, windows?: LivenessWindows): LivenessState;
|
|
60
|
+
/**
|
|
61
|
+
* Use recent Claude transcript file activity when a session has no mesh event
|
|
62
|
+
* entry. This reads file metadata only; transcript contents are never opened.
|
|
63
|
+
* If a session transcript exists in more than one configured home, the newest
|
|
64
|
+
* mtime is the activity signal. A stale or absent transcript remains unknown
|
|
65
|
+
* and is not evidence of an end.
|
|
66
|
+
*/
|
|
67
|
+
export declare function unindexedClaudeSessionLiveness(sessionId: string, nowMs: number, windows: LivenessWindows, deps?: LivenessDeps, configuredHomes?: readonly string[]): LivenessState | null;
|
|
56
68
|
export declare function ingestSpoolLine(line: string, sessions: Record<string, SessionIndexEntry>, lineNo: number): void;
|
|
57
69
|
export declare function buildLivenessIndexFromSpool(spoolContents: string, meta: {
|
|
58
70
|
spool_path: string;
|
|
@@ -37,6 +37,8 @@ export function defaultLivenessDeps() {
|
|
|
37
37
|
existsSync: (file) => fs.existsSync(file),
|
|
38
38
|
mkdirSync: fs.mkdirSync.bind(fs),
|
|
39
39
|
statSync: (file) => fs.statSync(file),
|
|
40
|
+
lstatSync: (file) => fs.lstatSync(file),
|
|
41
|
+
readdirSync: fs.readdirSync.bind(fs),
|
|
40
42
|
};
|
|
41
43
|
}
|
|
42
44
|
export function defaultSpoolPath(deps = defaultLivenessDeps()) {
|
|
@@ -81,6 +83,64 @@ export function classifySessionLiveness(entry, nowMs, presenceConnected = false,
|
|
|
81
83
|
return "idle";
|
|
82
84
|
return "idle";
|
|
83
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Use recent Claude transcript file activity when a session has no mesh event
|
|
88
|
+
* entry. This reads file metadata only; transcript contents are never opened.
|
|
89
|
+
* If a session transcript exists in more than one configured home, the newest
|
|
90
|
+
* mtime is the activity signal. A stale or absent transcript remains unknown
|
|
91
|
+
* and is not evidence of an end.
|
|
92
|
+
*/
|
|
93
|
+
export function unindexedClaudeSessionLiveness(sessionId, nowMs, windows, deps = defaultLivenessDeps(), configuredHomes = []) {
|
|
94
|
+
if (sessionId.length === 0 ||
|
|
95
|
+
sessionId.trim() !== sessionId ||
|
|
96
|
+
sessionId === "." ||
|
|
97
|
+
sessionId === ".." ||
|
|
98
|
+
/[\\/\0]/.test(sessionId)) {
|
|
99
|
+
throw new LanesError("session_activity_invalid_id", "Cannot inspect Claude activity for an invalid session id", { session_id: sessionId });
|
|
100
|
+
}
|
|
101
|
+
const homes = [
|
|
102
|
+
deps.env.CLAUDE_CONFIG_DIR?.trim(),
|
|
103
|
+
path.join(deps.homedir(), ".claude"),
|
|
104
|
+
...configuredHomes,
|
|
105
|
+
].filter((home) => typeof home === "string" && home.trim() !== "");
|
|
106
|
+
const projectsDirs = [...new Set(homes.map((home) => path.resolve(home, "projects")))];
|
|
107
|
+
let activityAt = null;
|
|
108
|
+
for (const projectsDir of projectsDirs) {
|
|
109
|
+
let projects;
|
|
110
|
+
try {
|
|
111
|
+
projects = deps.readdirSync(projectsDir, { withFileTypes: true });
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
if (err.code === "ENOENT")
|
|
115
|
+
continue;
|
|
116
|
+
throw new LanesError("session_activity_unreadable", `Cannot inspect Claude project activity: ${err instanceof Error ? err.message : String(err)}`, { path: projectsDir });
|
|
117
|
+
}
|
|
118
|
+
for (const project of projects) {
|
|
119
|
+
if (!project.isDirectory() || project.isSymbolicLink())
|
|
120
|
+
continue;
|
|
121
|
+
const transcriptPath = path.join(projectsDir, project.name, `${sessionId}.jsonl`);
|
|
122
|
+
let transcript;
|
|
123
|
+
try {
|
|
124
|
+
transcript = deps.lstatSync(transcriptPath);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
if (err.code === "ENOENT")
|
|
128
|
+
continue;
|
|
129
|
+
throw new LanesError("session_activity_unreadable", `Cannot inspect Claude session activity: ${err instanceof Error ? err.message : String(err)}`, { path: transcriptPath });
|
|
130
|
+
}
|
|
131
|
+
if (!transcript.isFile() || transcript.isSymbolicLink())
|
|
132
|
+
continue;
|
|
133
|
+
if (!Number.isFinite(transcript.mtimeMs)) {
|
|
134
|
+
throw new LanesError("session_activity_unreadable", `Cannot inspect Claude session activity: invalid modification time`, { path: transcriptPath });
|
|
135
|
+
}
|
|
136
|
+
activityAt = Math.max(activityAt ?? Number.NEGATIVE_INFINITY, transcript.mtimeMs);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (activityAt === null)
|
|
140
|
+
return null;
|
|
141
|
+
const ageMs = nowMs - activityAt;
|
|
142
|
+
return ageMs >= 0 && ageMs <= windows.live_within_ms ? "live" : null;
|
|
143
|
+
}
|
|
84
144
|
export function ingestSpoolLine(line, sessions, lineNo) {
|
|
85
145
|
const trimmed = line.trim();
|
|
86
146
|
if (!trimmed)
|
|
@@ -310,9 +370,11 @@ export function sessionLiveness(hqRoot, sessionId, opts = {}) {
|
|
|
310
370
|
const deps = opts.deps ?? defaultLivenessDeps();
|
|
311
371
|
const index = loadLivenessIndex(hqRoot, opts.spoolPath, deps);
|
|
312
372
|
const entry = index.sessions[sessionId];
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
373
|
+
const config = loadLanesConfig(hqRoot);
|
|
374
|
+
const windows = opts.windows ?? config.liveness;
|
|
375
|
+
if (!entry) {
|
|
376
|
+
return unindexedClaudeSessionLiveness(sessionId, opts.nowMs ?? deps.now(), windows, deps, Object.values(config.providers.claude.accounts).map((account) => account.home));
|
|
377
|
+
}
|
|
316
378
|
const presenceConnected = opts.presenceConnected ?? readDaemonPresenceConnected(deps);
|
|
317
379
|
return classifySessionLiveness(entry, opts.nowMs ?? deps.now(), presenceConnected, windows);
|
|
318
380
|
}
|
|
@@ -86,7 +86,7 @@ export declare function timeoutPath(hqRoot: string, laneId: string): string;
|
|
|
86
86
|
export declare function laneRunsHere(lane: LaneRecord, host: ReconcileHost): boolean;
|
|
87
87
|
export declare function agedAdvanceCount(question: QuestionRecord): number;
|
|
88
88
|
export declare function currentHopStartedAtMs(question: QuestionRecord): number;
|
|
89
|
-
export declare function shouldAgeQuestion(question: QuestionRecord, audience: SeniorRef, nowMs: number): boolean;
|
|
89
|
+
export declare function shouldAgeQuestion(question: QuestionRecord, audience: SeniorRef, nowMs: number, ageWindowMs?: number): boolean;
|
|
90
90
|
export declare function shouldAdvanceAudienceEnded(audience: SeniorRef, liveness: LivenessState | null): boolean;
|
|
91
91
|
export declare function readTimeoutDeadline(hqRoot: string, laneId: string): {
|
|
92
92
|
deadline_at: string;
|
|
@@ -14,7 +14,7 @@ import { loadLanesConfig, providerQuotaDefaultHoldMs } from "./config.js";
|
|
|
14
14
|
import { MAX_LANE_DEPTH } from "./constants.js";
|
|
15
15
|
import { AccountResolutionError, LanesError, isLaneVersionConflict } from "./errors.js";
|
|
16
16
|
import { loadWorkerProfile } from "./profile.js";
|
|
17
|
-
import { classifySessionLiveness, defaultLivenessDeps, loadLivenessIndex, readDaemonPresenceConnected, } from "./liveness.js";
|
|
17
|
+
import { classifySessionLiveness, defaultLivenessDeps, loadLivenessIndex, readDaemonPresenceConnected, unindexedClaudeSessionLiveness, } from "./liveness.js";
|
|
18
18
|
import { ANSWERED_QUESTIONS_FILE, armLaneWatcher, envelopeRepairMode, isPidRecordAlive, laneRunDir, readPidRecord, readSpawnManifest, markSpawnFailure, spawnEnvelopeRepair, spawnLaneWorker, retryLaneOnAlternateAccount, } from "./spawn.js";
|
|
19
19
|
import { appendCappedHistory, lanesStoreRoot, parseLaneRecord, parseQuestionRecord, readLane, readQuestion, updateLane, updateQuestion, } from "./store.js";
|
|
20
20
|
import { applyThrottleDecision, decideAdmission, defaultThrottleDeps, emptyThrottleState, evaluateThrottle, isCpuCheckEnabled, readAvailableMemMb, readThrottleState, resolveCpuBusyFraction, throttleStatePath, writeThrottleState, } from "./throttle.js";
|
|
@@ -106,7 +106,7 @@ export function currentHopStartedAtMs(question) {
|
|
|
106
106
|
const ms = Date.parse(raw);
|
|
107
107
|
return Number.isFinite(ms) ? ms : Number.NEGATIVE_INFINITY;
|
|
108
108
|
}
|
|
109
|
-
export function shouldAgeQuestion(question, audience, nowMs) {
|
|
109
|
+
export function shouldAgeQuestion(question, audience, nowMs, ageWindowMs = QUESTION_AGE_MS) {
|
|
110
110
|
if (question.status !== "pending")
|
|
111
111
|
return false;
|
|
112
112
|
if (audience.kind === "user")
|
|
@@ -116,14 +116,14 @@ export function shouldAgeQuestion(question, audience, nowMs) {
|
|
|
116
116
|
const started = currentHopStartedAtMs(question);
|
|
117
117
|
if (started === Number.NEGATIVE_INFINITY)
|
|
118
118
|
return false;
|
|
119
|
-
return nowMs - started >=
|
|
119
|
+
return nowMs - started >= ageWindowMs;
|
|
120
120
|
}
|
|
121
121
|
export function shouldAdvanceAudienceEnded(audience, liveness) {
|
|
122
122
|
if (audience.kind === "user")
|
|
123
123
|
return false;
|
|
124
124
|
if (audience.kind !== "session")
|
|
125
125
|
return false;
|
|
126
|
-
return liveness === "ended"
|
|
126
|
+
return liveness === "ended";
|
|
127
127
|
}
|
|
128
128
|
export function readTimeoutDeadline(hqRoot, laneId) {
|
|
129
129
|
const filePath = timeoutPath(hqRoot, laneId);
|
|
@@ -655,12 +655,25 @@ export async function runPassBody(hqRoot, deps) {
|
|
|
655
655
|
};
|
|
656
656
|
const readSessionSenior = deps.readSessionSenior ??
|
|
657
657
|
((sessionId) => defaultReadSessionSenior(hqRoot, sessionId));
|
|
658
|
+
const configuredClaudeHomes = Object.values(config.providers.claude.accounts).map((account) => account.home);
|
|
659
|
+
const sessionStateCache = new Map();
|
|
658
660
|
const sessionState = (sessionId) => {
|
|
661
|
+
if (sessionStateCache.has(sessionId)) {
|
|
662
|
+
return sessionStateCache.get(sessionId) ?? null;
|
|
663
|
+
}
|
|
659
664
|
const entry = livenessIndex.sessions[sessionId];
|
|
660
|
-
if (!entry)
|
|
661
|
-
|
|
662
|
-
|
|
665
|
+
if (!entry) {
|
|
666
|
+
const state = unindexedClaudeSessionLiveness(sessionId, nowMs, config.liveness, livenessDeps, configuredClaudeHomes);
|
|
667
|
+
sessionStateCache.set(sessionId, state);
|
|
668
|
+
return state;
|
|
669
|
+
}
|
|
670
|
+
const state = classifySessionLiveness(entry, nowMs, presenceConnected, config.liveness);
|
|
671
|
+
sessionStateCache.set(sessionId, state);
|
|
672
|
+
return state;
|
|
663
673
|
};
|
|
674
|
+
const questionAgeWindowMs = (audience) => audience.kind === "session"
|
|
675
|
+
? config.session_question_timeout_secs * 1000
|
|
676
|
+
: QUESTION_AGE_MS;
|
|
664
677
|
if (deps.afterLoad)
|
|
665
678
|
await deps.afterLoad();
|
|
666
679
|
markOverdueLanes(hqRoot, lanes, summary, deps, now);
|
|
@@ -680,12 +693,22 @@ export async function runPassBody(hqRoot, deps) {
|
|
|
680
693
|
});
|
|
681
694
|
continue;
|
|
682
695
|
}
|
|
683
|
-
const liveness = audience.kind === "session" && audience.id
|
|
684
|
-
? sessionState(audience.id)
|
|
685
|
-
: audience.kind === "user"
|
|
686
|
-
? "live"
|
|
687
|
-
: null;
|
|
688
696
|
try {
|
|
697
|
+
let liveness;
|
|
698
|
+
try {
|
|
699
|
+
liveness =
|
|
700
|
+
audience.kind === "session" && audience.id
|
|
701
|
+
? sessionState(audience.id)
|
|
702
|
+
: audience.kind === "user"
|
|
703
|
+
? "live"
|
|
704
|
+
: null;
|
|
705
|
+
}
|
|
706
|
+
catch (err) {
|
|
707
|
+
// A failed metadata probe cannot prove that the session ended, but it
|
|
708
|
+
// must not suppress the independent unanswered-timeout rule below.
|
|
709
|
+
markError(summary, err);
|
|
710
|
+
liveness = null;
|
|
711
|
+
}
|
|
689
712
|
if (shouldAdvanceAudienceEnded(audience, liveness)) {
|
|
690
713
|
const nextHop = question.hop + 1;
|
|
691
714
|
const nextAudience = resolveQuestionAudience({ ...question, hop: nextHop }, readLaneSenior, readSessionSenior);
|
|
@@ -712,12 +735,17 @@ export async function runPassBody(hqRoot, deps) {
|
|
|
712
735
|
}
|
|
713
736
|
continue;
|
|
714
737
|
}
|
|
715
|
-
if (shouldAgeQuestion(question, audience, nowMs)) {
|
|
738
|
+
if (shouldAgeQuestion(question, audience, nowMs, questionAgeWindowMs(audience))) {
|
|
716
739
|
const nextHop = question.hop + 1;
|
|
717
740
|
const nextAudience = resolveQuestionAudience({ ...question, hop: nextHop }, readLaneSenior, readSessionSenior);
|
|
718
|
-
const result = casQuestionIf(hqRoot, question.question_id, (current) =>
|
|
719
|
-
|
|
720
|
-
|
|
741
|
+
const result = casQuestionIf(hqRoot, question.question_id, (current) => {
|
|
742
|
+
if (current.status !== "pending" ||
|
|
743
|
+
agedAdvanceCount(current) >= MAX_AGED_ADVANCES) {
|
|
744
|
+
return false;
|
|
745
|
+
}
|
|
746
|
+
const currentAudience = resolveQuestionAudience(current, readLaneSenior, readSessionSenior);
|
|
747
|
+
return shouldAgeQuestion(current, currentAudience, nowMs, questionAgeWindowMs(currentAudience));
|
|
748
|
+
}, (current) => {
|
|
721
749
|
const entry = {
|
|
722
750
|
hop: current.hop + 1,
|
|
723
751
|
audience: nextAudience,
|
|
@@ -748,7 +776,7 @@ export async function runPassBody(hqRoot, deps) {
|
|
|
748
776
|
markDirty(summary, {
|
|
749
777
|
kind: "question",
|
|
750
778
|
id: question.question_id,
|
|
751
|
-
why: `advance failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
779
|
+
why: `advance failed: ${err instanceof LanesError ? `${err.error}: ` : ""}${err instanceof Error ? err.message : String(err)}`,
|
|
752
780
|
});
|
|
753
781
|
}
|
|
754
782
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type TrackerKind = "blocker" | "kpi" | "okr" | "directive";
|
|
2
|
+
export interface TrackerRecord {
|
|
3
|
+
schema_version: "hq-lanes-tracker-record.v1";
|
|
4
|
+
kind: TrackerKind;
|
|
5
|
+
id: string;
|
|
6
|
+
company: string;
|
|
7
|
+
project_id: string;
|
|
8
|
+
version: number;
|
|
9
|
+
created_at: string;
|
|
10
|
+
updated_at: string;
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
}
|
|
13
|
+
export interface TrackerRecordInput {
|
|
14
|
+
kind: TrackerKind;
|
|
15
|
+
id?: string;
|
|
16
|
+
company: string;
|
|
17
|
+
project_id: string;
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
export interface TrackerStore {
|
|
21
|
+
read(kind: TrackerKind, id: string): TrackerRecord | null;
|
|
22
|
+
list(kind: TrackerKind): TrackerRecord[];
|
|
23
|
+
create(record: TrackerRecordInput): TrackerRecord;
|
|
24
|
+
update(kind: TrackerKind, id: string, expectedVersion: number, change: (current: TrackerRecord) => TrackerRecord): TrackerRecord;
|
|
25
|
+
}
|
|
26
|
+
export declare function createLocalTrackerStore(projectDirectory: string, now?: () => Date): TrackerStore;
|
|
27
|
+
//# sourceMappingURL=tracker-store.d.ts.map
|