@indigoai-us/hq-cli 5.198.0 → 5.199.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 +29 -0
- package/dist/command-catalog.generated.d.ts +6 -0
- package/dist/command-catalog.generated.js +8 -0
- package/dist/commands/lanes.d.ts +3 -0
- package/dist/commands/lanes.js +471 -17
- package/dist/lib/core-utils/hook-timeout-debug-context-flag.js +5 -4
- package/dist/lib/doctor/client-health-reason-codes-flag.js +3 -2
- package/dist/lib/flag-registry-endpoint.d.ts +5 -0
- package/dist/lib/flag-registry-endpoint.js +9 -0
- package/dist/lib/flag-registry.d.ts +7 -3
- package/dist/lib/flag-registry.js +19 -7
- 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/outpost-dispatch.d.ts +7 -0
- package/dist/lib/lanes/outpost-dispatch.js +19 -0
- package/dist/lib/lanes/outpost-pruner-install.js +4 -3
- package/dist/lib/lanes/outpost-worktree-pruner-flag.js +3 -3
- package/dist/lib/lanes/reconcile.d.ts +1 -1
- package/dist/lib/lanes/reconcile.js +45 -17
- package/dist/lib/lanes/registry-flag.js +3 -2
- package/dist/lib/lanes/types.d.ts +2 -0
- package/dist/node-network-compat-flag.js +3 -3
- package/package.json +1 -1
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const FLAG_REGISTRY_ENDPOINT_ENV = "HQ_FLAGS_API_URL";
|
|
2
|
+
export declare const FLAG_REGISTRY_DEFAULT_ENDPOINT = "https://hqapi.hq.computer";
|
|
3
|
+
/** Resolve one shared endpoint from an explicit option, environment, or service default. */
|
|
4
|
+
export declare function resolveFlagRegistryEndpoint(explicitEndpoint?: string, env?: NodeJS.ProcessEnv): string;
|
|
5
|
+
//# sourceMappingURL=flag-registry-endpoint.d.ts.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const FLAG_REGISTRY_ENDPOINT_ENV = "HQ_FLAGS_API_URL";
|
|
2
|
+
export const FLAG_REGISTRY_DEFAULT_ENDPOINT = "https://hqapi.hq.computer";
|
|
3
|
+
/** Resolve one shared endpoint from an explicit option, environment, or service default. */
|
|
4
|
+
export function resolveFlagRegistryEndpoint(explicitEndpoint, env = process.env) {
|
|
5
|
+
return explicitEndpoint?.trim()
|
|
6
|
+
|| env[FLAG_REGISTRY_ENDPOINT_ENV]?.trim()
|
|
7
|
+
|| FLAG_REGISTRY_DEFAULT_ENDPOINT;
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=flag-registry-endpoint.js.map
|
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { type WorkerOptions } from "node:worker_threads";
|
|
9
9
|
import { type FlagClient, type FlagLookupOptions, type FlagSnapshot } from "@indigoai-us/hq-flags-client";
|
|
10
|
-
|
|
11
|
-
export declare const FLAG_REGISTRY_ENDPOINT_ENV = "HQ_FLAGS_API_URL";
|
|
10
|
+
export { FLAG_REGISTRY_ENDPOINT_ENV } from "./flag-registry-endpoint.js";
|
|
12
11
|
/** The synchronous capability that command gate call sites need. */
|
|
13
12
|
export type FlagReader = Pick<FlagClient, "isEnabled">;
|
|
14
13
|
interface WorkerLike {
|
|
@@ -20,6 +19,12 @@ export interface FlagRegistryDependencies {
|
|
|
20
19
|
endpoint?: string;
|
|
21
20
|
createWorker?: (filename: URL, options: WorkerOptions) => WorkerLike;
|
|
22
21
|
}
|
|
22
|
+
export interface ProcessFlagCompanyContext {
|
|
23
|
+
companyUid?: string;
|
|
24
|
+
companySlug?: string;
|
|
25
|
+
}
|
|
26
|
+
/** Resolve the same company identity used by the process-wide flag reader. */
|
|
27
|
+
export declare function resolveProcessFlagCompanyContext(env?: NodeJS.ProcessEnv): ProcessFlagCompanyContext;
|
|
23
28
|
/**
|
|
24
29
|
* Begin one registry refresh without awaiting it or retaining the CLI process.
|
|
25
30
|
*
|
|
@@ -46,5 +51,4 @@ export declare function resolveProcessFlagGate(flagKey: string, lookup: FlagLook
|
|
|
46
51
|
export declare function currentFlagSnapshot(): FlagSnapshot | null;
|
|
47
52
|
/** Test-only reset for process-global readiness state. */
|
|
48
53
|
export declare function _resetFlagRegistryForTests(): void;
|
|
49
|
-
export {};
|
|
50
54
|
//# sourceMappingURL=flag-registry.d.ts.map
|
|
@@ -12,8 +12,8 @@ import { readSessionState } from "./work-context/state.js";
|
|
|
12
12
|
import { workContextRoot } from "./work-context/paths.js";
|
|
13
13
|
import { readCompaniesManifestMap } from "./work-context/repo-remote.js";
|
|
14
14
|
import { resolveDefaultHqRoot } from "../utils/cognito-session.js";
|
|
15
|
-
|
|
16
|
-
export
|
|
15
|
+
import { resolveFlagRegistryEndpoint, } from "./flag-registry-endpoint.js";
|
|
16
|
+
export { FLAG_REGISTRY_ENDPOINT_ENV } from "./flag-registry-endpoint.js";
|
|
17
17
|
const COMPANY_UID_RE = /^cmp_[A-Za-z0-9]{3,128}$/;
|
|
18
18
|
const COMPANY_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
19
19
|
let heldSnapshot = null;
|
|
@@ -76,6 +76,15 @@ function companyUidForContext(context, env = process.env) {
|
|
|
76
76
|
const manifestUid = readCompaniesManifestMap(hqRoot)?.[context.companySlug]?.cloud_uid;
|
|
77
77
|
return typeof manifestUid === "string" ? normalizedCompanyUid(manifestUid) : undefined;
|
|
78
78
|
}
|
|
79
|
+
/** Resolve the same company identity used by the process-wide flag reader. */
|
|
80
|
+
export function resolveProcessFlagCompanyContext(env = process.env) {
|
|
81
|
+
const context = processCompanyContext(env);
|
|
82
|
+
const companyUid = companyUidForContext(context, env);
|
|
83
|
+
return {
|
|
84
|
+
...(companyUid ? { companyUid } : {}),
|
|
85
|
+
...(context.companySlug ? { companySlug: context.companySlug } : {}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
79
88
|
function processSnapshotReader(companyIdentifiers) {
|
|
80
89
|
return {
|
|
81
90
|
isEnabled(flagKey, lookup = {}) {
|
|
@@ -101,11 +110,9 @@ function processSnapshotReader(companyIdentifiers) {
|
|
|
101
110
|
export function kickFlagRegistryReadiness(dependencies = {}) {
|
|
102
111
|
if (processReader)
|
|
103
112
|
return processReader;
|
|
104
|
-
const endpoint = (dependencies.endpoint
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const company = processCompanyContext();
|
|
108
|
-
const companyUid = companyUidForContext(company);
|
|
113
|
+
const endpoint = resolveFlagRegistryEndpoint(dependencies.endpoint, process.env);
|
|
114
|
+
const company = resolveProcessFlagCompanyContext();
|
|
115
|
+
const companyUid = company.companyUid;
|
|
109
116
|
const companyIdentifiers = [companyUid, company.companySlug].filter((value) => Boolean(value));
|
|
110
117
|
processReader = processSnapshotReader(companyIdentifiers);
|
|
111
118
|
try {
|
|
@@ -149,6 +156,11 @@ export function resolveFlagGate(reader, flagKey, lookup, legacyFallback) {
|
|
|
149
156
|
}
|
|
150
157
|
/** Resolve a process gate against the shared snapshot reader, when configured. */
|
|
151
158
|
export function resolveProcessFlagGate(flagKey, lookup, legacyFallback) {
|
|
159
|
+
// The endpoint refresh is deliberately asynchronous. Until a validated
|
|
160
|
+
// snapshot arrives, preserve each caller's exact legacy fallback.
|
|
161
|
+
if (!processReader || !heldSnapshot) {
|
|
162
|
+
return localOverrideOrFallback(flagKey, legacyFallback);
|
|
163
|
+
}
|
|
152
164
|
return resolveFlagGate(processReader, flagKey, lookup, legacyFallback);
|
|
153
165
|
}
|
|
154
166
|
/**
|
|
@@ -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
|
}
|
|
@@ -46,6 +46,7 @@ export declare function buildOutpostQuestionAnswerCommand(input: {
|
|
|
46
46
|
choice?: string;
|
|
47
47
|
freeform?: string;
|
|
48
48
|
notes?: string;
|
|
49
|
+
company?: string;
|
|
49
50
|
hqBinary?: string;
|
|
50
51
|
}): string;
|
|
51
52
|
export interface OutpostExecPort {
|
|
@@ -73,6 +74,12 @@ export declare function buildOutpostPickupCommand(payloadUrl: string): string;
|
|
|
73
74
|
export declare function buildOutpostCapacityCommand(): string;
|
|
74
75
|
/** Read the lane records owned by an Outpost for exact per-account state counts. */
|
|
75
76
|
export declare function buildOutpostLaneListCommand(): string;
|
|
77
|
+
/** Read questions on one Outpost, scoped to a company when supplied. */
|
|
78
|
+
export declare function buildOutpostQuestionListCommand(input?: {
|
|
79
|
+
company?: string;
|
|
80
|
+
}): string;
|
|
81
|
+
/** Read one question's body and owner-computed audience from its Outpost. */
|
|
82
|
+
export declare function buildOutpostQuestionShowCommand(questionId: string): string;
|
|
76
83
|
/** Commit a queued lane move on the host that currently owns its state. */
|
|
77
84
|
export declare function buildOutpostMoveTargetCommand(input: {
|
|
78
85
|
laneId: string;
|
|
@@ -22,6 +22,7 @@ export function buildOutpostQuestionAnswerCommand(input) {
|
|
|
22
22
|
["--choice", input.choice],
|
|
23
23
|
["--freeform", input.freeform],
|
|
24
24
|
["--notes", input.notes],
|
|
25
|
+
["--company", input.company],
|
|
25
26
|
]) {
|
|
26
27
|
if (value !== undefined)
|
|
27
28
|
args.push(flag, value);
|
|
@@ -68,6 +69,24 @@ export function buildOutpostCapacityCommand() {
|
|
|
68
69
|
export function buildOutpostLaneListCommand() {
|
|
69
70
|
return withRemoteHqDir(joinCommandParts(["hq", "lanes", "list", "--json"]));
|
|
70
71
|
}
|
|
72
|
+
/** Read questions on one Outpost, scoped to a company when supplied. */
|
|
73
|
+
export function buildOutpostQuestionListCommand(input = {}) {
|
|
74
|
+
const args = ["hq", "lanes", "questions", "list"];
|
|
75
|
+
const company = input.company?.trim();
|
|
76
|
+
if (company)
|
|
77
|
+
args.push("--company", company);
|
|
78
|
+
return withRemoteHqDir(joinCommandParts(args));
|
|
79
|
+
}
|
|
80
|
+
/** Read one question's body and owner-computed audience from its Outpost. */
|
|
81
|
+
export function buildOutpostQuestionShowCommand(questionId) {
|
|
82
|
+
return withRemoteHqDir(joinCommandParts([
|
|
83
|
+
"hq",
|
|
84
|
+
"lanes",
|
|
85
|
+
"questions",
|
|
86
|
+
"show",
|
|
87
|
+
questionId,
|
|
88
|
+
]));
|
|
89
|
+
}
|
|
71
90
|
/** Commit a queued lane move on the host that currently owns its state. */
|
|
72
91
|
export function buildOutpostMoveTargetCommand(input) {
|
|
73
92
|
return withRemoteHqDir(joinCommandParts([
|
|
@@ -3,6 +3,7 @@ import * as os from "node:os";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { resolveHqBinary, resolveNodeBinary } from "../mesh/live/daemon/install.js";
|
|
5
5
|
import { SYSTEMD_PATH_ENV } from "../service-manager/systemd.js";
|
|
6
|
+
import { resolveFlagRegistryEndpoint } from "../flag-registry-endpoint.js";
|
|
6
7
|
export const OUTPOST_PRUNER_PRESSURE_SERVICE = "hq-outpost-worktree-pruner-pressure.service";
|
|
7
8
|
export const OUTPOST_PRUNER_PRESSURE_TIMER = "hq-outpost-worktree-pruner-pressure.timer";
|
|
8
9
|
export const OUTPOST_PRUNER_DAILY_SERVICE = "hq-outpost-worktree-pruner-daily.service";
|
|
@@ -160,10 +161,10 @@ export function installOutpostPrunerTimers(options) {
|
|
|
160
161
|
const nodeBinary = options.nodeBinary ?? resolveNodeBinary();
|
|
161
162
|
const hqBinary = options.hqBinary ?? resolveHqBinary();
|
|
162
163
|
const home = options.home ?? os.homedir();
|
|
163
|
-
const flagApiUrl = (options.flagApiUrl
|
|
164
|
+
const flagApiUrl = resolveFlagRegistryEndpoint(options.flagApiUrl);
|
|
164
165
|
const companyUid = (options.companyUid ?? process.env.HQ_COMPANY_UID ?? "").trim();
|
|
165
|
-
if (!
|
|
166
|
-
throw new TypeError("install requires
|
|
166
|
+
if (!companyUid) {
|
|
167
|
+
throw new TypeError("install requires HQ_COMPANY_UID for the timer services");
|
|
167
168
|
}
|
|
168
169
|
const flagEnvironment = [
|
|
169
170
|
systemdEnvironment("HQ_FLAGS_API_URL", flagApiUrl),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { loadCachedTokens } from "@indigoai-us/hq-cloud";
|
|
2
2
|
import { createFlagClient } from "@indigoai-us/hq-flags-client";
|
|
3
|
+
import { resolveFlagRegistryEndpoint } from "../flag-registry-endpoint.js";
|
|
3
4
|
export const OUTPOST_WORKTREE_PRUNER_FLAG = "outpost.worktree-pruner";
|
|
4
5
|
export const OUTPOST_WORKTREE_PRUNER_FLAG_FALLBACK = false;
|
|
5
6
|
export const OUTPOST_WORKTREE_PRUNER_FLAG_TIMEOUT_MS = 5_000;
|
|
@@ -21,14 +22,13 @@ function reportFlagFailure(error) {
|
|
|
21
22
|
}
|
|
22
23
|
export async function outpostWorktreePrunerEnabled(dependencies = {}) {
|
|
23
24
|
const env = dependencies.env ?? process.env;
|
|
24
|
-
const endpoint = env
|
|
25
|
+
const endpoint = resolveFlagRegistryEndpoint(undefined, env);
|
|
25
26
|
const companyUid = env.HQ_COMPANY_UID?.trim() || "";
|
|
26
|
-
if (!
|
|
27
|
+
if (!companyUid) {
|
|
27
28
|
process.stderr.write(`${JSON.stringify({
|
|
28
29
|
event: "outpost.worktree_pruner.flag_config_missing",
|
|
29
30
|
flag: OUTPOST_WORKTREE_PRUNER_FLAG,
|
|
30
31
|
missing: [
|
|
31
|
-
...(!endpoint ? ["HQ_FLAGS_API_URL"] : []),
|
|
32
32
|
...(!companyUid ? ["HQ_COMPANY_UID"] : []),
|
|
33
33
|
],
|
|
34
34
|
fallback: OUTPOST_WORKTREE_PRUNER_FLAG_FALLBACK,
|
|
@@ -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
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createFlagClient, readLocalOverride, } from "@indigoai-us/hq-flags-client";
|
|
2
2
|
import { ensureCognitoIdToken } from "../../utils/cognito-session.js";
|
|
3
|
-
|
|
3
|
+
import { FLAG_REGISTRY_DEFAULT_ENDPOINT, resolveFlagRegistryEndpoint, } from "../flag-registry-endpoint.js";
|
|
4
|
+
export const LANES_FLAG_API_DEFAULT_ENDPOINT = FLAG_REGISTRY_DEFAULT_ENDPOINT;
|
|
4
5
|
export const LANES_FLAG_READ_TIMEOUT_MS = 5_000;
|
|
5
6
|
const registryReads = new Map();
|
|
6
7
|
class MissingFlagTokenError extends Error {
|
|
@@ -45,7 +46,7 @@ export async function readLanesRegistryFlag(flagKey, options = {}) {
|
|
|
45
46
|
const localOverride = readLocalOverride(flagKey, env);
|
|
46
47
|
if (localOverride !== null)
|
|
47
48
|
return localOverride;
|
|
48
|
-
const endpoint = env
|
|
49
|
+
const endpoint = resolveFlagRegistryEndpoint(undefined, env);
|
|
49
50
|
const key = cacheKey(flagKey, options.company, endpoint);
|
|
50
51
|
const cached = registryReads.get(key);
|
|
51
52
|
if (cached)
|
|
@@ -706,6 +706,8 @@ export interface LanesConfig {
|
|
|
706
706
|
providers: Record<LaneProvider, ProviderConfig>;
|
|
707
707
|
throttle: ThrottleConfig;
|
|
708
708
|
liveness: LivenessWindows;
|
|
709
|
+
/** Seconds an unanswered question waits at a session audience before advancing. */
|
|
710
|
+
session_question_timeout_secs: number;
|
|
709
711
|
}
|
|
710
712
|
export declare function parseMajorSchema(schemaVersion: string, expectedPrefix: string, expectedMajor: number): void;
|
|
711
713
|
export declare function parseLaneEnvelope(raw: unknown): LaneEnvelope;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createFlagClient, readLocalOverride } from "@indigoai-us/hq-flags-client";
|
|
2
|
+
import { resolveFlagRegistryEndpoint } from "./lib/flag-registry-endpoint.js";
|
|
2
3
|
export const NODE_NETWORK_FORCE_IPV4_FLAG = "cli.network.force-ipv4";
|
|
3
4
|
export const NODE_NETWORK_FORCE_IPV4_FLAG_FALLBACK = false;
|
|
4
5
|
// Node waits up to 1s before trying the next address family. Keep this request
|
|
@@ -29,10 +30,9 @@ export async function nodeNetworkForceIpv4Enabled(dependencies = {}) {
|
|
|
29
30
|
const localOverride = readLocalOverride(NODE_NETWORK_FORCE_IPV4_FLAG, env);
|
|
30
31
|
if (localOverride !== null)
|
|
31
32
|
return localOverride;
|
|
32
|
-
const endpoint = env
|
|
33
|
+
const endpoint = resolveFlagRegistryEndpoint(undefined, env);
|
|
33
34
|
const companyUid = env.HQ_COMPANY_UID?.trim() || "";
|
|
34
|
-
if (
|
|
35
|
-
!/^cmp_[A-Za-z0-9]{3,128}$/.test(companyUid)) {
|
|
35
|
+
if (!/^cmp_[A-Za-z0-9]{3,128}$/.test(companyUid)) {
|
|
36
36
|
return NODE_NETWORK_FORCE_IPV4_FLAG_FALLBACK;
|
|
37
37
|
}
|
|
38
38
|
const companySlug = env.HQ_COMPANY_SLUG?.trim() || "";
|