@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +8 -11
  3. package/dist/command-catalog.generated.d.ts +148 -0
  4. package/dist/command-catalog.generated.js +193 -0
  5. package/dist/commands/lanes.js +1000 -268
  6. package/dist/commands/mesh.js +82 -11
  7. package/dist/lib/lanes/admission.d.ts +24 -0
  8. package/dist/lib/lanes/admission.js +193 -0
  9. package/dist/lib/lanes/delivery.js +5 -3
  10. package/dist/lib/lanes/index.d.ts +5 -2
  11. package/dist/lib/lanes/index.js +3 -1
  12. package/dist/lib/lanes/mesh-events.d.ts +8 -0
  13. package/dist/lib/lanes/mesh-events.js +83 -6
  14. package/dist/lib/lanes/outpost-dispatch.d.ts +12 -0
  15. package/dist/lib/lanes/outpost-dispatch.js +17 -0
  16. package/dist/lib/lanes/reconcile.js +194 -55
  17. package/dist/lib/lanes/session-relink.d.ts +25 -0
  18. package/dist/lib/lanes/session-relink.js +216 -0
  19. package/dist/lib/lanes/spawn.d.ts +1 -0
  20. package/dist/lib/lanes/spawn.js +52 -5
  21. package/dist/lib/lanes/store.js +156 -1
  22. package/dist/lib/lanes/stories.d.ts +17 -0
  23. package/dist/lib/lanes/stories.js +73 -0
  24. package/dist/lib/lanes/types.d.ts +28 -1
  25. package/dist/lib/lanes/types.js +24 -6
  26. package/dist/lib/lanes/watcher.js +69 -9
  27. package/dist/lib/mesh/live/daemon/doctor.d.ts +27 -0
  28. package/dist/lib/mesh/live/daemon/doctor.js +84 -0
  29. package/dist/lib/mesh/live/daemon/index.d.ts +3 -3
  30. package/dist/lib/mesh/live/daemon/index.js +2 -2
  31. package/dist/lib/mesh/live/daemon/install.d.ts +40 -0
  32. package/dist/lib/mesh/live/daemon/install.js +88 -0
  33. package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
  34. package/dist/lib/mesh/live/daemon/run.js +12 -1
  35. package/dist/lib/mesh/live/daemon/state.d.ts +4 -0
  36. package/dist/lib/mesh/project-board.d.ts +15 -0
  37. package/dist/lib/mesh/project-board.js +84 -0
  38. package/dist/lib/work-context/prd-sync.d.ts +5 -1
  39. package/dist/lib/work-context/prd-sync.js +22 -1
  40. package/package.json +5 -3
  41. package/scripts/mesh-daemon-if-stale.mjs +16 -0
@@ -9,11 +9,12 @@ import { resolve as resolvePath } from "node:path";
9
9
  import * as os from "node:os";
10
10
  import { readFileSync } from "node:fs";
11
11
  import { findHqRoot } from "../utils/manifest.js";
12
+ import { CLI_VERSION } from "../cli-version.js";
12
13
  import chalk from "chalk";
13
14
  import { loadCachedTokens, isMachineIdentity } from "../utils/cognito-session.js";
14
15
  import * as readline from "node:readline/promises";
15
16
  import { STORY_STATUSES, appendThreadEvent, ensureProjectThread, eventPayload, getProjectView, listActiveMembershipCompanies, listActiveThreads, patchStory, putProjectView, registerMeshProject, resolveActiveMembershipCompany, resolveMeshPrincipalUid, warmMeshConversationCache, } from "../lib/mesh/api.js";
16
- import { closeSupersededProjectThread, ensureProjectBoard } from "../lib/mesh/project-board.js";
17
+ import { closeSupersededProjectThread, ensureMissingBoardStories, ensureProjectBoard } from "../lib/mesh/project-board.js";
17
18
  import { buildPreflightFixtures } from "../lib/work-context/preflight-contract.js";
18
19
  import { createCandidatesFetcher, createMigratePoster, createOrganizePoster, createWorkSessionDeliverer, fetchCompanyLive, formatCompanyLiveTable, openMeshTransport, probeMigrationCapabilityForMemberships, requireToken, } from "../lib/mesh/client.js";
19
20
  import { clearDefaultCompany, isDefaultCompanyDisabled, readDeviceConfig, recordMigrationCapabilitySnapshot, setRepairHeldWithDefault, setDefaultCompany, } from "../lib/work-context/config.js";
@@ -34,7 +35,7 @@ import { generateUlid } from "../lib/mesh/live/ulid.js";
34
35
  import { readLegacyBacklog } from "../lib/mesh/live/emit-replay.js";
35
36
  import { resolveMeshEmitMode, writeMeshConfigMode, } from "../lib/mesh/live/daemon/mode.js";
36
37
  import { workMeshRoot } from "../lib/mesh/live/paths.js";
37
- import { buildInstallPaths, collectDaemonDoctor, daemonServiceStatus, findAncestorHqRoot, resolveInstallHqRoot, detectPlatform, formatDaemonDoctor, installDaemonService, readDaemonState, runMeshDaemon, uninstallDaemonService, daemonDir, } from "../lib/mesh/live/daemon/index.js";
38
+ import { buildInstallPaths, collectDaemonDoctor, daemonServiceStatus, findAncestorHqRoot, resolveInstallHqRoot, detectPlatform, formatCliIdentityLines, formatDaemonDoctor, installDaemonIfStale, installDaemonService, readDaemonState, resolveAbsoluteNodeBinary, resolveRunningCliEntry, runMeshDaemon, uninstallDaemonService, daemonDir, } from "../lib/mesh/live/daemon/index.js";
38
39
  import { workContextRoot } from "../lib/work-context/paths.js";
