@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/commands/mesh.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
605
|
-
|
|
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({
|
|
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
|
|
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"
|
|
284
|
-
? "use hq lanes
|
|
285
|
-
:
|
|
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 {
|
|
7
|
-
export type {
|
|
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";
|
package/dist/lib/lanes/index.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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, {
|