@indigoai-us/hq-cli 5.345.55 → 5.345.57

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/assets/scaffold/core/scripts/derive-trigger-facts.sh +10 -1
  3. package/assets/scaffold/core/scripts/jobs-validate.sh +2 -1
  4. package/assets/scaffold/core/scripts/session-title.sh +18 -15
  5. package/dist/command-catalog.generated.d.ts +124 -0
  6. package/dist/command-catalog.generated.js +155 -0
  7. package/dist/commands/core-delegate-w1b.js +18 -5
  8. package/dist/commands/core-derive-trigger-facts-command.js +14 -0
  9. package/dist/commands/core-derive-trigger-facts-native.d.ts +1 -0
  10. package/dist/commands/core-derive-trigger-facts-native.js +20 -1
  11. package/dist/commands/doctor.d.ts +1 -0
  12. package/dist/commands/doctor.js +15 -2
  13. package/dist/commands/lanes-checks.d.ts +3 -0
  14. package/dist/commands/lanes-checks.js +34 -0
  15. package/dist/commands/lanes-loop.d.ts +12 -0
  16. package/dist/commands/lanes-loop.js +214 -0
  17. package/dist/commands/lanes-questions.js +37 -11
  18. package/dist/commands/lanes-resume.js +19 -1
  19. package/dist/commands/lanes-session-start.d.ts +2 -7
  20. package/dist/commands/lanes-session-start.js +9 -29
  21. package/dist/commands/lanes-shared.d.ts +14 -2
  22. package/dist/commands/lanes.d.ts +3 -1
  23. package/dist/commands/lanes.js +376 -35
  24. package/dist/commands/members.d.ts +4 -0
  25. package/dist/commands/members.js +23 -2
  26. package/dist/lanes-drain-hook.js +6 -2
  27. package/dist/lib/doctor/checks/sync-health.d.ts +2 -0
  28. package/dist/lib/doctor/checks/sync-health.js +47 -23
  29. package/dist/lib/lanes/delivery.d.ts +3 -0
  30. package/dist/lib/lanes/delivery.js +5 -0
  31. package/dist/lib/lanes/dropbox.d.ts +7 -2
  32. package/dist/lib/lanes/dropbox.js +157 -10
  33. package/dist/lib/lanes/flag-keys.d.ts +12 -0
  34. package/dist/lib/lanes/flag-keys.js +12 -0
  35. package/dist/lib/lanes/hooks-install.d.ts +14 -11
  36. package/dist/lib/lanes/hooks-install.js +70 -50
  37. package/dist/lib/lanes/loop.d.ts +158 -0
  38. package/dist/lib/lanes/loop.js +743 -0
  39. package/dist/lib/lanes/outpost-dispatch.d.ts +9 -1
  40. package/dist/lib/lanes/outpost-dispatch.js +21 -7
  41. package/dist/lib/lanes/reaper.d.ts +58 -0
  42. package/dist/lib/lanes/reaper.js +403 -0
  43. package/dist/lib/lanes/reconcile.d.ts +19 -0
  44. package/dist/lib/lanes/reconcile.js +173 -6
  45. package/dist/lib/lanes/spawn.d.ts +4 -0
  46. package/dist/lib/lanes/spawn.js +41 -9
  47. package/dist/lib/lanes/status-fields.d.ts +12 -0
  48. package/dist/lib/lanes/status-fields.js +189 -0
  49. package/dist/lib/lanes/store.js +49 -2
  50. package/dist/lib/lanes/types.d.ts +18 -0
  51. package/dist/lib/lanes/types.js +3 -3
  52. package/dist/lib/lanes/watcher.js +3 -0
  53. package/dist/lib/lanes/worker-continuity.d.ts +18 -0
  54. package/dist/lib/lanes/worker-continuity.js +81 -0
  55. package/dist/lib/lanes/worker-process.d.ts +4 -0
  56. package/dist/lib/lanes/worker-process.js +77 -0
  57. package/dist/main.js +9 -3
  58. package/dist/utils/cli-telemetry.d.ts +4 -1
  59. package/dist/utils/cli-telemetry.js +8 -0
  60. package/dist/utils/jq-windows-compat.d.ts +13 -0
  61. package/dist/utils/jq-windows-compat.js +48 -0
  62. package/dist/utils/run-bundled-script.d.ts +3 -0
  63. package/dist/utils/run-bundled-script.js +9 -80
  64. package/dist/utils/windows-bash.d.ts +5 -0
  65. package/dist/utils/windows-bash.js +75 -0
  66. package/package.json +1 -1