39
40
  import { formatMigrateConfirmation, submitSessionMigration, } from "../lib/work-context/migrate.js";
40
41
  import { formatOrganizeList, prepareOrganizeDecision, settleOrganizeAskWithoutBind, submitOrganizeDecision, } from "../lib/work-context/organize.js";
@@ -575,6 +576,29 @@ async function runPrdSync(opts) {
575
576
  ...(bound?.projectId ? { project: bound.projectId } : {}),
576
577
  touchedPaths: [resolvePath(file)],
577
578
  };
579
+ if (bound?.companyUid && bound.projectId) {
580
+ try {
581
+ const source = parseProjectPlan(readFileSync(file, "utf8"), file);
582
+ await ensureMissingBoardStories({
583
+ token,
584
+ company: {
585
+ companyUid: bound.companyUid,
586
+ companySlug: bound.companySlug || bound.companyUid,
587
+ },
588
+ projectId: bound.projectId,
589
+ name: source.name || bound.projectId,
590
+ description: source.description ?? "",
591
+ stories: source.stories,
592
+ repos: source.repos ?? [],
593
+ requiredIds: plan.events.map((event) => event.taskId),
594
+ });
595
+ }
596
+ catch (err) {
597
+ const reason = err instanceof Error ? err.message : String(err);
598
+ console.error(`work-mesh: prd-sync ensure failed; emitting anyway (${reason.slice(0, 160)})`);
599
+ }
600
+ }
601
+ const meshRoot = workMeshHomeRoot();
578
602
  const seqFirst = reserveSessionSeq(sessionId, plan.events.length);
