@indigoai-us/hq-cli 5.148.0 → 5.149.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +8 -11
- package/dist/command-catalog.generated.d.ts +129 -0
- package/dist/command-catalog.generated.js +168 -0
- package/dist/commands/lanes.js +506 -23
- package/dist/commands/mesh.js +82 -11
- package/dist/lib/lanes/index.d.ts +3 -2
- package/dist/lib/lanes/index.js +2 -1
- package/dist/lib/lanes/mesh-events.d.ts +8 -0
- package/dist/lib/lanes/mesh-events.js +83 -6
- package/dist/lib/lanes/outpost-dispatch.d.ts +12 -0
- package/dist/lib/lanes/outpost-dispatch.js +17 -0
- package/dist/lib/lanes/reconcile.js +39 -12
- package/dist/lib/lanes/session-relink.d.ts +25 -0
- package/dist/lib/lanes/session-relink.js +216 -0
- package/dist/lib/lanes/spawn.d.ts +1 -0
- package/dist/lib/lanes/spawn.js +48 -5
- package/dist/lib/lanes/store.js +109 -1
- package/dist/lib/lanes/stories.d.ts +17 -0
- package/dist/lib/lanes/stories.js +73 -0
- package/dist/lib/lanes/types.d.ts +20 -0
- package/dist/lib/lanes/types.js +9 -2
- package/dist/lib/lanes/watcher.js +69 -9
- package/dist/lib/mesh/live/daemon/doctor.d.ts +27 -0
- package/dist/lib/mesh/live/daemon/doctor.js +84 -0
- package/dist/lib/mesh/live/daemon/index.d.ts +3 -3
- package/dist/lib/mesh/live/daemon/index.js +2 -2
- package/dist/lib/mesh/live/daemon/install.d.ts +40 -0
- package/dist/lib/mesh/live/daemon/install.js +88 -0
- package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/run.js +12 -1
- package/dist/lib/mesh/live/daemon/state.d.ts +4 -0
- package/dist/lib/mesh/project-board.d.ts +15 -0
- package/dist/lib/mesh/project-board.js +84 -0
- package/dist/lib/work-context/prd-sync.d.ts +5 -1
- package/dist/lib/work-context/prd-sync.js +22 -1
- package/package.json +5 -3
- package/scripts/mesh-daemon-if-stale.mjs +16 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { copySessionFiles, encodeClaudeProjectDir, planSessionCopy, } from "./move-account.js";
|
|
4
|
+
import { LanesError } from "./errors.js";
|
|
5
|
+
function statDirectory(target) {
|
|
6
|
+
try {
|
|
7
|
+
return fs.statSync(target).isDirectory();
|
|
8
|
+
}
|
|
9
|
+
catch (err) {
|
|
10
|
+
const code = err.code;
|
|
11
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
12
|
+
return false;
|
|
13
|
+
throw err;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function claudeTranscriptPath(home, cwd, sessionId) {
|
|
17
|
+
return path.join(home, "projects", encodeClaudeProjectDir(cwd), `${sessionId}.jsonl`);
|
|
18
|
+
}
|
|
19
|
+
function findCodexRollouts(home, sessionId) {
|
|
20
|
+
const root = path.join(home, "sessions");
|
|
21
|
+
if (!statDirectory(root))
|
|
22
|
+
return [];
|
|
23
|
+
const suffix = `-${sessionId}.jsonl`;
|
|
24
|
+
const stack = [{ dir: root, depth: 0 }];
|
|
25
|
+
const matches = [];
|
|
26
|
+
while (stack.length > 0) {
|
|
27
|
+
const { dir, depth } = stack.pop();
|
|
28
|
+
let entries;
|
|
29
|
+
try {
|
|
30
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
const code = err.code;
|
|
34
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
35
|
+
continue;
|
|
36
|
+
throw new LanesError("provider_home_search_failed", `Could not search provider home ${home}: ${err instanceof Error ? err.message : String(err)}`, { provider: "codex", home });
|
|
37
|
+
}
|
|
38
|
+
for (const entry of entries) {
|
|
39
|
+
const full = path.join(dir, entry.name);
|
|
40
|
+
if (entry.isDirectory()) {
|
|
41
|
+
if (depth < 3)
|
|
42
|
+
stack.push({ dir: full, depth: depth + 1 });
|
|
43
|
+
}
|
|
44
|
+
else if (entry.isFile() &&
|
|
45
|
+
entry.name.startsWith("rollout-") &&
|
|
46
|
+
entry.name.endsWith(suffix)) {
|
|
47
|
+
matches.push(full);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return matches;
|
|
52
|
+
}
|
|
53
|
+
function sessionTranscripts(provider, home, cwd, sessionId) {
|
|
54
|
+
if (provider === "codex")
|
|
55
|
+
return findCodexRollouts(home, sessionId);
|
|
56
|
+
const transcript = claudeTranscriptPath(home, cwd, sessionId);
|
|
57
|
+
try {
|
|
58
|
+
return fs.statSync(transcript).isFile() ? [transcript] : [];
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
const code = err.code;
|
|
62
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
63
|
+
return [];
|
|
64
|
+
throw new LanesError("provider_home_search_failed", `Could not check provider home ${home}: ${err instanceof Error ? err.message : String(err)}`, { provider, home });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Older Claude account homes can share a registered `account-N/.claude`
|
|
69
|
+
* parent even after one account is re-registered elsewhere. Discover only
|
|
70
|
+
* those sibling homes, then inspect the exact session path; credential files
|
|
71
|
+
* are never opened.
|
|
72
|
+
*/
|
|
73
|
+
export function discoverSiblingAccountHomes(provider, registeredHomes) {
|
|
74
|
+
const roots = new Set();
|
|
75
|
+
for (const home of registeredHomes) {
|
|
76
|
+
if (provider === "claude") {
|
|
77
|
+
const accountDir = path.dirname(home);
|
|
78
|
+
if (path.basename(home) === ".claude" &&
|
|
79
|
+
path.basename(accountDir).startsWith("account-")) {
|
|
80
|
+
roots.add(path.dirname(accountDir));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
else if (path.basename(home).startsWith("account-")) {
|
|
84
|
+
roots.add(path.dirname(home));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const homes = [];
|
|
88
|
+
for (const root of [...roots].sort()) {
|
|
89
|
+
let entries;
|
|
90
|
+
try {
|
|
91
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
const code = err.code;
|
|
95
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
96
|
+
continue;
|
|
97
|
+
throw new LanesError("provider_home_search_failed", `Could not search provider account directory ${root}: ${err instanceof Error ? err.message : String(err)}`, { provider, home: root });
|
|
98
|
+
}
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
if (!entry.isDirectory() || !entry.name.startsWith("account-"))
|
|
101
|
+
continue;
|
|
102
|
+
const home = provider === "claude"
|
|
103
|
+
? path.join(root, entry.name, ".claude")
|
|
104
|
+
: path.join(root, entry.name);
|
|
105
|
+
if (statDirectory(home))
|
|
106
|
+
homes.push(home);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return homes;
|
|
110
|
+
}
|
|
111
|
+
function uniqueHomes(homes) {
|
|
112
|
+
const unique = new Set();
|
|
113
|
+
for (const home of homes) {
|
|
114
|
+
if (typeof home !== "string" || home.trim() === "")
|
|
115
|
+
continue;
|
|
116
|
+
unique.add(path.resolve(home));
|
|
117
|
+
}
|
|
118
|
+
return [...unique];
|
|
119
|
+
}
|
|
120
|
+
function missingTranscript(input, homes) {
|
|
121
|
+
return new LanesError("provider_transcript_missing", `Provider transcript for session ${input.sessionId} was not found. Homes searched: ${homes.join(", ")}`, { provider: input.provider, session_id: input.sessionId, homes_searched: homes });
|
|
122
|
+
}
|
|
123
|
+
function copyVerifiedTranscript(input, sourceHome, targetHome, searched) {
|
|
124
|
+
let copied;
|
|
125
|
+
try {
|
|
126
|
+
copied = copySessionFiles(planSessionCopy(input.provider, {
|
|
127
|
+
sourceHome,
|
|
128
|
+
targetHome,
|
|
129
|
+
cwd: input.cwd,
|
|
130
|
+
sessionId: input.sessionId,
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
if (err instanceof LanesError && err.error === "provider_session_missing") {
|
|
135
|
+
throw missingTranscript(input, searched);
|
|
136
|
+
}
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
const target = sessionTranscripts(input.provider, targetHome, input.cwd, input.sessionId);
|
|
140
|
+
if (target.length !== 1) {
|
|
141
|
+
throw new LanesError("provider_transcript_relink_failed", `Could not verify session ${input.sessionId} in target home ${targetHome}`, { provider: input.provider, session_id: input.sessionId, target_home: targetHome });
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
...copied,
|
|
145
|
+
source_home: sourceHome,
|
|
146
|
+
transcript_path: target[0],
|
|
147
|
+
homes_searched: searched,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/** Find, copy, and verify a provider transcript in the home the next run uses. */
|
|
151
|
+
export function ensureSessionHome(input) {
|
|
152
|
+
const targetHome = path.resolve(input.targetHome);
|
|
153
|
+
const homes = uniqueHomes([
|
|
154
|
+
targetHome,
|
|
155
|
+
...input.registeredHomes,
|
|
156
|
+
...(input.providerHomeAtSpawn ? [input.providerHomeAtSpawn] : []),
|
|
157
|
+
...discoverSiblingAccountHomes(input.provider, input.registeredHomes),
|
|
158
|
+
]);
|
|
159
|
+
const searched = [targetHome];
|
|
160
|
+
const current = sessionTranscripts(input.provider, targetHome, input.cwd, input.sessionId);
|
|
161
|
+
if (current.length > 1) {
|
|
162
|
+
throw new LanesError("provider_transcript_ambiguous", `Multiple ${input.provider} transcripts for session ${input.sessionId} exist in ${targetHome}`, { provider: input.provider, session_id: input.sessionId, homes_searched: searched, matches: current });
|
|
163
|
+
}
|
|
164
|
+
const lastRunHome = input.providerHomeAtSpawn
|
|
165
|
+
? path.resolve(input.providerHomeAtSpawn)
|
|
166
|
+
: undefined;
|
|
167
|
+
if (lastRunHome !== undefined && lastRunHome !== targetHome) {
|
|
168
|
+
searched.push(lastRunHome);
|
|
169
|
+
const lastRun = sessionTranscripts(input.provider, lastRunHome, input.cwd, input.sessionId);
|
|
170
|
+
if (lastRun.length > 1) {
|
|
171
|
+
throw new LanesError("provider_transcript_ambiguous", `Multiple ${input.provider} transcripts for session ${input.sessionId} exist in the last run home ${lastRunHome}`, { provider: input.provider, session_id: input.sessionId, homes_searched: searched, matches: lastRun });
|
|
172
|
+
}
|
|
173
|
+
if (lastRun.length === 1) {
|
|
174
|
+
return copyVerifiedTranscript(input, lastRunHome, targetHome, searched);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (current.length === 1) {
|
|
178
|
+
const plan = planSessionCopy(input.provider, {
|
|
179
|
+
sourceHome: targetHome,
|
|
180
|
+
targetHome,
|
|
181
|
+
cwd: input.cwd,
|
|
182
|
+
sessionId: input.sessionId,
|
|
183
|
+
});
|
|
184
|
+
return {
|
|
185
|
+
...plan,
|
|
186
|
+
copied: [],
|
|
187
|
+
source_home: targetHome,
|
|
188
|
+
transcript_path: current[0],
|
|
189
|
+
homes_searched: searched,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const matches = [];
|
|
193
|
+
for (const home of homes.slice(1)) {
|
|
194
|
+
if (home === lastRunHome && searched.includes(home))
|
|
195
|
+
continue;
|
|
196
|
+
searched.push(home);
|
|
197
|
+
const found = sessionTranscripts(input.provider, home, input.cwd, input.sessionId);
|
|
198
|
+
if (found.length > 1) {
|
|
199
|
+
throw new LanesError("provider_transcript_ambiguous", `Multiple ${input.provider} transcripts for session ${input.sessionId} exist in ${home}`, { provider: input.provider, session_id: input.sessionId, homes_searched: searched, matches: found });
|
|
200
|
+
}
|
|
201
|
+
if (found.length === 1)
|
|
202
|
+
matches.push({ home, transcript: found[0] });
|
|
203
|
+
}
|
|
204
|
+
if (matches.length === 0)
|
|
205
|
+
throw missingTranscript(input, searched);
|
|
206
|
+
if (matches.length > 1) {
|
|
207
|
+
throw new LanesError("provider_transcript_ambiguous", `Provider transcript for session ${input.sessionId} exists in multiple homes: ${matches.map((match) => match.home).join(", ")}`, {
|
|
208
|
+
provider: input.provider,
|
|
209
|
+
session_id: input.sessionId,
|
|
210
|
+
homes_searched: searched,
|
|
211
|
+
matching_homes: matches.map((match) => match.home),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return copyVerifiedTranscript(input, matches[0].home, targetHome, searched);
|
|
215
|
+
}
|
|
216
|
+
//# sourceMappingURL=session-relink.js.map
|
|
@@ -235,6 +235,7 @@ export interface SpawnManifestFile {
|
|
|
235
235
|
resume?: {
|
|
236
236
|
session_id: string;
|
|
237
237
|
} | null;
|
|
238
|
+
provider_home_at_spawn?: string;
|
|
238
239
|
/** Byte length of logs/worker.out before this run started; its result is after this. */
|
|
239
240
|
worker_out_offset?: number;
|
|
240
241
|
fast_mode?: boolean;
|
package/dist/lib/lanes/spawn.js
CHANGED
|
@@ -22,6 +22,7 @@ import { AccountResolutionError, LanesError } from "./errors.js";
|
|
|
22
22
|
import { ignoredWorkerProfileFields, loadWorkerProfile, materializeWorkerProfile, workerProfilePrompt, } from "./profile.js";
|
|
23
23
|
import { resolveMachineIdentity, } from "./machine-identity.js";
|
|
24
24
|
import { readLane, retryUpdateLane } from "./store.js";
|
|
25
|
+
import { ensureSessionHome } from "./session-relink.js";
|
|
25
26
|
import { LANE_ENVELOPE_JSON_SCHEMA, parseLaneProvider, } from "./types.js";
|
|
26
27
|
export const LANES_RUNS_REL = path.join("workspace", "lanes-runs");
|
|
27
28
|
/** Written by reconcile before a resume spawn; the worker prompt names this path. */
|
|
@@ -1027,6 +1028,9 @@ export function readSpawnManifest(runDir) {
|
|
|
1027
1028
|
const out = {};
|
|
1028
1029
|
if (typeof rec.envelope_path === "string")
|
|
1029
1030
|
out.envelope_path = rec.envelope_path;
|
|
1031
|
+
if (typeof rec.provider_home_at_spawn === "string") {
|
|
1032
|
+
out.provider_home_at_spawn = rec.provider_home_at_spawn;
|
|
1033
|
+
}
|
|
1030
1034
|
if (typeof rec.worker_out_offset === "number" &&
|
|
1031
1035
|
Number.isInteger(rec.worker_out_offset) &&
|
|
1032
1036
|
rec.worker_out_offset >= 0) {
|
|
@@ -1053,6 +1057,10 @@ export function readSpawnManifest(runDir) {
|
|
|
1053
1057
|
}
|
|
1054
1058
|
return out;
|
|
1055
1059
|
}
|
|
1060
|
+
function laneLaunchResult(lane) {
|
|
1061
|
+
const { provider_home_at_spawn: _providerHomeAtSpawn, ...safe } = lane;
|
|
1062
|
+
return safe;
|
|
1063
|
+
}
|
|
1056
1064
|
function inferTrigger(lane) {
|
|
1057
1065
|
if (lane.state === "awaiting_input")
|
|
1058
1066
|
return { kind: "answered_questions" };
|
|
@@ -1075,12 +1083,32 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1075
1083
|
const spawnProcess = deps.spawnProcess ?? defaultSpawnProcess;
|
|
1076
1084
|
const proveDetached = deps.proveDetached ?? defaultProveDetached;
|
|
1077
1085
|
const killProcess = deps.killProcess ?? defaultKillProcess;
|
|
1078
|
-
const
|
|
1086
|
+
const runDir = laneRunDir(hqRoot, lane.lane_id);
|
|
1087
|
+
const previousSpawn = readSpawnManifest(runDir);
|
|
1088
|
+
const stored = readLane(hqRoot, lane.lane_id);
|
|
1089
|
+
const recoveredSessionId = (typeof stored.provider_session_id === "string" && stored.provider_session_id.trim()
|
|
1090
|
+
? stored.provider_session_id
|
|
1091
|
+
: undefined) ??
|
|
1092
|
+
(typeof stored.last_resume?.session_id === "string" && stored.last_resume.session_id.trim()
|
|
1093
|
+
? stored.last_resume.session_id
|
|
1094
|
+
: undefined) ??
|
|
1095
|
+
(typeof previousSpawn.resume?.session_id === "string" && previousSpawn.resume.session_id.trim()
|
|
1096
|
+
? previousSpawn.resume.session_id
|
|
1097
|
+
: undefined);
|
|
1098
|
+
const existing = {
|
|
1099
|
+
...stored,
|
|
1100
|
+
...(stored.provider_session_id === undefined && recoveredSessionId !== undefined
|
|
1101
|
+
? { provider_session_id: recoveredSessionId }
|
|
1102
|
+
: {}),
|
|
1103
|
+
...(stored.provider_home_at_spawn === undefined && previousSpawn.provider_home_at_spawn !== undefined
|
|
1104
|
+
? { provider_home_at_spawn: previousSpawn.provider_home_at_spawn }
|
|
1105
|
+
: {}),
|
|
1106
|
+
};
|
|
1079
1107
|
if (existing.state === "running") {
|
|
1080
1108
|
if (launch.trigger?.kind === "resume") {
|
|
1081
1109
|
throw new LanesError("lane_not_resumable", `Lane ${lane.lane_id} is already running`, { lane_id: lane.lane_id, state: existing.state });
|
|
1082
1110
|
}
|
|
1083
|
-
return existing;
|
|
1111
|
+
return laneLaunchResult(existing);
|
|
1084
1112
|
}
|
|
1085
1113
|
// A queued lane that a move parked at the target's cap carries the turn
|
|
1086
1114
|
// it was resumed with: the admit spawn continues that session instead of
|
|
@@ -1098,7 +1126,6 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1098
1126
|
if (existing.host.kind === "outpost") {
|
|
1099
1127
|
refuseOutpostSelfTarget(hqRoot, existing, deps);
|
|
1100
1128
|
}
|
|
1101
|
-
const runDir = laneRunDir(hqRoot, lane.lane_id);
|
|
1102
1129
|
const logsDir = path.join(runDir, "logs");
|
|
1103
1130
|
ensureDir(logsDir);
|
|
1104
1131
|
ensureDir(path.join(runDir, "envelopes"));
|
|
@@ -1159,6 +1186,17 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1159
1186
|
if (profileDefaultsApply && profileModel !== undefined && pinnedModel === undefined) {
|
|
1160
1187
|
validateProfileLaneModel(existing.provider, profileModel, resolved.home, profile.id, modelDeps);
|
|
1161
1188
|
}
|
|
1189
|
+
if (plan.mode === "resume" && existing.provider !== "grok") {
|
|
1190
|
+
ensureSessionHome({
|
|
1191
|
+
provider: existing.provider,
|
|
1192
|
+
sessionId: plan.session_id,
|
|
1193
|
+
cwd: hqRoot,
|
|
1194
|
+
targetHome: resolved.home,
|
|
1195
|
+
registeredHomes: Object.values(config.providers[existing.provider].accounts)
|
|
1196
|
+
.map((account) => account.home),
|
|
1197
|
+
providerHomeAtSpawn: existing.provider_home_at_spawn,
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1162
1200
|
if (lane.account !== resolved.id) {
|
|
1163
1201
|
retryUpdateLane(hqRoot, lane.lane_id, "spawn", (current) => {
|
|
1164
1202
|
if (current.account === resolved.id)
|
|
@@ -1325,6 +1363,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1325
1363
|
// An account move's reservation ends with the claim.
|
|
1326
1364
|
pending_resume: undefined,
|
|
1327
1365
|
account: resolved.id,
|
|
1366
|
+
provider_home_at_spawn: resolved.home,
|
|
1328
1367
|
...(model !== undefined ? { model } : {}),
|
|
1329
1368
|
...(model !== undefined
|
|
1330
1369
|
? {
|
|
@@ -1348,6 +1387,10 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1348
1387
|
}
|
|
1349
1388
|
: {}),
|
|
1350
1389
|
};
|
|
1390
|
+
if (resume !== undefined) {
|
|
1391
|
+
next.provider_session_id = resume.session_id;
|
|
1392
|
+
next.provider_session_id_absent = undefined;
|
|
1393
|
+
}
|
|
1351
1394
|
if (effort.effort !== undefined)
|
|
1352
1395
|
next.effort = effort.effort;
|
|
1353
1396
|
else
|
|
@@ -1406,7 +1449,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1406
1449
|
last_compaction: record,
|
|
1407
1450
|
}));
|
|
1408
1451
|
}
|
|
1409
|
-
return claimedRecord;
|
|
1452
|
+
return laneLaunchResult(claimedRecord);
|
|
1410
1453
|
}
|
|
1411
1454
|
const workerOutPath = path.join(logsDir, "worker.out");
|
|
1412
1455
|
const workerOutOffset = fileSizeOrZero(workerOutPath);
|
|
@@ -1476,7 +1519,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1476
1519
|
}
|
|
1477
1520
|
throw err;
|
|
1478
1521
|
}
|
|
1479
|
-
return readLane(hqRoot, lane.lane_id);
|
|
1522
|
+
return laneLaunchResult(readLane(hqRoot, lane.lane_id));
|
|
1480
1523
|
}
|
|
1481
1524
|
/**
|
|
1482
1525
|
* `resume`: claude and codex continue the recorded session with the repair
|
package/dist/lib/lanes/store.js
CHANGED
|
@@ -13,7 +13,8 @@ import * as path from "node:path";
|
|
|
13
13
|
import { generateUlid } from "../mesh/live/ulid.js";
|
|
14
14
|
import { resolveLanesHqRoot } from "./hq-root.js";
|
|
15
15
|
import { LaneNotFoundError, LaneSchemaError, LaneVersionConflict, LanesError, isLaneVersionConflict, } from "./errors.js";
|
|
16
|
-
import { HISTORY_CAP, HOST_KINDS, LANE_SCHEMA_VERSION, QUESTION_SCHEMA_VERSION, SENIOR_KINDS, isHostKind, isLaneProvider, isLaneState, isQuestionStatus, isSeniorKind, parseMajorSchema, REQUIRED_LANE_RECORD_FIELDS, LANE_FIELD_WRITERS, LANE_WRITERS, } from "./types.js";
|
|
16
|
+
import { HISTORY_CAP, HOST_KINDS, LANE_STORY_STATUSES, LANE_SCHEMA_VERSION, QUESTION_SCHEMA_VERSION, SENIOR_KINDS, isHostKind, isLaneProvider, isLaneState, isQuestionStatus, isSeniorKind, parseMajorSchema, REQUIRED_LANE_RECORD_FIELDS, LANE_FIELD_WRITERS, LANE_WRITERS, } from "./types.js";
|
|
17
|
+
import { laneStories } from "./stories.js";
|
|
17
18
|
export const LANES_STORE_REL = path.join("workspace", "lanes");
|
|
18
19
|
export const LANE_RECORDS_DIR = "lanes";
|
|
19
20
|
export const QUESTION_RECORDS_DIR = "questions";
|
|
@@ -277,6 +278,80 @@ function parseOptionalMeshCompanyField(raw, filePath) {
|
|
|
277
278
|
requireString(raw.mesh_company.slug, "mesh_company.slug");
|
|
278
279
|
requireString(raw.mesh_company.uid, "mesh_company.uid");
|
|
279
280
|
}
|
|
281
|
+
function legacyLaneStory(raw) {
|
|
282
|
+
const status = raw.state === "blocked"
|
|
283
|
+
? "blocked"
|
|
284
|
+
: raw.story_status === "review"
|
|
285
|
+
? "review"
|
|
286
|
+
: raw.story_status === "done" || raw.story_complete === true
|
|
287
|
+
? "review"
|
|
288
|
+
: "in_progress";
|
|
289
|
+
const story = {
|
|
290
|
+
project_id: requireString(raw.project_id, "project_id"),
|
|
291
|
+
story_id: requireString(raw.story_id, "story_id"),
|
|
292
|
+
status,
|
|
293
|
+
added_at: requireString(raw.created_at, "created_at"),
|
|
294
|
+
updated_at: requireString(raw.updated_at, "updated_at"),
|
|
295
|
+
};
|
|
296
|
+
if (typeof raw.blocked_reason === "string" && raw.blocked_reason.length > 0) {
|
|
297
|
+
story.blocked_reason = raw.blocked_reason;
|
|
298
|
+
}
|
|
299
|
+
return story;
|
|
300
|
+
}
|
|
301
|
+
function parseLaneStories(raw, filePath) {
|
|
302
|
+
if (raw.stories === undefined) {
|
|
303
|
+
// v1 records carried one story in the top-level fields. Keep the old
|
|
304
|
+
// fields authoritative for the migration and give worker-completed work
|
|
305
|
+
// the review state until a senior explicitly verifies it.
|
|
306
|
+
raw.stories = [legacyLaneStory(raw)];
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (!Array.isArray(raw.stories) || raw.stories.length === 0) {
|
|
310
|
+
throw new LaneSchemaError("stories must be a non-empty array when present", {
|
|
311
|
+
path: filePath,
|
|
312
|
+
field: "stories",
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
for (const [index, value] of raw.stories.entries()) {
|
|
316
|
+
const field = `stories[${index}]`;
|
|
317
|
+
if (!isPlainObject(value)) {
|
|
318
|
+
throw new LaneSchemaError(`${field} must be an object`, { path: filePath, field });
|
|
319
|
+
}
|
|
320
|
+
requireString(value.project_id, `${field}.project_id`);
|
|
321
|
+
requireString(value.story_id, `${field}.story_id`);
|
|
322
|
+
requireString(value.added_at, `${field}.added_at`);
|
|
323
|
+
requireString(value.updated_at, `${field}.updated_at`);
|
|
324
|
+
if (typeof value.status !== "string" ||
|
|
325
|
+
!LANE_STORY_STATUSES.includes(value.status)) {
|
|
326
|
+
throw new LaneSchemaError(`Unknown story status ${JSON.stringify(value.status)}`, {
|
|
327
|
+
path: filePath,
|
|
328
|
+
field: `${field}.status`,
|
|
329
|
+
value: value.status,
|
|
330
|
+
accepted: [...LANE_STORY_STATUSES],
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
if (value.blocked_reason !== undefined) {
|
|
334
|
+
requireString(value.blocked_reason, `${field}.blocked_reason`);
|
|
335
|
+
}
|
|
336
|
+
if (value.verified_at !== undefined) {
|
|
337
|
+
requireString(value.verified_at, `${field}.verified_at`);
|
|
338
|
+
}
|
|
339
|
+
if (value.verified_by !== undefined) {
|
|
340
|
+
if (!isPlainObject(value.verified_by)) {
|
|
341
|
+
throw new LaneSchemaError(`${field}.verified_by must be an object`, {
|
|
342
|
+
path: filePath,
|
|
343
|
+
field: `${field}.verified_by`,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
if (!isSeniorKind(value.verified_by.kind)) {
|
|
347
|
+
throw new LaneSchemaError(`Unknown senior kind ${JSON.stringify(value.verified_by.kind)}`, { path: filePath, field: `${field}.verified_by.kind` });
|
|
348
|
+
}
|
|
349
|
+
if (value.verified_by.kind !== "user") {
|
|
350
|
+
requireString(value.verified_by.id, `${field}.verified_by.id`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
280
355
|
export function parseLaneRecord(raw, filePath) {
|
|
281
356
|
if (!isPlainObject(raw)) {
|
|
282
357
|
throw new LaneSchemaError("Lane record must be a JSON object", {
|
|
@@ -302,6 +377,7 @@ export function parseLaneRecord(raw, filePath) {
|
|
|
302
377
|
if (!isLaneProvider(raw.provider)) {
|
|
303
378
|
throw new LaneSchemaError(`Unknown provider ${JSON.stringify(raw.provider)}`, { path: filePath, value: raw.provider });
|
|
304
379
|
}
|
|
380
|
+
parseLaneStories(raw, filePath);
|
|
305
381
|
parseOptionalCompanyField(raw, filePath);
|
|
306
382
|
parseOptionalMeshCompanyField(raw, filePath);
|
|
307
383
|
if (!isPlainObject(raw.senior)) {
|
|
@@ -472,6 +548,37 @@ function higherVersionRecord(live, others) {
|
|
|
472
548
|
}
|
|
473
549
|
return best;
|
|
474
550
|
}
|
|
551
|
+
function laneStoryIdentity(story) {
|
|
552
|
+
return `${story.project_id}\u0000${story.story_id}`;
|
|
553
|
+
}
|
|
554
|
+
function mergeLaneStories(records, base) {
|
|
555
|
+
const baseStories = laneStories(base);
|
|
556
|
+
const merged = new Map();
|
|
557
|
+
const baseOrder = [];
|
|
558
|
+
for (const story of baseStories) {
|
|
559
|
+
const key = laneStoryIdentity(story);
|
|
560
|
+
if (!merged.has(key))
|
|
561
|
+
baseOrder.push(key);
|
|
562
|
+
merged.set(key, story);
|
|
563
|
+
}
|
|
564
|
+
for (const record of records) {
|
|
565
|
+
if (record === base)
|
|
566
|
+
continue;
|
|
567
|
+
for (const story of laneStories(record)) {
|
|
568
|
+
const key = laneStoryIdentity(story);
|
|
569
|
+
const current = merged.get(key);
|
|
570
|
+
if (!current || story.updated_at > current.updated_at) {
|
|
571
|
+
merged.set(key, story);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
const sideOnly = [...merged.keys()]
|
|
576
|
+
.filter((key) => !baseOrder.includes(key))
|
|
577
|
+
.sort();
|
|
578
|
+
return [...baseOrder, ...sideOnly]
|
|
579
|
+
.map((key) => merged.get(key))
|
|
580
|
+
.filter((story) => story !== undefined);
|
|
581
|
+
}
|
|
475
582
|
export function mergeLaneRecords(live, sidecars) {
|
|
476
583
|
if (sidecars.length === 0)
|
|
477
584
|
return live;
|
|
@@ -486,6 +593,7 @@ export function mergeLaneRecords(live, sidecars) {
|
|
|
486
593
|
account_history: unionHistory(all.map((record) => record.account_history ?? []), entryIdentity, base.account_history ?? []),
|
|
487
594
|
}
|
|
488
595
|
: {}),
|
|
596
|
+
stories: mergeLaneStories(all, base),
|
|
489
597
|
};
|
|
490
598
|
}
|
|
491
599
|
export function mergeQuestionRecords(live, sidecars) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { LaneRecord, LaneStory } from "./types.js";
|
|
2
|
+
/** Board-facing states for the stories a lane currently carries. */
|
|
3
|
+
export declare const ACTIVE_LANE_STORY_STATUSES: readonly ["in_progress", "blocked", "review"];
|
|
4
|
+
export declare function storyKey(projectId: string, storyId: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* Read the v1 multi-story field while keeping old one-story records usable.
|
|
7
|
+
* The returned legacy row is deliberately in-memory until the next normal
|
|
8
|
+
* lane write, so reading an old record never changes it on disk by itself.
|
|
9
|
+
*/
|
|
10
|
+
export declare function laneStories(lane: LaneRecord): LaneStory[];
|
|
11
|
+
export declare function activeLaneStories(lane: LaneRecord): LaneStory[];
|
|
12
|
+
export declare function laneStory(lane: LaneRecord, projectId: string, storyId: string): LaneStory | undefined;
|
|
13
|
+
export declare function storyWarning(lane: LaneRecord): string | undefined;
|
|
14
|
+
export declare function withCurrentStory(lane: LaneRecord, projectId: string, storyId: string): LaneRecord;
|
|
15
|
+
export declare function withLaneStories(lane: LaneRecord, stories: LaneStory[]): LaneRecord;
|
|
16
|
+
export declare function storyStatusForEnvelope(status: string): "in_progress" | "review";
|
|
17
|
+
//# sourceMappingURL=stories.d.ts.map
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Board-facing states for the stories a lane currently carries. */
|
|
2
|
+
export const ACTIVE_LANE_STORY_STATUSES = [
|
|
3
|
+
"in_progress",
|
|
4
|
+
"blocked",
|
|
5
|
+
"review",
|
|
6
|
+
];
|
|
7
|
+
export function storyKey(projectId, storyId) {
|
|
8
|
+
return `${projectId}\u0000${storyId}`;
|
|
9
|
+
}
|
|
10
|
+
function legacyStatus(lane) {
|
|
11
|
+
if (lane.state === "blocked")
|
|
12
|
+
return "blocked";
|
|
13
|
+
if (lane.story_status === "review")
|
|
14
|
+
return "review";
|
|
15
|
+
// A worker envelope was historically allowed to say done. The worker's
|
|
16
|
+
// terminal result only earns review; senior verification is the sole path
|
|
17
|
+
// to the durable done state.
|
|
18
|
+
if (lane.story_status === "done" || lane.story_complete === true) {
|
|
19
|
+
return "review";
|
|
20
|
+
}
|
|
21
|
+
return "in_progress";
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Read the v1 multi-story field while keeping old one-story records usable.
|
|
25
|
+
* The returned legacy row is deliberately in-memory until the next normal
|
|
26
|
+
* lane write, so reading an old record never changes it on disk by itself.
|
|
27
|
+
*/
|
|
28
|
+
export function laneStories(lane) {
|
|
29
|
+
if (Array.isArray(lane.stories) && lane.stories.length > 0) {
|
|
30
|
+
return lane.stories;
|
|
31
|
+
}
|
|
32
|
+
const at = lane.updated_at || lane.created_at;
|
|
33
|
+
return [
|
|
34
|
+
{
|
|
35
|
+
project_id: lane.project_id,
|
|
36
|
+
story_id: lane.story_id,
|
|
37
|
+
status: legacyStatus(lane),
|
|
38
|
+
added_at: lane.created_at,
|
|
39
|
+
updated_at: at,
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
export function activeLaneStories(lane) {
|
|
44
|
+
return laneStories(lane).filter((story) => ACTIVE_LANE_STORY_STATUSES.includes(story.status));
|
|
45
|
+
}
|
|
46
|
+
export function laneStory(lane, projectId, storyId) {
|
|
47
|
+
const key = storyKey(projectId, storyId);
|
|
48
|
+
return laneStories(lane).find((story) => storyKey(story.project_id, story.story_id) === key);
|
|
49
|
+
}
|
|
50
|
+
export function storyWarning(lane) {
|
|
51
|
+
const count = activeLaneStories(lane).length;
|
|
52
|
+
if (count <= 1)
|
|
53
|
+
return undefined;
|
|
54
|
+
return `Lane ${lane.lane_id} carries ${count} active stories; parallel story work is allowed but discouraged.`;
|
|
55
|
+
}
|
|
56
|
+
export function withCurrentStory(lane, projectId, storyId) {
|
|
57
|
+
return {
|
|
58
|
+
...lane,
|
|
59
|
+
project_id: projectId,
|
|
60
|
+
story_id: storyId,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function withLaneStories(lane, stories) {
|
|
64
|
+
return { ...lane, stories };
|
|
65
|
+
}
|
|
66
|
+
export function storyStatusForEnvelope(status) {
|
|
67
|
+
// A worker's `done` envelope is reviewable work. Durable `done` is reserved
|
|
68
|
+
// for the senior verification command.
|
|
69
|
+
if (status === "review")
|
|
70
|
+
return "review";
|
|
71
|
+
return "in_progress";
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=stories.js.map
|
|
@@ -176,6 +176,9 @@ export declare const ENVELOPE_DECISIONS: readonly ["done", "ask", "blocked"];
|
|
|
176
176
|
export type EnvelopeDecision = (typeof ENVELOPE_DECISIONS)[number];
|
|
177
177
|
export declare const STORY_STATUSES: readonly ["queued", "in_progress", "review", "done"];
|
|
178
178
|
export type StoryStatus = (typeof STORY_STATUSES)[number];
|
|
179
|
+
/** Durable Board status for a story carried by a lane. */
|
|
180
|
+
export declare const LANE_STORY_STATUSES: readonly ["in_progress", "blocked", "review", "done"];
|
|
181
|
+
export type LaneStoryStatus = (typeof LANE_STORY_STATUSES)[number];
|
|
179
182
|
/**
|
|
180
183
|
* `expired` is accepted on read for records that already carry it. Spec §9
|
|
181
184
|
* ageing advances hop with `unanswered_timeout` and never expires a question,
|
|
@@ -237,6 +240,16 @@ export interface LastEnvelopeRef {
|
|
|
237
240
|
decision: EnvelopeDecision;
|
|
238
241
|
at: string;
|
|
239
242
|
}
|
|
243
|
+
export interface LaneStory {
|
|
244
|
+
project_id: string;
|
|
245
|
+
story_id: string;
|
|
246
|
+
status: LaneStoryStatus;
|
|
247
|
+
added_at: string;
|
|
248
|
+
updated_at: string;
|
|
249
|
+
blocked_reason?: string;
|
|
250
|
+
verified_at?: string;
|
|
251
|
+
verified_by?: SeniorRef;
|
|
252
|
+
}
|
|
240
253
|
export interface LaneRecord {
|
|
241
254
|
schema_version: typeof LANE_SCHEMA_VERSION | string;
|
|
242
255
|
lane_id: string;
|
|
@@ -246,6 +259,11 @@ export interface LaneRecord {
|
|
|
246
259
|
mesh_company?: LaneCompanyRef;
|
|
247
260
|
project_id: string;
|
|
248
261
|
story_id: string;
|
|
262
|
+
/**
|
|
263
|
+
* Stories currently carried by this lane. Old records omit this field and
|
|
264
|
+
* are migrated in memory from project_id/story_id on read.
|
|
265
|
+
*/
|
|
266
|
+
stories?: LaneStory[];
|
|
249
267
|
thread_id?: string;
|
|
250
268
|
depth: number;
|
|
251
269
|
senior: SeniorRef;
|
|
@@ -367,6 +385,8 @@ export interface LaneRecord {
|
|
|
367
385
|
*/
|
|
368
386
|
provider_session_id?: string;
|
|
369
387
|
provider_session_id_absent?: string;
|
|
388
|
+
/** Provider home used by the most recently spawned run, for transcript recovery. */
|
|
389
|
+
provider_home_at_spawn?: string;
|
|
370
390
|
/** Set at spawn when this run continued a recorded session instead of starting fresh. */
|
|
371
391
|
last_resume?: LaneResumeRecord;
|
|
372
392
|
/** Cache counters from the last exited run's result. Nothing else from the payload. */
|
package/dist/lib/lanes/types.js
CHANGED
|
@@ -105,6 +105,13 @@ export const STORY_STATUSES = [
|
|
|
105
105
|
"review",
|
|
106
106
|
"done",
|
|
107
107
|
];
|
|
108
|
+
/** Durable Board status for a story carried by a lane. */
|
|
109
|
+
export const LANE_STORY_STATUSES = [
|
|
110
|
+
"in_progress",
|
|
111
|
+
"blocked",
|
|
112
|
+
"review",
|
|
113
|
+
"done",
|
|
114
|
+
];
|
|
108
115
|
/**
|
|
109
116
|
* `expired` is accepted on read for records that already carry it. Spec §9
|
|
110
117
|
* ageing advances hop with `unanswered_timeout` and never expires a question,
|
|
@@ -195,7 +202,7 @@ export const LANE_WRITERS = ["creator", "command", "watcher", "reconcile", "spaw
|
|
|
195
202
|
/** Field ownership at the durable-record mutation boundary (spec §10.1). */
|
|
196
203
|
export const LANE_FIELD_WRITERS = {
|
|
197
204
|
schema_version: ["creator"], lane_id: ["creator"], company: ["creator"],
|
|
198
|
-
project_id: ["command"], story_id: ["command"], thread_id: ["creator", "command"], mesh_company: ["creator"],
|
|
205
|
+
project_id: ["command"], story_id: ["command"], stories: ["creator", "command", "watcher", "reconcile", "spawn"], thread_id: ["creator", "command", "spawn"], mesh_company: ["creator"],
|
|
199
206
|
depth: ["command"], senior: ["command"], senior_history: ["command"],
|
|
200
207
|
state: ["creator", "command", "watcher", "reconcile", "spawn"], queued_reason: ["creator", "command", "spawn", "reconcile"],
|
|
201
208
|
pickup_validation_pending: ["creator", "command", "spawn", "reconcile"],
|
|
@@ -208,7 +215,7 @@ export const LANE_FIELD_WRITERS = {
|
|
|
208
215
|
overdue: ["reconcile"], overdue_at: ["reconcile"], last_host_report_at: ["watcher", "spawn", "reconcile"], stalled: ["watcher"], stalled_at: ["watcher"], stalled_reason: ["watcher"],
|
|
209
216
|
admitted_reason: ["reconcile"], terminal_reason: ["command", "watcher", "spawn", "reconcile"], terminal_details: ["command", "spawn", "watcher", "reconcile"],
|
|
210
217
|
quota_reset_at: ["watcher", "reconcile"], model_quota_marker: ["watcher", "reconcile"], outpost_command_id: ["command"],
|
|
211
|
-
provider_session_id: ["watcher"], provider_session_id_absent: ["watcher"], last_resume: ["spawn"], last_run_usage: ["watcher"], envelope_repair: ["watcher", "reconcile"], last_progress_event_at: ["watcher"],
|
|
218
|
+
provider_session_id: ["watcher", "command", "spawn"], provider_session_id_absent: ["watcher", "command"], provider_home_at_spawn: ["spawn"], last_resume: ["spawn"], last_run_usage: ["watcher"], envelope_repair: ["watcher", "reconcile"], last_progress_event_at: ["watcher"],
|
|
212
219
|
};
|
|
213
220
|
function isPlainObject(value) {
|
|
214
221
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|