@indigoai-us/hq-cli 5.148.0 → 5.150.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 +30 -0
- package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +8 -11
- package/dist/command-catalog.generated.d.ts +148 -0
- package/dist/command-catalog.generated.js +193 -0
- package/dist/commands/lanes.js +1000 -268
- package/dist/commands/mesh.js +82 -11
- package/dist/lib/lanes/admission.d.ts +24 -0
- package/dist/lib/lanes/admission.js +193 -0
- package/dist/lib/lanes/delivery.js +5 -3
- package/dist/lib/lanes/index.d.ts +5 -2
- package/dist/lib/lanes/index.js +3 -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 +194 -55
- 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 +52 -5
- package/dist/lib/lanes/store.js +156 -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 +28 -1
- package/dist/lib/lanes/types.js +24 -6
- 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
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. */
|
|
@@ -843,6 +844,8 @@ export function markSpawnFailure(hqRoot, laneId, reasonRaw, details) {
|
|
|
843
844
|
...current,
|
|
844
845
|
state: "failed",
|
|
845
846
|
queued_reason: undefined,
|
|
847
|
+
queued_at: undefined,
|
|
848
|
+
admission_reserved_at: undefined,
|
|
846
849
|
terminal_reason: reason,
|
|
847
850
|
terminal_details: details,
|
|
848
851
|
};
|
|
@@ -1027,6 +1030,9 @@ export function readSpawnManifest(runDir) {
|
|
|
1027
1030
|
const out = {};
|
|
1028
1031
|
if (typeof rec.envelope_path === "string")
|
|
1029
1032
|
out.envelope_path = rec.envelope_path;
|
|
1033
|
+
if (typeof rec.provider_home_at_spawn === "string") {
|
|
1034
|
+
out.provider_home_at_spawn = rec.provider_home_at_spawn;
|
|
1035
|
+
}
|
|
1030
1036
|
if (typeof rec.worker_out_offset === "number" &&
|
|
1031
1037
|
Number.isInteger(rec.worker_out_offset) &&
|
|
1032
1038
|
rec.worker_out_offset >= 0) {
|
|
@@ -1053,6 +1059,10 @@ export function readSpawnManifest(runDir) {
|
|
|
1053
1059
|
}
|
|
1054
1060
|
return out;
|
|
1055
1061
|
}
|
|
1062
|
+
function laneLaunchResult(lane) {
|
|
1063
|
+
const { provider_home_at_spawn: _providerHomeAtSpawn, ...safe } = lane;
|
|
1064
|
+
return safe;
|
|
1065
|
+
}
|
|
1056
1066
|
function inferTrigger(lane) {
|
|
1057
1067
|
if (lane.state === "awaiting_input")
|
|
1058
1068
|
return { kind: "answered_questions" };
|
|
@@ -1075,12 +1085,32 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1075
1085
|
const spawnProcess = deps.spawnProcess ?? defaultSpawnProcess;
|
|
1076
1086
|
const proveDetached = deps.proveDetached ?? defaultProveDetached;
|
|
1077
1087
|
const killProcess = deps.killProcess ?? defaultKillProcess;
|
|
1078
|
-
const
|
|
1088
|
+
const runDir = laneRunDir(hqRoot, lane.lane_id);
|
|
1089
|
+
const previousSpawn = readSpawnManifest(runDir);
|
|
1090
|
+
const stored = readLane(hqRoot, lane.lane_id);
|
|
1091
|
+
const recoveredSessionId = (typeof stored.provider_session_id === "string" && stored.provider_session_id.trim()
|
|
1092
|
+
? stored.provider_session_id
|
|
1093
|
+
: undefined) ??
|
|
1094
|
+
(typeof stored.last_resume?.session_id === "string" && stored.last_resume.session_id.trim()
|
|
1095
|
+
? stored.last_resume.session_id
|
|
1096
|
+
: undefined) ??
|
|
1097
|
+
(typeof previousSpawn.resume?.session_id === "string" && previousSpawn.resume.session_id.trim()
|
|
1098
|
+
? previousSpawn.resume.session_id
|
|
1099
|
+
: undefined);
|
|
1100
|
+
const existing = {
|
|
1101
|
+
...stored,
|
|
1102
|
+
...(stored.provider_session_id === undefined && recoveredSessionId !== undefined
|
|
1103
|
+
? { provider_session_id: recoveredSessionId }
|
|
1104
|
+
: {}),
|
|
1105
|
+
...(stored.provider_home_at_spawn === undefined && previousSpawn.provider_home_at_spawn !== undefined
|
|
1106
|
+
? { provider_home_at_spawn: previousSpawn.provider_home_at_spawn }
|
|
1107
|
+
: {}),
|
|
1108
|
+
};
|
|
1079
1109
|
if (existing.state === "running") {
|
|
1080
1110
|
if (launch.trigger?.kind === "resume") {
|
|
1081
1111
|
throw new LanesError("lane_not_resumable", `Lane ${lane.lane_id} is already running`, { lane_id: lane.lane_id, state: existing.state });
|
|
1082
1112
|
}
|
|
1083
|
-
return existing;
|
|
1113
|
+
return laneLaunchResult(existing);
|
|
1084
1114
|
}
|
|
1085
1115
|
// A queued lane that a move parked at the target's cap carries the turn
|
|
1086
1116
|
// it was resumed with: the admit spawn continues that session instead of
|
|
@@ -1098,7 +1128,6 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1098
1128
|
if (existing.host.kind === "outpost") {
|
|
1099
1129
|
refuseOutpostSelfTarget(hqRoot, existing, deps);
|
|
1100
1130
|
}
|
|
1101
|
-
const runDir = laneRunDir(hqRoot, lane.lane_id);
|
|
1102
1131
|
const logsDir = path.join(runDir, "logs");
|
|
1103
1132
|
ensureDir(logsDir);
|
|
1104
1133
|
ensureDir(path.join(runDir, "envelopes"));
|
|
@@ -1159,6 +1188,17 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1159
1188
|
if (profileDefaultsApply && profileModel !== undefined && pinnedModel === undefined) {
|
|
1160
1189
|
validateProfileLaneModel(existing.provider, profileModel, resolved.home, profile.id, modelDeps);
|
|
1161
1190
|
}
|
|
1191
|
+
if (plan.mode === "resume" && existing.provider !== "grok") {
|
|
1192
|
+
ensureSessionHome({
|
|
1193
|
+
provider: existing.provider,
|
|
1194
|
+
sessionId: plan.session_id,
|
|
1195
|
+
cwd: hqRoot,
|
|
1196
|
+
targetHome: resolved.home,
|
|
1197
|
+
registeredHomes: Object.values(config.providers[existing.provider].accounts)
|
|
1198
|
+
.map((account) => account.home),
|
|
1199
|
+
providerHomeAtSpawn: existing.provider_home_at_spawn,
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1162
1202
|
if (lane.account !== resolved.id) {
|
|
1163
1203
|
retryUpdateLane(hqRoot, lane.lane_id, "spawn", (current) => {
|
|
1164
1204
|
if (current.account === resolved.id)
|
|
@@ -1322,9 +1362,12 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1322
1362
|
...current,
|
|
1323
1363
|
state: "running",
|
|
1324
1364
|
queued_reason: undefined,
|
|
1365
|
+
queued_at: undefined,
|
|
1366
|
+
admission_reserved_at: undefined,
|
|
1325
1367
|
// An account move's reservation ends with the claim.
|
|
1326
1368
|
pending_resume: undefined,
|
|
1327
1369
|
account: resolved.id,
|
|
1370
|
+
provider_home_at_spawn: resolved.home,
|
|
1328
1371
|
...(model !== undefined ? { model } : {}),
|
|
1329
1372
|
...(model !== undefined
|
|
1330
1373
|
? {
|
|
@@ -1348,6 +1391,10 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1348
1391
|
}
|
|
1349
1392
|
: {}),
|
|
1350
1393
|
};
|
|
1394
|
+
if (resume !== undefined) {
|
|
1395
|
+
next.provider_session_id = resume.session_id;
|
|
1396
|
+
next.provider_session_id_absent = undefined;
|
|
1397
|
+
}
|
|
1351
1398
|
if (effort.effort !== undefined)
|
|
1352
1399
|
next.effort = effort.effort;
|
|
1353
1400
|
else
|
|
@@ -1406,7 +1453,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1406
1453
|
last_compaction: record,
|
|
1407
1454
|
}));
|
|
1408
1455
|
}
|
|
1409
|
-
return claimedRecord;
|
|
1456
|
+
return laneLaunchResult(claimedRecord);
|
|
1410
1457
|
}
|
|
1411
1458
|
const workerOutPath = path.join(logsDir, "worker.out");
|
|
1412
1459
|
const workerOutOffset = fileSizeOrZero(workerOutPath);
|
|
@@ -1476,7 +1523,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1476
1523
|
}
|
|
1477
1524
|
throw err;
|
|
1478
1525
|
}
|
|
1479
|
-
return readLane(hqRoot, lane.lane_id);
|
|
1526
|
+
return laneLaunchResult(readLane(hqRoot, lane.lane_id));
|
|
1480
1527
|
}
|
|
1481
1528
|
/**
|
|
1482
1529
|
* `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, isQueuedReason, 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";
|
|
@@ -244,6 +245,25 @@ function requireString(value, field) {
|
|
|
244
245
|
}
|
|
245
246
|
return value;
|
|
246
247
|
}
|
|
248
|
+
function requireTimestamp(value, field, filePath) {
|
|
249
|
+
const parsedAt = typeof value === "string" ? Date.parse(value) : NaN;
|
|
250
|
+
const datePart = typeof value === "string" ? value.slice(0, 10) : "";
|
|
251
|
+
const datePartAt = /^\d{4}-\d{2}-\d{2}$/.test(datePart)
|
|
252
|
+
? Date.parse(`${datePart}T00:00:00.000Z`)
|
|
253
|
+
: NaN;
|
|
254
|
+
if (typeof value !== "string" ||
|
|
255
|
+
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) ||
|
|
256
|
+
!Number.isFinite(parsedAt) ||
|
|
257
|
+
!Number.isFinite(datePartAt) ||
|
|
258
|
+
new Date(datePartAt).toISOString().slice(0, 10) !== datePart) {
|
|
259
|
+
throw new LaneSchemaError(`Expected ISO timestamp for ${field}`, {
|
|
260
|
+
path: filePath,
|
|
261
|
+
field,
|
|
262
|
+
value,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
return value;
|
|
266
|
+
}
|
|
247
267
|
function requireInt(value, field) {
|
|
248
268
|
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
249
269
|
throw new LaneSchemaError(`Expected integer for ${field}`, { field });
|
|
@@ -277,6 +297,80 @@ function parseOptionalMeshCompanyField(raw, filePath) {
|
|
|
277
297
|
requireString(raw.mesh_company.slug, "mesh_company.slug");
|
|
278
298
|
requireString(raw.mesh_company.uid, "mesh_company.uid");
|
|
279
299
|
}
|
|
300
|
+
function legacyLaneStory(raw) {
|
|
301
|
+
const status = raw.state === "blocked"
|
|
302
|
+
? "blocked"
|
|
303
|
+
: raw.story_status === "review"
|
|
304
|
+
? "review"
|
|
305
|
+
: raw.story_status === "done" || raw.story_complete === true
|
|
306
|
+
? "review"
|
|
307
|
+
: "in_progress";
|
|
308
|
+
const story = {
|
|
309
|
+
project_id: requireString(raw.project_id, "project_id"),
|
|
310
|
+
story_id: requireString(raw.story_id, "story_id"),
|
|
311
|
+
status,
|
|
312
|
+
added_at: requireString(raw.created_at, "created_at"),
|
|
313
|
+
updated_at: requireString(raw.updated_at, "updated_at"),
|
|
314
|
+
};
|
|
315
|
+
if (typeof raw.blocked_reason === "string" && raw.blocked_reason.length > 0) {
|
|
316
|
+
story.blocked_reason = raw.blocked_reason;
|
|
317
|
+
}
|
|
318
|
+
return story;
|
|
319
|
+
}
|
|
320
|
+
function parseLaneStories(raw, filePath) {
|
|
321
|
+
if (raw.stories === undefined) {
|
|
322
|
+
// v1 records carried one story in the top-level fields. Keep the old
|
|
323
|
+
// fields authoritative for the migration and give worker-completed work
|
|
324
|
+
// the review state until a senior explicitly verifies it.
|
|
325
|
+
raw.stories = [legacyLaneStory(raw)];
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (!Array.isArray(raw.stories) || raw.stories.length === 0) {
|
|
329
|
+
throw new LaneSchemaError("stories must be a non-empty array when present", {
|
|
330
|
+
path: filePath,
|
|
331
|
+
field: "stories",
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
for (const [index, value] of raw.stories.entries()) {
|
|
335
|
+
const field = `stories[${index}]`;
|
|
336
|
+
if (!isPlainObject(value)) {
|
|
337
|
+
throw new LaneSchemaError(`${field} must be an object`, { path: filePath, field });
|
|
338
|
+
}
|
|
339
|
+
requireString(value.project_id, `${field}.project_id`);
|
|
340
|
+
requireString(value.story_id, `${field}.story_id`);
|
|
341
|
+
requireString(value.added_at, `${field}.added_at`);
|
|
342
|
+
requireString(value.updated_at, `${field}.updated_at`);
|
|
343
|
+
if (typeof value.status !== "string" ||
|
|
344
|
+
!LANE_STORY_STATUSES.includes(value.status)) {
|
|
345
|
+
throw new LaneSchemaError(`Unknown story status ${JSON.stringify(value.status)}`, {
|
|
346
|
+
path: filePath,
|
|
347
|
+
field: `${field}.status`,
|
|
348
|
+
value: value.status,
|
|
349
|
+
accepted: [...LANE_STORY_STATUSES],
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
if (value.blocked_reason !== undefined) {
|
|
353
|
+
requireString(value.blocked_reason, `${field}.blocked_reason`);
|
|
354
|
+
}
|
|
355
|
+
if (value.verified_at !== undefined) {
|
|
356
|
+
requireString(value.verified_at, `${field}.verified_at`);
|
|
357
|
+
}
|
|
358
|
+
if (value.verified_by !== undefined) {
|
|
359
|
+
if (!isPlainObject(value.verified_by)) {
|
|
360
|
+
throw new LaneSchemaError(`${field}.verified_by must be an object`, {
|
|
361
|
+
path: filePath,
|
|
362
|
+
field: `${field}.verified_by`,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
if (!isSeniorKind(value.verified_by.kind)) {
|
|
366
|
+
throw new LaneSchemaError(`Unknown senior kind ${JSON.stringify(value.verified_by.kind)}`, { path: filePath, field: `${field}.verified_by.kind` });
|
|
367
|
+
}
|
|
368
|
+
if (value.verified_by.kind !== "user") {
|
|
369
|
+
requireString(value.verified_by.id, `${field}.verified_by.id`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
280
374
|
export function parseLaneRecord(raw, filePath) {
|
|
281
375
|
if (!isPlainObject(raw)) {
|
|
282
376
|
throw new LaneSchemaError("Lane record must be a JSON object", {
|
|
@@ -296,12 +390,38 @@ export function parseLaneRecord(raw, filePath) {
|
|
|
296
390
|
requireInt(raw.priority, "priority");
|
|
297
391
|
requireString(raw.project_id, "project_id");
|
|
298
392
|
requireString(raw.story_id, "story_id");
|
|
393
|
+
requireTimestamp(raw.created_at, "created_at", filePath);
|
|
394
|
+
requireTimestamp(raw.updated_at, "updated_at", filePath);
|
|
395
|
+
if (raw.queued_reason !== undefined && !isQueuedReason(raw.queued_reason)) {
|
|
396
|
+
throw new LaneSchemaError(`Unknown queued reason ${JSON.stringify(raw.queued_reason)}`, {
|
|
397
|
+
path: filePath,
|
|
398
|
+
field: "queued_reason",
|
|
399
|
+
value: raw.queued_reason,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
if (raw.queued_at !== undefined)
|
|
403
|
+
requireTimestamp(raw.queued_at, "queued_at", filePath);
|
|
404
|
+
if (raw.admission_reserved_at !== undefined) {
|
|
405
|
+
requireTimestamp(raw.admission_reserved_at, "admission_reserved_at", filePath);
|
|
406
|
+
}
|
|
407
|
+
if (raw.admission_accounts !== undefined) {
|
|
408
|
+
if (!Array.isArray(raw.admission_accounts) ||
|
|
409
|
+
raw.admission_accounts.length === 0 ||
|
|
410
|
+
raw.admission_accounts.some((account) => typeof account !== "string" || account.trim() === "") ||
|
|
411
|
+
new Set(raw.admission_accounts).size !== raw.admission_accounts.length) {
|
|
412
|
+
throw new LaneSchemaError("admission_accounts must be a non-empty array of unique account ids", { path: filePath, field: "admission_accounts" });
|
|
413
|
+
}
|
|
414
|
+
if (typeof raw.account !== "string" || !raw.admission_accounts.includes(raw.account)) {
|
|
415
|
+
throw new LaneSchemaError("account must be present in admission_accounts", { path: filePath, field: "admission_accounts", account: raw.account });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
299
418
|
if (!isLaneState(raw.state)) {
|
|
300
419
|
throw new LaneSchemaError(`Unknown lane state ${JSON.stringify(raw.state)}`, { path: filePath, value: raw.state });
|
|
301
420
|
}
|
|
302
421
|
if (!isLaneProvider(raw.provider)) {
|
|
303
422
|
throw new LaneSchemaError(`Unknown provider ${JSON.stringify(raw.provider)}`, { path: filePath, value: raw.provider });
|
|
304
423
|
}
|
|
424
|
+
parseLaneStories(raw, filePath);
|
|
305
425
|
parseOptionalCompanyField(raw, filePath);
|
|
306
426
|
parseOptionalMeshCompanyField(raw, filePath);
|
|
307
427
|
if (!isPlainObject(raw.senior)) {
|
|
@@ -472,6 +592,37 @@ function higherVersionRecord(live, others) {
|
|
|
472
592
|
}
|
|
473
593
|
return best;
|
|
474
594
|
}
|
|
595
|
+
function laneStoryIdentity(story) {
|
|
596
|
+
return `${story.project_id}\u0000${story.story_id}`;
|
|
597
|
+
}
|
|
598
|
+
function mergeLaneStories(records, base) {
|
|
599
|
+
const baseStories = laneStories(base);
|
|
600
|
+
const merged = new Map();
|
|
601
|
+
const baseOrder = [];
|
|
602
|
+
for (const story of baseStories) {
|
|
603
|
+
const key = laneStoryIdentity(story);
|
|
604
|
+
if (!merged.has(key))
|
|
605
|
+
baseOrder.push(key);
|
|
606
|
+
merged.set(key, story);
|
|
607
|
+
}
|
|
608
|
+
for (const record of records) {
|
|
609
|
+
if (record === base)
|
|
610
|
+
continue;
|
|
611
|
+
for (const story of laneStories(record)) {
|
|
612
|
+
const key = laneStoryIdentity(story);
|
|
613
|
+
const current = merged.get(key);
|
|
614
|
+
if (!current || story.updated_at > current.updated_at) {
|
|
615
|
+
merged.set(key, story);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
const sideOnly = [...merged.keys()]
|
|
620
|
+
.filter((key) => !baseOrder.includes(key))
|
|
621
|
+
.sort();
|
|
622
|
+
return [...baseOrder, ...sideOnly]
|
|
623
|
+
.map((key) => merged.get(key))
|
|
624
|
+
.filter((story) => story !== undefined);
|
|
625
|
+
}
|
|
475
626
|
export function mergeLaneRecords(live, sidecars) {
|
|
476
627
|
if (sidecars.length === 0)
|
|
477
628
|
return live;
|
|
@@ -486,6 +637,7 @@ export function mergeLaneRecords(live, sidecars) {
|
|
|
486
637
|
account_history: unionHistory(all.map((record) => record.account_history ?? []), entryIdentity, base.account_history ?? []),
|
|
487
638
|
}
|
|
488
639
|
: {}),
|
|
640
|
+
stories: mergeLaneStories(all, base),
|
|
489
641
|
};
|
|
490
642
|
}
|
|
491
643
|
export function mergeQuestionRecords(live, sidecars) {
|
|
@@ -596,6 +748,9 @@ export function createLane(hqRoot, init, deps = defaultStoreDeps()) {
|
|
|
596
748
|
version: 1,
|
|
597
749
|
created_at: init.created_at ?? now,
|
|
598
750
|
updated_at: init.updated_at ?? now,
|
|
751
|
+
...(init.state === "queued"
|
|
752
|
+
? { queued_at: init.queued_at ?? init.created_at ?? now }
|
|
753
|
+
: {}),
|
|
599
754
|
senior_history: init.senior_history ?? [],
|
|
600
755
|
attribution_history: init.attribution_history ?? [],
|
|
601
756
|
};
|
|
@@ -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
|
|
@@ -166,8 +166,9 @@ export declare const TERMINAL_LANE_STATES: readonly ["done", "failed", "interrup
|
|
|
166
166
|
export type TerminalLaneState = (typeof TERMINAL_LANE_STATES)[number];
|
|
167
167
|
export declare const LANE_PROVIDERS: readonly ["claude", "codex", "grok"];
|
|
168
168
|
export type LaneProvider = (typeof LANE_PROVIDERS)[number];
|
|
169
|
-
export declare const QUEUED_REASONS: readonly ["cap", "queue_position", "throttle", "provider_binary_missing"];
|
|
169
|
+
export declare const QUEUED_REASONS: readonly ["cap", "provider_cap", "account_cap", "profile_cap", "queue_position", "throttle", "provider_binary_missing"];
|
|
170
170
|
export type QueuedReason = (typeof QUEUED_REASONS)[number];
|
|
171
|
+
export declare function isQueuedReason(value: unknown): value is QueuedReason;
|
|
171
172
|
export declare const HOST_KINDS: readonly ["local", "outpost"];
|
|
172
173
|
export type HostKind = (typeof HOST_KINDS)[number];
|
|
173
174
|
export declare const SENIOR_KINDS: readonly ["session", "lane", "user"];
|
|
@@ -176,6 +177,9 @@ export declare const ENVELOPE_DECISIONS: readonly ["done", "ask", "blocked"];
|
|
|
176
177
|
export type EnvelopeDecision = (typeof ENVELOPE_DECISIONS)[number];
|
|
177
178
|
export declare const STORY_STATUSES: readonly ["queued", "in_progress", "review", "done"];
|
|
178
179
|
export type StoryStatus = (typeof STORY_STATUSES)[number];
|
|
180
|
+
/** Durable Board status for a story carried by a lane. */
|
|
181
|
+
export declare const LANE_STORY_STATUSES: readonly ["in_progress", "blocked", "review", "done"];
|
|
182
|
+
export type LaneStoryStatus = (typeof LANE_STORY_STATUSES)[number];
|
|
179
183
|
/**
|
|
180
184
|
* `expired` is accepted on read for records that already carry it. Spec §9
|
|
181
185
|
* ageing advances hop with `unanswered_timeout` and never expires a question,
|
|
@@ -237,6 +241,16 @@ export interface LastEnvelopeRef {
|
|
|
237
241
|
decision: EnvelopeDecision;
|
|
238
242
|
at: string;
|
|
239
243
|
}
|
|
244
|
+
export interface LaneStory {
|
|
245
|
+
project_id: string;
|
|
246
|
+
story_id: string;
|
|
247
|
+
status: LaneStoryStatus;
|
|
248
|
+
added_at: string;
|
|
249
|
+
updated_at: string;
|
|
250
|
+
blocked_reason?: string;
|
|
251
|
+
verified_at?: string;
|
|
252
|
+
verified_by?: SeniorRef;
|
|
253
|
+
}
|
|
240
254
|
export interface LaneRecord {
|
|
241
255
|
schema_version: typeof LANE_SCHEMA_VERSION | string;
|
|
242
256
|
lane_id: string;
|
|
@@ -246,12 +260,21 @@ export interface LaneRecord {
|
|
|
246
260
|
mesh_company?: LaneCompanyRef;
|
|
247
261
|
project_id: string;
|
|
248
262
|
story_id: string;
|
|
263
|
+
/**
|
|
264
|
+
* Stories currently carried by this lane. Old records omit this field and
|
|
265
|
+
* are migrated in memory from project_id/story_id on read.
|
|
266
|
+
*/
|
|
267
|
+
stories?: LaneStory[];
|
|
249
268
|
thread_id?: string;
|
|
250
269
|
depth: number;
|
|
251
270
|
senior: SeniorRef;
|
|
252
271
|
senior_history: SeniorHistoryEntry[];
|
|
253
272
|
state: LaneState;
|
|
254
273
|
queued_reason?: QueuedReason;
|
|
274
|
+
/** Time the current queue turn began; old records fall back to created_at. */
|
|
275
|
+
queued_at?: string;
|
|
276
|
+
/** Short-lived slot reservation between create persistence and worker spawn. */
|
|
277
|
+
admission_reserved_at?: string;
|
|
255
278
|
/**
|
|
256
279
|
* Set by `hq lanes pickup` when it stores an outpost lane and cleared by
|
|
257
280
|
* the account bind once the model probe has finished. While set, the
|
|
@@ -275,6 +298,8 @@ export interface LaneRecord {
|
|
|
275
298
|
worker_profile_ignored?: string[];
|
|
276
299
|
provider: LaneProvider;
|
|
277
300
|
account?: string;
|
|
301
|
+
/** Eligible accounts for a queue entry created without an explicit account. */
|
|
302
|
+
admission_accounts?: string[];
|
|
278
303
|
/**
|
|
279
304
|
* Every operator move of the lane's session to another account of the
|
|
280
305
|
* same provider (`hq lanes resume|message --account`), newest last, capped
|
|
@@ -367,6 +392,8 @@ export interface LaneRecord {
|
|
|
367
392
|
*/
|
|
368
393
|
provider_session_id?: string;
|
|
369
394
|
provider_session_id_absent?: string;
|
|
395
|
+
/** Provider home used by the most recently spawned run, for transcript recovery. */
|
|
396
|
+
provider_home_at_spawn?: string;
|
|
370
397
|
/** Set at spawn when this run continued a recorded session instead of starting fresh. */
|
|
371
398
|
last_resume?: LaneResumeRecord;
|
|
372
399
|
/** Cache counters from the last exited run's result. Nothing else from the payload. */
|
package/dist/lib/lanes/types.js
CHANGED
|
@@ -95,7 +95,18 @@ export const TERMINAL_LANE_STATES = [
|
|
|
95
95
|
"killed",
|
|
96
96
|
];
|
|
97
97
|
export const LANE_PROVIDERS = ["claude", "codex", "grok"];
|
|
98
|
-
export const QUEUED_REASONS = [
|
|
98
|
+
export const QUEUED_REASONS = [
|
|
99
|
+
"cap",
|
|
100
|
+
"provider_cap",
|
|
101
|
+
"account_cap",
|
|
102
|
+
"profile_cap",
|
|
103
|
+
"queue_position",
|
|
104
|
+
"throttle",
|
|
105
|
+
"provider_binary_missing",
|
|
106
|
+
];
|
|
107
|
+
export function isQueuedReason(value) {
|
|
108
|
+
return typeof value === "string" && QUEUED_REASONS.includes(value);
|
|
109
|
+
}
|
|
99
110
|
export const HOST_KINDS = ["local", "outpost"];
|
|
100
111
|
export const SENIOR_KINDS = ["session", "lane", "user"];
|
|
101
112
|
export const ENVELOPE_DECISIONS = ["done", "ask", "blocked"];
|
|
@@ -105,6 +116,13 @@ export const STORY_STATUSES = [
|
|
|
105
116
|
"review",
|
|
106
117
|
"done",
|
|
107
118
|
];
|
|
119
|
+
/** Durable Board status for a story carried by a lane. */
|
|
120
|
+
export const LANE_STORY_STATUSES = [
|
|
121
|
+
"in_progress",
|
|
122
|
+
"blocked",
|
|
123
|
+
"review",
|
|
124
|
+
"done",
|
|
125
|
+
];
|
|
108
126
|
/**
|
|
109
127
|
* `expired` is accepted on read for records that already carry it. Spec §9
|
|
110
128
|
* ageing advances hop with `unanswered_timeout` and never expires a question,
|
|
@@ -195,20 +213,20 @@ export const LANE_WRITERS = ["creator", "command", "watcher", "reconcile", "spaw
|
|
|
195
213
|
/** Field ownership at the durable-record mutation boundary (spec §10.1). */
|
|
196
214
|
export const LANE_FIELD_WRITERS = {
|
|
197
215
|
schema_version: ["creator"], lane_id: ["creator"], company: ["creator"],
|
|
198
|
-
project_id: ["command"], story_id: ["command"], thread_id: ["creator", "command"], mesh_company: ["creator"],
|
|
216
|
+
project_id: ["command"], story_id: ["command"], stories: ["creator", "command", "watcher", "reconcile", "spawn"], thread_id: ["creator", "command", "spawn"], mesh_company: ["creator"],
|
|
199
217
|
depth: ["command"], senior: ["command"], senior_history: ["command"],
|
|
200
|
-
state: ["creator", "command", "watcher", "reconcile", "spawn"], queued_reason: ["creator", "command", "spawn", "reconcile"],
|
|
218
|
+
state: ["creator", "command", "watcher", "reconcile", "spawn"], queued_reason: ["creator", "command", "spawn", "reconcile"], queued_at: ["creator", "command", "spawn"], admission_reserved_at: ["creator", "command", "reconcile", "spawn"],
|
|
201
219
|
pickup_validation_pending: ["creator", "command", "spawn", "reconcile"],
|
|
202
220
|
pickup_validation_pending_since: ["creator", "command", "spawn", "reconcile"],
|
|
203
221
|
priority: ["command"], worker: ["creator"], worker_path: ["creator"], worker_pack: ["creator"], worker_profile: ["creator"], worker_profile_hash: ["creator", "spawn"], worker_profile_ignored: ["creator", "spawn"],
|
|
204
|
-
provider: ["creator"], account: ["creator", "spawn", "command"], account_history: ["command"], pending_resume: ["command", "spawn"], host: ["creator"], origin_host: ["creator"], origin_host_source: ["creator"],
|
|
222
|
+
provider: ["creator"], account: ["creator", "spawn", "command", "reconcile"], admission_accounts: ["creator", "command"], account_history: ["command"], pending_resume: ["command", "spawn"], host: ["creator"], origin_host: ["creator"], origin_host_source: ["creator"],
|
|
205
223
|
version: ["creator"], created_at: ["creator"], updated_at: ["creator"], attribution_history: ["command"],
|
|
206
224
|
last_envelope: ["watcher", "reconcile"], model: ["creator", "spawn", "command"], model_source: ["creator", "spawn", "command"], effort: ["creator", "spawn"], effort_source: ["creator", "spawn"], fast_mode: ["spawn"],
|
|
207
225
|
last_message_at: ["command"], last_compaction: ["spawn"], story_complete: ["watcher", "reconcile"], story_complete_reason: ["watcher", "reconcile"], story_status: ["watcher", "reconcile"],
|
|
208
226
|
overdue: ["reconcile"], overdue_at: ["reconcile"], last_host_report_at: ["watcher", "spawn", "reconcile"], stalled: ["watcher"], stalled_at: ["watcher"], stalled_reason: ["watcher"],
|
|
209
|
-
admitted_reason: ["reconcile"], terminal_reason: ["command", "watcher", "spawn", "reconcile"], terminal_details: ["command", "spawn", "watcher", "reconcile"],
|
|
227
|
+
admitted_reason: ["command", "reconcile"], terminal_reason: ["command", "watcher", "spawn", "reconcile"], terminal_details: ["command", "spawn", "watcher", "reconcile"],
|
|
210
228
|
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"],
|
|
229
|
+
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
230
|
};
|
|
213
231
|
function isPlainObject(value) {
|
|
214
232
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|