579
603
  const events = plan.events.map((event, index) => buildMeshEmitEvent({
580
604
  eventId: generateUlid(Date.now() + index),
@@ -592,7 +616,7 @@ async function runPrdSync(opts) {
592
616
  }));
593
617
  const poster = createEmitPoster({ token, baseUrl: resolveVaultApiBase(process.env) });
594
618
  const emitted = await emitEvents({
595
- workMeshRoot: workMeshHomeRoot(),
619
+ workMeshRoot: meshRoot,
596
620
  poster,
597
621
  newEvents: events,
598
622
  });
@@ -601,8 +625,15 @@ async function runPrdSync(opts) {
601
625
  for (const line of formatEmitRejectionLines(emitted.rejections))
602
626
  console.error(line);
603
627
  }
604
- plan.commit();
605
- if (plan.taskId) {
628
+ const blocked = new Set(emitted.rejections.map((row) => row.eventId));
629
+ for (const pending of readEmitRetry(meshRoot)) {
630
+ const id = typeof pending.eventId === "string" ? pending.eventId : "";
631
+ if (id)
632
+ blocked.add(id);
633
+ }
634
+ const advanced = events.flatMap((event) => (event.eventId && event.taskId && !blocked.has(event.eventId) ? [event.taskId] : []));
635
+ plan.commit(advanced);
636
+ if (plan.taskId && advanced.includes(plan.taskId)) {
606
637
  updateSessionState(sessionId, root, (current) => {
607
638
  if (!current)
608
639
  return null;
@@ -639,6 +670,12 @@ async function runDoctor(opts) {
639
670
  fail("`hq mesh doctor --apply` (paced Board PUTs from local prd.json) is not in the CLI yet.\n" +
640
671
  "Omit --apply to warm the conversation cache (directory, inbox, pair DMs).");
641
672
  }
673
+ const identity = collectDaemonDoctor();
674
+ const identityLines = formatCliIdentityLines(identity);
675
+ if (!opts.json) {
676
+ for (const line of identityLines)
677
+ console.log(line);
678
+ }
642
679
  const token = await requireToken();
643
680
  const principalUid = await resolveMeshPrincipalUid(token, loadCachedTokens());
644
681
  if (!principalUid) {
@@ -647,7 +684,15 @@ async function runDoctor(opts) {
647
684
  const warmed = await warmMeshConversationCache(token, principalUid);
648
685
  const needsProject = listNeedsProjectSessions(workContextHomeRoot());
649
686
  if (opts.json) {
650
- console.log(JSON.stringify({ ok: true, action: "doctor", ...warmed, needsProject }, null, 2));
687
+ console.log(JSON.stringify({
688
+ ok: true,
689
+ action: "doctor",
690
+ ...warmed,
691
+ needsProject,
692
+ cliMismatchLine: identity.cliMismatchLine ?? null,
693
+ hqOnPath: identity.hqOnPath,
694
+ multipleHqOnPath: identity.multipleHqOnPath,
695
+ }, null, 2));
651
696
  return;
652
697
  }
653
698
  console.log(`Work mesh cache warmed at ${warmed.cacheRoot}` +
@@ -1938,6 +1983,7 @@ export function registerMeshCommand(program) {
1938
1983
  .description("Install LaunchAgent (macOS), systemd user unit (Linux), or Scheduled Task (Windows)")
1939
1984
  .option("--system-user <user>", "Write a root-owned unit template for agent boxes (e.g. ec2-user)")
1940
1985
  .option("--hq-root <path>", "HQ root to record for the service (else HQ_ROOT, else the nearest HQ root above cwd)")
1986
+ .option("--if-stale", "Reinstall and restart only when this CLI is newer than the daemon's recorded version")
1941
1987
  .option("--json", "Print machine-readable JSON")
1942
1988
  .action((opts) => wrap(() => runDaemonInstall(opts))());
1943
1989
  daemon
@@ -1971,13 +2017,38 @@ async function runDaemonInstall(opts) {
1971
2017
  // Verified root only: a repo's own .claude/ must never become the daemon's
1972
2018
  // HQ root (that path is persisted as trusted and used for attribution).
1973
2019
  const hqRoot = resolveInstallHqRoot({ explicit: opts.hqRoot });
2020
+ const paths = buildInstallPaths({
2021
+ systemUser: opts.systemUser,
2022
+ hqRoot: hqRoot.root,
2023
+ hqRootSource: hqRoot.source,
2024
+ hqBinary: resolveRunningCliEntry(),
2025
+ nodeBinary: resolveAbsoluteNodeBinary(),
2026
+ });
2027
+ if (opts.ifStale) {
2028
+ const stale = installDaemonIfStale({
2029
+ currentVersion: CLI_VERSION,
2030
+ platform: detectPlatform(),
2031
+ paths,
2032
+ systemUser: opts.systemUser,
2033
+ });
2034
+ if (opts.json) {
2035
+ console.log(JSON.stringify({
2036
+ ok: true,
2037
+ action: stale.reinstalled ? "install" : "unchanged",
2038
+ reinstalled: stale.reinstalled,
2039
+ message: stale.message,
2040
+ hqBinary: paths.hqBinary,
2041
+ nodeBinary: paths.nodeBinary,
2042
+ hqRoot: hqRoot.root,
2043
+ }, null, 2));
2044
+ return;
2045
+ }
2046
+ console.log(stale.message);
2047
+ return;
2048
+ }
1974
2049
  const result = installDaemonService({
1975
2050
  platform: detectPlatform(),
1976
- paths: buildInstallPaths({
1977
- systemUser: opts.systemUser,
1978
- hqRoot: hqRoot.root,
1979
- hqRootSource: hqRoot.source,
1980
- }),
2051
+ paths,
1981
2052
  systemUser: opts.systemUser,
1982
2053
  });
1983
2054
  if (opts.json) {
@@ -0,0 +1,24 @@
1
+ import type { LaneRecord, LanesConfig } from "./types.js";
2
+ export interface AdmissionHost {
3
+ hostname: string;
4
+ outpostId: string | null;
5
+ }
6
+ export type AdmissionCapReason = "provider_cap" | "account_cap" | "profile_cap";
7
+ export declare const ADMISSION_LOCK_STALE_MS = 30000;
8
+ export declare const ADMISSION_LOCK_TIMEOUT_MS = 5000;
9
+ export declare const ADMISSION_RESERVATION_GRACE_MS = 30000;
10
+ export declare function withLaneAdmissionLock<T>(hqRoot: string, provider: LaneRecord["provider"], fn: () => Promise<T>, sleep?: (ms: number) => Promise<void>): Promise<T>;
11
+ export declare function withAllLaneAdmissionLocks<T>(hqRoot: string, fn: () => Promise<T>, sleep?: (ms: number) => Promise<void>): Promise<T>;
12
+ export declare function laneRunsOnHost(lane: LaneRecord, host: AdmissionHost): boolean;
13
+ export declare function compareLaneQueueOrder(a: LaneRecord, b: LaneRecord): number;
14
+ export declare function hasActiveAdmissionReservation(lane: LaneRecord, nowMs: number): boolean;
15
+ export declare function hasActivePendingResume(lane: LaneRecord, nowMs: number): boolean;
16
+ /** Return the first capacity limit that currently prevents this lane from starting. */
17
+ export declare function laneAdmissionCapReason(lanes: LaneRecord[], candidate: LaneRecord, config: LanesConfig, host: AdmissionHost, profileMaxConcurrent?: number, nowMs?: number): AdmissionCapReason | undefined;
18
+ /**
19
+ * A prior queue entry owns a free slot only when its own provider, account,
20
+ * and profile caps allow it to use that slot. A blocked profile therefore
21
+ * cannot hold an unrelated profile's queue position.
22
+ */
23
+ export declare function hasEarlierEligibleQueuedLane(lanes: LaneRecord[], candidate: LaneRecord, config: LanesConfig, host: AdmissionHost, profileMaxFor: (lane: LaneRecord) => number | undefined, nowMs?: number): boolean;
24
+ //# sourceMappingURL=admission.d.ts.map
@@ -0,0 +1,193 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { accountMaxRunning } from "./config.js";
4
+ import { LanesError } from "./errors.js";
5
+ export const ADMISSION_LOCK_STALE_MS = 30_000;
6
+ export const ADMISSION_LOCK_TIMEOUT_MS = 5_000;
7
+ export const ADMISSION_RESERVATION_GRACE_MS = 30_000;
8
+ function defaultAdmissionSleep(ms) {
9
+ return new Promise((resolve) => setTimeout(resolve, ms));
10
+ }
11
+ function admissionLockPath(hqRoot, provider) {
12
+ return path.join(hqRoot, "workspace", "lanes-runs", ".admission-locks", `${provider}-provider.lock`);
13
+ }
14
+ function admissionLockOwnerAlive(pid) {
15
+ try {
16
+ process.kill(pid, 0);
17
+ return true;
18
+ }
19
+ catch (err) {
20
+ return err.code !== "ESRCH";
21
+ }
22
+ }
23
+ async function withAdmissionLock(lockPath, fn, sleep) {
24
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
25
+ const started = Date.now();
26
+ while (true) {
27
+ let fd;
28
+ try {
29
+ fd = fs.openSync(lockPath, "wx");
30
+ const ownedStat = fs.fstatSync(fd);
31
+ try {
32
+ fs.writeSync(fd, Buffer.from(`${process.pid}\n${Date.now()}\n`, "utf8"));
33
+ }
34
+ catch (err) {
35
+ fs.closeSync(fd);
36
+ fd = undefined;
37
+ throw err;
38
+ }
39
+ try {
40
+ await new Promise((resolve) => setImmediate(resolve));
41
+ return await fn();
42
+ }
43
+ finally {
44
+ try {
45
+ fs.closeSync(fd);
46
+ }
47
+ catch (err) {
48
+ process.stderr.write(`hq lanes: admission lock close failed for ${lockPath}: ${err instanceof Error ? err.message : String(err)}\n`);
49
+ }
50
+ try {
51
+ const currentStat = fs.statSync(lockPath);
52
+ if (currentStat.dev === ownedStat.dev && currentStat.ino === ownedStat.ino) {
53
+ fs.unlinkSync(lockPath);
54
+ }
55
+ }
56
+ catch (err) {
57
+ if (err.code !== "ENOENT") {
58
+ process.stderr.write(`hq lanes: admission lock unlink failed for ${lockPath}: ${err instanceof Error ? err.message : String(err)}\n`);
59
+ }
60
+ }
61
+ }
62
+ }
63
+ catch (err) {
64
+ if (err.code !== "EEXIST")
65
+ throw err;
66
+ if (Date.now() - started > ADMISSION_LOCK_TIMEOUT_MS) {
67
+ throw new LanesError("store_lock_timeout", `Timed out waiting for admission lock ${lockPath}`, { path: lockPath });
68
+ }
69
+ try {
70
+ const raw = fs.readFileSync(lockPath, "utf8");
71
+ const [ownerLine, timestampLine] = raw.split("\n");
72
+ const ownerPid = ownerLine ? Number(ownerLine) : NaN;
73
+ const timestamp = timestampLine ? Number(timestampLine) : NaN;
74
+ if (Number.isSafeInteger(ownerPid) &&
75
+ ownerPid > 0 &&
76
+ Number.isFinite(timestamp) &&
77
+ Date.now() - timestamp > ADMISSION_LOCK_STALE_MS &&
78
+ !admissionLockOwnerAlive(ownerPid)) {
79
+ if (fs.readFileSync(lockPath, "utf8") !== raw)
80
+ continue;
81
+ try {
82
+ fs.unlinkSync(lockPath);
83
+ }
84
+ catch (unlinkErr) {
85
+ if (unlinkErr.code !== "ENOENT")
86
+ throw unlinkErr;
87
+ }
88
+ continue;
89
+ }
90
+ }
91
+ catch (probeErr) {
92
+ if (probeErr.code !== "ENOENT") {
93
+ process.stderr.write(`hq lanes: stale admission-lock probe failed for ${lockPath}: ${probeErr instanceof Error ? probeErr.message : String(probeErr)}\n`);
94
+ }
95
+ }
96
+ await sleep(10);
97
+ await new Promise((resolve) => setImmediate(resolve));
98
+ }
99
+ }
100
+ }
101
+ export function withLaneAdmissionLock(hqRoot, provider, fn, sleep = defaultAdmissionSleep) {
102
+ return withAdmissionLock(admissionLockPath(hqRoot, provider), fn, sleep);
103
+ }
104
+ export async function withAllLaneAdmissionLocks(hqRoot, fn, sleep = defaultAdmissionSleep) {
105
+ const providers = ["claude", "codex", "grok"];
106
+ const acquire = (index) => {
107
+ const provider = providers[index];
108
+ if (provider === undefined)
109
+ return fn();
110
+ return withLaneAdmissionLock(hqRoot, provider, () => acquire(index + 1), sleep);
111
+ };
112
+ return acquire(0);
113
+ }
114
+ export function laneRunsOnHost(lane, host) {
115
+ if (host.outpostId && lane.host.kind === "outpost") {
116
+ return lane.host.id === host.outpostId;
117
+ }
118
+ return lane.host.kind === "local" && lane.host.id === host.hostname;
119
+ }
120
+ export function compareLaneQueueOrder(a, b) {
121
+ if (b.priority !== a.priority)
122
+ return b.priority - a.priority;
123
+ const byQueueAge = Date.parse(a.queued_at ?? a.created_at) - Date.parse(b.queued_at ?? b.created_at);
124
+ return byQueueAge || a.lane_id.localeCompare(b.lane_id);
125
+ }
126
+ export function hasActiveAdmissionReservation(lane, nowMs) {
127
+ if (lane.state !== "queued" || lane.queued_reason !== undefined || !lane.admission_reserved_at) {
128
+ return false;
129
+ }
130
+ const reservedAt = Date.parse(lane.admission_reserved_at);
131
+ const ageMs = nowMs - reservedAt;
132
+ return Number.isFinite(reservedAt) && ageMs >= 0 && ageMs < ADMISSION_RESERVATION_GRACE_MS;
133
+ }
134
+ export function hasActivePendingResume(lane, nowMs) {
135
+ const pending = lane.pending_resume;
136
+ if (!pending)
137
+ return false;
138
+ const reservedAt = Date.parse(pending.at);
139
+ const ageMs = nowMs - reservedAt;
140
+ return Number.isFinite(reservedAt) && ageMs >= 0 && ageMs < ADMISSION_RESERVATION_GRACE_MS;
141
+ }
142
+ /** Return the first capacity limit that currently prevents this lane from starting. */
143
+ export function laneAdmissionCapReason(lanes, candidate, config, host, profileMaxConcurrent, nowMs = Date.now()) {
144
+ const running = lanes.filter((lane) => lane.lane_id !== candidate.lane_id &&
145
+ (lane.state === "running" ||
146
+ hasActiveAdmissionReservation(lane, nowMs) ||
147
+ hasActivePendingResume(lane, nowMs)) &&
148
+ laneRunsOnHost(lane, host));
149
+ const providerCfg = config.providers[candidate.provider];
150
+ const providerRunning = running.filter((lane) => lane.provider === candidate.provider);
151
+ if (providerRunning.length >= providerCfg.max_running)
152
+ return "provider_cap";
153
+ if (candidate.account &&
154
+ providerRunning.filter((lane) => lane.account === candidate.account).length >=
155
+ accountMaxRunning(providerCfg, candidate.account)) {
156
+ return "account_cap";
157
+ }
158
+ if (candidate.worker_profile &&
159
+ profileMaxConcurrent !== undefined &&
160
+ running.filter((lane) => lane.worker_profile === candidate.worker_profile).length >=
161
+ profileMaxConcurrent) {
162
+ return "profile_cap";
163
+ }
164
+ return undefined;
165
+ }
166
+ /**
167
+ * A prior queue entry owns a free slot only when its own provider, account,
168
+ * and profile caps allow it to use that slot. A blocked profile therefore
169
+ * cannot hold an unrelated profile's queue position.
170
+ */
171
+ export function hasEarlierEligibleQueuedLane(lanes, candidate, config, host, profileMaxFor, nowMs = Date.now()) {
172
+ return lanes
173
+ .filter((lane) => lane.lane_id !== candidate.lane_id &&
174
+ lane.state === "queued" &&
175
+ lane.provider === candidate.provider &&
176
+ laneRunsOnHost(lane, host) &&
177
+ compareLaneQueueOrder(lane, candidate) < 0)
178
+ .some((lane) => {
179
+ const profileMaxConcurrent = profileMaxFor(lane);
180
+ const capReason = laneAdmissionCapReason(lanes, lane, config, host, profileMaxConcurrent, nowMs);
181
+ if (capReason === undefined)
182
+ return true;
183
+ if (capReason !== "account_cap" || lane.admission_accounts === undefined)
184
+ return false;
185
+ return lane.admission_accounts.some((account) => {
186
+ if (account === lane.account || config.providers[lane.provider].accounts[account]?.enabled !== true) {
187
+ return false;
188
+ }
189
+ return laneAdmissionCapReason(lanes, { ...lane, account }, config, host, profileMaxConcurrent, nowMs) === undefined;
190
+ });
191
+ });
192
+ }
193
+ //# sourceMappingURL=admission.js.map
@@ -280,9 +280,11 @@ export function resumeCommandRefusal(lane) {
280
280
  error: "lane_not_resumable",
281
281
  message: `Lane is ${lane.state}. hq lanes resume continues a lane in ${RESUME_COMMAND_STATES.join(", ")}; ${lane.state === "awaiting_input"
282
282
  ? "use hq lanes questions answer <question-id>."
283
- : lane.state === "queued" || lane.state === "running"
284
- ? "use hq lanes message for a live lane."
285
- : "a lane in this state is not resumed."}`,
283
+ : lane.state === "queued"
284
+ ? "use hq lanes admit <lane-id>."
285
+ : lane.state === "running"
286
+ ? "use hq lanes message for a live lane."
287
+ : "a lane in this state is not resumed."}`,
286
288
  };
287
289
  }
288
290
  if (!providerSupportsSessionResume(lane.provider)) {
@@ -3,8 +3,11 @@
3
3
  */
4
4
  export { AccountResolutionError, LaneNotFoundError, LaneSchemaError, LaneVersionConflict, LanesConfigError, LanesError, WorkerResolutionError, isLaneVersionConflict, isLanesError, } from "./errors.js";
5
5
  export { MAX_LANE_DEPTH } from "./constants.js";
6
- export { CONFIG_SCHEMA_VERSION, ENVELOPE_DECISIONS, ENVELOPE_SCHEMA_VERSION, HISTORY_CAP, HOST_KINDS, LANE_PROVIDERS, LANE_SCHEMA_VERSION, LANE_STATES, LIVENESS_STATES, QUESTION_BODY_STORES, QUESTION_SCHEMA_VERSION, QUESTION_STATUSES, QUEUED_REASONS, RAISED_VIA, ROUTING_REASONS, SENIOR_KINDS, STORY_STATUSES, TERMINAL_LANE_STATES, isEnvelopeDecision, isHostKind, isLaneProvider, isLaneState, isLivenessState, isQuestionStatus, isSeniorKind, isTerminalLaneState, parseLaneEnvelope, parseLaneProvider, parseLaneState, parseLivenessState, parseMajorSchema, parseQuestionStatus, parseSeniorKind, } from "./types.js";
7
- export type { AccountConfig, AttributionHistoryEntry, EnvelopeDecision, EnvelopeQuestion, HostKind, LaneCompanyRef, LaneEnvelope, LaneHost, LaneProvider, LaneRecord, PendingResume, AccountHistoryEntry, LaneState, LanesConfig, LastEnvelopeRef, LivenessState, LivenessWindows, ProviderConfig, QuestionAnswer, QuestionBodyRef, QuestionRecord, QuestionStatus, QueuedReason, RaisedVia, RoutingHistoryEntry, RoutingReason, SeniorHistoryEntry, SeniorKind, SeniorRef, StoryStatus, TerminalLaneState, ThrottleConfig, } from "./types.js";
6
+ export { compareLaneQueueOrder, hasActiveAdmissionReservation, hasActivePendingResume, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, laneRunsOnHost, withAllLaneAdmissionLocks, withLaneAdmissionLock, } from "./admission.js";
7
+ export type { AdmissionCapReason, AdmissionHost } from "./admission.js";
8
+ export { CONFIG_SCHEMA_VERSION, ENVELOPE_DECISIONS, ENVELOPE_SCHEMA_VERSION, HISTORY_CAP, HOST_KINDS, LANE_PROVIDERS, LANE_SCHEMA_VERSION, LANE_STORY_STATUSES, LANE_STATES, LIVENESS_STATES, QUESTION_BODY_STORES, QUESTION_SCHEMA_VERSION, QUESTION_STATUSES, QUEUED_REASONS, RAISED_VIA, ROUTING_REASONS, SENIOR_KINDS, STORY_STATUSES, TERMINAL_LANE_STATES, isEnvelopeDecision, isHostKind, isLaneProvider, isLaneState, isLivenessState, isQuestionStatus, isSeniorKind, isTerminalLaneState, parseLaneEnvelope, parseLaneProvider, parseLaneState, parseLivenessState, parseMajorSchema, parseQuestionStatus, parseSeniorKind, } from "./types.js";
9
+ export type { AccountConfig, AttributionHistoryEntry, EnvelopeDecision, EnvelopeQuestion, HostKind, LaneCompanyRef, LaneEnvelope, LaneHost, LaneProvider, LaneRecord, LaneStory, LaneStoryStatus, PendingResume, AccountHistoryEntry, LaneState, LanesConfig, LastEnvelopeRef, LivenessState, LivenessWindows, ProviderConfig, QuestionAnswer, QuestionBodyRef, QuestionRecord, QuestionStatus, QueuedReason, RaisedVia, RoutingHistoryEntry, RoutingReason, SeniorHistoryEntry, SeniorKind, SeniorRef, StoryStatus, TerminalLaneState, ThrottleConfig, } from "./types.js";
10
+ export { ACTIVE_LANE_STORY_STATUSES, activeLaneStories, laneStories, laneStory, storyKey, storyStatusForEnvelope, storyWarning, withCurrentStory, withLaneStories, } from "./stories.js";
8
11
  export { DEFAULT_ACCOUNT_HOMES, DEFAULT_AUTO_COMPACT_TOKENS, DEFAULT_COMPACT_AFTER_MS, DEFAULT_CPU_BUSY_PAUSE, DEFAULT_CPU_BUSY_RESUME, DEFAULT_LIVE_WITHIN_MS, DEFAULT_LIVENESS_WINDOWS, DEFAULT_MAX_QUEUED, DEFAULT_MIN_AVAILABLE_MEM_MB, DEFAULT_PROVIDER_MAX_RUNNING, DEFAULT_SILENT_LOST_MS, DEFAULT_TURN_START_LOST_MS, LANES_CONFIG_REL, accountMaxRunning, canonicalizeHome, documentedDefaultConfig, expandHome, lanesConfigPath, loadLanesConfig, parseLanesConfig, providerCompactAfterMs, providerAutoCompactTokens, providerQuotaDefaultHoldMs, DEFAULT_QUOTA_HOLD_MS, writeLanesConfig, } from "./config.js";
9
12
  export type { ConfigLoadDeps, ConfigWriteDeps } from "./config.js";
10
13
  export { resolveLanesHqRoot } from "./hq-root.js";
@@ -3,7 +3,9 @@
3
3
  */
4
4
  export { AccountResolutionError, LaneNotFoundError, LaneSchemaError, LaneVersionConflict, LanesConfigError, LanesError, WorkerResolutionError, isLaneVersionConflict, isLanesError, } from "./errors.js";
5
5
  export { MAX_LANE_DEPTH } from "./constants.js";
6
- export { CONFIG_SCHEMA_VERSION, ENVELOPE_DECISIONS, ENVELOPE_SCHEMA_VERSION, HISTORY_CAP, HOST_KINDS, LANE_PROVIDERS, LANE_SCHEMA_VERSION, LANE_STATES, LIVENESS_STATES, QUESTION_BODY_STORES, QUESTION_SCHEMA_VERSION, QUESTION_STATUSES, QUEUED_REASONS, RAISED_VIA, ROUTING_REASONS, SENIOR_KINDS, STORY_STATUSES, TERMINAL_LANE_STATES, isEnvelopeDecision, isHostKind, isLaneProvider, isLaneState, isLivenessState, isQuestionStatus, isSeniorKind, isTerminalLaneState, parseLaneEnvelope, parseLaneProvider, parseLaneState, parseLivenessState, parseMajorSchema, parseQuestionStatus, parseSeniorKind, } from "./types.js";
6
+ export { compareLaneQueueOrder, hasActiveAdmissionReservation, hasActivePendingResume, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, laneRunsOnHost, withAllLaneAdmissionLocks, withLaneAdmissionLock, } from "./admission.js";
7
+ export { CONFIG_SCHEMA_VERSION, ENVELOPE_DECISIONS, ENVELOPE_SCHEMA_VERSION, HISTORY_CAP, HOST_KINDS, LANE_PROVIDERS, LANE_SCHEMA_VERSION, LANE_STORY_STATUSES, LANE_STATES, LIVENESS_STATES, QUESTION_BODY_STORES, QUESTION_SCHEMA_VERSION, QUESTION_STATUSES, QUEUED_REASONS, RAISED_VIA, ROUTING_REASONS, SENIOR_KINDS, STORY_STATUSES, TERMINAL_LANE_STATES, isEnvelopeDecision, isHostKind, isLaneProvider, isLaneState, isLivenessState, isQuestionStatus, isSeniorKind, isTerminalLaneState, parseLaneEnvelope, parseLaneProvider, parseLaneState, parseLivenessState, parseMajorSchema, parseQuestionStatus, parseSeniorKind, } from "./types.js";
8
+ export { ACTIVE_LANE_STORY_STATUSES, activeLaneStories, laneStories, laneStory, storyKey, storyStatusForEnvelope, storyWarning, withCurrentStory, withLaneStories, } from "./stories.js";
7
9
  export { DEFAULT_ACCOUNT_HOMES, DEFAULT_AUTO_COMPACT_TOKENS, DEFAULT_COMPACT_AFTER_MS, DEFAULT_CPU_BUSY_PAUSE, DEFAULT_CPU_BUSY_RESUME, DEFAULT_LIVE_WITHIN_MS, DEFAULT_LIVENESS_WINDOWS, DEFAULT_MAX_QUEUED, DEFAULT_MIN_AVAILABLE_MEM_MB, DEFAULT_PROVIDER_MAX_RUNNING, DEFAULT_SILENT_LOST_MS, DEFAULT_TURN_START_LOST_MS, LANES_CONFIG_REL, accountMaxRunning, canonicalizeHome, documentedDefaultConfig, expandHome, lanesConfigPath, loadLanesConfig, parseLanesConfig, providerCompactAfterMs, providerAutoCompactTokens, providerQuotaDefaultHoldMs, DEFAULT_QUOTA_HOLD_MS, writeLanesConfig, } from "./config.js";
8
10
  export { resolveLanesHqRoot } from "./hq-root.js";
9
11
  export { buildOutpostPickupCommand, parseOutpostPickupPayload, } from "./outpost-dispatch.js";
@@ -78,6 +78,14 @@ export interface CreateLaneMeshOptions {
78
78
  interactive?: boolean;
79
79
  /** Test seam. Defaults to the shared `requireToken`. */
80
80
  token?: () => Promise<string>;
81
+ /** Repair a missing project view before retrying a story status patch. */
82
+ repairProjectView?: (input: {
83
+ token: string;
84
+ company: LaneCompanyRef;
85
+ projectId: string;
86
+ storyId: string;
87
+ status: string;
88
+ }) => Promise<void>;
81
89
  }
82
90
  export declare function createLaneMesh(opts?: CreateLaneMeshOptions): LaneMesh;
83
91
  /** Resolve the explicit mesh attribution before falling back to the lane tenant. */
@@ -42,7 +42,7 @@
42
42
  * of opening a browser nobody is watching, and the watcher records
43
43
  * `mesh_event_failed` and moves on. No credential file is read here.
44
44
  */
45
- import { appendThreadEvent, clamp, ensureProjectThread, patchStoryStatus, requireToken, resolveMeshCompany, } from "../mesh/api.js";
45
+ import { appendThreadEvent, clamp, ensureProjectThread, getProjectView, patchStoryStatus, putProjectView, requireToken, registerMeshProject, resolveMeshCompany, } from "../mesh/api.js";
46
46
  import { LanesError } from "./errors.js";
47
47
  export const LANE_MESH_EVENTS = [
48
48
  "lane_started",
@@ -163,9 +163,64 @@ export function toWireThreadEvent(event, payload) {
163
163
  }
164
164
  }
165
165
  }
166
+ function isProjectViewNotFound(error) {
167
+ if (!error || typeof error !== "object")
168
+ return false;
169
+ const candidate = error;
170
+ return (candidate.code === "PROJECT_VIEW_NOT_FOUND" ||
171
+ (typeof candidate.message === "string" && candidate.message.includes("PROJECT_VIEW_NOT_FOUND")));
172
+ }
173
+ function isAlreadyRegistered(error) {
174
+ if (!error || typeof error !== "object")
175
+ return false;
176
+ const candidate = error;
177
+ return (candidate.code === "PROJECT_ALREADY_REGISTERED" ||
178
+ (typeof candidate.message === "string" && candidate.message.includes("PROJECT_ALREADY_REGISTERED")));
179
+ }
180
+ async function repairProjectView(input) {
181
+ const company = { companyUid: input.company.uid, companySlug: input.company.slug };
182
+ await ensureProjectThread(input.token, company, input.projectId, {
183
+ summary: `HQ lane ${input.projectId}`,
184
+ });
185
+ let current;
186
+ try {
187
+ current = await getProjectView(input.token, input.company.uid, input.projectId);
188
+ }
189
+ catch (error) {
190
+ if (!isProjectViewNotFound(error))
191
+ throw error;
192
+ }
193
+ const stories = [...(current?.stories ?? [])];
194
+ const index = stories.findIndex((story) => story.id === input.storyId);
195
+ const nextStory = {
196
+ ...(index >= 0 ? stories[index] : {}),
197
+ id: input.storyId,
198
+ title: index >= 0 ? stories[index]?.title || input.storyId : input.storyId,
199
+ status: input.status,
200
+ };
201
+ if (index >= 0)
202
+ stories[index] = nextStory;
203
+ else
204
+ stories.push(nextStory);
205
+ await putProjectView(input.token, input.company.uid, input.projectId, {
206
+ name: current?.name || input.projectId,
207
+ description: current?.description || "HQ lane project",
208
+ stories,
209
+ repos: current?.repos ?? [],
210
+ ...(current?.version !== undefined ? { expectedVersion: current.version } : {}),
211
+ });
212
+ try {
213
+ await registerMeshProject(input.token, input.company.uid, input.projectId);
214
+ }
215
+ catch (error) {
216
+ if (!isAlreadyRegistered(error))
217
+ throw error;
218
+ }
219
+ }
166
220
  export function createLaneMesh(opts = {}) {
167
221
  const interactive = opts.interactive ?? true;
168
222
  const token = opts.token ?? (() => requireToken({ interactive }));
223
+ const repair = opts.repairProjectView ?? repairProjectView;
169
224
  return {
170
225
  async ensureProjectThread(company, projectId) {
171
226
  const tok = await token();
@@ -182,7 +237,21 @@ export function createLaneMesh(opts = {}) {
182
237
  },
183
238
  async patchStoryStatus(company, projectId, storyId, status) {
184
239
  const tok = await token();
185
- await patchStoryStatus(tok, company.uid, projectId, storyId, status);
240
+ try {
241
+ await patchStoryStatus(tok, company.uid, projectId, storyId, status);
242
+ }
243
+ catch (error) {
244
+ if (!isProjectViewNotFound(error))
245
+ throw error;
246
+ await repair({
247
+ token: tok,
248
+ company,
249
+ projectId,
250
+ storyId,
251
+ status,
252
+ });
253
+ await patchStoryStatus(tok, company.uid, projectId, storyId, status);
254
+ }
186
255
  },
187
256
  };
188
257
  }
@@ -232,10 +301,18 @@ export async function emitLaneDecisionEvents(mesh, lane, envelope, opts = {}) {
232
301
  return;
233
302
  }
234
303
  case "blocked":
235
- await mesh.appendThreadEvent(company, threadId, "lane_blocked", {
236
- ...baseIdentity(lane),
237
- blocked_reason: envelope.blocked_reason ?? "",
238
- });
304
+ {
305
+ const outcomes = await Promise.allSettled([
306
+ mesh.appendThreadEvent(company, threadId, "lane_blocked", {
307
+ ...baseIdentity(lane),
308
+ blocked_reason: envelope.blocked_reason ?? "",
309
+ }),
310
+ mesh.patchStoryStatus(company, lane.project_id, lane.story_id, "in_progress"),
311
+ ]);
312
+ const failure = outcomes.find((outcome) => outcome.status === "rejected");
313
+ if (failure?.status === "rejected")
314
+ throw failure.reason;
315
+ }
239
316
  return;
240
317
  case "ask":
241
318
  // A mesh question event has one `questionId`; sending one event per id
@@ -26,6 +26,16 @@ export interface OutpostDispatchResult {
26
26
  stdout: string;
27
27
  stderr: string;
28
28
  }
29
+ export interface OutpostLaneCommand {
30
+ action: "message" | "resume";
31
+ laneId: string;
32
+ text?: string;
33
+ project?: string;
34
+ story?: string;
35
+ account?: string;
36
+ model?: string;
37
+ compact?: boolean;
38
+ }
29
39
  export interface OutpostExecPort {
30
40
  stageExecInput: (outpostId?: string) => Promise<{
31
41
  putUrl: string;
@@ -47,6 +57,8 @@ export interface OutpostExecPort {
47
57
  export declare function parseOutpostPickupPayload(raw: unknown): OutpostPickupPayload;
48
58
  /** Same wrapping `hq outposts exec` applies: run from the box's HQ folder. */
49
59
  export declare function buildOutpostPickupCommand(payloadUrl: string): string;
60
+ /** Build the same `hq outposts exec` remote command used by lane pickup. */
61
+ export declare function buildOutpostLaneCommand(input: OutpostLaneCommand): string;
50
62
  export declare function stageOutpostPayload(port: Pick<OutpostExecPort, "stageExecInput">, outpostId: string, bytes: Uint8Array, put?: typeof fetch): Promise<{
51
63
  getUrl: string;
52
64
  }>;
@@ -42,6 +42,23 @@ export function parseOutpostPickupPayload(raw) {
42
42
  export function buildOutpostPickupCommand(payloadUrl) {
43
43
  return withRemoteHqDir(joinCommandParts(["hq", "lanes", "pickup", "--payload-url", payloadUrl]));
44
44
  }
45
+ /** Build the same `hq outposts exec` remote command used by lane pickup. */
46
+ export function buildOutpostLaneCommand(input) {
47
+ const args = ["hq", "lanes", input.action, input.laneId];
48
+ for (const [flag, value] of [
49
+ ["--text", input.text],
50
+ ["--project", input.project],
51
+ ["--story", input.story],
52
+ ["--account", input.account],
53
+ ["--model", input.model],
54
+ ]) {
55
+ if (value !== undefined)
56
+ args.push(flag, value);
57
+ }
58
+ if (input.compact === false)
59
+ args.push("--no-compact");
60
+ return withRemoteHqDir(joinCommandParts(args));
61
+ }
45
62
  export async function stageOutpostPayload(port, outpostId, bytes, put = fetch) {
46
63
  const staged = await port.stageExecInput(outpostId);
47
64
  const upload = await put(staged.putUrl, {