@@ -27,7 +27,7 @@ export interface OutpostDispatchResult {
27
27
  stderr: string;
28
28
  }
29
29
  export interface OutpostLaneCommand {
30
- action: "message" | "resume" | "stop" | "show" | "logs" | "grant" | "grant-target" | "reparent-target" | "approve-target" | "repair-company-target";
30
+ action: "message" | "resume" | "stop" | "show" | "logs" | "grant" | "grant-target" | "reparent-target" | "approve-target" | "repair-company-target" | "blocked-escalation-target";
31
31
  laneId: string;
32
32
  hqBinary?: string;
33
33
  text?: string;
@@ -53,6 +53,8 @@ export interface OutpostLaneCommand {
53
53
  json?: boolean;
54
54
  callerLaneId?: string;
55
55
  callerSessionId?: string;
56
+ messageId?: string;
57
+ recipientKind?: "lane" | "session";
56
58
  }
57
59
  export declare function buildOutpostQuestionAnswerCommand(input: {
58
60
  questionId: string;
@@ -114,6 +116,12 @@ export declare function buildOutpostLegacyLaneUlidListCommand(input: {
114
116
  /** Read questions on one Outpost, scoped to a company when supplied. */
115
117
  export declare function buildOutpostQuestionListCommand(input?: {
116
118
  company?: string;
119
+ sessionId?: string;
120
+ for?: string;
121
+ from?: string;
122
+ project?: string;
123
+ story?: string;
124
+ status?: string;
117
125
  }): string;
118
126
  /** Read one question's body and owner-computed audience from its Outpost. */
119
127
  export declare function buildOutpostQuestionShowCommand(questionId: string): string;
@@ -121,10 +121,23 @@ export function buildOutpostLegacyLaneUlidListCommand(input) {
121
121
  }
122
122
  /** Read questions on one Outpost, scoped to a company when supplied. */
123
123
  export function buildOutpostQuestionListCommand(input = {}) {
124
- const args = ["hq", "lanes", "questions", "list"];
124
+ const args = [
125
+ ...(input.sessionId?.trim() ? ["env", `HQ_SESSION_ID=${input.sessionId.trim()}`] : []),
126
+ "hq", "lanes", "questions", "list",
127
+ ];
125
128
  const company = input.company?.trim();
126
129
  if (company)
127
130
  args.push("--company", company);
131
+ for (const [flag, value] of [
132
+ ["--for", input.for],
133
+ ["--from", input.from],
134
+ ["--project", input.project],
135
+ ["--story", input.story],
136
+ ["--status", input.status],
137
+ ]) {
138
+ if (value !== undefined)
139
+ args.push(flag, value);
140
+ }
128
141
  return withRemoteHqDir(joinCommandParts(args));
129
142
  }
130
143
  /** Read one question's body and owner-computed audience from its Outpost. */
@@ -191,12 +204,12 @@ export function buildOutpostIdentityBackfillCommand(expectedId) {
191
204
  }
192
205
  /** Build the same `hq outposts exec` remote command used by lane pickup. */
193
206
  export function buildOutpostLaneCommand(input) {
194
- const args = [
195
- input.hqBinary?.trim() || "hq",
196
- "lanes",
197
- input.action,
198
- input.laneId,
199
- ];
207
+ const action = input.action === "blocked-escalation-target" ? "_enqueue-blocked-escalation" : input.action;
208
+ const args = [input.hqBinary?.trim() || "hq", "lanes", action];
209
+ if (input.action === "blocked-escalation-target")
210
+ args.push(input.recipientKind ?? "lane", input.laneId);
211
+ else
212
+ args.push(input.laneId);
200
213
  for (const [flag, value] of [
201
214
  ["--text", input.text],
202
215
  ["--project", input.project],
@@ -214,6 +227,7 @@ export function buildOutpostLaneCommand(input) {
214
227
  ["--senior", input.senior],
215
228
  ["--reason", input.reason],
216
229
  ["--company", input.company],
230
+ ["--message-id", input.messageId],
217
231
  ]) {
218
232
  if (value !== undefined)
219
233
  args.push(flag, value);
@@ -0,0 +1,58 @@
1
+ import type { LaneRecord } from "./types.js";
2
+ export declare const DEFAULT_REAP_MIN_AGE_MINUTES = 120;
3
+ export declare const REAP_SINGLE_FLIGHT_LOCK_STALE_MS: number;
4
+ export declare const REAP_RECENT_SWEEP_MS: number;
5
+ export interface ReapProcess {
6
+ pid: number;
7
+ ppid: number;
8
+ args: string;
9
+ }
10
+ export interface ReapFinding {
11
+ lane_id: string;
12
+ orphaned: boolean;
13
+ reason: string;
14
+ age_minutes: number;
15
+ session_id?: string;
16
+ pid?: number;
17
+ process_starttime?: number | null;
18
+ would_stop?: boolean;
19
+ stopped?: boolean;
20
+ stop_complete?: boolean;
21
+ stop_error?: string;
22
+ stale_state?: string[];
23
+ }
24
+ export interface ReapDeps {
25
+ now?: () => Date;
26
+ hostname?: () => string;
27
+ processes?: () => ReapProcess[];
28
+ isAlive?: (pid: number) => boolean;
29
+ readStarttime?: (pid: number) => number | null;
30
+ sessionState?: (root: string, sessionId: string, now: Date) => string | null;
31
+ stopEnabled?: (lane: LaneRecord) => Promise<boolean> | boolean;
32
+ stopLane?: (laneId: string, reason: string) => Promise<void>;
33
+ sleep?: (ms: number) => void;
34
+ }
35
+ export declare function parseReapMinAge(value: string | undefined): number;
36
+ export declare function isProcessRunning(pid: number): boolean;
37
+ export declare function classifyLocalLanes(root: string, now: Date, minAgeMinutes: number, deps?: ReapDeps): Promise<ReapFinding[]>;
38
+ export interface ReapOptions {
39
+ minAgeMinutes: number;
40
+ stop: boolean;
41
+ applyStaleState: boolean;
42
+ reason: string;
43
+ json: boolean;
44
+ }
45
+ export interface ReapSummary {
46
+ ok: true;
47
+ action: "lanes.reap";
48
+ generated_at: string;
49
+ min_age_minutes: number;
50
+ mode: "report" | "apply_stale_state" | "stop";
51
+ stop_enabled: boolean;
52
+ skipped?: "already_running" | "recent_sweep";
53
+ lanes: ReapFinding[];
54
+ }
55
+ export declare function reapLocalLanes(root: string, options: ReapOptions, deps?: ReapDeps): Promise<ReapSummary>;
56
+ /** Internal alias used by session-start hooks; it has no shell or OS-specific dependency. */
57
+ export declare const runSessionStartReap: typeof reapLocalLanes;
58
+ //# sourceMappingURL=reaper.d.ts.map
@@ -0,0 +1,403 @@
1
+ /** Session-start orphan lane sweep. Every destructive action is opt-in and gated. */
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+ import { laneRunsOnHost } from "./admission.js";
7
+ import { laneRunDir, isPidRecordAlive, readPidRecord, readProcStarttime } from "./spawn.js";
8
+ import { readAllLanes, readLane } from "./store.js";
9
+ import { LanesError } from "./errors.js";
10
+ export const DEFAULT_REAP_MIN_AGE_MINUTES = 120;
11
+ export const REAP_SINGLE_FLIGHT_LOCK_STALE_MS = 60 * 60 * 1000;
12
+ export const REAP_RECENT_SWEEP_MS = 30 * 60 * 1000;
13
+ const SESSION_COMMANDS = new Set(["claude", "codex", "codex-code-mode-host", "grok", "grok-cli"]);
14
+ const staleMarkerSnapshots = new WeakMap();
15
+ function safeHostComponent(hostname) {
16
+ return hostname.replace(/[^A-Za-z0-9._-]/g, "_") || "unknown-host";
17
+ }
18
+ function reaperStateDirectory() {
19
+ const stateRoot = process.env.HQ_LANES_REAPER_STATE_HOME?.trim() ||
20
+ process.env.XDG_STATE_HOME?.trim() || path.join(os.homedir(), ".local", "state");
21
+ return path.join(stateRoot, "hq-cli", "lanes-reaper");
22
+ }
23
+ export function parseReapMinAge(value) {
24
+ if (value === undefined)
25
+ return DEFAULT_REAP_MIN_AGE_MINUTES;
26
+ const invalid = (message) => {
27
+ throw new LanesError("lane_reap_min_age_invalid", message, { value });
28
+ };
29
+ if (!/^(0|[1-9]\d*)$/.test(value))
30
+ invalid("--min-age must be a non-negative whole number of minutes");
31
+ const minutes = Number(value);
32
+ if (!Number.isSafeInteger(minutes) || minutes > Math.floor(Number.MAX_SAFE_INTEGER / 60_000)) {
33
+ invalid("--min-age is outside the supported range");
34
+ }
35
+ return minutes;
36
+ }
37
+ function readProcesses() {
38
+ const result = spawnSync("ps", ["-ww", "-axo", "pid=,ppid=,args="], { encoding: "utf8", windowsHide: true });
39
+ if (result.status !== 0)
40
+ throw new Error(`ps failed: ${result.stderr.trim()}`);
41
+ return result.stdout.split("\n").flatMap((line) => {
42
+ const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
43
+ if (!match)
44
+ return [];
45
+ return [{ pid: Number(match[1]), ppid: Number(match[2]), args: match[3] ?? "" }];
46
+ });
47
+ }
48
+ export function isProcessRunning(pid) {
49
+ try {
50
+ process.kill(pid, 0);
51
+ }
52
+ catch (err) {
53
+ const code = err.code;
54
+ if (code === "ESRCH")
55
+ return false;
56
+ if (code !== "EPERM")
57
+ throw err;
58
+ }
59
+ const result = spawnSync("ps", ["-p", String(pid), "-o", "stat="], { encoding: "utf8", windowsHide: true });
60
+ return result.status === 0 && !/^\s*Z/.test(result.stdout);
61
+ }
62
+ function sessionCommand(args) {
63
+ const argv = args.trim().split(/\s+/);
64
+ const base = (value) => value?.split(/[\\/]/).at(-1);
65
+ const executable = base(argv[0]);
66
+ const isSessionName = (value) => {
67
+ const name = value?.toLowerCase() ?? "";
68
+ return SESSION_COMMANDS.has(name) || /^(codex|grok|claude)(?:[-.]|$)/.test(name);
69
+ };
70
+ if (isSessionName(executable))
71
+ return true;
72
+ if (["bash", "sh", "zsh", "dash", "ksh", "node"].includes(executable ?? "") && isSessionName(base(argv[1])))
73
+ return true;
74
+ if (executable === "env" && isSessionName(base(argv[1])))
75
+ return true;
76
+ return false;
77
+ }
78
+ function ancestryReachesSession(pid, processes) {
79
+ const byPid = new Map(processes.map((proc) => [proc.pid, proc]));
80
+ const worker = byPid.get(pid);
81
+ let current = worker ? byPid.get(worker.ppid) : undefined;
82
+ const seen = new Set();
83
+ for (let hops = 0; current && current.pid > 1 && hops < 64; hops += 1) {
84
+ if (seen.has(current.pid))
85
+ return true;
86
+ seen.add(current.pid);
87
+ if (sessionCommand(current.args))
88
+ return true;
89
+ current = byPid.get(current.ppid);
90
+ }
91
+ return false;
92
+ }
93
+ function descendantPids(rootPid, processes) {
94
+ const children = new Map();
95
+ for (const proc of processes)
96
+ children.set(proc.ppid, [...(children.get(proc.ppid) ?? []), proc.pid]);
97
+ const output = [];
98
+ const visit = (pid, seen) => {
99
+ for (const child of children.get(pid) ?? []) {
100
+ if (seen.has(child))
101
+ continue;
102
+ seen.add(child);
103
+ visit(child, seen);
104
+ output.push(child);
105
+ }
106
+ };
107
+ visit(rootPid, new Set([rootPid]));
108
+ return output;
109
+ }
110
+ function processGroup(pid) {
111
+ const result = spawnSync("ps", ["-p", String(pid), "-o", "pgid="], { encoding: "utf8", windowsHide: true });
112
+ if (result.status !== 0)
113
+ return null;
114
+ const value = Number(result.stdout.trim());
115
+ return Number.isSafeInteger(value) && value > 0 ? value : null;
116
+ }
117
+ function processGroupPids(groupId) {
118
+ const result = spawnSync("ps", ["-ww", "-e", "-o", "pid=,pgid="], { encoding: "utf8", windowsHide: true });
119
+ if (result.status !== 0)
120
+ return null;
121
+ return result.stdout.split("\n").flatMap((line) => {
122
+ const match = /^\s*(\d+)\s+(\d+)\s*$/.exec(line);
123
+ return match && Number(match[2]) === groupId ? [Number(match[1])] : [];
124
+ });
125
+ }
126
+ function stopTree(rootPid, rootStarttime, processes, deps) {
127
+ const targets = [...descendantPids(rootPid, processes), rootPid];
128
+ const readStart = deps.readStarttime ?? readProcStarttime;
129
+ const expectedStarttimes = new Map(targets.map((pid) => [pid, pid === rootPid ? rootStarttime : readStart(pid)]));
130
+ const identityMatches = (pid) => {
131
+ const expected = expectedStarttimes.get(pid);
132
+ return expected !== undefined && expected !== null && readStart(pid) === expected;
133
+ };
134
+ const signal = (pid, value) => {
135
+ if (!identityMatches(pid))
136
+ return;
137
+ try {
138
+ process.kill(pid, value);
139
+ }
140
+ catch (err) {
141
+ const code = err.code;
142
+ if (code !== "ESRCH" && code !== "EPERM")
143
+ throw err;
144
+ }
145
+ };
146
+ const sleep = deps.sleep ?? ((ms) => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); });
147
+ const groupId = processGroup(rootPid);
148
+ const groupMembersAtStart = groupId === rootPid ? processGroupPids(groupId) : [];
149
+ const groupStarttimes = new Map((groupMembersAtStart ?? []).map((pid) => [pid, pid === rootPid ? rootStarttime : readStart(pid)]));
150
+ const sameGroupRemains = () => {
151
+ if (groupId !== rootPid)
152
+ return false;
153
+ const current = processGroupPids(groupId);
154
+ if (current === null)
155
+ return false;
156
+ return current.some((pid) => {
157
+ const expected = groupStarttimes.get(pid);
158
+ return expected !== undefined && expected !== null && readStart(pid) === expected;
159
+ });
160
+ };
161
+ const signalGroup = (value, requireRootIdentity = true) => {
162
+ if (groupId !== rootPid || (requireRootIdentity && !identityMatches(rootPid)))
163
+ return;
164
+ try {
165
+ process.kill(-groupId, value);
166
+ }
167
+ catch (err) {
168
+ const code = err.code;
169
+ if (code !== "ESRCH" && code !== "EPERM" && code !== "EINVAL")
170
+ throw err;
171
+ }
172
+ };
173
+ // A detached worker group includes grandchildren that a PPID snapshot can miss.
174
+ signalGroup("SIGTERM");
175
+ for (const pid of targets)
176
+ signal(pid, "SIGTERM");
177
+ sleep(500);
178
+ const remainingGroup = groupId === rootPid ? processGroupPids(groupId) : [];
179
+ if (remainingGroup !== null && remainingGroup.length > 0 && sameGroupRemains())
180
+ signalGroup("SIGKILL", false);
181
+ for (const pid of targets)
182
+ signal(pid, "SIGKILL");
183
+ const isAlive = deps.isAlive ?? isProcessRunning;
184
+ const liveGroupMembers = () => {
185
+ if (groupId !== rootPid)
186
+ return [];
187
+ const members = processGroupPids(groupId);
188
+ return members === null ? [rootPid] : members.filter(isAlive);
189
+ };
190
+ const hasLiveTargets = () => targets.some(isAlive) || liveGroupMembers().length > 0;
191
+ for (let attempt = 0; attempt < 50 && hasLiveTargets(); attempt += 1) {
192
+ sleep(20);
193
+ }
194
+ return { targets, remaining: [...new Set([...targets.filter(isAlive), ...liveGroupMembers()])] };
195
+ }
196
+ function localRun(lane, hostname) {
197
+ if (!laneRunsOnHost(lane, { hostname, outpostId: null }))
198
+ return false;
199
+ return true;
200
+ }
201
+ export async function classifyLocalLanes(root, now, minAgeMinutes, deps = {}) {
202
+ const hostname = deps.hostname?.() ?? os.hostname();
203
+ const processes = (deps.processes ?? readProcesses)();
204
+ const alive = deps.isAlive ?? isProcessRunning;
205
+ const readStart = deps.readStarttime ?? readProcStarttime;
206
+ const result = [];
207
+ for (const lane of readAllLanes(root)) {
208
+ if (!localRun(lane, hostname))
209
+ continue;
210
+ const ageMs = now.getTime() - Date.parse(lane.created_at);
211
+ const age = Number.isFinite(ageMs) ? Math.max(0, Math.floor(ageMs / 60_000)) : 0;
212
+ const finding = { lane_id: lane.lane_id, orphaned: false, reason: "not_a_candidate", age_minutes: age };
213
+ const laneRunDirectory = laneRunDir(root, lane.lane_id);
214
+ const workerRecord = readPidRecord(path.join(laneRunDirectory, "worker.pid"));
215
+ const watcherRecord = readPidRecord(path.join(laneRunDirectory, "watcher.pid"));
216
+ const markerRecords = new Map();
217
+ const staleState = [];
218
+ for (const [marker, record] of [["worker.pid", workerRecord], ["watcher.pid", watcherRecord]]) {
219
+ if (!record || isPidRecordAlive(record, alive, readStart))
220
+ continue;
221
+ staleState.push(marker);
222
+ markerRecords.set(marker, record);
223
+ }
224
+ if (staleState.length)
225
+ finding.stale_state = staleState;
226
+ staleMarkerSnapshots.set(finding, markerRecords);
227
+ if (lane.senior.kind !== "session" || !lane.senior.id)
228
+ finding.reason = "senior_is_not_a_session";
229
+ else {
230
+ finding.session_id = lane.senior.id;
231
+ const sessionState = deps.sessionState?.(root, lane.senior.id, now) ?? null;
232
+ if (sessionState !== "ended" && sessionState !== "lost")
233
+ finding.reason = sessionState === "live" ? "senior_session_alive" : "senior_session_not_proven_dead";
234
+ else if (lane.state !== "running")
235
+ finding.reason = "lane_not_running";
236
+ else if (lane.keep_alive === true || lane.keep_alive_standing === true)
237
+ finding.reason = "keep_alive";
238
+ else if (lane.senior_history.some((entry) => entry.reason === "reparented"))
239
+ finding.reason = "lane_adopted_or_reparented";
240
+ else if (age < minAgeMinutes)
241
+ finding.reason = "under_minimum_age";
242
+ else {
243
+ const worker = workerRecord;
244
+ if (!worker) {
245
+ finding.reason = "no_worker_pid";
246
+ }
247
+ else if (worker.starttime === undefined) {
248
+ finding.pid = worker.pid;
249
+ finding.reason = "worker_process_starttime_missing";
250
+ }
251
+ else {
252
+ const starttime = worker.starttime ?? null;
253
+ finding.pid = worker.pid;
254
+ finding.process_starttime = starttime;
255
+ const workerAlive = isPidRecordAlive(worker, alive, readStart);
256
+ if (!workerAlive) {
257
+ finding.reason = "worker_process_gone_or_pid_reused";
258
+ finding.stale_state = staleState;
259
+ }
260
+ else if (ancestryReachesSession(worker.pid, processes))
261
+ finding.reason = "process_has_live_session_ancestor";
262
+ else {
263
+ finding.orphaned = true;
264
+ finding.reason = `senior_session_${sessionState};worker_process_has_no_session_ancestor`;
265
+ }
266
+ }
267
+ }
268
+ }
269
+ result.push(finding);
270
+ }
271
+ return result.sort((a, b) => b.age_minutes - a.age_minutes || a.lane_id.localeCompare(b.lane_id));
272
+ }
273
+ export async function reapLocalLanes(root, options, deps = {}) {
274
+ const nowFn = deps.now ?? (() => new Date());
275
+ const now = nowFn();
276
+ const stateDir = reaperStateDirectory();
277
+ fs.mkdirSync(stateDir, { recursive: true });
278
+ const host = safeHostComponent(deps.hostname?.() ?? os.hostname());
279
+ const lockDir = path.join(stateDir, `.lock-${host}`);
280
+ try {
281
+ fs.mkdirSync(lockDir);
282
+ }
283
+ catch (err) {
284
+ if (err.code !== "EEXIST")
285
+ throw err;
286
+ const lockAge = now.getTime() - fs.statSync(lockDir).mtimeMs;
287
+ if (lockAge <= REAP_SINGLE_FLIGHT_LOCK_STALE_MS)
288
+ return { ok: true, action: "lanes.reap", generated_at: now.toISOString(), min_age_minutes: options.minAgeMinutes, mode: options.stop ? "stop" : options.applyStaleState ? "apply_stale_state" : "report", stop_enabled: false, skipped: "already_running", lanes: [] };
289
+ const retiredLock = `${lockDir}.stale-${process.pid}-${now.getTime()}`;
290
+ try {
291
+ fs.renameSync(lockDir, retiredLock);
292
+ }
293
+ catch {
294
+ return { ok: true, action: "lanes.reap", generated_at: now.toISOString(), min_age_minutes: options.minAgeMinutes, mode: "report", stop_enabled: false, skipped: "already_running", lanes: [] };
295
+ }
296
+ fs.rmSync(retiredLock, { recursive: true, force: true });
297
+ try {
298
+ fs.mkdirSync(lockDir);
299
+ }
300
+ catch {
301
+ return { ok: true, action: "lanes.reap", generated_at: now.toISOString(), min_age_minutes: options.minAgeMinutes, mode: "report", stop_enabled: false, skipped: "already_running", lanes: [] };
302
+ }
303
+ }
304
+ const stamp = path.join(stateDir, `.last-sweep-${host}`);
305
+ if (!options.stop && !options.applyStaleState) {
306
+ try {
307
+ const stampText = fs.readFileSync(stamp, "utf8").trim();
308
+ const stampedAt = /^\d+$/.test(stampText) ? Number(stampText) : fs.statSync(stamp).mtimeMs;
309
+ const age = now.getTime() - stampedAt;
310
+ if (age >= 0 && age < REAP_RECENT_SWEEP_MS) {
311
+ fs.rmSync(lockDir, { recursive: true, force: true });
312
+ return { ok: true, action: "lanes.reap", generated_at: now.toISOString(), min_age_minutes: options.minAgeMinutes, mode: "report", stop_enabled: false, skipped: "recent_sweep", lanes: [] };
313
+ }
314
+ }
315
+ catch (err) {
316
+ if (err.code !== "ENOENT")
317
+ throw err;
318
+ }
319
+ }
320
+ try {
321
+ const findings = await classifyLocalLanes(root, now, options.minAgeMinutes, deps);
322
+ const isAlive = deps.isAlive ?? isProcessRunning;
323
+ const readStart = deps.readStarttime ?? readProcStarttime;
324
+ const clearStaleMarkerIfUnchanged = (finding, marker) => {
325
+ const expected = staleMarkerSnapshots.get(finding)?.get(marker);
326
+ if (!expected)
327
+ return;
328
+ const markerPath = path.join(laneRunDir(root, finding.lane_id), marker);
329
+ const current = readPidRecord(markerPath);
330
+ if (!current || current.pid !== expected.pid || current.starttime !== expected.starttime)
331
+ return;
332
+ if (isPidRecordAlive(current, isAlive, readStart))
333
+ return;
334
+ fs.rmSync(markerPath, { force: true });
335
+ };
336
+ let stopEnabled = false;
337
+ for (const finding of findings) {
338
+ if (finding.stale_state?.length && options.applyStaleState) {
339
+ for (const marker of finding.stale_state)
340
+ if (marker.endsWith(".pid"))
341
+ clearStaleMarkerIfUnchanged(finding, marker);
342
+ }
343
+ if (!finding.orphaned || !options.stop || finding.pid === undefined) {
344
+ if (finding.orphaned && options.stop)
345
+ finding.would_stop = true;
346
+ continue;
347
+ }
348
+ const lane = readLane(root, finding.lane_id);
349
+ const enabled = await (deps.stopEnabled?.(lane) ?? false);
350
+ stopEnabled ||= enabled;
351
+ if (!enabled) {
352
+ finding.would_stop = true;
353
+ continue;
354
+ }
355
+ if (!deps.stopLane)
356
+ throw new Error("--stop requires the normal lane stop path");
357
+ const current = readLane(root, finding.lane_id);
358
+ const currentNow = nowFn();
359
+ const currentAge = currentNow.getTime() - Date.parse(current.created_at);
360
+ if (!localRun(current, deps.hostname?.() ?? os.hostname()) || current.state !== "running" ||
361
+ current.senior.kind !== "session" || !current.senior.id || current.senior.id !== finding.session_id ||
362
+ current.keep_alive === true || current.keep_alive_standing === true ||
363
+ current.senior_history.some((entry) => entry.reason === "reparented") ||
364
+ !Number.isFinite(currentAge) || currentAge < options.minAgeMinutes * 60_000) {
365
+ finding.orphaned = false;
366
+ finding.reason = "lane_protection_changed_before_stop";
367
+ continue;
368
+ }
369
+ const currentState = deps.sessionState?.(root, current.senior.id, currentNow) ?? null;
370
+ if (currentState !== "ended" && currentState !== "lost") {
371
+ finding.orphaned = false;
372
+ finding.reason = currentState === "live" ? "senior_session_alive" : "senior_session_not_proven_dead";
373
+ continue;
374
+ }
375
+ const rec = readPidRecord(path.join(laneRunDir(root, current.lane_id), "worker.pid"));
376
+ if (!rec || rec.pid !== finding.pid || rec.starttime !== finding.process_starttime || rec.starttime == null || !isPidRecordAlive(rec, isAlive, readStart)) {
377
+ finding.orphaned = false;
378
+ finding.reason = "worker_pid_changed_before_stop";
379
+ continue;
380
+ }
381
+ const processes = (deps.processes ?? readProcesses)();
382
+ if (ancestryReachesSession(finding.pid, processes)) {
383
+ finding.orphaned = false;
384
+ finding.reason = "process_has_live_session_ancestor";
385
+ continue;
386
+ }
387
+ const { remaining } = stopTree(rec.pid, rec.starttime, processes, deps);
388
+ await deps.stopLane(current.lane_id, options.reason);
389
+ finding.stop_complete = remaining.length === 0;
390
+ finding.stopped = finding.stop_complete;
391
+ if (!finding.stop_complete)
392
+ finding.stop_error = `process_tree_stop_incomplete:${remaining.join(",")}`;
393
+ }
394
+ fs.writeFileSync(stamp, `${now.getTime()}\n`);
395
+ return { ok: true, action: "lanes.reap", generated_at: now.toISOString(), min_age_minutes: options.minAgeMinutes, mode: options.stop ? "stop" : options.applyStaleState ? "apply_stale_state" : "report", stop_enabled: stopEnabled, lanes: findings };
396
+ }
397
+ finally {
398
+ fs.rmSync(lockDir, { recursive: true, force: true });
399
+ }
400
+ }
401
+ /** Internal alias used by session-start hooks; it has no shell or OS-specific dependency. */
402
+ export const runSessionStartReap = reapLocalLanes;
403
+ //# sourceMappingURL=reaper.js.map
@@ -40,6 +40,20 @@ export interface ReconcileChange {
40
40
  /** The attempted provider PATH when admission found no executable. */
41
41
  searched_path?: string;
42
42
  }
43
+ export interface BlockedLaneEscalationDelivery {
44
+ lane_id: string;
45
+ project_id: string;
46
+ story_id: string;
47
+ direct_senior: SeniorRef;
48
+ recipient: SeniorRef;
49
+ recipient_host?: LaneRecord["host"];
50
+ blocked_since: string;
51
+ at: string;
52
+ hours: number;
53
+ blocked_reason: string;
54
+ message_id: string;
55
+ text: string;
56
+ }
43
57
  export interface ReconcileSummary {
44
58
  ok: boolean;
45
59
  action: "reconcile";
@@ -57,6 +71,7 @@ export interface ReconcileSummary {
57
71
  id: string;
58
72
  reason: KeepAliveBlockedReason;
59
73
  }>;
74
+ blocked_escalations: Array<Pick<BlockedLaneEscalationDelivery, "lane_id" | "recipient" | "blocked_since" | "at" | "hours" | "blocked_reason" | "message_id" | "text">>;
60
75
  errors: Array<{
61
76
  message: string;
62
77
  code?: string;
@@ -94,6 +109,10 @@ export interface ReconcileDeps {
94
109
  skipAdmissionWithoutCpuSample?: boolean;
95
110
  /** Flag reader seam used to assert rollout reads happen before admission locks. */
96
111
  readRegistryFlag?: typeof readLanesRegistryFlag;
112
+ /** Durable delivery seam for six-hour blocked-lane escalation. */
113
+ deliverBlockedEscalation?: (delivery: BlockedLaneEscalationDelivery) => Promise<void>;
114
+ /** Read a missing escalation ancestor from the authoritative hierarchy. */
115
+ resolveEscalationLane?: (laneId: string) => Promise<LaneRecord | null>;
97
116
  /** Active-company fallback for questions raised from an unscoped lane. */
98
117
  resolveQuestionActiveCompany?: () => Promise<LaneCompanyRef>;
99
118
  /** Resume queued messages after this pass wins a local dead-worker CAS. */