@indigoai-us/hq-cli 5.209.0 → 5.211.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/commands/lanes.js +12 -1
- package/dist/lanes-drain-hook.d.ts +2 -0
- package/dist/lanes-drain-hook.js +52 -0
- package/dist/lib/lanes/admission.js +1 -1
- package/dist/lib/lanes/compaction.d.ts +15 -1
- package/dist/lib/lanes/compaction.js +96 -0
- package/dist/lib/lanes/dropbox.d.ts +21 -3
- package/dist/lib/lanes/dropbox.js +181 -7
- package/dist/lib/lanes/hooks-install.d.ts +4 -3
- package/dist/lib/lanes/hooks-install.js +171 -18
- package/dist/lib/lanes/message-replay.d.ts +14 -0
- package/dist/lib/lanes/message-replay.js +4 -0
- package/dist/lib/lanes/profile.d.ts +7 -1
- package/dist/lib/lanes/profile.js +13 -0
- package/dist/lib/lanes/reconcile.js +39 -9
- package/dist/lib/lanes/spawn.js +28 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.211.0] — 2026-09-26
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Reconcile now applies `keep_alive` from a recorded worker profile to older
|
|
10
|
+
lanes whose lane field is unset. An explicit lane value wins, unreadable
|
|
11
|
+
profiles fail closed and are reported, and `lanes show --json` identifies
|
|
12
|
+
the source of its effective value.
|
|
13
|
+
|
|
14
|
+
## [5.210.0] — 2026-09-26
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Lane messages delivered during a provider turn are replayed after transcript
|
|
19
|
+
compaction when `lanes.message-compaction-replay` is enabled, limited to that
|
|
20
|
+
provider turn. Monitor events keep their data framing.
|
|
21
|
+
|
|
5
22
|
## [5.209.0] — 2026-09-26
|
|
6
23
|
|
|
7
24
|
### Added
|
package/dist/commands/lanes.js
CHANGED
|
@@ -38,6 +38,7 @@ import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
|
|
|
38
38
|
import { effectiveAccountHome } from "../lib/lanes/account-home.js";
|
|
39
39
|
import { AccountResolutionError, compareLaneQueueOrder, coveredLaneIds, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountStatus, appendCappedHistory, canonicalizeHome, createLane, createQuestion, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, aliveDirectChildren, assignedChildHireGrants, LANE_HIRE_GRANTS_FLAG, planHireGrantBackfill, terminalChildResumeHireGrantError, withHireGrantAdmission, laneStoryLedgerEnabled, validateProjectStories, buildLaneStatusSnapshot, formatLaneStatusHuman, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, laneRunsHere, MAX_LANE_DEPTH, MONITOR_ERROR_EXIT_CODE, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_UNCOVERED_EXIT_CODE, MAX_WATCHED_LANES, monitorReminderText, readLiveMonitorEvidence, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneRecord, parseLaneState, parseLivenessState, parseQuestionRecord, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, syncLaneRecordFromOwner, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile, runReconcilePass, wakeAnsweredLaneNow, runLaneWatch, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, hasActiveAdmissionReservation, hasActivePendingResume, hasRecordedSessionId, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, providerSupportsSessionResume, providerSessionPortable, sessionNotPortableMessage, withLaneAdmissionLock, withLaneHireLocks, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
|
|
40
40
|
import { activeLaneStories, laneStories, laneStory, storyWarning, } from "../lib/lanes/stories.js";
|
|
41
|
+
import { resolveLaneKeepAlive } from "../lib/lanes/profile.js";
|
|
41
42
|
import { questionBodyAbsPath } from "../lib/lanes/questions.js";
|
|
42
43
|
import { compactionAfterUsage, } from "../lib/lanes/compaction.js";
|
|
43
44
|
import { assertUniqueAccountHomes, providerCompactAfterMs } from "../lib/lanes/config.js";
|
|
@@ -4489,6 +4490,15 @@ async function runShow(ctx, laneId) {
|
|
|
4489
4490
|
? { ...storedLane, state: "remote", remote_state: storedLane.state, queued_reason: undefined }
|
|
4490
4491
|
: storedLane;
|
|
4491
4492
|
const askDeliveryFailed = readJsonFile(askDeliveryFailedPath(hqRoot, laneId));
|
|
4493
|
+
let keepAliveState;
|
|
4494
|
+
try {
|
|
4495
|
+
keepAliveState = resolveLaneKeepAlive(hqRoot, storedLane);
|
|
4496
|
+
}
|
|
4497
|
+
catch (err) {
|
|
4498
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4499
|
+
process.stderr.write(`hq lanes show: keep-alive profile could not be loaded for ${laneId}: ${message}\n`);
|
|
4500
|
+
keepAliveState = { enabled: false, source: "none" };
|
|
4501
|
+
}
|
|
4492
4502
|
printJson({
|
|
4493
4503
|
ok: true,
|
|
4494
4504
|
action: "show",
|
|
@@ -4514,7 +4524,8 @@ async function runShow(ctx, laneId) {
|
|
|
4514
4524
|
terminal_reason: lane.terminal_reason ?? null,
|
|
4515
4525
|
terminal_details: lane.terminal_details ?? null,
|
|
4516
4526
|
keep_alive_state: {
|
|
4517
|
-
enabled:
|
|
4527
|
+
enabled: keepAliveState.enabled,
|
|
4528
|
+
source: keepAliveState.source,
|
|
4518
4529
|
consecutive_auto_resumes: lane.keep_alive_resume_count ?? 0,
|
|
4519
4530
|
last_auto_resume_at: lane.keep_alive_last_resume_at ?? null,
|
|
4520
4531
|
next_eligible_at: lane.keep_alive_next_eligible_at ?? null,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { runDrainHook } from "./lib/lanes/dropbox.js";
|
|
2
|
+
function parseHookInput(text) {
|
|
3
|
+
if (text.trim() === "")
|
|
4
|
+
return {};
|
|
5
|
+
let parsed;
|
|
6
|
+
try {
|
|
7
|
+
parsed = JSON.parse(text);
|
|
8
|
+
}
|
|
9
|
+
catch (err) {
|
|
10
|
+
throw new Error(`invalid_hook_input_json: ${err instanceof Error ? err.message : String(err)}`);
|
|
11
|
+
}
|
|
12
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
13
|
+
throw new Error("invalid_hook_input_shape: expected a JSON object");
|
|
14
|
+
}
|
|
15
|
+
const value = parsed;
|
|
16
|
+
for (const key of ["transcript_path", "session_id", "hook_event_name"]) {
|
|
17
|
+
if (value[key] !== undefined && value[key] !== null && typeof value[key] !== "string") {
|
|
18
|
+
throw new Error(`invalid_hook_input_field: ${key} must be a string`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
...(typeof value.transcript_path === "string" ? { transcript_path: value.transcript_path } : {}),
|
|
23
|
+
...(typeof value.session_id === "string" ? { session_id: value.session_id } : {}),
|
|
24
|
+
...(typeof value.hook_event_name === "string" ? { hook_event_name: value.hook_event_name } : {}),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
async function main() {
|
|
28
|
+
const chunks = [];
|
|
29
|
+
for await (const chunk of process.stdin)
|
|
30
|
+
chunks.push(Buffer.from(chunk));
|
|
31
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
32
|
+
let input = {};
|
|
33
|
+
let inputError = "";
|
|
34
|
+
try {
|
|
35
|
+
input = parseHookInput(text);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
inputError = `hq-lanes-drain: ${err instanceof Error ? err.message : String(err)}\n`;
|
|
39
|
+
}
|
|
40
|
+
const result = await runDrainHook(process.env, input);
|
|
41
|
+
if (result.stdout)
|
|
42
|
+
process.stdout.write(result.stdout);
|
|
43
|
+
if (inputError || result.stderr)
|
|
44
|
+
process.stderr.write(`${inputError}${result.stderr}`);
|
|
45
|
+
process.exitCode = result.exitCode;
|
|
46
|
+
}
|
|
47
|
+
main().catch((err) => {
|
|
48
|
+
process.stderr.write(`hq-lanes-drain: hook_runner_failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
49
|
+
// A hook runner failure must not stop the provider's requested tool call.
|
|
50
|
+
process.exitCode = 0;
|
|
51
|
+
});
|
|
52
|
+
//# sourceMappingURL=lanes-drain-hook.js.map
|
|
@@ -137,8 +137,8 @@ export function hasActivePendingResume(lane, nowMs) {
|
|
|
137
137
|
return false;
|
|
138
138
|
if (lane.state === "queued" && pending.trigger === "answered_questions")
|
|
139
139
|
return false;
|
|
140
|
+
// This timestamp tags automatic keep-alive resumes, including profile-derived ones.
|
|
140
141
|
if (lane.state === "queued" &&
|
|
141
|
-
lane.keep_alive === true &&
|
|
142
142
|
pending.trigger === "resume" &&
|
|
143
143
|
pending.resumed_from === "done" &&
|
|
144
144
|
lane.keep_alive_last_resume_at === pending.at)
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* transcript is read only for `compact_boundary` system records, and only
|
|
26
26
|
* their `compactMetadata.preTokens` is kept.
|
|
27
27
|
*/
|
|
28
|
-
import type { LaneCompactionRecord, LaneRecord, LaneRunUsage } from "./types.js";
|
|
28
|
+
import type { LaneCompactionRecord, LaneProvider, LaneRecord, LaneRunUsage } from "./types.js";
|
|
29
29
|
export declare const COMPACTION_MECHANISM: "claude_print_compact";
|
|
30
30
|
/** The whole prompt of the compaction turn. */
|
|
31
31
|
export declare const COMPACTION_PROMPT = "/compact";
|
|
@@ -47,6 +47,20 @@ export interface CompactBoundaryProbe {
|
|
|
47
47
|
count: number;
|
|
48
48
|
last_pre_tokens: number | null;
|
|
49
49
|
}
|
|
50
|
+
export interface ProviderCompactionDelta {
|
|
51
|
+
count: number;
|
|
52
|
+
next_offset: number;
|
|
53
|
+
replaced: boolean;
|
|
54
|
+
}
|
|
55
|
+
/** Read newly appended, newline-complete marker records after a saved byte offset. */
|
|
56
|
+
export declare function probeProviderCompactionsSince(transcriptPath: string, provider: LaneProvider, startOffset: number): ProviderCompactionDelta;
|
|
57
|
+
export declare function providerTranscriptSize(transcriptPath: string): number;
|
|
58
|
+
/**
|
|
59
|
+
* Read only provider compaction markers from a lane transcript. Claude writes
|
|
60
|
+
* `system/compact_boundary`; Codex rollout files write a top-level
|
|
61
|
+
* `compacted` record. Tool and prompt contents are never returned or logged.
|
|
62
|
+
*/
|
|
63
|
+
export declare function probeProviderCompactions(transcriptPath: string | null, provider: LaneProvider): number;
|
|
50
64
|
/** Locate `<home>/projects/<cwd-slug>/<sid>.jsonl` without deriving the slug. */
|
|
51
65
|
export declare function findSessionTranscript(home: string, sessionId: string): string | null;
|
|
52
66
|
/** Count compact boundaries in a transcript. Other lines are never parsed. */
|
|
@@ -49,6 +49,102 @@ export function compactionDue(lane, nowMs, thresholdMs) {
|
|
|
49
49
|
threshold_ms: thresholdMs,
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
|
+
function isProviderMarkerLine(line, lineNumber, transcriptPath, provider) {
|
|
53
|
+
const needle = provider === "claude" ? "compact_boundary" : "compacted";
|
|
54
|
+
if (!line.includes(needle))
|
|
55
|
+
return false;
|
|
56
|
+
let record;
|
|
57
|
+
try {
|
|
58
|
+
const parsed = JSON.parse(line);
|
|
59
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
60
|
+
return false;
|
|
61
|
+
record = parsed;
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
process.stderr.write(`hq lanes: skipped unparseable provider transcript marker line ${lineNumber} in ${transcriptPath}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return ((provider === "claude" && record.type === "system" && record.subtype === "compact_boundary") ||
|
|
68
|
+
(provider === "codex" && record.type === "compacted"));
|
|
69
|
+
}
|
|
70
|
+
/** Read newly appended, newline-complete marker records after a saved byte offset. */
|
|
71
|
+
export function probeProviderCompactionsSince(transcriptPath, provider, startOffset) {
|
|
72
|
+
if (provider === "grok")
|
|
73
|
+
return { count: 0, next_offset: startOffset, replaced: false };
|
|
74
|
+
const fd = fs.openSync(transcriptPath, "r");
|
|
75
|
+
try {
|
|
76
|
+
const size = fs.fstatSync(fd).size;
|
|
77
|
+
if (startOffset > size)
|
|
78
|
+
return { count: 0, next_offset: size, replaced: true };
|
|
79
|
+
if (startOffset === size)
|
|
80
|
+
return { count: 0, next_offset: size, replaced: false };
|
|
81
|
+
const chunkSize = 1024 * 1024;
|
|
82
|
+
const lineParts = [];
|
|
83
|
+
let count = 0;
|
|
84
|
+
let position = startOffset;
|
|
85
|
+
let nextOffset = startOffset;
|
|
86
|
+
let lineNumber = 0;
|
|
87
|
+
while (position < size) {
|
|
88
|
+
const chunk = Buffer.allocUnsafe(Math.min(chunkSize, size - position));
|
|
89
|
+
const read = fs.readSync(fd, chunk, 0, chunk.length, position);
|
|
90
|
+
if (read === 0)
|
|
91
|
+
break;
|
|
92
|
+
position += read;
|
|
93
|
+
let lineStart = 0;
|
|
94
|
+
for (let index = 0; index < read; index += 1) {
|
|
95
|
+
if (chunk[index] !== 0x0a)
|
|
96
|
+
continue;
|
|
97
|
+
lineParts.push(chunk.subarray(lineStart, index));
|
|
98
|
+
const line = Buffer.concat(lineParts).toString("utf8");
|
|
99
|
+
lineParts.length = 0;
|
|
100
|
+
lineNumber += 1;
|
|
101
|
+
if (isProviderMarkerLine(line, lineNumber, transcriptPath, provider))
|
|
102
|
+
count += 1;
|
|
103
|
+
lineStart = index + 1;
|
|
104
|
+
nextOffset = position - (read - lineStart);
|
|
105
|
+
}
|
|
106
|
+
if (lineStart < read)
|
|
107
|
+
lineParts.push(chunk.subarray(lineStart, read));
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
count,
|
|
111
|
+
next_offset: nextOffset,
|
|
112
|
+
replaced: false,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
fs.closeSync(fd);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export function providerTranscriptSize(transcriptPath) {
|
|
120
|
+
try {
|
|
121
|
+
return fs.statSync(transcriptPath).size;
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
if (err.code === "ENOENT")
|
|
125
|
+
return 0;
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Read only provider compaction markers from a lane transcript. Claude writes
|
|
131
|
+
* `system/compact_boundary`; Codex rollout files write a top-level
|
|
132
|
+
* `compacted` record. Tool and prompt contents are never returned or logged.
|
|
133
|
+
*/
|
|
134
|
+
export function probeProviderCompactions(transcriptPath, provider) {
|
|
135
|
+
if (transcriptPath === null)
|
|
136
|
+
return 0;
|
|
137
|
+
if (provider === "grok")
|
|
138
|
+
return 0;
|
|
139
|
+
try {
|
|
140
|
+
return probeProviderCompactionsSince(transcriptPath, provider, 0).count;
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
if (err.code === "ENOENT")
|
|
144
|
+
return 0;
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
52
148
|
/** Locate `<home>/projects/<cwd-slug>/<sid>.jsonl` without deriving the slug. */
|
|
53
149
|
export function findSessionTranscript(home, sessionId) {
|
|
54
150
|
const projects = path.join(home, "projects");
|
|
@@ -8,12 +8,15 @@
|
|
|
8
8
|
* overlap.
|
|
9
9
|
*/
|
|
10
10
|
import * as fs from "node:fs";
|
|
11
|
-
import {
|
|
11
|
+
import { probeProviderCompactionsSince } from "./compaction.js";
|
|
12
|
+
import { tryReadLane } from "./store.js";
|
|
13
|
+
import { type LaneCompanyRef, type LaneRecord, type LaneState, type QuestionRecord } from "./types.js";
|
|
12
14
|
export declare const DROPBOX_DIR = "dropbox";
|
|
13
15
|
export declare const INBOX_NAME = "inbox.jsonl";
|
|
14
16
|
export declare const DRAINING_DIR = "draining";
|
|
15
17
|
export declare const DELIVERED_DIR = "delivered";
|
|
16
18
|
export declare const INFLIGHT_DIR = "inflight";
|
|
19
|
+
export { LANE_MESSAGE_COMPACTION_REPLAY_FLAG } from "./message-replay.js";
|
|
17
20
|
export interface DropboxMessage {
|
|
18
21
|
at?: string;
|
|
19
22
|
message_id?: string;
|
|
@@ -45,6 +48,21 @@ export interface DrainDeps {
|
|
|
45
48
|
canDrain?: () => boolean;
|
|
46
49
|
/** Test seam: runs after this process has claimed and read its bytes. */
|
|
47
50
|
afterRead?: () => void;
|
|
51
|
+
/** Prefix marks archives that belong to one provider turn. */
|
|
52
|
+
archivePrefix?: string;
|
|
53
|
+
}
|
|
54
|
+
export interface LaneDrainHookInput {
|
|
55
|
+
transcript_path?: string | null;
|
|
56
|
+
session_id?: string;
|
|
57
|
+
hook_event_name?: string;
|
|
58
|
+
}
|
|
59
|
+
export interface RunDrainHookDeps {
|
|
60
|
+
readFlag?: (flag: string, options: {
|
|
61
|
+
company?: LaneCompanyRef | null;
|
|
62
|
+
env: NodeJS.ProcessEnv;
|
|
63
|
+
}) => Promise<boolean>;
|
|
64
|
+
readLane?: typeof tryReadLane;
|
|
65
|
+
probeCompactionsSince?: typeof probeProviderCompactionsSince;
|
|
48
66
|
}
|
|
49
67
|
export declare function mintClaimId(): string;
|
|
50
68
|
export declare function assertSafeLaneId(laneId: string): void;
|
|
@@ -81,7 +99,7 @@ export declare function formatOperatorInstruction(messages: DropboxMessage[]): s
|
|
|
81
99
|
export declare function formatMonitorEvent(message: DropboxMessage): string;
|
|
82
100
|
/** Keep event order while framing monitor output separately from operator messages. */
|
|
83
101
|
export declare function formatDropboxMessages(messages: DropboxMessage[]): string;
|
|
84
|
-
export declare function hookAdditionalContextJson(messages: DropboxMessage[], hookEventName?: "PreToolUse" | "UserPromptSubmit"): string;
|
|
102
|
+
export declare function hookAdditionalContextJson(messages: DropboxMessage[], hookEventName?: "PreToolUse" | "UserPromptSubmit", leadingNote?: string): string;
|
|
85
103
|
export interface DrainHookResult {
|
|
86
104
|
exitCode: number;
|
|
87
105
|
stdout: string;
|
|
@@ -92,7 +110,7 @@ export interface DrainHookResult {
|
|
|
92
110
|
* That no-op is load-bearing: this hook is registered in the grok home and runs
|
|
93
111
|
* on every session on the box.
|
|
94
112
|
*/
|
|
95
|
-
export declare function runDrainHook(env: NodeJS.ProcessEnv): DrainHookResult
|
|
113
|
+
export declare function runDrainHook(env: NodeJS.ProcessEnv, input?: LaneDrainHookInput, deps?: RunDrainHookDeps): Promise<DrainHookResult>;
|
|
96
114
|
/** Spec §6.5: only a still-running lane gets drop-box delivery. */
|
|
97
115
|
export declare function laneAcceptsDropboxDelivery(lane: Pick<LaneRecord, "state">): boolean;
|
|
98
116
|
/**
|
|
@@ -10,13 +10,18 @@
|
|
|
10
10
|
import { randomBytes } from "node:crypto";
|
|
11
11
|
import * as fs from "node:fs";
|
|
12
12
|
import * as path from "node:path";
|
|
13
|
+
import { probeProviderCompactionsSince, providerTranscriptSize, } from "./compaction.js";
|
|
13
14
|
import { LanesError } from "./errors.js";
|
|
15
|
+
import { readLanesRegistryFlag } from "./registry-flag.js";
|
|
16
|
+
import { LANE_MESSAGE_COMPACTION_REPLAY_FLAG, MESSAGE_COMPACTION_REPLAY_NOTE, MESSAGE_REPLAY_STATE_NAME, } from "./message-replay.js";
|
|
14
17
|
import { LANES_RUNS_REL, readProcStarttime } from "./spawn.js";
|
|
18
|
+
import { tryReadLane } from "./store.js";
|
|
15
19
|
export const DROPBOX_DIR = "dropbox";
|
|
16
20
|
export const INBOX_NAME = "inbox.jsonl";
|
|
17
21
|
export const DRAINING_DIR = "draining";
|
|
18
22
|
export const DELIVERED_DIR = "delivered";
|
|
19
23
|
export const INFLIGHT_DIR = "inflight";
|
|
24
|
+
export { LANE_MESSAGE_COMPACTION_REPLAY_FLAG } from "./message-replay.js";
|
|
20
25
|
function defaultDrainDeps() {
|
|
21
26
|
return {
|
|
22
27
|
mkdirSync: fs.mkdirSync.bind(fs),
|
|
@@ -278,7 +283,7 @@ export function drainDropboxAtPath(box, depsInput = {}) {
|
|
|
278
283
|
deps.afterRead?.();
|
|
279
284
|
for (const name of claimed) {
|
|
280
285
|
const src = path.join(inflightDir, name);
|
|
281
|
-
const dest = path.join(delivered, `${deps.unique()}.${name}`);
|
|
286
|
+
const dest = path.join(delivered, `${deps.archivePrefix ?? ""}${deps.unique()}.${name}`);
|
|
282
287
|
if (!tryRename(src, dest, deps)) {
|
|
283
288
|
throw new LanesError("dropbox_unreadable", `Claimed drop box file disappeared before delivery archive: ${src}`, { path: src });
|
|
284
289
|
}
|
|
@@ -346,20 +351,118 @@ export function formatDropboxMessages(messages) {
|
|
|
346
351
|
flushOperatorMessages();
|
|
347
352
|
return sections.join("\n\n");
|
|
348
353
|
}
|
|
349
|
-
export function hookAdditionalContextJson(messages, hookEventName = "PreToolUse") {
|
|
354
|
+
export function hookAdditionalContextJson(messages, hookEventName = "PreToolUse", leadingNote) {
|
|
355
|
+
const formatted = formatDropboxMessages(messages);
|
|
350
356
|
return `${JSON.stringify({
|
|
351
357
|
hookSpecificOutput: {
|
|
352
358
|
hookEventName,
|
|
353
|
-
additionalContext:
|
|
359
|
+
additionalContext: leadingNote ? `${leadingNote}\n\n${formatted}` : formatted,
|
|
354
360
|
},
|
|
355
361
|
})}\n`;
|
|
356
362
|
}
|
|
363
|
+
function replayStatePath(hqRoot, laneId) {
|
|
364
|
+
return path.join(dropboxDir(hqRoot, laneId), MESSAGE_REPLAY_STATE_NAME);
|
|
365
|
+
}
|
|
366
|
+
function safeProviderTranscriptPath(transcriptPath, provider, env) {
|
|
367
|
+
if (provider === "grok")
|
|
368
|
+
return transcriptPath;
|
|
369
|
+
const home = provider === "claude" ? env.CLAUDE_CONFIG_DIR : env.CODEX_HOME;
|
|
370
|
+
if (!home) {
|
|
371
|
+
throw new LanesError("message_replay_provider_home_missing", `Cannot validate ${provider} transcript without its provider home`);
|
|
372
|
+
}
|
|
373
|
+
if (!path.isAbsolute(transcriptPath) || path.extname(transcriptPath) !== ".jsonl") {
|
|
374
|
+
throw new LanesError("message_replay_transcript_path_invalid", `The ${provider} hook transcript path must be an absolute JSONL path`);
|
|
375
|
+
}
|
|
376
|
+
const configuredHomePath = path.resolve(home);
|
|
377
|
+
const homePath = fs.realpathSync(home);
|
|
378
|
+
const candidate = path.resolve(transcriptPath);
|
|
379
|
+
const expectedRoot = provider === "claude" ? "projects" : "sessions";
|
|
380
|
+
const assertWithinProviderHome = (root, resolvedTranscript, message) => {
|
|
381
|
+
const relative = path.relative(root, resolvedTranscript);
|
|
382
|
+
if (relative === "" ||
|
|
383
|
+
relative === ".." ||
|
|
384
|
+
relative.startsWith(`..${path.sep}`) ||
|
|
385
|
+
path.isAbsolute(relative) ||
|
|
386
|
+
relative.split(path.sep)[0] !== expectedRoot) {
|
|
387
|
+
throw new LanesError("message_replay_transcript_outside_provider_home", message);
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
try {
|
|
391
|
+
const realTranscript = fs.realpathSync(candidate);
|
|
392
|
+
assertWithinProviderHome(homePath, realTranscript, `The ${provider} hook transcript resolves outside its ${expectedRoot} directory`);
|
|
393
|
+
return realTranscript;
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
if (err.code === "ENOENT") {
|
|
397
|
+
assertWithinProviderHome(configuredHomePath, candidate, `The ${provider} hook transcript is outside its configured ${expectedRoot} directory`);
|
|
398
|
+
return candidate;
|
|
399
|
+
}
|
|
400
|
+
throw err;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function readReplayState(filePath, turnId) {
|
|
404
|
+
const text = fs.readFileSync(filePath, "utf8");
|
|
405
|
+
let parsed;
|
|
406
|
+
try {
|
|
407
|
+
parsed = JSON.parse(text);
|
|
408
|
+
}
|
|
409
|
+
catch (err) {
|
|
410
|
+
throw new LanesError("message_replay_state_invalid", `Replay state ${filePath} is invalid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
411
|
+
}
|
|
412
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
413
|
+
throw new LanesError("message_replay_state_invalid", `Replay state ${filePath} is not an object`);
|
|
414
|
+
}
|
|
415
|
+
const state = parsed;
|
|
416
|
+
if (state.schema_version !== 1 ||
|
|
417
|
+
state.turn_id !== turnId ||
|
|
418
|
+
(state.last_compaction_marker !== null &&
|
|
419
|
+
(!Number.isSafeInteger(state.last_compaction_marker) || Number(state.last_compaction_marker) < 0)) ||
|
|
420
|
+
(state.transcript_offset !== undefined &&
|
|
421
|
+
(!Number.isSafeInteger(state.transcript_offset) || Number(state.transcript_offset) < 0)) ||
|
|
422
|
+
(state.transcript_path !== undefined && typeof state.transcript_path !== "string") ||
|
|
423
|
+
(state.flag_enabled !== undefined && typeof state.flag_enabled !== "boolean")) {
|
|
424
|
+
throw new LanesError("message_replay_state_invalid", `Replay state ${filePath} does not match the current provider turn`);
|
|
425
|
+
}
|
|
426
|
+
// Older state files persisted this per-turn cache. Accept it, but remove it
|
|
427
|
+
// before the next write so the flag is refreshed for every compaction.
|
|
428
|
+
delete state.flag_enabled;
|
|
429
|
+
return state;
|
|
430
|
+
}
|
|
431
|
+
function writeReplayState(filePath, state) {
|
|
432
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
433
|
+
const tempPath = `${filePath}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
434
|
+
fs.writeFileSync(tempPath, `${JSON.stringify(state)}\n`, { mode: 0o600 });
|
|
435
|
+
try {
|
|
436
|
+
fs.renameSync(tempPath, filePath);
|
|
437
|
+
}
|
|
438
|
+
catch (err) {
|
|
439
|
+
fs.rmSync(tempPath, { force: true });
|
|
440
|
+
throw err;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
function deliveredMessagesForTurn(hqRoot, laneId, turnId) {
|
|
444
|
+
const delivered = path.join(dropboxDir(hqRoot, laneId), DELIVERED_DIR);
|
|
445
|
+
let names;
|
|
446
|
+
try {
|
|
447
|
+
names = fs.readdirSync(delivered).filter((name) => name.startsWith(`${turnId}.`));
|
|
448
|
+
}
|
|
449
|
+
catch (err) {
|
|
450
|
+
if (err.code === "ENOENT")
|
|
451
|
+
return [];
|
|
452
|
+
throw err;
|
|
453
|
+
}
|
|
454
|
+
names.sort();
|
|
455
|
+
return names.flatMap((name) => readClaimedFile(path.join(delivered, name), defaultDrainDeps()));
|
|
456
|
+
}
|
|
457
|
+
function reportReplayFailure(err) {
|
|
458
|
+
return `hq-lanes-drain: message compaction replay failed: ${err instanceof Error ? err.message : String(err)}\n`;
|
|
459
|
+
}
|
|
357
460
|
/**
|
|
358
461
|
* Hook protocol. HQ_LANE_ID absent or empty → exit 0, empty stdout, empty stderr.
|
|
359
462
|
* That no-op is load-bearing: this hook is registered in the grok home and runs
|
|
360
463
|
* on every session on the box.
|
|
361
464
|
*/
|
|
362
|
-
export function runDrainHook(env) {
|
|
465
|
+
export async function runDrainHook(env, input = {}, deps = {}) {
|
|
363
466
|
const laneId = env.HQ_LANE_ID ?? "";
|
|
364
467
|
if (laneId === "") {
|
|
365
468
|
return { exitCode: 0, stdout: "", stderr: "" };
|
|
@@ -382,10 +485,81 @@ export function runDrainHook(env) {
|
|
|
382
485
|
stderr += `hq-lanes-drain: failed to record tool activity: ${err instanceof Error ? err.message : String(err)}\n`;
|
|
383
486
|
}
|
|
384
487
|
try {
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
488
|
+
const turnId = env.HQ_LANE_TURN_ID ?? "";
|
|
489
|
+
const validTurnId = /^[A-Za-z0-9-]{1,80}$/.test(turnId);
|
|
490
|
+
const messages = drainDropbox(hqRoot, laneId, {
|
|
491
|
+
...(validTurnId ? { archivePrefix: `${turnId}.` } : {}),
|
|
492
|
+
});
|
|
493
|
+
let replayed;
|
|
494
|
+
let replayNote;
|
|
495
|
+
if (validTurnId) {
|
|
496
|
+
try {
|
|
497
|
+
const statePath = replayStatePath(hqRoot, laneId);
|
|
498
|
+
let state;
|
|
499
|
+
try {
|
|
500
|
+
state = readReplayState(statePath, turnId);
|
|
501
|
+
}
|
|
502
|
+
catch (err) {
|
|
503
|
+
if (err.code !== "ENOENT")
|
|
504
|
+
throw err;
|
|
505
|
+
throw new LanesError("message_replay_state_missing", `Replay state ${statePath} was not initialized for this provider turn`);
|
|
506
|
+
}
|
|
507
|
+
if (input.transcript_path) {
|
|
508
|
+
const lane = (deps.readLane ?? tryReadLane)(hqRoot, laneId);
|
|
509
|
+
if (!lane) {
|
|
510
|
+
throw new LanesError("message_replay_lane_missing", `Lane ${laneId} could not be read for compaction replay`);
|
|
511
|
+
}
|
|
512
|
+
if (lane.provider === "grok") {
|
|
513
|
+
state.last_compaction_marker = 0;
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
const transcriptPath = safeProviderTranscriptPath(input.transcript_path, lane.provider, env);
|
|
517
|
+
if (state.last_compaction_marker === null ||
|
|
518
|
+
state.transcript_path !== transcriptPath ||
|
|
519
|
+
state.transcript_offset === undefined) {
|
|
520
|
+
// Existing transcript history belongs to the turn's resume
|
|
521
|
+
// prompt. Start watching only records appended from this point.
|
|
522
|
+
state.last_compaction_marker = 0;
|
|
523
|
+
state.transcript_path = transcriptPath;
|
|
524
|
+
state.transcript_offset = providerTranscriptSize(transcriptPath);
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
const delta = (deps.probeCompactionsSince ?? probeProviderCompactionsSince)(transcriptPath, lane.provider, state.transcript_offset);
|
|
528
|
+
state.transcript_offset = delta.next_offset;
|
|
529
|
+
if (delta.replaced) {
|
|
530
|
+
// A replaced or truncated transcript establishes a new
|
|
531
|
+
// baseline; it is not evidence of a fresh compaction.
|
|
532
|
+
state.last_compaction_marker = 0;
|
|
533
|
+
}
|
|
534
|
+
else if (delta.count > 0) {
|
|
535
|
+
state.last_compaction_marker += delta.count;
|
|
536
|
+
const flagEnabled = await (deps.readFlag ?? readLanesRegistryFlag)(LANE_MESSAGE_COMPACTION_REPLAY_FLAG, { company: lane.company, env });
|
|
537
|
+
if (flagEnabled) {
|
|
538
|
+
replayed = deliveredMessagesForTurn(hqRoot, laneId, turnId);
|
|
539
|
+
if (replayed.length > 0)
|
|
540
|
+
replayNote = MESSAGE_COMPACTION_REPLAY_NOTE;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
writeReplayState(statePath, state);
|
|
547
|
+
}
|
|
548
|
+
catch (err) {
|
|
549
|
+
// Replay is advisory hook context. Report failures but let the provider
|
|
550
|
+
// tool call continue with whatever the ordinary inbox drain delivered.
|
|
551
|
+
stderr += reportReplayFailure(err);
|
|
552
|
+
}
|
|
388
553
|
}
|
|
554
|
+
if (replayNote && replayed && replayed.length > 0) {
|
|
555
|
+
return {
|
|
556
|
+
exitCode: 0,
|
|
557
|
+
stdout: hookAdditionalContextJson(replayed, "PreToolUse", replayNote),
|
|
558
|
+
stderr,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
if (messages.length === 0)
|
|
562
|
+
return { exitCode: 0, stdout: "", stderr };
|
|
389
563
|
return { exitCode: 0, stdout: hookAdditionalContextJson(messages), stderr };
|
|
390
564
|
}
|
|
391
565
|
catch (err) {
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import type { LaneProvider } from "./types.js";
|
|
21
21
|
export declare const DRAIN_HOOK_BASENAME = "hq-lanes-drain";
|
|
22
22
|
export declare const DRAIN_HOOK_TIMEOUT_SECONDS = 30;
|
|
23
|
+
export declare function drainHookEntryPath(): string;
|
|
23
24
|
export declare function lanesHookAssetsDir(): string;
|
|
24
25
|
export declare const DRAIN_HOOK_REGISTRATION_KINDS: readonly ["hooks_dir", "settings_json", "config_toml"];
|
|
25
26
|
export type DrainHookRegistrationKind = (typeof DRAIN_HOOK_REGISTRATION_KINDS)[number];
|
|
@@ -40,7 +41,7 @@ export interface DrainHookInstallResult {
|
|
|
40
41
|
files: string[];
|
|
41
42
|
registration: DrainHookRegistration;
|
|
42
43
|
}
|
|
43
|
-
export declare function drainHookCommand(
|
|
44
|
+
export declare function drainHookCommand(_shPath?: string): string;
|
|
44
45
|
export declare function drainHookShPath(providerHome: string): string;
|
|
45
46
|
export declare function renderDrainHookJson(shPath: string): string;
|
|
46
47
|
export interface MergeResult {
|
|
@@ -53,7 +54,7 @@ export interface MergeResult {
|
|
|
53
54
|
* and do nothing when a command hook with this exact command string is
|
|
54
55
|
* already in the list. No `matcher` means every tool call.
|
|
55
56
|
*/
|
|
56
|
-
export declare function mergeClaudeSettingsHook(existingText: string | null, command: string, settingsPath: string): MergeResult;
|
|
57
|
+
export declare function mergeClaudeSettingsHook(existingText: string | null, command: string, settingsPath: string, legacyCommand?: string): MergeResult;
|
|
57
58
|
/**
|
|
58
59
|
* Codex: hooks come from `config.toml` in the codex home. The drain is
|
|
59
60
|
* appended as one `[[hooks.PreToolUse]]` table and `[features] hooks = true`
|
|
@@ -61,7 +62,7 @@ export declare function mergeClaudeSettingsHook(existingText: string | null, com
|
|
|
61
62
|
* byte for byte; the result is re-parsed and must contain the registration,
|
|
62
63
|
* otherwise nothing is written.
|
|
63
64
|
*/
|
|
64
|
-
export declare function mergeCodexConfigHook(existingText: string | null, command: string, configPath: string): MergeResult;
|
|
65
|
+
export declare function mergeCodexConfigHook(existingText: string | null, command: string, configPath: string, legacyCommand?: string): MergeResult;
|
|
65
66
|
export declare function drainHookRegistrationPath(provider: LaneProvider, providerHome: string): string;
|
|
66
67
|
export interface DrainHookRegistrationProbe {
|
|
67
68
|
kind: DrainHookRegistrationKind;
|
|
@@ -25,6 +25,12 @@ import { LanesError } from "./errors.js";
|
|
|
25
25
|
import { parseLaneProvider } from "./types.js";
|
|
26
26
|
export const DRAIN_HOOK_BASENAME = "hq-lanes-drain";
|
|
27
27
|
export const DRAIN_HOOK_TIMEOUT_SECONDS = 30;
|
|
28
|
+
function shellQuote(value) {
|
|
29
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
30
|
+
}
|
|
31
|
+
export function drainHookEntryPath() {
|
|
32
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../dist/lanes-drain-hook.js");
|
|
33
|
+
}
|
|
28
34
|
export function lanesHookAssetsDir() {
|
|
29
35
|
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../assets/lanes-hooks");
|
|
30
36
|
}
|
|
@@ -38,7 +44,10 @@ export const PROVIDER_REGISTRATION_KIND = {
|
|
|
38
44
|
codex: "config_toml",
|
|
39
45
|
grok: "hooks_dir",
|
|
40
46
|
};
|
|
41
|
-
export function drainHookCommand(
|
|
47
|
+
export function drainHookCommand(_shPath) {
|
|
48
|
+
return `${shellQuote(process.execPath)} ${shellQuote(drainHookEntryPath())}`;
|
|
49
|
+
}
|
|
50
|
+
function legacyDrainHookCommand(shPath) {
|
|
42
51
|
return `bash ${shPath}`;
|
|
43
52
|
}
|
|
44
53
|
export function drainHookShPath(providerHome) {
|
|
@@ -87,6 +96,21 @@ function groupsContainCommand(groups, command) {
|
|
|
87
96
|
return group.hooks.some((hook) => isPlainObject(hook) && hook.type === "command" && hook.command === command);
|
|
88
97
|
});
|
|
89
98
|
}
|
|
99
|
+
function removeCommandFromGroups(groups, command) {
|
|
100
|
+
let changed = false;
|
|
101
|
+
const next = groups.flatMap((group) => {
|
|
102
|
+
if (!isPlainObject(group) || !Array.isArray(group.hooks))
|
|
103
|
+
return [group];
|
|
104
|
+
const hooks = group.hooks.filter((hook) => !(isPlainObject(hook) &&
|
|
105
|
+
hook.type === "command" &&
|
|
106
|
+
hook.command === command));
|
|
107
|
+
if (hooks.length === group.hooks.length)
|
|
108
|
+
return [group];
|
|
109
|
+
changed = true;
|
|
110
|
+
return hooks.length === 0 ? [] : [{ ...group, hooks }];
|
|
111
|
+
});
|
|
112
|
+
return { groups: next, changed };
|
|
113
|
+
}
|
|
90
114
|
export function renderDrainHookJson(shPath) {
|
|
91
115
|
return `${JSON.stringify({
|
|
92
116
|
hooks: {
|
|
@@ -110,7 +134,7 @@ export function renderDrainHookJson(shPath) {
|
|
|
110
134
|
* and do nothing when a command hook with this exact command string is
|
|
111
135
|
* already in the list. No `matcher` means every tool call.
|
|
112
136
|
*/
|
|
113
|
-
export function mergeClaudeSettingsHook(existingText, command, settingsPath) {
|
|
137
|
+
export function mergeClaudeSettingsHook(existingText, command, settingsPath, legacyCommand) {
|
|
114
138
|
let root = {};
|
|
115
139
|
if (existingText !== null && existingText.trim() !== "") {
|
|
116
140
|
try {
|
|
@@ -131,17 +155,22 @@ export function mergeClaudeSettingsHook(existingText, command, settingsPath) {
|
|
|
131
155
|
if (!Array.isArray(pre)) {
|
|
132
156
|
throw new LanesError("settings_unparseable", `${settingsPath} has a non-array "hooks.PreToolUse"; refusing to rewrite it`, { path: settingsPath });
|
|
133
157
|
}
|
|
134
|
-
|
|
158
|
+
const migration = legacyCommand && legacyCommand !== command
|
|
159
|
+
? removeCommandFromGroups(pre, legacyCommand)
|
|
160
|
+
: { groups: pre, changed: false };
|
|
161
|
+
if (groupsContainCommand(migration.groups, command) && !migration.changed) {
|
|
135
162
|
return { text: existingText ?? "", changed: false };
|
|
136
163
|
}
|
|
137
|
-
const nextPre =
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
164
|
+
const nextPre = groupsContainCommand(migration.groups, command)
|
|
165
|
+
? migration.groups
|
|
166
|
+
: [
|
|
167
|
+
...migration.groups,
|
|
168
|
+
{
|
|
169
|
+
hooks: [
|
|
170
|
+
{ type: "command", command, timeout: DRAIN_HOOK_TIMEOUT_SECONDS },
|
|
171
|
+
],
|
|
172
|
+
},
|
|
173
|
+
];
|
|
145
174
|
const nextRoot = { ...root, hooks: { ...hooks, PreToolUse: nextPre } };
|
|
146
175
|
return { text: `${JSON.stringify(nextRoot, null, 2)}\n`, changed: true };
|
|
147
176
|
}
|
|
@@ -156,6 +185,127 @@ function tomlHooksFeatureEnabled(parsed) {
|
|
|
156
185
|
function tomlHasCommand(parsed, command) {
|
|
157
186
|
return isPlainObject(parsed.hooks) && groupsContainCommand(parsed.hooks.PreToolUse, command);
|
|
158
187
|
}
|
|
188
|
+
function tomlHeader(line) {
|
|
189
|
+
const match = /^\s*(\[\[|\[)([^\]]+)(\]\]|\])\s*(?:#.*)?$/.exec(line);
|
|
190
|
+
if (!match) {
|
|
191
|
+
const trimmed = line.trimStart();
|
|
192
|
+
return trimmed.startsWith("[")
|
|
193
|
+
? { path: "", array: trimmed.startsWith("[[") }
|
|
194
|
+
: undefined;
|
|
195
|
+
}
|
|
196
|
+
const array = match[1] === "[[";
|
|
197
|
+
if ((array && match[3] !== "]]") ||
|
|
198
|
+
(!array && match[3] !== "]")) {
|
|
199
|
+
return { path: "", array };
|
|
200
|
+
}
|
|
201
|
+
return { path: match[2].trim(), array };
|
|
202
|
+
}
|
|
203
|
+
function tomlCommandValue(line) {
|
|
204
|
+
const match = /^\s*command\s*=\s*(.+)$/.exec(line);
|
|
205
|
+
if (!match)
|
|
206
|
+
return undefined;
|
|
207
|
+
try {
|
|
208
|
+
const parsed = parseToml(`command = ${match[1]}\n`);
|
|
209
|
+
return isPlainObject(parsed) && typeof parsed.command === "string"
|
|
210
|
+
? parsed.command
|
|
211
|
+
: undefined;
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/** Remove old installer-created Codex command tables while retaining TOML text and operator hooks. */
|
|
218
|
+
function removeLegacyCodexHook(text, parsed, legacyCommand, configPath) {
|
|
219
|
+
if (!legacyCommand || legacyCommand === "")
|
|
220
|
+
return { text, changed: false };
|
|
221
|
+
const hooks = isPlainObject(parsed.hooks) ? parsed.hooks.PreToolUse : undefined;
|
|
222
|
+
const groups = Array.isArray(hooks) ? hooks : [];
|
|
223
|
+
const expectedCount = groups.reduce((total, group) => {
|
|
224
|
+
if (!isPlainObject(group) || !Array.isArray(group.hooks))
|
|
225
|
+
return total;
|
|
226
|
+
return total + group.hooks.filter((hook) => isPlainObject(hook) &&
|
|
227
|
+
hook.type === "command" &&
|
|
228
|
+
hook.command === legacyCommand).length;
|
|
229
|
+
}, 0);
|
|
230
|
+
if (expectedCount === 0)
|
|
231
|
+
return { text, changed: false };
|
|
232
|
+
const lines = text.split("\n");
|
|
233
|
+
const parentHeaders = lines.flatMap((line, index) => {
|
|
234
|
+
const header = tomlHeader(line);
|
|
235
|
+
return header?.array && header.path === "hooks.PreToolUse" ? [index] : [];
|
|
236
|
+
});
|
|
237
|
+
const wholeGroupIndexes = new Set();
|
|
238
|
+
if (parentHeaders.length === groups.length) {
|
|
239
|
+
groups.forEach((group, index) => {
|
|
240
|
+
if (isPlainObject(group) &&
|
|
241
|
+
Array.isArray(group.hooks) &&
|
|
242
|
+
group.hooks.length === 1 &&
|
|
243
|
+
isPlainObject(group.hooks[0]) &&
|
|
244
|
+
group.hooks[0].type === "command" &&
|
|
245
|
+
group.hooks[0].command === legacyCommand) {
|
|
246
|
+
wholeGroupIndexes.add(index);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
const spans = [];
|
|
251
|
+
let parentGroupIndex = -1;
|
|
252
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
253
|
+
const header = tomlHeader(lines[index]);
|
|
254
|
+
if (header?.array && header.path === "hooks.PreToolUse") {
|
|
255
|
+
parentGroupIndex += 1;
|
|
256
|
+
if (!wholeGroupIndexes.has(parentGroupIndex))
|
|
257
|
+
continue;
|
|
258
|
+
let end = index + 1;
|
|
259
|
+
for (; end < lines.length; end += 1) {
|
|
260
|
+
const nextHeader = tomlHeader(lines[end]);
|
|
261
|
+
if (!nextHeader)
|
|
262
|
+
continue;
|
|
263
|
+
if (nextHeader.array && nextHeader.path === "hooks.PreToolUse")
|
|
264
|
+
break;
|
|
265
|
+
if (nextHeader.path.startsWith("hooks.PreToolUse."))
|
|
266
|
+
continue;
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
spans.push({ start: index, end });
|
|
270
|
+
index = end - 1;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (!header?.array || header.path !== "hooks.PreToolUse.hooks")
|
|
274
|
+
continue;
|
|
275
|
+
let end = index + 1;
|
|
276
|
+
let foundLegacyCommand = false;
|
|
277
|
+
for (; end < lines.length; end += 1) {
|
|
278
|
+
const nextHeader = tomlHeader(lines[end]);
|
|
279
|
+
if (!nextHeader) {
|
|
280
|
+
if (tomlCommandValue(lines[end]) === legacyCommand) {
|
|
281
|
+
foundLegacyCommand = true;
|
|
282
|
+
}
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (nextHeader.array &&
|
|
286
|
+
nextHeader.path === "hooks.PreToolUse.hooks") {
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
if (nextHeader.path.startsWith("hooks.PreToolUse.hooks."))
|
|
290
|
+
continue;
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
if (foundLegacyCommand)
|
|
294
|
+
spans.push({ start: index, end });
|
|
295
|
+
index = end - 1;
|
|
296
|
+
}
|
|
297
|
+
if (spans.length !== expectedCount) {
|
|
298
|
+
throw new LanesError("config_toml_migration_failed", `Could not safely remove all legacy lane drain hooks from ${configPath}; refusing to add a second drain`, { path: configPath, expected: expectedCount, found: spans.length });
|
|
299
|
+
}
|
|
300
|
+
for (const span of spans.reverse())
|
|
301
|
+
lines.splice(span.start, span.end - span.start);
|
|
302
|
+
const migrated = lines.join("\n");
|
|
303
|
+
const migratedConfig = parseTomlOrThrow(migrated, configPath, "after legacy hook removal");
|
|
304
|
+
if (tomlHasCommand(migratedConfig, legacyCommand)) {
|
|
305
|
+
throw new LanesError("config_toml_migration_failed", `Legacy lane drain hook remains in ${configPath}; refusing to add a second drain`, { path: configPath });
|
|
306
|
+
}
|
|
307
|
+
return { text: migrated, changed: true };
|
|
308
|
+
}
|
|
159
309
|
function parseTomlOrThrow(text, configPath, stage) {
|
|
160
310
|
let parsed;
|
|
161
311
|
try {
|
|
@@ -203,15 +353,18 @@ function enableTomlHooksFeature(text) {
|
|
|
203
353
|
* byte for byte; the result is re-parsed and must contain the registration,
|
|
204
354
|
* otherwise nothing is written.
|
|
205
355
|
*/
|
|
206
|
-
export function mergeCodexConfigHook(existingText, command, configPath) {
|
|
356
|
+
export function mergeCodexConfigHook(existingText, command, configPath, legacyCommand) {
|
|
207
357
|
const original = existingText ?? "";
|
|
208
358
|
const parsed = parseTomlOrThrow(original, configPath, "before merge");
|
|
209
|
-
const
|
|
210
|
-
const
|
|
211
|
-
|
|
359
|
+
const migration = removeLegacyCodexHook(original, parsed, legacyCommand, configPath);
|
|
360
|
+
const textAfterMigration = migration.text;
|
|
361
|
+
const current = parseTomlOrThrow(textAfterMigration, configPath, "after legacy hook removal");
|
|
362
|
+
const hasCommand = tomlHasCommand(current, command);
|
|
363
|
+
const featureOn = tomlHooksFeatureEnabled(current);
|
|
364
|
+
if (hasCommand && featureOn && !migration.changed) {
|
|
212
365
|
return { text: original, changed: false };
|
|
213
366
|
}
|
|
214
|
-
let text =
|
|
367
|
+
let text = textAfterMigration;
|
|
215
368
|
if (!featureOn)
|
|
216
369
|
text = enableTomlHooksFeature(text);
|
|
217
370
|
if (!hasCommand) {
|
|
@@ -286,8 +439,8 @@ function registerDrainHook(provider, providerHome, shPath) {
|
|
|
286
439
|
}
|
|
287
440
|
const existing = readTextIfExists(regPath);
|
|
288
441
|
const merged = kind === "settings_json"
|
|
289
|
-
? mergeClaudeSettingsHook(existing, command, regPath)
|
|
290
|
-
: mergeCodexConfigHook(existing, command, regPath);
|
|
442
|
+
? mergeClaudeSettingsHook(existing, command, regPath, legacyDrainHookCommand(shPath))
|
|
443
|
+
: mergeCodexConfigHook(existing, command, regPath, legacyDrainHookCommand(shPath));
|
|
291
444
|
if (!merged.changed) {
|
|
292
445
|
return { kind, path: regPath, command, changed: false };
|
|
293
446
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare const LANE_MESSAGE_COMPACTION_REPLAY_FLAG = "lanes.message-compaction-replay";
|
|
2
|
+
export declare const MESSAGE_REPLAY_STATE_NAME = "message-compaction-replay.json";
|
|
3
|
+
export declare const MESSAGE_COMPACTION_REPLAY_NOTE = "These messages were delivered before a context compaction and still apply.";
|
|
4
|
+
export interface MessageReplayState {
|
|
5
|
+
schema_version: 1;
|
|
6
|
+
turn_id: string;
|
|
7
|
+
/** Null until the provider transcript is first observed in this turn. */
|
|
8
|
+
last_compaction_marker: number | null;
|
|
9
|
+
/** Last complete JSONL byte offset scanned in the provider transcript. */
|
|
10
|
+
transcript_offset?: number;
|
|
11
|
+
/** Canonical transcript path for this provider turn. */
|
|
12
|
+
transcript_path?: string;
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=message-replay.d.ts.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export const LANE_MESSAGE_COMPACTION_REPLAY_FLAG = "lanes.message-compaction-replay";
|
|
2
|
+
export const MESSAGE_REPLAY_STATE_NAME = "message-compaction-replay.json";
|
|
3
|
+
export const MESSAGE_COMPACTION_REPLAY_NOTE = "These messages were delivered before a context compaction and still apply.";
|
|
4
|
+
//# sourceMappingURL=message-replay.js.map
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* and package trees; this module describes the provider launch contract that
|
|
7
7
|
* must be re-read for every spawn.
|
|
8
8
|
*/
|
|
9
|
-
import { type LaneProvider } from "./types.js";
|
|
9
|
+
import { type LaneRecord, type LaneProvider } from "./types.js";
|
|
10
10
|
export interface WorkerProfileMeta {
|
|
11
11
|
version?: string;
|
|
12
12
|
provider: LaneProvider;
|
|
@@ -37,6 +37,12 @@ export declare function personalWorkerProfileDir(hqRoot: string, id: string): st
|
|
|
37
37
|
/** A meta.yaml file is the explicit marker for a personal worker profile. */
|
|
38
38
|
export declare function workerProfileMarker(hqRoot: string, id: string): "absent" | "present";
|
|
39
39
|
export declare function loadWorkerProfile(hqRoot: string, id: string): WorkerProfile;
|
|
40
|
+
export interface EffectiveLaneKeepAlive {
|
|
41
|
+
enabled: boolean;
|
|
42
|
+
source: "lane" | "profile" | "none";
|
|
43
|
+
}
|
|
44
|
+
/** Resolve the keep-alive opt-in recorded on a lane or its attached profile. */
|
|
45
|
+
export declare function resolveLaneKeepAlive(hqRoot: string, lane: Pick<LaneRecord, "keep_alive" | "worker_profile">): EffectiveLaneKeepAlive;
|
|
40
46
|
export declare function workerProfilePrompt(profile: WorkerProfile, delivery?: "hook" | "prompt"): string;
|
|
41
47
|
export declare function ignoredWorkerProfileFields(provider: LaneProvider): string[];
|
|
42
48
|
/**
|
|
@@ -370,6 +370,19 @@ export function loadWorkerProfile(hqRoot, id) {
|
|
|
370
370
|
contentHash: hashProfileFiles(dir, hashFiles),
|
|
371
371
|
};
|
|
372
372
|
}
|
|
373
|
+
/** Resolve the keep-alive opt-in recorded on a lane or its attached profile. */
|
|
374
|
+
export function resolveLaneKeepAlive(hqRoot, lane) {
|
|
375
|
+
if (lane.keep_alive !== undefined) {
|
|
376
|
+
return { enabled: lane.keep_alive, source: "lane" };
|
|
377
|
+
}
|
|
378
|
+
if (lane.worker_profile === undefined) {
|
|
379
|
+
return { enabled: false, source: "none" };
|
|
380
|
+
}
|
|
381
|
+
const profileKeepAlive = loadWorkerProfile(hqRoot, lane.worker_profile).meta.keep_alive;
|
|
382
|
+
return profileKeepAlive === undefined
|
|
383
|
+
? { enabled: false, source: "none" }
|
|
384
|
+
: { enabled: profileKeepAlive, source: "profile" };
|
|
385
|
+
}
|
|
373
386
|
export function workerProfilePrompt(profile, delivery = "prompt") {
|
|
374
387
|
const parts = [profile.systemPrompt.trimEnd()];
|
|
375
388
|
if (delivery === "hook")
|
|
@@ -15,7 +15,7 @@ import { loadLanesConfig, providerQuotaDefaultHoldMs } from "./config.js";
|
|
|
15
15
|
import { MAX_LANE_DEPTH } from "./constants.js";
|
|
16
16
|
import { AccountResolutionError, LanesError, isLaneVersionConflict } from "./errors.js";
|
|
17
17
|
import { LANE_HIRE_GRANTS_FLAG, terminalChildResumeHireGrantError, withHireGrantAdmission, } from "./hire-grants.js";
|
|
18
|
-
import { loadWorkerProfile } from "./profile.js";
|
|
18
|
+
import { loadWorkerProfile, resolveLaneKeepAlive } from "./profile.js";
|
|
19
19
|
import { classifySessionLiveness, defaultLivenessDeps, loadLivenessIndex, readDaemonPresenceConnected, unindexedClaudeSessionLiveness, } from "./liveness.js";
|
|
20
20
|
import { ANSWERED_QUESTIONS_FILE, armLaneWatcher, envelopeRepairMode, isPidRecordAlive, laneRunDir, readPidRecord, readSpawnManifest, markSpawnFailure, spawnEnvelopeRepair, spawnLaneWorker, retryLaneOnAlternateAccount, } from "./spawn.js";
|
|
21
21
|
import { appendCappedHistory, lanesStoreRoot, parseLaneRecord, parseQuestionRecord, readLane, readQuestion, updateLane, updateQuestion, } from "./store.js";
|
|
@@ -801,7 +801,8 @@ export async function runPassBody(hqRoot, deps) {
|
|
|
801
801
|
const keepAliveHireGrantFlagCompanies = new Map();
|
|
802
802
|
for (const lane of lanes) {
|
|
803
803
|
if (lane.state !== "done" ||
|
|
804
|
-
lane.keep_alive !== true
|
|
804
|
+
(lane.keep_alive !== true &&
|
|
805
|
+
!(lane.keep_alive === undefined && lane.worker_profile !== undefined)) ||
|
|
805
806
|
!laneRunsHere(lane, deps.host) ||
|
|
806
807
|
lane.terminal_reason === PROVIDER_REJECTED_REASON ||
|
|
807
808
|
lane.terminal_reason === "account_not_logged_in" ||
|
|
@@ -1417,19 +1418,27 @@ function resumeDoneKeepAlive(hqRoot, lanes, summary, deps, now, flagByCompany, h
|
|
|
1417
1418
|
const nowIso = now.toISOString();
|
|
1418
1419
|
const config = loadLanesConfig(hqRoot);
|
|
1419
1420
|
for (const lane of lanes) {
|
|
1421
|
+
const hasRecordedProfileFallback = lane.keep_alive === undefined && lane.worker_profile !== undefined;
|
|
1420
1422
|
if (lane.state !== "done" ||
|
|
1421
|
-
lane.keep_alive
|
|
1423
|
+
lane.keep_alive === false ||
|
|
1424
|
+
(lane.keep_alive !== true && !hasRecordedProfileFallback) ||
|
|
1422
1425
|
!laneRunsHere(lane, deps.host))
|
|
1423
1426
|
continue;
|
|
1424
1427
|
const latest = readLatestEnvelopeRecord(hqRoot, lane.lane_id);
|
|
1425
1428
|
const envelope = latest?.envelope;
|
|
1426
1429
|
if (!envelope || envelope.decision !== "done")
|
|
1427
1430
|
continue;
|
|
1431
|
+
const keepAliveConfigurationUnchanged = (candidate) => lane.keep_alive === true
|
|
1432
|
+
? candidate.keep_alive === true
|
|
1433
|
+
: lane.keep_alive === undefined &&
|
|
1434
|
+
lane.worker_profile !== undefined &&
|
|
1435
|
+
candidate.keep_alive === undefined &&
|
|
1436
|
+
candidate.worker_profile === lane.worker_profile;
|
|
1428
1437
|
if (envelope.story_complete === true) {
|
|
1429
1438
|
if (hasKeepAliveProgress(lane)) {
|
|
1430
1439
|
casLaneIf(hqRoot, lane.lane_id, (current) => current.version === lane.version &&
|
|
1431
1440
|
current.state === "done" &&
|
|
1432
|
-
current
|
|
1441
|
+
keepAliveConfigurationUnchanged(current), (current) => clearedKeepAliveProgress(current), deps.dryRun);
|
|
1433
1442
|
}
|
|
1434
1443
|
continue;
|
|
1435
1444
|
}
|
|
@@ -1440,10 +1449,31 @@ function resumeDoneKeepAlive(hqRoot, lanes, summary, deps, now, flagByCompany, h
|
|
|
1440
1449
|
continue;
|
|
1441
1450
|
if (flagByCompany.get(keepAliveCompanyKey(lane.company)) !== true)
|
|
1442
1451
|
continue;
|
|
1452
|
+
let keepAlive;
|
|
1453
|
+
try {
|
|
1454
|
+
keepAlive = resolveLaneKeepAlive(hqRoot, lane);
|
|
1455
|
+
}
|
|
1456
|
+
catch (err) {
|
|
1457
|
+
const message = err instanceof LanesError
|
|
1458
|
+
? `${err.error}: ${err.message}`
|
|
1459
|
+
: err instanceof Error ? err.message : String(err);
|
|
1460
|
+
markDirty(summary, {
|
|
1461
|
+
kind: "lane",
|
|
1462
|
+
id: lane.lane_id,
|
|
1463
|
+
why: `keep-alive worker profile could not be loaded: ${message}`,
|
|
1464
|
+
});
|
|
1465
|
+
continue;
|
|
1466
|
+
}
|
|
1467
|
+
if (!keepAlive.enabled)
|
|
1468
|
+
continue;
|
|
1469
|
+
const keepAliveStillEnabled = (candidate) => keepAlive.source === "lane"
|
|
1470
|
+
? candidate.keep_alive === true
|
|
1471
|
+
: candidate.keep_alive === undefined &&
|
|
1472
|
+
candidate.worker_profile === lane.worker_profile;
|
|
1443
1473
|
const current = readLane(hqRoot, lane.lane_id);
|
|
1444
1474
|
if (current.version !== lane.version ||
|
|
1445
1475
|
current.state !== "done" ||
|
|
1446
|
-
current
|
|
1476
|
+
!keepAliveStillEnabled(current) ||
|
|
1447
1477
|
!laneRunsHere(current, deps.host))
|
|
1448
1478
|
continue;
|
|
1449
1479
|
const currentEnvelope = readLatestEnvelopeRecord(hqRoot, current.lane_id)?.envelope;
|
|
@@ -1453,7 +1483,7 @@ function resumeDoneKeepAlive(hqRoot, lanes, summary, deps, now, flagByCompany, h
|
|
|
1453
1483
|
if (hasKeepAliveProgress(current)) {
|
|
1454
1484
|
casLaneIf(hqRoot, current.lane_id, (latestLane) => latestLane.version === current.version &&
|
|
1455
1485
|
latestLane.state === "done" &&
|
|
1456
|
-
latestLane
|
|
1486
|
+
keepAliveStillEnabled(latestLane), (latestLane) => clearedKeepAliveProgress(latestLane), deps.dryRun);
|
|
1457
1487
|
}
|
|
1458
1488
|
continue;
|
|
1459
1489
|
}
|
|
@@ -1470,7 +1500,7 @@ function resumeDoneKeepAlive(hqRoot, lanes, summary, deps, now, flagByCompany, h
|
|
|
1470
1500
|
continue;
|
|
1471
1501
|
const exhausted = casLaneIf(hqRoot, current.lane_id, (latestLane) => latestLane.version === current.version &&
|
|
1472
1502
|
latestLane.state === "done" &&
|
|
1473
|
-
latestLane
|
|
1503
|
+
keepAliveStillEnabled(latestLane) &&
|
|
1474
1504
|
(latestLane.keep_alive_resume_count ?? 0) >= LEAD_LANE_KEEP_ALIVE_MAX_RESUMES &&
|
|
1475
1505
|
latestLane.keep_alive_exhausted_at === undefined, (latestLane) => ({ ...latestLane, keep_alive_exhausted_at: nowIso }), deps.dryRun);
|
|
1476
1506
|
if (exhausted.changed && !deps.dryRun) {
|
|
@@ -1495,7 +1525,7 @@ function resumeDoneKeepAlive(hqRoot, lanes, summary, deps, now, flagByCompany, h
|
|
|
1495
1525
|
if (resetByHuman && hasKeepAliveProgress(current)) {
|
|
1496
1526
|
casLaneIf(hqRoot, current.lane_id, (latestLane) => latestLane.version === current.version &&
|
|
1497
1527
|
latestLane.state === "done" &&
|
|
1498
|
-
latestLane
|
|
1528
|
+
keepAliveStillEnabled(latestLane), (latestLane) => clearedKeepAliveProgress(latestLane), deps.dryRun);
|
|
1499
1529
|
}
|
|
1500
1530
|
continue;
|
|
1501
1531
|
}
|
|
@@ -1515,7 +1545,7 @@ function resumeDoneKeepAlive(hqRoot, lanes, summary, deps, now, flagByCompany, h
|
|
|
1515
1545
|
}
|
|
1516
1546
|
const queue = (expectedSeniorId) => casLaneIf(hqRoot, current.lane_id, (latestLane) => latestLane.version === current.version &&
|
|
1517
1547
|
latestLane.state === "done" &&
|
|
1518
|
-
latestLane
|
|
1548
|
+
keepAliveStillEnabled(latestLane) &&
|
|
1519
1549
|
(expectedSeniorId === undefined ||
|
|
1520
1550
|
(latestLane.senior.kind === "lane" && latestLane.senior.id === expectedSeniorId)), (latestLane) => ({
|
|
1521
1551
|
...latestLane,
|
package/dist/lib/lanes/spawn.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* mutable fields are re-read from the lane record (spec §5.4).
|
|
9
9
|
*/
|
|
10
10
|
import { spawn, spawnSync } from "node:child_process";
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
11
12
|
import * as fs from "node:fs";
|
|
12
13
|
import * as path from "node:path";
|
|
13
14
|
import { fileURLToPath } from "node:url";
|
|
@@ -25,6 +26,7 @@ import { resolveMachineIdentity, } from "./machine-identity.js";
|
|
|
25
26
|
import { appendCappedHistory, listLaneIds, readLane, retryUpdateLane } from "./store.js";
|
|
26
27
|
import { copySessionFiles, planSessionCopy } from "./move-account.js";
|
|
27
28
|
import { ensureSessionHome } from "./session-relink.js";
|
|
29
|
+
import { MESSAGE_REPLAY_STATE_NAME } from "./message-replay.js";
|
|
28
30
|
import { hasEarlierEligibleQueuedLane, laneAdmissionCapReason, withLaneAdmissionLock, } from "./admission.js";
|
|
29
31
|
import { resolveAdmissionHost } from "./admission-host.js";
|
|
30
32
|
import { LANE_ENVELOPE_JSON_SCHEMA, ENVELOPE_INVALID_REASON, parseLaneProvider, } from "./types.js";
|
|
@@ -1750,6 +1752,8 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1750
1752
|
? providerAutoCompactTokens(config.providers.claude)
|
|
1751
1753
|
: undefined;
|
|
1752
1754
|
const childEnv = workerEnv(env, { envVar: resolved.envVar, home: resolved.home }, lane.lane_id, hqRoot, autoCompactTokens, lane.mesh_company ?? lane.company ?? undefined, workerPolicyDir);
|
|
1755
|
+
const laneTurnId = randomUUID();
|
|
1756
|
+
childEnv.HQ_LANE_TURN_ID = laneTurnId;
|
|
1753
1757
|
const model = resolveLaneModel({
|
|
1754
1758
|
cliModel: pinnedModel,
|
|
1755
1759
|
profileModel,
|
|
@@ -2014,6 +2018,30 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
2014
2018
|
}
|
|
2015
2019
|
return laneLaunchResult(claimedRecord);
|
|
2016
2020
|
}
|
|
2021
|
+
// Each normal spawn/resume starts a fresh provider turn. The resume prompt
|
|
2022
|
+
// carries the prior message text, so only messages delivered after this
|
|
2023
|
+
// reset may be replayed if the new turn compacts.
|
|
2024
|
+
const replayStatePath = path.join(runDir, "dropbox", MESSAGE_REPLAY_STATE_NAME);
|
|
2025
|
+
const replayStateTempPath = `${replayStatePath}.${process.pid}.${laneTurnId}.tmp`;
|
|
2026
|
+
try {
|
|
2027
|
+
fs.mkdirSync(path.dirname(replayStatePath), { recursive: true });
|
|
2028
|
+
fs.writeFileSync(replayStateTempPath, `${JSON.stringify({
|
|
2029
|
+
schema_version: 1,
|
|
2030
|
+
turn_id: laneTurnId,
|
|
2031
|
+
last_compaction_marker: null,
|
|
2032
|
+
})}\n`, { mode: 0o600 });
|
|
2033
|
+
fs.renameSync(replayStateTempPath, replayStatePath);
|
|
2034
|
+
}
|
|
2035
|
+
catch (err) {
|
|
2036
|
+
try {
|
|
2037
|
+
fs.rmSync(replayStateTempPath, { force: true });
|
|
2038
|
+
}
|
|
2039
|
+
catch (cleanupErr) {
|
|
2040
|
+
process.stderr.write(`hq lanes: compaction replay state temp cleanup failed for ${lane.lane_id}: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}\n`);
|
|
2041
|
+
}
|
|
2042
|
+
delete childEnv.HQ_LANE_TURN_ID;
|
|
2043
|
+
process.stderr.write(`hq lanes: compaction replay disabled for ${lane.lane_id}; failed to reset turn state: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
2044
|
+
}
|
|
2017
2045
|
const workerOutPath = path.join(logsDir, "worker.out");
|
|
2018
2046
|
const workerOutOffset = fileSizeOrZero(workerOutPath);
|
|
2019
2047
|
let workerPid;
|