@indigoai-us/hq-cli 5.126.0 → 5.128.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 +218 -0
- package/assets/lanes-hooks/hq-lanes-drain.json +15 -0
- package/assets/lanes-hooks/hq-lanes-drain.mjs +254 -0
- package/assets/lanes-hooks/hq-lanes-drain.sh +21 -0
- package/assets/lanes-reconcile/hq-lanes-reconcile.service +11 -0
- package/assets/lanes-reconcile/hq-lanes-reconcile.timer +12 -0
- package/dist/command-catalog.generated.d.ts +245 -11
- package/dist/command-catalog.generated.js +323 -11
- package/dist/command-registration-plan.d.ts +1 -0
- package/dist/command-registration-plan.js +11 -2
- package/dist/commands/agents.d.ts +12 -0
- package/dist/commands/agents.js +46 -2
- package/dist/commands/lanes.d.ts +55 -16
- package/dist/commands/lanes.js +1187 -250
- package/dist/commands/mesh.js +254 -19
- package/dist/lib/core-utils/sentry-report.d.ts +2 -0
- package/dist/lib/core-utils/sentry-report.js +47 -7
- package/dist/lib/lanes/accounts.d.ts +67 -7
- package/dist/lib/lanes/accounts.js +233 -43
- package/dist/lib/lanes/compaction.d.ts +96 -0
- package/dist/lib/lanes/compaction.js +197 -0
- package/dist/lib/lanes/config.d.ts +15 -0
- package/dist/lib/lanes/config.js +65 -8
- package/dist/lib/lanes/delivery.d.ts +151 -0
- package/dist/lib/lanes/delivery.js +354 -0
- package/dist/lib/lanes/dropbox.d.ts +96 -0
- package/dist/lib/lanes/dropbox.js +408 -0
- package/dist/lib/lanes/effort.d.ts +50 -0
- package/dist/lib/lanes/effort.js +109 -0
- package/dist/lib/lanes/errors.d.ts +2 -2
- package/dist/lib/lanes/hooks-install.d.ts +81 -0
- package/dist/lib/lanes/hooks-install.js +326 -0
- package/dist/lib/lanes/hq-root.d.ts +19 -0
- package/dist/lib/lanes/hq-root.js +28 -0
- package/dist/lib/lanes/index.d.ts +35 -5
- package/dist/lib/lanes/index.js +19 -4
- package/dist/lib/lanes/liveness.d.ts +7 -1
- package/dist/lib/lanes/liveness.js +64 -5
- package/dist/lib/lanes/machine-identity.d.ts +34 -0
- package/dist/lib/lanes/machine-identity.js +92 -0
- package/dist/lib/lanes/mesh-events.d.ts +109 -0
- package/dist/lib/lanes/mesh-events.js +275 -0
- package/dist/lib/lanes/outpost-dispatch.d.ts +65 -0
- package/dist/lib/lanes/outpost-dispatch.js +90 -0
- package/dist/lib/lanes/questions.d.ts +75 -0
- package/dist/lib/lanes/questions.js +243 -0
- package/dist/lib/lanes/quota.d.ts +95 -0
- package/dist/lib/lanes/quota.js +320 -0
- package/dist/lib/lanes/reconcile-install.d.ts +69 -0
- package/dist/lib/lanes/reconcile-install.js +221 -0
- package/dist/lib/lanes/reconcile.d.ts +102 -0
- package/dist/lib/lanes/reconcile.js +985 -0
- package/dist/lib/lanes/registry.d.ts +18 -5
- package/dist/lib/lanes/registry.js +293 -19
- package/dist/lib/lanes/spawn.d.ts +274 -0
- package/dist/lib/lanes/spawn.js +1363 -0
- package/dist/lib/lanes/store.d.ts +7 -3
- package/dist/lib/lanes/store.js +150 -32
- package/dist/lib/lanes/types.d.ts +346 -4
- package/dist/lib/lanes/types.js +138 -5
- package/dist/lib/lanes/watcher-main.d.ts +9 -0
- package/dist/lib/lanes/watcher-main.js +22 -0
- package/dist/lib/lanes/watcher.d.ts +216 -0
- package/dist/lib/lanes/watcher.js +859 -0
- package/dist/lib/mesh/api.d.ts +71 -7
- package/dist/lib/mesh/api.js +167 -28
- package/dist/lib/mesh/live/backfill-held.d.ts +18 -7
- package/dist/lib/mesh/live/backfill-held.js +66 -21
- package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/run.js +111 -54
- package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +3 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.js +31 -8
- package/dist/lib/scan-packages/index.d.ts +2 -0
- package/dist/lib/scan-packages/index.js +1 -1
- package/dist/lib/work-context/state.d.ts +12 -0
- package/dist/lib/work-context/state.js +131 -3
- package/package.json +3 -1
package/dist/commands/lanes.js
CHANGED
|
@@ -4,21 +4,29 @@
|
|
|
4
4
|
* Every command prints JSON on stdout and takes no interactive input, except
|
|
5
5
|
* `accounts login`, which hands the terminal to the vendor's own sign-in.
|
|
6
6
|
*/
|
|
7
|
-
import { spawn } from "node:child_process";
|
|
8
|
-
import {
|
|
7
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
10
|
import * as os from "node:os";
|
|
11
11
|
import * as path from "node:path";
|
|
12
|
-
import { OutpostsClient } from "@indigoai-us/hq-cloud/outposts";
|
|
13
12
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
14
|
-
import {
|
|
13
|
+
import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
|
|
14
|
+
import { buildOutpostPickupCommand, createOutpostsClient, dispatchErrorMessage, dispatchOutpostPickup, parseOutpostPickupPayload, stageOutpostPayload, } from "../lib/lanes/outpost-dispatch.js";
|
|
15
15
|
import { parseUserYaml } from "../utils/user-yaml-error.js";
|
|
16
16
|
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
17
17
|
import { openMeshTransport } from "../lib/mesh/client.js";
|
|
18
|
-
import {
|
|
18
|
+
import { createLaneMesh, emitLaneDecisionEvents, } from "../lib/lanes/mesh-events.js";
|
|
19
19
|
import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
|
|
20
|
-
import { AccountResolutionError, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV,
|
|
20
|
+
import { AccountResolutionError, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountMaxRunning, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneState, parseLivenessState, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, readQuestion, resolveAccount, resolveLaneWorker, isLoggedOut, runReconcilePass, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
|
|
21
|
+
import { questionBodyAbsPath } from "../lib/lanes/questions.js";
|
|
22
|
+
import { compactionAfterUsage, } from "../lib/lanes/compaction.js";
|
|
23
|
+
import { providerCompactAfterMs } from "../lib/lanes/config.js";
|
|
24
|
+
import { parseCliEffort, resolveLaneEffort } from "../lib/lanes/effort.js";
|
|
21
25
|
import { LanesError } from "../lib/lanes/errors.js";
|
|
26
|
+
import { accountQuotaView, loadQuotaState } from "../lib/lanes/quota.js";
|
|
27
|
+
import { parseCliModel, refuseOutpostSelfTarget, resolveLaneModel, FOREGROUND_ONLY_RULE, STANDING_AUTHORIZATION, spawnLaneWorker, stopLaneProcesses, } from "../lib/lanes/spawn.js";
|
|
28
|
+
export { spawnLaneWorker };
|
|
29
|
+
export { questionBodyAbsPath };
|
|
22
30
|
export const MAX_LANE_DEPTH = 2;
|
|
23
31
|
export const OUTPOST_ID_PATH = "/etc/outpost/outpost-id";
|
|
24
32
|
export const OUTPOST_DIR = "/etc/outpost";
|
|
@@ -28,14 +36,7 @@ const VENDOR_LOGIN = {
|
|
|
28
36
|
codex: { command: "codex", args: ["login"] },
|
|
29
37
|
grok: { command: "grok", args: ["login"] },
|
|
30
38
|
};
|
|
31
|
-
|
|
32
|
-
* Spawn seam for the worker process (spec §5). Process spawning and the
|
|
33
|
-
* reconcile daemon are out of scope for this command surface; calling this
|
|
34
|
-
* throws rather than faking a running lane.
|
|
35
|
-
*/
|
|
36
|
-
export function spawnLaneWorker(lane) {
|
|
37
|
-
throw new LanesError("not_implemented", `Lane process spawn is not implemented (lane ${lane.lane_id})`, { lane_id: lane.lane_id });
|
|
38
|
-
}
|
|
39
|
+
export { emitLaneDecisionEvents };
|
|
39
40
|
function printJson(value) {
|
|
40
41
|
console.log(JSON.stringify(value, null, 2));
|
|
41
42
|
}
|
|
@@ -66,14 +67,36 @@ async function defaultResolveCompany(company) {
|
|
|
66
67
|
const { company: mesh } = await openMeshTransport({ company });
|
|
67
68
|
return { slug: mesh.companySlug, uid: mesh.companyUid };
|
|
68
69
|
}
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
function defaultOutpostsClient() {
|
|
71
|
+
return createOutpostsClient({
|
|
71
72
|
transport: vaultApiFetch,
|
|
72
73
|
token: () => ensureCognitoToken({ interactive: false }),
|
|
73
74
|
});
|
|
74
|
-
|
|
75
|
+
}
|
|
76
|
+
async function defaultListOutpostIds() {
|
|
77
|
+
const rows = await defaultOutpostsClient().list();
|
|
75
78
|
return rows.map((row) => row.outpostId);
|
|
76
79
|
}
|
|
80
|
+
async function defaultStageOutpostPayload(outpostId, bytes) {
|
|
81
|
+
return stageOutpostPayload(defaultOutpostsClient(), outpostId, bytes);
|
|
82
|
+
}
|
|
83
|
+
async function defaultDispatchOutpostExec(input) {
|
|
84
|
+
try {
|
|
85
|
+
return await dispatchOutpostPickup(defaultOutpostsClient(), input);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
if (err instanceof LanesError)
|
|
89
|
+
throw err;
|
|
90
|
+
throw new LanesError("outpost_dispatch_failed", `Outpost exec failed: ${dispatchErrorMessage(err)}`, { outpost_id: input.outpostId, message: dispatchErrorMessage(err) });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function defaultFetchText(url) {
|
|
94
|
+
const res = await fetch(url);
|
|
95
|
+
if (!res.ok) {
|
|
96
|
+
throw new LanesError("outpost_payload_invalid", `Failed to download pickup payload: HTTP ${res.status}`, { status: res.status });
|
|
97
|
+
}
|
|
98
|
+
return res.text();
|
|
99
|
+
}
|
|
77
100
|
export function readOutpostIdentity(existsSync = (p) => fs.existsSync(p), readFileSync = (p) => fs.readFileSync(p, "utf8")) {
|
|
78
101
|
const hasMarker = existsSync(OUTPOST_ID_PATH);
|
|
79
102
|
const hasDir = existsSync(OUTPOST_DIR);
|
|
@@ -110,35 +133,42 @@ function defaultSpawnLogin(opts) {
|
|
|
110
133
|
});
|
|
111
134
|
});
|
|
112
135
|
}
|
|
113
|
-
function toMeshCompany(company) {
|
|
114
|
-
return { companyUid: company.uid, companySlug: company.slug };
|
|
115
|
-
}
|
|
116
136
|
function createDefaultMesh() {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const ensured = await ensureProjectThread(token, toMeshCompany(company), projectId, { summary: `HQ lane ${projectId}` });
|
|
121
|
-
return ensured.threadId;
|
|
122
|
-
},
|
|
123
|
-
async appendThreadEvent(company, threadId, eventKind, payload) {
|
|
124
|
-
const { token } = await openMeshTransport({ company: company.slug });
|
|
125
|
-
await appendThreadEvent(token, company.uid, threadId, eventKind, payload);
|
|
126
|
-
},
|
|
127
|
-
async patchStoryStatus(company, projectId, storyId, status) {
|
|
128
|
-
const { token } = await openMeshTransport({ company: company.slug });
|
|
129
|
-
await patchStoryStatus(token, company.uid, projectId, storyId, status);
|
|
130
|
-
},
|
|
131
|
-
};
|
|
137
|
+
// Interactive: the command runs in the operator's shell, where a browser
|
|
138
|
+
// login is acceptable. The watcher builds its own non-interactive mesh.
|
|
139
|
+
return createLaneMesh({ interactive: true });
|
|
132
140
|
}
|
|
133
141
|
function defaultSleep(ms) {
|
|
134
142
|
return new Promise((resolve) => {
|
|
135
143
|
setTimeout(resolve, ms);
|
|
136
144
|
});
|
|
137
145
|
}
|
|
146
|
+
function resolveCallerPath(callerCwd, filePath) {
|
|
147
|
+
return path.resolve(callerCwd, filePath);
|
|
148
|
+
}
|
|
138
149
|
function resolveDeps(deps = {}) {
|
|
139
150
|
const env = deps.env ?? process.env;
|
|
151
|
+
const cwd = deps.cwd ?? (() => process.cwd());
|
|
152
|
+
let capturedCallerCwd;
|
|
153
|
+
const callerCwd = () => {
|
|
154
|
+
if (capturedCallerCwd === undefined)
|
|
155
|
+
capturedCallerCwd = cwd();
|
|
156
|
+
return capturedCallerCwd;
|
|
157
|
+
};
|
|
158
|
+
const bindProcessCwd = deps.hqRoot === undefined;
|
|
159
|
+
const resolveRoot = deps.hqRoot ?? (() => resolveLanesHqRoot({ env, cwd: cwd() }));
|
|
160
|
+
const hqRoot = () => {
|
|
161
|
+
callerCwd();
|
|
162
|
+
const root = resolveRoot();
|
|
163
|
+
if (bindProcessCwd && process.cwd() !== root) {
|
|
164
|
+
process.chdir(root);
|
|
165
|
+
}
|
|
166
|
+
return root;
|
|
167
|
+
};
|
|
140
168
|
return {
|
|
141
|
-
|
|
169
|
+
cwd,
|
|
170
|
+
callerCwd,
|
|
171
|
+
hqRoot,
|
|
142
172
|
env,
|
|
143
173
|
now: deps.now ?? (() => new Date()),
|
|
144
174
|
hostname: deps.hostname ?? (() => os.hostname()),
|
|
@@ -152,6 +182,48 @@ function resolveDeps(deps = {}) {
|
|
|
152
182
|
homedir: deps.homedir ?? (() => os.homedir()),
|
|
153
183
|
mesh: deps.mesh ?? createDefaultMesh(),
|
|
154
184
|
sleep: deps.sleep ?? defaultSleep,
|
|
185
|
+
spawnProcess: deps.spawnProcess,
|
|
186
|
+
proveDetached: deps.proveDetached,
|
|
187
|
+
killProcess: deps.killProcess,
|
|
188
|
+
isAlive: deps.isAlive,
|
|
189
|
+
stopSleepMs: deps.stopSleepMs,
|
|
190
|
+
livenessDeps: deps.livenessDeps,
|
|
191
|
+
presenceConnected: deps.presenceConnected,
|
|
192
|
+
systemdRoot: deps.systemdRoot,
|
|
193
|
+
runSystemctl: deps.runSystemctl ??
|
|
194
|
+
((command, args) => {
|
|
195
|
+
const result = spawnSync(command, args, { encoding: "utf8" });
|
|
196
|
+
return {
|
|
197
|
+
status: result.status ?? 1,
|
|
198
|
+
stdout: result.stdout ?? "",
|
|
199
|
+
stderr: result.stderr ??
|
|
200
|
+
(result.error instanceof Error ? result.error.message : ""),
|
|
201
|
+
};
|
|
202
|
+
}),
|
|
203
|
+
resolveMachineIdentity: deps.resolveMachineIdentity ?? lookupMachineIdentity,
|
|
204
|
+
runCompaction: deps.runCompaction,
|
|
205
|
+
locateTranscript: deps.locateTranscript,
|
|
206
|
+
stageOutpostPayload: deps.stageOutpostPayload ?? defaultStageOutpostPayload,
|
|
207
|
+
dispatchOutpostExec: deps.dispatchOutpostExec ?? defaultDispatchOutpostExec,
|
|
208
|
+
fetchText: deps.fetchText ?? defaultFetchText,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function livenessDepsFromCtx(ctx) {
|
|
212
|
+
const base = ctx.livenessDeps ?? defaultLivenessDeps();
|
|
213
|
+
return {
|
|
214
|
+
...base,
|
|
215
|
+
now: () => ctx.now().getTime(),
|
|
216
|
+
homedir: ctx.homedir,
|
|
217
|
+
env: ctx.env,
|
|
218
|
+
existsSync: ctx.existsSync,
|
|
219
|
+
readFileSync: ctx.readFileSync,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
function reconcileHostFromCtx(ctx) {
|
|
223
|
+
const identity = ctx.readOutpostIdentity();
|
|
224
|
+
return {
|
|
225
|
+
hostname: ctx.hostname(),
|
|
226
|
+
outpostId: identity.kind === "id" ? identity.id : null,
|
|
155
227
|
};
|
|
156
228
|
}
|
|
157
229
|
export function parseSeniorRef(raw) {
|
|
@@ -220,9 +292,6 @@ function parseOptionalInt(raw, flag) {
|
|
|
220
292
|
function runDir(hqRoot, laneId) {
|
|
221
293
|
return path.join(hqRoot, LANES_RUNS_REL, laneId);
|
|
222
294
|
}
|
|
223
|
-
function sha256Text(text) {
|
|
224
|
-
return `sha256:${createHash("sha256").update(text).digest("hex")}`;
|
|
225
|
-
}
|
|
226
295
|
function ensureDir(dir) {
|
|
227
296
|
fs.mkdirSync(dir, { recursive: true });
|
|
228
297
|
}
|
|
@@ -241,30 +310,6 @@ function readJsonFile(filePath) {
|
|
|
241
310
|
throw err;
|
|
242
311
|
}
|
|
243
312
|
}
|
|
244
|
-
function parseQuestionBodyStore(value) {
|
|
245
|
-
if (typeof value === "string" &&
|
|
246
|
-
QUESTION_BODY_STORES.includes(value)) {
|
|
247
|
-
return value;
|
|
248
|
-
}
|
|
249
|
-
throw new LanesError("unknown_body_store", `Unknown question body store ${JSON.stringify(value)}. Accepted: ${QUESTION_BODY_STORES.join(", ")}`, { accepted: [...QUESTION_BODY_STORES], value });
|
|
250
|
-
}
|
|
251
|
-
function bodyStoreForLane(lane) {
|
|
252
|
-
return lane.company.slug ? "company" : "personal";
|
|
253
|
-
}
|
|
254
|
-
/** Tenant-store path for a question body (spec §6.2 / §10). Never the personal vault for company prose. */
|
|
255
|
-
export function questionBodyAbsPath(hqRoot, question) {
|
|
256
|
-
const store = parseQuestionBodyStore(question.body.store);
|
|
257
|
-
if (store === "company") {
|
|
258
|
-
if (!question.company.slug) {
|
|
259
|
-
throw new LanesError("question_body_store_unresolved", "Company question body is missing a company slug", { question_id: question.question_id });
|
|
260
|
-
}
|
|
261
|
-
return path.join(hqRoot, "companies", question.company.slug, "lanes", "questions", `${question.question_id}.json`);
|
|
262
|
-
}
|
|
263
|
-
return path.join(hqRoot, "personal", "lanes", "questions", `${question.question_id}.json`);
|
|
264
|
-
}
|
|
265
|
-
function questionBodyRef(questionId) {
|
|
266
|
-
return path.posix.join("lanes", "questions", `${questionId}.json`);
|
|
267
|
-
}
|
|
268
313
|
function parseQuestionBody(raw) {
|
|
269
314
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
270
315
|
return null;
|
|
@@ -333,18 +378,6 @@ function requireQuestionBody(hqRoot, question) {
|
|
|
333
378
|
}
|
|
334
379
|
return body;
|
|
335
380
|
}
|
|
336
|
-
function writeQuestionBody(hqRoot, question, body) {
|
|
337
|
-
const store = parseQuestionBodyStore(question.body.store);
|
|
338
|
-
const filePath = questionBodyAbsPath(hqRoot, question);
|
|
339
|
-
const serialized = `${JSON.stringify(body, null, 2)}\n`;
|
|
340
|
-
ensureDir(path.dirname(filePath));
|
|
341
|
-
fs.writeFileSync(filePath, serialized, "utf8");
|
|
342
|
-
return {
|
|
343
|
-
store,
|
|
344
|
-
ref: questionBodyRef(question.question_id),
|
|
345
|
-
digest: sha256Text(serialized),
|
|
346
|
-
};
|
|
347
|
-
}
|
|
348
381
|
function loadAllLanes(hqRoot) {
|
|
349
382
|
return listLaneIds(hqRoot).map((id) => readLane(hqRoot, id));
|
|
350
383
|
}
|
|
@@ -354,15 +387,36 @@ function loadAllQuestions(hqRoot) {
|
|
|
354
387
|
function sessionMetaPath(hqRoot, sessionId) {
|
|
355
388
|
return path.join(hqRoot, "workspace", "sessions", sessionId, "meta.yaml");
|
|
356
389
|
}
|
|
357
|
-
function
|
|
390
|
+
function loadSessionMeta(hqRoot, sessionId, ctx) {
|
|
358
391
|
const filePath = sessionMetaPath(hqRoot, sessionId);
|
|
359
392
|
if (!ctx.existsSync(filePath))
|
|
393
|
+
return { status: "missing" };
|
|
394
|
+
let text;
|
|
395
|
+
try {
|
|
396
|
+
text = ctx.readFileSync(filePath, "utf8");
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
process.stderr.write(`hq lanes: failed to read session meta ${filePath}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
400
|
+
return { status: "unreadable" };
|
|
401
|
+
}
|
|
402
|
+
let doc;
|
|
403
|
+
try {
|
|
404
|
+
doc = parseUserYaml(text, filePath);
|
|
405
|
+
}
|
|
406
|
+
catch (err) {
|
|
407
|
+
process.stderr.write(`hq lanes: failed to parse session meta ${filePath}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
408
|
+
return { status: "malformed" };
|
|
409
|
+
}
|
|
410
|
+
if (doc === null || typeof doc !== "object" || Array.isArray(doc)) {
|
|
411
|
+
return { status: "malformed" };
|
|
412
|
+
}
|
|
413
|
+
return { status: "ok", doc: doc };
|
|
414
|
+
}
|
|
415
|
+
function readSessionMeta(hqRoot, sessionId, ctx) {
|
|
416
|
+
const loaded = loadSessionMeta(hqRoot, sessionId, ctx);
|
|
417
|
+
if (loaded.status !== "ok")
|
|
360
418
|
return null;
|
|
361
|
-
|
|
362
|
-
const doc = parseUserYaml(text, filePath);
|
|
363
|
-
if (doc === null || typeof doc !== "object" || Array.isArray(doc))
|
|
364
|
-
return null;
|
|
365
|
-
return doc;
|
|
419
|
+
return loaded.doc;
|
|
366
420
|
}
|
|
367
421
|
function seniorOfSession(hqRoot, sessionId, ctx) {
|
|
368
422
|
const meta = readSessionMeta(hqRoot, sessionId, ctx);
|
|
@@ -468,28 +522,63 @@ function defaultSenior(ctx) {
|
|
|
468
522
|
}
|
|
469
523
|
function companyOfSenior(hqRoot, ref, ctx) {
|
|
470
524
|
if (ref.kind === "user")
|
|
471
|
-
return
|
|
525
|
+
return { binding: "unknown" };
|
|
472
526
|
if (ref.kind === "lane") {
|
|
473
527
|
if (!ref.id)
|
|
474
|
-
return
|
|
528
|
+
return { binding: "unknown" };
|
|
475
529
|
const lane = readLane(hqRoot, ref.id);
|
|
476
|
-
|
|
530
|
+
if (!lane.company)
|
|
531
|
+
return { binding: "none" };
|
|
532
|
+
return {
|
|
533
|
+
binding: "company",
|
|
534
|
+
slug: lane.company.slug,
|
|
535
|
+
uid: lane.company.uid,
|
|
536
|
+
};
|
|
477
537
|
}
|
|
478
538
|
if (!ref.id)
|
|
479
|
-
return
|
|
480
|
-
const
|
|
481
|
-
if (
|
|
482
|
-
return
|
|
483
|
-
|
|
484
|
-
|
|
539
|
+
return { binding: "unknown" };
|
|
540
|
+
const loaded = loadSessionMeta(hqRoot, ref.id, ctx);
|
|
541
|
+
if (loaded.status !== "ok")
|
|
542
|
+
return { binding: "unknown" };
|
|
543
|
+
return companyBindingFromSessionMeta(loaded.doc);
|
|
544
|
+
}
|
|
545
|
+
function companyBindingFromSessionMeta(meta) {
|
|
546
|
+
const slugRaw = meta.company_slug;
|
|
547
|
+
const uidRaw = meta.company_uid;
|
|
548
|
+
if (slugRaw !== undefined && slugRaw !== null && typeof slugRaw !== "string") {
|
|
549
|
+
return { binding: "unknown" };
|
|
550
|
+
}
|
|
551
|
+
if (uidRaw !== undefined && uidRaw !== null && typeof uidRaw !== "string") {
|
|
552
|
+
return { binding: "unknown" };
|
|
553
|
+
}
|
|
554
|
+
const slug = typeof slugRaw === "string" ? slugRaw : undefined;
|
|
555
|
+
const uid = typeof uidRaw === "string" ? uidRaw : undefined;
|
|
485
556
|
if (!slug && !uid)
|
|
557
|
+
return { binding: "none" };
|
|
558
|
+
return { binding: "company", slug, uid };
|
|
559
|
+
}
|
|
560
|
+
function serializeSeniorCompany(seniorCompany) {
|
|
561
|
+
if (seniorCompany.binding === "unknown")
|
|
486
562
|
return null;
|
|
487
|
-
|
|
563
|
+
if (seniorCompany.binding === "none")
|
|
564
|
+
return { kind: "none" };
|
|
565
|
+
const out = {};
|
|
566
|
+
if (seniorCompany.slug)
|
|
567
|
+
out.slug = seniorCompany.slug;
|
|
568
|
+
if (seniorCompany.uid)
|
|
569
|
+
out.uid = seniorCompany.uid;
|
|
570
|
+
return out;
|
|
488
571
|
}
|
|
489
572
|
function sameTenant(laneCompany, senior, seniorCompany) {
|
|
490
573
|
if (senior.kind === "user")
|
|
491
574
|
return true;
|
|
492
|
-
|
|
575
|
+
// Unknown company stays a refusal for every lane, including no-company.
|
|
576
|
+
if (seniorCompany.binding === "unknown")
|
|
577
|
+
return false;
|
|
578
|
+
if (!laneCompany) {
|
|
579
|
+
return seniorCompany.binding === "none";
|
|
580
|
+
}
|
|
581
|
+
if (seniorCompany.binding !== "company")
|
|
493
582
|
return false;
|
|
494
583
|
if (seniorCompany.uid && seniorCompany.uid === laneCompany.uid)
|
|
495
584
|
return true;
|
|
@@ -500,15 +589,20 @@ function sameTenant(laneCompany, senior, seniorCompany) {
|
|
|
500
589
|
function assertSameTenant(laneCompany, senior, seniorCompany, extra = {}) {
|
|
501
590
|
if (sameTenant(laneCompany, senior, seniorCompany))
|
|
502
591
|
return;
|
|
503
|
-
throw new LanesError("tenancy_mismatch", seniorCompany
|
|
504
|
-
? "Cannot attach a lane to a senior
|
|
505
|
-
: "Cannot attach a lane to a senior
|
|
592
|
+
throw new LanesError("tenancy_mismatch", seniorCompany.binding === "unknown"
|
|
593
|
+
? "Cannot attach a lane to a senior whose company is unknown"
|
|
594
|
+
: "Cannot attach a lane to a senior in a different company", {
|
|
506
595
|
lane_company: laneCompany,
|
|
507
596
|
senior,
|
|
508
|
-
senior_company: seniorCompany,
|
|
597
|
+
senior_company: serializeSeniorCompany(seniorCompany),
|
|
509
598
|
...extra,
|
|
510
599
|
});
|
|
511
600
|
}
|
|
601
|
+
function recordMatchesCompany(recordCompany, filter) {
|
|
602
|
+
if (!recordCompany)
|
|
603
|
+
return false;
|
|
604
|
+
return recordCompany.uid === filter.uid || recordCompany.slug === filter.slug;
|
|
605
|
+
}
|
|
512
606
|
function walkHitsLane(hqRoot, start, laneId, ctx) {
|
|
513
607
|
const seen = new Set();
|
|
514
608
|
let current = start;
|
|
@@ -599,7 +693,7 @@ function compareQueueOrder(a, b) {
|
|
|
599
693
|
function admitCreate(input) {
|
|
600
694
|
const { hqRoot, provider, pinnedAccount, priority, createdAt, config } = input;
|
|
601
695
|
const providerCfg = config.providers[provider];
|
|
602
|
-
const lanes = loadAllLanes(hqRoot);
|
|
696
|
+
const lanes = loadAllLanes(hqRoot).filter((lane) => lane.lane_id !== input.excludeLaneId);
|
|
603
697
|
const runningProvider = lanes.filter((lane) => lane.provider === provider && lane.state === "running");
|
|
604
698
|
const queuedSame = lanes.filter((lane) => {
|
|
605
699
|
if (lane.provider !== provider || lane.state !== "queued")
|
|
@@ -686,16 +780,10 @@ async function applyThrottle(hqRoot, config, ctx, current, provider, pinnedAccou
|
|
|
686
780
|
}
|
|
687
781
|
return { action: "queue", reason: "throttle" };
|
|
688
782
|
}
|
|
689
|
-
function assertKnownOutpost(requested, known
|
|
783
|
+
function assertKnownOutpost(requested, known) {
|
|
690
784
|
if (!known.includes(requested)) {
|
|
691
785
|
throw new LanesError("unknown_outpost", `Unknown Outpost ${JSON.stringify(requested)}. Accepted: ${known.join(", ") || "(none)"}`, { accepted: known, value: requested });
|
|
692
786
|
}
|
|
693
|
-
if (identity.kind === "unknown") {
|
|
694
|
-
throw new LanesError("outpost_identity_unknown", "This host is an Outpost but its id is unknown; refusing all --outpost targets", { outpost: requested });
|
|
695
|
-
}
|
|
696
|
-
if (identity.kind === "id" && identity.id === requested) {
|
|
697
|
-
throw new LanesError("outpost_self_target", `A host may not target its own Outpost id ${JSON.stringify(requested)}`, { outpost: requested });
|
|
698
|
-
}
|
|
699
787
|
}
|
|
700
788
|
function copyHookRails(fromHome, toHome) {
|
|
701
789
|
const src = path.join(fromHome, "hooks");
|
|
@@ -709,7 +797,14 @@ function railsPresent(home) {
|
|
|
709
797
|
}
|
|
710
798
|
const ADMISSION_LOCK_STALE_MS = 30_000;
|
|
711
799
|
const ADMISSION_LOCK_TIMEOUT_MS = 5_000;
|
|
712
|
-
|
|
800
|
+
/**
|
|
801
|
+
* Spec §5.1: `hq lanes ask` from inside a lane can hit a binary or dist
|
|
802
|
+
* being replaced mid-call (ENOENT / ETXTBSY / a module that no longer
|
|
803
|
+
* resolves) or a record written by a newer CLI. Three attempts, waits of
|
|
804
|
+
* 5 s then 15 s, 20 s of backoff in total, inside the 30 s bound.
|
|
805
|
+
*/
|
|
806
|
+
export const ASK_BACKOFF_MS = [5_000, 15_000];
|
|
807
|
+
export const ASK_ATTEMPTS = ASK_BACKOFF_MS.length + 1;
|
|
713
808
|
function yieldEventLoop() {
|
|
714
809
|
return new Promise((resolve) => {
|
|
715
810
|
setImmediate(resolve);
|
|
@@ -815,7 +910,7 @@ export function casUpdateQuestion(hqRoot, questionId, updater) {
|
|
|
815
910
|
export function casUpdateLane(hqRoot, laneId, updater) {
|
|
816
911
|
return withCasRetry(() => {
|
|
817
912
|
const current = readLane(hqRoot, laneId);
|
|
818
|
-
return updateLane(hqRoot, laneId, current.version, updater);
|
|
913
|
+
return updateLane(hqRoot, laneId, current.version, "command", updater);
|
|
819
914
|
});
|
|
820
915
|
}
|
|
821
916
|
function throwIfQuestionNotPending(current) {
|
|
@@ -836,6 +931,29 @@ function formatUnknownValue(value) {
|
|
|
836
931
|
}
|
|
837
932
|
return "";
|
|
838
933
|
}
|
|
934
|
+
/**
|
|
935
|
+
* Spec §5.3: an approval gate ends the run. The lane stops at each
|
|
936
|
+
* checkpoint with a `decision: ask` envelope whose question names it; the
|
|
937
|
+
* reconcile pass resumes the lane once every question is answered.
|
|
938
|
+
*/
|
|
939
|
+
export function checkpointInstruction(worker) {
|
|
940
|
+
const names = [
|
|
941
|
+
...(worker.approvalRequired ? ["approval_required"] : []),
|
|
942
|
+
...worker.humanCheckpoints,
|
|
943
|
+
];
|
|
944
|
+
if (names.length === 0)
|
|
945
|
+
return "";
|
|
946
|
+
return [
|
|
947
|
+
"Each checkpoint above is an approval gate that ends this run. When you reach a checkpoint, " +
|
|
948
|
+
"stop and write the envelope with decision \"ask\" and story_status \"in_progress\". " +
|
|
949
|
+
"Put exactly one entry in questions for the checkpoint; its question text must start with " +
|
|
950
|
+
"the checkpoint name, followed by what you need approved, and its options must include \"approve\". " +
|
|
951
|
+
"Do not continue past a checkpoint in the same run. When this run is resumed, the answer to " +
|
|
952
|
+
"that question is an operator decision that outranks the brief; continue from the checkpoint.",
|
|
953
|
+
"Checkpoint names, in order:",
|
|
954
|
+
...names.map((name) => `- ${name}`),
|
|
955
|
+
].join("\n");
|
|
956
|
+
}
|
|
839
957
|
export function composeWorkerBrief(worker, taskBrief) {
|
|
840
958
|
const yaml = worker.workerYaml;
|
|
841
959
|
const name = yaml.worker?.name ?? worker.id;
|
|
@@ -864,10 +982,31 @@ export function composeWorkerBrief(worker, taskBrief) {
|
|
|
864
982
|
"",
|
|
865
983
|
"## Verification checkpoints",
|
|
866
984
|
checkpoints || "(none)",
|
|
985
|
+
...(checkpoints ? ["", checkpointInstruction(worker)] : []),
|
|
867
986
|
"",
|
|
868
987
|
"## Task",
|
|
869
988
|
taskBrief.replace(/\s+$/, ""),
|
|
870
989
|
"",
|
|
990
|
+
"## Standing authorization",
|
|
991
|
+
STANDING_AUTHORIZATION,
|
|
992
|
+
"",
|
|
993
|
+
FOREGROUND_ONLY_RULE,
|
|
994
|
+
"",
|
|
995
|
+
].join("\n");
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* A lane with no worker still carries the standing authorization paragraph
|
|
999
|
+
* and the foreground-only rule.
|
|
1000
|
+
*/
|
|
1001
|
+
export function composeGeneralBrief(taskBrief) {
|
|
1002
|
+
return [
|
|
1003
|
+
taskBrief.replace(/\s+$/, ""),
|
|
1004
|
+
"",
|
|
1005
|
+
"## Standing authorization",
|
|
1006
|
+
STANDING_AUTHORIZATION,
|
|
1007
|
+
"",
|
|
1008
|
+
FOREGROUND_ONLY_RULE,
|
|
1009
|
+
"",
|
|
871
1010
|
].join("\n");
|
|
872
1011
|
}
|
|
873
1012
|
export function parseMaxRuntimeSeconds(raw, workerId) {
|
|
@@ -899,36 +1038,37 @@ export function parseMaxRuntimeSeconds(raw, workerId) {
|
|
|
899
1038
|
function askDeliveryFailedPath(hqRoot, laneId) {
|
|
900
1039
|
return path.join(runDir(hqRoot, laneId), "ask_delivery_failed.json");
|
|
901
1040
|
}
|
|
902
|
-
async function emitLaneMeshEvent(ctx, company, threadId,
|
|
903
|
-
if (!threadId)
|
|
1041
|
+
async function emitLaneMeshEvent(ctx, company, threadId, event, payload) {
|
|
1042
|
+
if (!threadId || !company)
|
|
904
1043
|
return;
|
|
905
|
-
await ctx.mesh.appendThreadEvent(company, threadId,
|
|
906
|
-
}
|
|
907
|
-
export async function emitLaneDecisionEvents(mesh, lane, decision, extra = {}) {
|
|
908
|
-
const eventKind = decision === "blocked" ? "lane_blocked" : "lane_done";
|
|
909
|
-
if (lane.thread_id) {
|
|
910
|
-
await mesh.appendThreadEvent(lane.company, lane.thread_id, eventKind, {
|
|
911
|
-
lane_id: lane.lane_id,
|
|
912
|
-
project_id: lane.project_id,
|
|
913
|
-
story_id: lane.story_id,
|
|
914
|
-
...extra,
|
|
915
|
-
});
|
|
916
|
-
}
|
|
917
|
-
if (decision === "done") {
|
|
918
|
-
await mesh.patchStoryStatus(lane.company, lane.project_id, lane.story_id, "review");
|
|
919
|
-
}
|
|
1044
|
+
await ctx.mesh.appendThreadEvent(company, threadId, event, payload);
|
|
920
1045
|
}
|
|
921
1046
|
export const PICKUP_TERMINAL_REASONS = [
|
|
922
1047
|
"account_not_logged_in",
|
|
923
1048
|
"unknown_account",
|
|
924
1049
|
"account_disabled",
|
|
925
1050
|
];
|
|
1051
|
+
export const DISPATCH_TERMINAL_REASON = "outpost_dispatch_failed";
|
|
926
1052
|
function parsePickupTerminalReason(value) {
|
|
927
1053
|
if (PICKUP_TERMINAL_REASONS.includes(value)) {
|
|
928
1054
|
return value;
|
|
929
1055
|
}
|
|
930
1056
|
throw new LanesError("unknown_pickup_reason", `Unknown Outpost pickup reason ${JSON.stringify(value)}. Accepted: ${PICKUP_TERMINAL_REASONS.join(", ")}`, { accepted: [...PICKUP_TERMINAL_REASONS], value });
|
|
931
1057
|
}
|
|
1058
|
+
function markDispatchFailure(hqRoot, laneId, details) {
|
|
1059
|
+
return casUpdateLane(hqRoot, laneId, (current) => {
|
|
1060
|
+
if (isTerminalLaneState(current.state) || current.state === "running") {
|
|
1061
|
+
return current;
|
|
1062
|
+
}
|
|
1063
|
+
return {
|
|
1064
|
+
...current,
|
|
1065
|
+
state: "failed",
|
|
1066
|
+
queued_reason: undefined,
|
|
1067
|
+
terminal_reason: DISPATCH_TERMINAL_REASON,
|
|
1068
|
+
terminal_details: details,
|
|
1069
|
+
};
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
932
1072
|
function markPickupFailure(hqRoot, laneId, reasonRaw, details) {
|
|
933
1073
|
const reason = parsePickupTerminalReason(reasonRaw);
|
|
934
1074
|
return casUpdateLane(hqRoot, laneId, (current) => {
|
|
@@ -954,9 +1094,11 @@ export function applyOutpostAccountPickup(hqRoot, laneId) {
|
|
|
954
1094
|
const lane = readLane(hqRoot, laneId);
|
|
955
1095
|
const config = loadLanesConfig(hqRoot);
|
|
956
1096
|
try {
|
|
957
|
-
const resolved = resolveAccount(lane.provider, lane.account, config
|
|
1097
|
+
const resolved = resolveAccount(lane.provider, lane.account, config, {
|
|
1098
|
+
hqRoot,
|
|
1099
|
+
});
|
|
958
1100
|
const status = accountStatus(lane.provider, resolved.id, config);
|
|
959
|
-
if (
|
|
1101
|
+
if (isLoggedOut(status)) {
|
|
960
1102
|
return markPickupFailure(hqRoot, laneId, "account_not_logged_in", {
|
|
961
1103
|
provider: lane.provider,
|
|
962
1104
|
account: resolved.id,
|
|
@@ -964,7 +1106,11 @@ export function applyOutpostAccountPickup(hqRoot, laneId) {
|
|
|
964
1106
|
}
|
|
965
1107
|
if (lane.account === resolved.id)
|
|
966
1108
|
return lane;
|
|
967
|
-
|
|
1109
|
+
// The bind is the executing host's pre-spawn account resolution, the same
|
|
1110
|
+
// write spawnLaneWorker makes for a local lane, so it carries the spawn
|
|
1111
|
+
// writer. `account` stays closed to `command`: an operator command never
|
|
1112
|
+
// re-pins the account of a lane it does not execute.
|
|
1113
|
+
return retryUpdateLane(hqRoot, laneId, "spawn", (current) => {
|
|
968
1114
|
if (current.account === resolved.id)
|
|
969
1115
|
return current;
|
|
970
1116
|
return { ...current, account: resolved.id };
|
|
@@ -1020,9 +1166,6 @@ async function runCreate(ctx, opts) {
|
|
|
1020
1166
|
const project = opts.project?.trim();
|
|
1021
1167
|
const story = opts.story?.trim();
|
|
1022
1168
|
const briefFile = opts.briefFile?.trim();
|
|
1023
|
-
if (!companyFlag) {
|
|
1024
|
-
throw new LanesError("missing_company", "--company is required", {});
|
|
1025
|
-
}
|
|
1026
1169
|
if (!project) {
|
|
1027
1170
|
throw new LanesError("missing_project", "--project is required", {});
|
|
1028
1171
|
}
|
|
@@ -1044,7 +1187,7 @@ async function runCreate(ctx, opts) {
|
|
|
1044
1187
|
catch (err) {
|
|
1045
1188
|
throw new LanesError("outpost_list_unavailable", `Failed to list Outpost ids: ${err instanceof Error ? err.message : String(err)}`, {});
|
|
1046
1189
|
}
|
|
1047
|
-
assertKnownOutpost(requested, known
|
|
1190
|
+
assertKnownOutpost(requested, known);
|
|
1048
1191
|
}
|
|
1049
1192
|
const depth = callerDepth(hqRoot, ctx);
|
|
1050
1193
|
if (depth >= MAX_LANE_DEPTH) {
|
|
@@ -1056,14 +1199,17 @@ async function runCreate(ctx, opts) {
|
|
|
1056
1199
|
if (laneDepth > MAX_LANE_DEPTH) {
|
|
1057
1200
|
throw new LanesError("max_lane_depth", `Lane depth ${laneDepth} exceeds cap ${MAX_LANE_DEPTH}`, { depth: laneDepth, max: MAX_LANE_DEPTH });
|
|
1058
1201
|
}
|
|
1059
|
-
|
|
1202
|
+
// A no-company lane must not call company resolution (openMeshTransport).
|
|
1203
|
+
const company = companyFlag
|
|
1204
|
+
? await ctx.resolveCompany(companyFlag)
|
|
1205
|
+
: null;
|
|
1060
1206
|
const seniorCompany = companyOfSenior(hqRoot, senior, ctx);
|
|
1061
1207
|
assertSameTenant(company, senior, seniorCompany);
|
|
1062
1208
|
let resolvedWorker;
|
|
1063
1209
|
if (opts.worker) {
|
|
1064
|
-
resolvedWorker = resolveLaneWorker(hqRoot, opts.worker, company
|
|
1210
|
+
resolvedWorker = resolveLaneWorker(hqRoot, opts.worker, company?.slug);
|
|
1065
1211
|
}
|
|
1066
|
-
const briefAbs =
|
|
1212
|
+
const briefAbs = resolveCallerPath(ctx.callerCwd(), briefFile);
|
|
1067
1213
|
if (!ctx.existsSync(briefAbs)) {
|
|
1068
1214
|
throw new LanesError("brief_not_found", `Brief file not found: ${briefAbs}`, {
|
|
1069
1215
|
path: briefAbs,
|
|
@@ -1071,9 +1217,23 @@ async function runCreate(ctx, opts) {
|
|
|
1071
1217
|
}
|
|
1072
1218
|
const config = loadLanesConfig(hqRoot);
|
|
1073
1219
|
const outpostId = opts.outpost?.trim() || undefined;
|
|
1220
|
+
let originHost;
|
|
1221
|
+
let originHostSource;
|
|
1222
|
+
if (outpostId) {
|
|
1223
|
+
const identity = ctx.resolveMachineIdentity();
|
|
1224
|
+
if (identity.status !== "ok") {
|
|
1225
|
+
throw new LanesError("origin_host_unresolvable", "Cannot record origin_host; this machine's identity is unresolvable", { identity });
|
|
1226
|
+
}
|
|
1227
|
+
originHost = identity.id;
|
|
1228
|
+
originHostSource = identity.source;
|
|
1229
|
+
}
|
|
1230
|
+
const cliModel = parseCliModel(opts.model);
|
|
1231
|
+
// Validated here because `claude --effort <unknown>` only warns and runs.
|
|
1232
|
+
const cliEffort = parseCliEffort(provider, opts.effort);
|
|
1074
1233
|
let pinnedAccount;
|
|
1234
|
+
const accountWasExplicit = Boolean(opts.account && opts.account.trim() !== "");
|
|
1075
1235
|
if (outpostId) {
|
|
1076
|
-
if (
|
|
1236
|
+
if (accountWasExplicit) {
|
|
1077
1237
|
pinnedAccount = opts.account.trim();
|
|
1078
1238
|
}
|
|
1079
1239
|
}
|
|
@@ -1082,14 +1242,12 @@ async function runCreate(ctx, opts) {
|
|
|
1082
1242
|
if (!requested) {
|
|
1083
1243
|
throw new AccountResolutionError("unknown_account", `Unknown account ${JSON.stringify(opts.account)} for ${provider}`, { provider, account: opts.account, enabled: [] });
|
|
1084
1244
|
}
|
|
1085
|
-
const resolved = resolveAccount(provider, requested, config
|
|
1245
|
+
const resolved = resolveAccount(provider, requested, config, {
|
|
1246
|
+
hqRoot,
|
|
1247
|
+
cliModel,
|
|
1248
|
+
});
|
|
1086
1249
|
pinnedAccount = resolved.id;
|
|
1087
1250
|
}
|
|
1088
|
-
else {
|
|
1089
|
-
// Unpinned: still refuse at the bind point when every account is disabled
|
|
1090
|
-
// (spec §5.5). The chosen login is not stored; admission binds later.
|
|
1091
|
-
resolveAccount(provider, undefined, config);
|
|
1092
|
-
}
|
|
1093
1251
|
const priority = parsePriority(opts.priority);
|
|
1094
1252
|
let timeoutSecs = parseOptionalInt(opts.timeout, "--timeout");
|
|
1095
1253
|
if (timeoutSecs === undefined && resolvedWorker) {
|
|
@@ -1097,10 +1255,35 @@ async function runCreate(ctx, opts) {
|
|
|
1097
1255
|
}
|
|
1098
1256
|
const now = ctx.now();
|
|
1099
1257
|
const createdAt = now.toISOString();
|
|
1100
|
-
const threadId =
|
|
1258
|
+
const threadId = company
|
|
1259
|
+
? await ctx.mesh.ensureProjectThread(company, project)
|
|
1260
|
+
: undefined;
|
|
1101
1261
|
const persist = async () => {
|
|
1102
1262
|
let admission;
|
|
1103
1263
|
let validation;
|
|
1264
|
+
if (!outpostId && pinnedAccount === undefined) {
|
|
1265
|
+
pinnedAccount = resolveAccount(provider, undefined, config, {
|
|
1266
|
+
hqRoot,
|
|
1267
|
+
existsSync: ctx.existsSync,
|
|
1268
|
+
cliModel,
|
|
1269
|
+
}).id;
|
|
1270
|
+
}
|
|
1271
|
+
const accountCfg = pinnedAccount !== undefined
|
|
1272
|
+
? config.providers[provider].accounts[pinnedAccount]
|
|
1273
|
+
: undefined;
|
|
1274
|
+
const model = resolveLaneModel({
|
|
1275
|
+
cliModel,
|
|
1276
|
+
accountModel: accountCfg?.model,
|
|
1277
|
+
providerModel: config.providers[provider].model,
|
|
1278
|
+
});
|
|
1279
|
+
// Recorded now so a queued lane already shows it; spawn re-resolves
|
|
1280
|
+
// every source except `cli`.
|
|
1281
|
+
const effort = resolveLaneEffort({
|
|
1282
|
+
cliEffort,
|
|
1283
|
+
accountEffort: accountCfg?.effort,
|
|
1284
|
+
providerEffort: config.providers[provider].effort,
|
|
1285
|
+
model,
|
|
1286
|
+
});
|
|
1104
1287
|
if (outpostId) {
|
|
1105
1288
|
admission = { action: "queue" };
|
|
1106
1289
|
validation = "pending";
|
|
@@ -1138,9 +1321,18 @@ async function runCreate(ctx, opts) {
|
|
|
1138
1321
|
queued_reason: queuedReason,
|
|
1139
1322
|
priority,
|
|
1140
1323
|
worker: resolvedWorker?.id ?? opts.worker,
|
|
1324
|
+
...(resolvedWorker ? { worker_path: resolvedWorker.path } : {}),
|
|
1325
|
+
...(resolvedWorker?.pack ? { worker_pack: resolvedWorker.pack } : {}),
|
|
1141
1326
|
provider,
|
|
1142
1327
|
account: pinnedAccount,
|
|
1328
|
+
...(model !== undefined ? { model } : {}),
|
|
1329
|
+
...(effort.effort !== undefined ? { effort: effort.effort } : {}),
|
|
1330
|
+
effort_source: effort.source,
|
|
1143
1331
|
host,
|
|
1332
|
+
...(originHost !== undefined ? { origin_host: originHost } : {}),
|
|
1333
|
+
...(originHostSource !== undefined
|
|
1334
|
+
? { origin_host_source: originHostSource }
|
|
1335
|
+
: {}),
|
|
1144
1336
|
attribution_history: [],
|
|
1145
1337
|
});
|
|
1146
1338
|
return { lane, admission, validation };
|
|
@@ -1150,7 +1342,7 @@ async function runCreate(ctx, opts) {
|
|
|
1150
1342
|
persisted = await persist();
|
|
1151
1343
|
}
|
|
1152
1344
|
else {
|
|
1153
|
-
persisted = await withAdmissionLock(admissionLockPath(hqRoot, provider, pinnedAccount), persist, ctx.sleep);
|
|
1345
|
+
persisted = await withAdmissionLock(admissionLockPath(hqRoot, provider, accountWasExplicit ? pinnedAccount : undefined), persist, ctx.sleep);
|
|
1154
1346
|
}
|
|
1155
1347
|
const { lane, admission, validation } = persisted;
|
|
1156
1348
|
const dest = runDir(hqRoot, lane.lane_id);
|
|
@@ -1161,7 +1353,7 @@ async function runCreate(ctx, opts) {
|
|
|
1161
1353
|
const taskBrief = fs.readFileSync(briefAbs, "utf8");
|
|
1162
1354
|
const briefText = resolvedWorker
|
|
1163
1355
|
? composeWorkerBrief(resolvedWorker, taskBrief)
|
|
1164
|
-
: taskBrief;
|
|
1356
|
+
: composeGeneralBrief(taskBrief);
|
|
1165
1357
|
fs.writeFileSync(path.join(dest, "brief.md"), briefText);
|
|
1166
1358
|
if (timeoutSecs !== undefined) {
|
|
1167
1359
|
writeJsonFile(timeoutPath(hqRoot, lane.lane_id), {
|
|
@@ -1169,40 +1361,323 @@ async function runCreate(ctx, opts) {
|
|
|
1169
1361
|
deadline_at: new Date(now.getTime() + timeoutSecs * 1000).toISOString(),
|
|
1170
1362
|
});
|
|
1171
1363
|
}
|
|
1364
|
+
let started = lane;
|
|
1172
1365
|
if (admission.action === "start") {
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1366
|
+
started = spawnLaneWorker(hqRoot, lane, {
|
|
1367
|
+
env: ctx.env,
|
|
1368
|
+
existsSync: ctx.existsSync,
|
|
1369
|
+
spawnProcess: ctx.spawnProcess,
|
|
1370
|
+
proveDetached: ctx.proveDetached,
|
|
1371
|
+
killProcess: ctx.killProcess,
|
|
1372
|
+
now: ctx.now,
|
|
1373
|
+
resolveMachineIdentity: ctx.resolveMachineIdentity,
|
|
1374
|
+
runCompaction: ctx.runCompaction,
|
|
1375
|
+
locateTranscript: ctx.locateTranscript,
|
|
1376
|
+
});
|
|
1377
|
+
if (company) {
|
|
1378
|
+
await emitLaneMeshEvent(ctx, company, threadId, "lane_started", {
|
|
1379
|
+
lane_id: started.lane_id,
|
|
1380
|
+
project_id: project,
|
|
1381
|
+
story_id: story,
|
|
1382
|
+
});
|
|
1383
|
+
await ctx.mesh.patchStoryStatus(company, project, story, "in_progress");
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
else if (outpostId) {
|
|
1387
|
+
started = await dispatchCreatedOutpostLane(ctx, {
|
|
1388
|
+
hqRoot,
|
|
1389
|
+
lane,
|
|
1390
|
+
outpostId,
|
|
1391
|
+
briefText,
|
|
1392
|
+
timeout: timeoutSecs !== undefined
|
|
1393
|
+
? {
|
|
1394
|
+
timeout_secs: timeoutSecs,
|
|
1395
|
+
deadline_at: new Date(now.getTime() + timeoutSecs * 1000).toISOString(),
|
|
1396
|
+
}
|
|
1397
|
+
: undefined,
|
|
1177
1398
|
});
|
|
1178
|
-
await ctx.mesh.patchStoryStatus(company, project, story, "in_progress");
|
|
1179
1399
|
}
|
|
1180
|
-
// spawnLaneWorker is the process-launch seam. Create never calls it: spawn
|
|
1181
|
-
// and the watcher are owned by the reconcile daemon (spec §5.7). Admitted
|
|
1182
|
-
// lanes stay queued until that seam is implemented.
|
|
1183
1400
|
printJson({
|
|
1184
1401
|
ok: true,
|
|
1185
1402
|
action: "create",
|
|
1186
|
-
lane_id:
|
|
1187
|
-
state:
|
|
1188
|
-
queued_reason:
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
depth: lane.depth,
|
|
1403
|
+
lane_id: started.lane_id,
|
|
1404
|
+
state: started.state,
|
|
1405
|
+
queued_reason: started.queued_reason ?? null,
|
|
1406
|
+
priority: started.priority,
|
|
1407
|
+
provider: started.provider,
|
|
1408
|
+
account: started.account ?? null,
|
|
1409
|
+
model: started.model ?? null,
|
|
1410
|
+
effort: started.effort ?? null,
|
|
1411
|
+
effort_source: started.effort_source ?? null,
|
|
1412
|
+
host: started.host,
|
|
1413
|
+
company: started.company,
|
|
1414
|
+
project_id: started.project_id,
|
|
1415
|
+
story_id: started.story_id,
|
|
1416
|
+
thread_id: started.thread_id ?? null,
|
|
1417
|
+
senior: started.senior,
|
|
1418
|
+
depth: started.depth,
|
|
1203
1419
|
timeout_secs: timeoutSecs ?? null,
|
|
1204
1420
|
...(validation ? { validation } : {}),
|
|
1421
|
+
...(started.outpost_command_id
|
|
1422
|
+
? {
|
|
1423
|
+
outpost_command_id: started
|
|
1424
|
+
.outpost_command_id,
|
|
1425
|
+
}
|
|
1426
|
+
: {}),
|
|
1427
|
+
lane: started,
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
async function dispatchCreatedOutpostLane(ctx, input) {
|
|
1431
|
+
const { hqRoot, lane, outpostId, briefText, timeout } = input;
|
|
1432
|
+
const payload = {
|
|
1205
1433
|
lane,
|
|
1434
|
+
brief: briefText,
|
|
1435
|
+
timeout,
|
|
1436
|
+
};
|
|
1437
|
+
const bytes = Buffer.from(`${JSON.stringify(payload)}\n`, "utf8");
|
|
1438
|
+
try {
|
|
1439
|
+
const staged = await ctx.stageOutpostPayload(outpostId, bytes);
|
|
1440
|
+
const command = buildOutpostPickupCommand(staged.getUrl);
|
|
1441
|
+
const dispatched = await ctx.dispatchOutpostExec({ outpostId, command });
|
|
1442
|
+
const remote = parseRemotePickupJson(dispatched.stdout);
|
|
1443
|
+
if (remote?.error === "outpost_self_target") {
|
|
1444
|
+
return casUpdateLane(hqRoot, lane.lane_id, (current) => ({
|
|
1445
|
+
...current,
|
|
1446
|
+
state: "cancelled",
|
|
1447
|
+
queued_reason: undefined,
|
|
1448
|
+
terminal_reason: "outpost_self_target",
|
|
1449
|
+
outpost_command_id: dispatched.commandId,
|
|
1450
|
+
}));
|
|
1451
|
+
}
|
|
1452
|
+
const stdoutTrimmed = dispatched.stdout.trim();
|
|
1453
|
+
if (remote === null && stdoutTrimmed !== "" && dispatched.status !== "Success") {
|
|
1454
|
+
const failed = markDispatchFailure(hqRoot, lane.lane_id, {
|
|
1455
|
+
outpost_id: outpostId,
|
|
1456
|
+
command_id: dispatched.commandId,
|
|
1457
|
+
exit_code: dispatched.exitCode,
|
|
1458
|
+
status: dispatched.status,
|
|
1459
|
+
stderr: dispatched.stderr.slice(0, 2000),
|
|
1460
|
+
parse_error: "unparseable_pickup_stdout",
|
|
1461
|
+
});
|
|
1462
|
+
throw new LanesError(DISPATCH_TERMINAL_REASON, "Outpost pickup command failed", {
|
|
1463
|
+
outpost_id: outpostId,
|
|
1464
|
+
lane_id: failed.lane_id,
|
|
1465
|
+
state: failed.state,
|
|
1466
|
+
terminal_reason: DISPATCH_TERMINAL_REASON,
|
|
1467
|
+
command_id: dispatched.commandId,
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
const dispatchOk = dispatched.status === "Success" || dispatched.exitCode === 0;
|
|
1471
|
+
if (!dispatchOk) {
|
|
1472
|
+
const failed = markDispatchFailure(hqRoot, lane.lane_id, {
|
|
1473
|
+
outpost_id: outpostId,
|
|
1474
|
+
command_id: dispatched.commandId,
|
|
1475
|
+
exit_code: dispatched.exitCode,
|
|
1476
|
+
status: dispatched.status,
|
|
1477
|
+
stderr: dispatched.stderr.slice(0, 2000),
|
|
1478
|
+
});
|
|
1479
|
+
throw new LanesError(DISPATCH_TERMINAL_REASON, "Outpost pickup command failed", {
|
|
1480
|
+
outpost_id: outpostId,
|
|
1481
|
+
lane_id: failed.lane_id,
|
|
1482
|
+
state: failed.state,
|
|
1483
|
+
terminal_reason: DISPATCH_TERMINAL_REASON,
|
|
1484
|
+
command_id: dispatched.commandId,
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
return casUpdateLane(hqRoot, lane.lane_id, (current) => ({
|
|
1488
|
+
...current,
|
|
1489
|
+
outpost_command_id: dispatched.commandId,
|
|
1490
|
+
}));
|
|
1491
|
+
}
|
|
1492
|
+
catch (err) {
|
|
1493
|
+
if (isLanesError(err) &&
|
|
1494
|
+
err.error === DISPATCH_TERMINAL_REASON &&
|
|
1495
|
+
readLane(hqRoot, lane.lane_id).state === "failed") {
|
|
1496
|
+
throw err;
|
|
1497
|
+
}
|
|
1498
|
+
const message = dispatchErrorMessage(err);
|
|
1499
|
+
const failed = markDispatchFailure(hqRoot, lane.lane_id, {
|
|
1500
|
+
outpost_id: outpostId,
|
|
1501
|
+
message,
|
|
1502
|
+
});
|
|
1503
|
+
throw new LanesError(DISPATCH_TERMINAL_REASON, `Failed to dispatch lane to Outpost ${outpostId}: ${message}`, {
|
|
1504
|
+
outpost_id: outpostId,
|
|
1505
|
+
lane_id: failed.lane_id,
|
|
1506
|
+
state: failed.state,
|
|
1507
|
+
terminal_reason: DISPATCH_TERMINAL_REASON,
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
function parseRemotePickupJson(stdout) {
|
|
1512
|
+
const trimmed = stdout.trim();
|
|
1513
|
+
if (!trimmed)
|
|
1514
|
+
return null;
|
|
1515
|
+
try {
|
|
1516
|
+
const parsed = JSON.parse(trimmed);
|
|
1517
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1518
|
+
return parsed;
|
|
1519
|
+
}
|
|
1520
|
+
process.stderr.write("hq lanes: Outpost pickup stdout is not a JSON object\n");
|
|
1521
|
+
}
|
|
1522
|
+
catch (err) {
|
|
1523
|
+
process.stderr.write(`hq lanes: failed to parse Outpost pickup JSON: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1524
|
+
return null;
|
|
1525
|
+
}
|
|
1526
|
+
return null;
|
|
1527
|
+
}
|
|
1528
|
+
function ingestPickupPayload(hqRoot, payload) {
|
|
1529
|
+
const laneId = payload.lane.lane_id;
|
|
1530
|
+
if (!laneId) {
|
|
1531
|
+
throw new LanesError("outpost_payload_invalid", "Pickup payload lane.lane_id is required", {});
|
|
1532
|
+
}
|
|
1533
|
+
const existing = tryReadLane(hqRoot, laneId);
|
|
1534
|
+
const lane = existing ?? createLane(hqRoot, payload.lane);
|
|
1535
|
+
const dest = runDir(hqRoot, lane.lane_id);
|
|
1536
|
+
ensureDir(path.join(dest, "logs"));
|
|
1537
|
+
ensureDir(path.join(dest, "envelopes"));
|
|
1538
|
+
ensureDir(path.join(dest, "dropbox"));
|
|
1539
|
+
ensureDir(path.join(dest, "questions"));
|
|
1540
|
+
fs.writeFileSync(path.join(dest, "brief.md"), payload.brief);
|
|
1541
|
+
if (payload.timeout) {
|
|
1542
|
+
writeJsonFile(timeoutPath(hqRoot, lane.lane_id), payload.timeout);
|
|
1543
|
+
}
|
|
1544
|
+
return lane;
|
|
1545
|
+
}
|
|
1546
|
+
async function runPickup(ctx, opts) {
|
|
1547
|
+
const hqRoot = ctx.hqRoot();
|
|
1548
|
+
const payloadFile = opts.payloadFile?.trim();
|
|
1549
|
+
const payloadUrl = opts.payloadUrl?.trim();
|
|
1550
|
+
if (!payloadFile && !payloadUrl) {
|
|
1551
|
+
throw new LanesError("missing_payload", "--payload-file or --payload-url is required", {});
|
|
1552
|
+
}
|
|
1553
|
+
if (payloadFile && payloadUrl) {
|
|
1554
|
+
throw new LanesError("invalid_payload", "Pass only one of --payload-file or --payload-url", {});
|
|
1555
|
+
}
|
|
1556
|
+
let rawText;
|
|
1557
|
+
if (payloadUrl) {
|
|
1558
|
+
rawText = await ctx.fetchText(payloadUrl);
|
|
1559
|
+
}
|
|
1560
|
+
else {
|
|
1561
|
+
const abs = resolveCallerPath(ctx.callerCwd(), payloadFile);
|
|
1562
|
+
if (!ctx.existsSync(abs)) {
|
|
1563
|
+
throw new LanesError("brief_not_found", `Payload file not found: ${abs}`, {
|
|
1564
|
+
path: abs,
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
rawText = ctx.readFileSync(abs, "utf8");
|
|
1568
|
+
}
|
|
1569
|
+
let parsedJson;
|
|
1570
|
+
try {
|
|
1571
|
+
parsedJson = JSON.parse(rawText);
|
|
1572
|
+
}
|
|
1573
|
+
catch (err) {
|
|
1574
|
+
throw new LanesError("outpost_payload_invalid", `Pickup payload is not JSON: ${err instanceof Error ? err.message : String(err)}`, {});
|
|
1575
|
+
}
|
|
1576
|
+
const payload = parseOutpostPickupPayload(parsedJson);
|
|
1577
|
+
const ingested = ingestPickupPayload(hqRoot, payload);
|
|
1578
|
+
if (ingested.host.kind === "outpost") {
|
|
1579
|
+
try {
|
|
1580
|
+
refuseOutpostSelfTarget(hqRoot, ingested, {
|
|
1581
|
+
resolveMachineIdentity: ctx.resolveMachineIdentity,
|
|
1582
|
+
runCompaction: ctx.runCompaction,
|
|
1583
|
+
locateTranscript: ctx.locateTranscript,
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
catch (err) {
|
|
1587
|
+
if (isLanesError(err) && err.error === "outpost_self_target") {
|
|
1588
|
+
const cancelled = readLane(hqRoot, ingested.lane_id);
|
|
1589
|
+
printJson({
|
|
1590
|
+
ok: true,
|
|
1591
|
+
action: "pickup",
|
|
1592
|
+
lane_id: cancelled.lane_id,
|
|
1593
|
+
state: cancelled.state,
|
|
1594
|
+
error: "outpost_self_target",
|
|
1595
|
+
lane: cancelled,
|
|
1596
|
+
});
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
throw err;
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
const afterAccount = applyOutpostAccountPickup(hqRoot, ingested.lane_id);
|
|
1603
|
+
if (isTerminalLaneState(afterAccount.state)) {
|
|
1604
|
+
printJson({
|
|
1605
|
+
ok: true,
|
|
1606
|
+
action: "pickup",
|
|
1607
|
+
lane_id: afterAccount.lane_id,
|
|
1608
|
+
state: afterAccount.state,
|
|
1609
|
+
terminal_reason: afterAccount
|
|
1610
|
+
.terminal_reason,
|
|
1611
|
+
lane: afterAccount,
|
|
1612
|
+
});
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
const config = loadLanesConfig(hqRoot);
|
|
1616
|
+
let admission = admitCreate({
|
|
1617
|
+
hqRoot,
|
|
1618
|
+
provider: afterAccount.provider,
|
|
1619
|
+
pinnedAccount: afterAccount.account,
|
|
1620
|
+
priority: afterAccount.priority,
|
|
1621
|
+
createdAt: afterAccount.created_at,
|
|
1622
|
+
config,
|
|
1623
|
+
excludeLaneId: afterAccount.lane_id,
|
|
1624
|
+
});
|
|
1625
|
+
if (admission.action === "refuse" && admission.error) {
|
|
1626
|
+
throw admission.error;
|
|
1627
|
+
}
|
|
1628
|
+
admission = await applyThrottle(hqRoot, config, ctx, admission, afterAccount.provider, afterAccount.account);
|
|
1629
|
+
if (admission.action === "refuse" && admission.error) {
|
|
1630
|
+
throw admission.error;
|
|
1631
|
+
}
|
|
1632
|
+
let started;
|
|
1633
|
+
if (admission.action === "queue") {
|
|
1634
|
+
started = casUpdateLane(hqRoot, afterAccount.lane_id, (current) => {
|
|
1635
|
+
if (isTerminalLaneState(current.state))
|
|
1636
|
+
return current;
|
|
1637
|
+
return {
|
|
1638
|
+
...current,
|
|
1639
|
+
state: "queued",
|
|
1640
|
+
queued_reason: admission.reason,
|
|
1641
|
+
};
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
else {
|
|
1645
|
+
try {
|
|
1646
|
+
started = spawnLaneWorker(hqRoot, afterAccount, {
|
|
1647
|
+
env: ctx.env,
|
|
1648
|
+
existsSync: ctx.existsSync,
|
|
1649
|
+
spawnProcess: ctx.spawnProcess,
|
|
1650
|
+
proveDetached: ctx.proveDetached,
|
|
1651
|
+
killProcess: ctx.killProcess,
|
|
1652
|
+
now: ctx.now,
|
|
1653
|
+
resolveMachineIdentity: ctx.resolveMachineIdentity,
|
|
1654
|
+
runCompaction: ctx.runCompaction,
|
|
1655
|
+
locateTranscript: ctx.locateTranscript,
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
catch (err) {
|
|
1659
|
+
if (isLanesError(err) && err.error === "outpost_self_target") {
|
|
1660
|
+
const cancelled = readLane(hqRoot, afterAccount.lane_id);
|
|
1661
|
+
printJson({
|
|
1662
|
+
ok: true,
|
|
1663
|
+
action: "pickup",
|
|
1664
|
+
lane_id: cancelled.lane_id,
|
|
1665
|
+
state: cancelled.state,
|
|
1666
|
+
error: "outpost_self_target",
|
|
1667
|
+
lane: cancelled,
|
|
1668
|
+
});
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
throw err;
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
printJson({
|
|
1675
|
+
ok: true,
|
|
1676
|
+
action: "pickup",
|
|
1677
|
+
lane_id: started.lane_id,
|
|
1678
|
+
state: started.state,
|
|
1679
|
+
queued_reason: started.queued_reason ?? null,
|
|
1680
|
+
lane: started,
|
|
1206
1681
|
});
|
|
1207
1682
|
}
|
|
1208
1683
|
async function runList(ctx, opts) {
|
|
@@ -1222,7 +1697,7 @@ async function runList(ctx, opts) {
|
|
|
1222
1697
|
return false;
|
|
1223
1698
|
if (opts.story && lane.story_id !== opts.story)
|
|
1224
1699
|
return false;
|
|
1225
|
-
if (company && lane.company
|
|
1700
|
+
if (company && !recordMatchesCompany(lane.company, company)) {
|
|
1226
1701
|
return false;
|
|
1227
1702
|
}
|
|
1228
1703
|
if (senior && seniorKey(lane.senior) !== seniorKey(senior))
|
|
@@ -1232,7 +1707,19 @@ async function runList(ctx, opts) {
|
|
|
1232
1707
|
.sort(compareQueueOrder)
|
|
1233
1708
|
.map((lane) => ({
|
|
1234
1709
|
...lane,
|
|
1235
|
-
overdue: isOverdue(hqRoot, lane.lane_id, now),
|
|
1710
|
+
overdue: Boolean(lane.overdue) || isOverdue(hqRoot, lane.lane_id, now),
|
|
1711
|
+
stalled: Boolean(lane.stalled),
|
|
1712
|
+
// Listing is observational. Reconcile owns durable liveness transitions.
|
|
1713
|
+
senior_lost: lane.state === "running" && lane.senior.kind === "session" && lane.senior.id
|
|
1714
|
+
? (() => {
|
|
1715
|
+
const state = sessionLiveness(hqRoot, lane.senior.id, {
|
|
1716
|
+
nowMs: now.getTime(),
|
|
1717
|
+
deps: livenessDepsFromCtx(ctx),
|
|
1718
|
+
presenceConnected: ctx.presenceConnected,
|
|
1719
|
+
});
|
|
1720
|
+
return state === null || state === "ended" || state === "lost";
|
|
1721
|
+
})()
|
|
1722
|
+
: false,
|
|
1236
1723
|
}));
|
|
1237
1724
|
printJson({ ok: true, action: "list", lanes });
|
|
1238
1725
|
}
|
|
@@ -1244,8 +1731,24 @@ async function runShow(ctx, laneId) {
|
|
|
1244
1731
|
ok: true,
|
|
1245
1732
|
action: "show",
|
|
1246
1733
|
lane,
|
|
1247
|
-
overdue: isOverdue(hqRoot, lane.lane_id, ctx.now()),
|
|
1734
|
+
overdue: Boolean(lane.overdue) || isOverdue(hqRoot, lane.lane_id, ctx.now()),
|
|
1735
|
+
stalled: Boolean(lane.stalled),
|
|
1736
|
+
stalled_at: lane.stalled_at ?? null,
|
|
1737
|
+
stalled_reason: lane.stalled_reason ?? null,
|
|
1248
1738
|
ask_delivery_failed: askDeliveryFailed,
|
|
1739
|
+
delivery: laneDeliveryMechanism(lane),
|
|
1740
|
+
provider_session_id: lane.provider_session_id ?? null,
|
|
1741
|
+
provider_session_id_absent: lane.provider_session_id_absent ?? null,
|
|
1742
|
+
envelope_repair_mode: lane.envelope_repair?.mode ?? null,
|
|
1743
|
+
last_resume: lane.last_resume ?? null,
|
|
1744
|
+
last_run_usage: lane.last_run_usage ?? null,
|
|
1745
|
+
model: lane.model ?? null,
|
|
1746
|
+
effort: lane.effort ?? null,
|
|
1747
|
+
effort_source: lane.effort_source ?? null,
|
|
1748
|
+
last_message_at: lane.last_message_at ?? null,
|
|
1749
|
+
last_compaction: lane.last_compaction
|
|
1750
|
+
? { ...lane.last_compaction, after: compactionAfterUsage(lane) }
|
|
1751
|
+
: null,
|
|
1249
1752
|
});
|
|
1250
1753
|
}
|
|
1251
1754
|
async function runMessage(ctx, laneId, opts) {
|
|
@@ -1255,14 +1758,64 @@ async function runMessage(ctx, laneId, opts) {
|
|
|
1255
1758
|
throw new LanesError("missing_text", "--text is required", {});
|
|
1256
1759
|
}
|
|
1257
1760
|
const lane = readLane(hqRoot, laneId);
|
|
1761
|
+
// One decision point for how a follow-up reaches this lane (delivery.ts).
|
|
1762
|
+
// `none` refuses before anything is written: no inbox append, no spawn.
|
|
1763
|
+
const mechanism = laneDeliveryMechanism(lane);
|
|
1764
|
+
if (mechanism === "none") {
|
|
1765
|
+
const message = deliveryRefusalReason(lane);
|
|
1766
|
+
throw new LanesError("lane_not_accepting_messages", message, {
|
|
1767
|
+
lane_id: laneId,
|
|
1768
|
+
state: lane.state,
|
|
1769
|
+
provider: lane.provider,
|
|
1770
|
+
message,
|
|
1771
|
+
});
|
|
1772
|
+
}
|
|
1258
1773
|
const messageId = mintMessageId();
|
|
1259
1774
|
const at = ctx.now().toISOString();
|
|
1260
|
-
const drop = path.join(runDir(hqRoot, laneId), "dropbox", "inbox.jsonl");
|
|
1261
|
-
ensureDir(path.dirname(drop));
|
|
1262
1775
|
const projectId = opts.project?.trim() || lane.project_id;
|
|
1263
1776
|
const storyId = opts.story?.trim() || lane.story_id;
|
|
1264
|
-
|
|
1265
|
-
|
|
1777
|
+
const dropboxMessage = {
|
|
1778
|
+
at,
|
|
1779
|
+
message_id: messageId,
|
|
1780
|
+
text,
|
|
1781
|
+
project_id: projectId,
|
|
1782
|
+
story_id: storyId,
|
|
1783
|
+
};
|
|
1784
|
+
let resumed = null;
|
|
1785
|
+
if (mechanism === "dropbox") {
|
|
1786
|
+
// Live (or about-to-be-live) process: the PreToolUse drain delivers it.
|
|
1787
|
+
if (!laneAcceptsOperatorMessage(lane)) {
|
|
1788
|
+
throw new LanesError("lane_not_accepting_messages", operatorMessageRefusalReason(lane.state), { lane_id: laneId, state: lane.state });
|
|
1789
|
+
}
|
|
1790
|
+
appendDropboxMessage(hqRoot, laneId, dropboxMessage);
|
|
1791
|
+
}
|
|
1792
|
+
else {
|
|
1793
|
+
// Exited Claude worker with a recorded session: the message becomes the
|
|
1794
|
+
// next turn of that session. It is archived under delivered/ (never the
|
|
1795
|
+
// inbox, which would hand it to the drain hook a second time) and the
|
|
1796
|
+
// spawn happens before any attribution write so a failed spawn leaves
|
|
1797
|
+
// the record untouched.
|
|
1798
|
+
archiveResumeMessage(hqRoot, laneId, dropboxMessage);
|
|
1799
|
+
resumed = spawnLaneWorker(hqRoot, lane, {
|
|
1800
|
+
env: ctx.env,
|
|
1801
|
+
existsSync: ctx.existsSync,
|
|
1802
|
+
spawnProcess: ctx.spawnProcess,
|
|
1803
|
+
proveDetached: ctx.proveDetached,
|
|
1804
|
+
killProcess: ctx.killProcess,
|
|
1805
|
+
now: ctx.now,
|
|
1806
|
+
resolveMachineIdentity: ctx.resolveMachineIdentity,
|
|
1807
|
+
runCompaction: ctx.runCompaction,
|
|
1808
|
+
locateTranscript: ctx.locateTranscript,
|
|
1809
|
+
}, {
|
|
1810
|
+
trigger: { kind: "message", text, message_id: messageId },
|
|
1811
|
+
compact: opts.compact !== false,
|
|
1812
|
+
});
|
|
1813
|
+
}
|
|
1814
|
+
// Every accepted message moves the compaction reference forward.
|
|
1815
|
+
let next = casUpdateLane(hqRoot, laneId, (current) => ({
|
|
1816
|
+
...current,
|
|
1817
|
+
last_message_at: at,
|
|
1818
|
+
}));
|
|
1266
1819
|
if (projectId !== lane.project_id || storyId !== lane.story_id) {
|
|
1267
1820
|
next = casUpdateLane(hqRoot, laneId, (current) => {
|
|
1268
1821
|
const already = current.attribution_history.some((entry) => entry.message_id === messageId);
|
|
@@ -1281,16 +1834,171 @@ async function runMessage(ctx, laneId, opts) {
|
|
|
1281
1834
|
};
|
|
1282
1835
|
});
|
|
1283
1836
|
}
|
|
1837
|
+
// What this command can claim when it returns: the message is durably
|
|
1838
|
+
// written (accepted). Delivery to the model is asynchronous in both
|
|
1839
|
+
// mechanisms. `dropbox` delivers on the worker's next tool call, and only
|
|
1840
|
+
// if the drain hook is registered in the account home the worker was
|
|
1841
|
+
// started from; `resume` has started a new worker with the message as its
|
|
1842
|
+
// prompt. Neither is "delivered", so the response never says so.
|
|
1843
|
+
const drainHook = drainHookStatusFor(ctx, next);
|
|
1844
|
+
const deliveryStatus = mechanism === "dropbox" ? "awaiting_next_tool_call" : "worker_spawned";
|
|
1845
|
+
const warning = mechanism === "dropbox" && drainHook !== null && !drainHook.registered
|
|
1846
|
+
? `The drain hook is not registered in ${drainHook.path}; the running worker will not receive this message. It stays in the inbox and is delivered on the lane's next start.`
|
|
1847
|
+
: mechanism === "dropbox" && next.stalled === true
|
|
1848
|
+
? `The worker has made no tool call since ${next.stalled_at ?? "an unknown time"} (${next.stalled_reason ?? "stalled"}); delivery waits on its next tool call.`
|
|
1849
|
+
: null;
|
|
1284
1850
|
printJson({
|
|
1285
1851
|
ok: true,
|
|
1286
1852
|
action: "message",
|
|
1287
1853
|
lane_id: laneId,
|
|
1288
1854
|
message_id: messageId,
|
|
1855
|
+
accepted: true,
|
|
1856
|
+
delivered: false,
|
|
1857
|
+
delivery: mechanism,
|
|
1858
|
+
delivery_status: deliveryStatus,
|
|
1859
|
+
inbox_path: mechanism === "dropbox" ? dropboxInboxPath(hqRoot, laneId) : null,
|
|
1860
|
+
drain_hook: drainHook,
|
|
1861
|
+
warning,
|
|
1862
|
+
resumed_session_id: resumed?.last_resume?.session_id ?? null,
|
|
1863
|
+
compaction: resumed?.last_compaction && resumed.last_compaction.resume_at === resumed.last_resume?.at
|
|
1864
|
+
? resumed.last_compaction
|
|
1865
|
+
: null,
|
|
1289
1866
|
project_id: next.project_id,
|
|
1290
1867
|
story_id: next.story_id,
|
|
1291
1868
|
lane: next,
|
|
1292
1869
|
});
|
|
1293
1870
|
}
|
|
1871
|
+
/**
|
|
1872
|
+
* `hq lanes resume <lane-id> [--text <msg>]`: the explicit opt-in that
|
|
1873
|
+
* continues a lane in `interrupted`, `done` or `blocked` on its recorded
|
|
1874
|
+
* provider session. `hq lanes message` keeps refusing `interrupted`; this
|
|
1875
|
+
* command is the path its refusal names. The launch is the same
|
|
1876
|
+
* `spawnLaneWorker` a message resume uses, so the resume-time compaction
|
|
1877
|
+
* rule and the watcher's mesh events are the same. The record gains
|
|
1878
|
+
* `last_resume` with `trigger: "resume"`, `resumed_from` and the text.
|
|
1879
|
+
*/
|
|
1880
|
+
async function runResume(ctx, laneId, opts) {
|
|
1881
|
+
const hqRoot = ctx.hqRoot();
|
|
1882
|
+
const lane = readLane(hqRoot, laneId);
|
|
1883
|
+
const refusal = resumeCommandRefusal(lane);
|
|
1884
|
+
if (refusal !== null) {
|
|
1885
|
+
throw new LanesError(refusal.error, refusal.message, {
|
|
1886
|
+
lane_id: laneId,
|
|
1887
|
+
state: lane.state,
|
|
1888
|
+
provider: lane.provider,
|
|
1889
|
+
message: refusal.message,
|
|
1890
|
+
});
|
|
1891
|
+
}
|
|
1892
|
+
const resumedFrom = lane.state;
|
|
1893
|
+
const text = opts.text?.trim() ? opts.text : undefined;
|
|
1894
|
+
const at = ctx.now().toISOString();
|
|
1895
|
+
const projectId = opts.project?.trim() || lane.project_id;
|
|
1896
|
+
const storyId = opts.story?.trim() || lane.story_id;
|
|
1897
|
+
const attributionChanged = projectId !== lane.project_id || storyId !== lane.story_id;
|
|
1898
|
+
const messageId = text !== undefined || attributionChanged ? mintMessageId() : undefined;
|
|
1899
|
+
const resumed = spawnLaneWorker(hqRoot, lane, {
|
|
1900
|
+
env: ctx.env,
|
|
1901
|
+
existsSync: ctx.existsSync,
|
|
1902
|
+
spawnProcess: ctx.spawnProcess,
|
|
1903
|
+
proveDetached: ctx.proveDetached,
|
|
1904
|
+
killProcess: ctx.killProcess,
|
|
1905
|
+
now: ctx.now,
|
|
1906
|
+
resolveMachineIdentity: ctx.resolveMachineIdentity,
|
|
1907
|
+
runCompaction: ctx.runCompaction,
|
|
1908
|
+
locateTranscript: ctx.locateTranscript,
|
|
1909
|
+
}, {
|
|
1910
|
+
trigger: {
|
|
1911
|
+
kind: "resume",
|
|
1912
|
+
from: resumedFrom,
|
|
1913
|
+
...(text !== undefined ? { text } : {}),
|
|
1914
|
+
...(messageId !== undefined ? { message_id: messageId } : {}),
|
|
1915
|
+
},
|
|
1916
|
+
compact: opts.compact !== false,
|
|
1917
|
+
});
|
|
1918
|
+
if (text !== undefined && messageId !== undefined) {
|
|
1919
|
+
// Same audit copy a message resume leaves; nothing drains it. Archive only
|
|
1920
|
+
// after a successful spawn so a concurrent resume cannot leave a phantom.
|
|
1921
|
+
archiveResumeMessage(hqRoot, laneId, {
|
|
1922
|
+
at,
|
|
1923
|
+
message_id: messageId,
|
|
1924
|
+
text,
|
|
1925
|
+
project_id: projectId,
|
|
1926
|
+
story_id: storyId,
|
|
1927
|
+
resumed_from: resumedFrom,
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
let next = resumed;
|
|
1931
|
+
if (text !== undefined) {
|
|
1932
|
+
// An operator text moves the compaction reference forward, as a message does.
|
|
1933
|
+
next = casUpdateLane(hqRoot, laneId, (current) => ({
|
|
1934
|
+
...current,
|
|
1935
|
+
last_message_at: at,
|
|
1936
|
+
}));
|
|
1937
|
+
}
|
|
1938
|
+
if (messageId !== undefined && attributionChanged) {
|
|
1939
|
+
next = casUpdateLane(hqRoot, laneId, (current) => {
|
|
1940
|
+
const already = current.attribution_history.some((entry) => entry.message_id === messageId);
|
|
1941
|
+
if (already)
|
|
1942
|
+
return current;
|
|
1943
|
+
return {
|
|
1944
|
+
...current,
|
|
1945
|
+
project_id: projectId,
|
|
1946
|
+
story_id: storyId,
|
|
1947
|
+
attribution_history: appendCappedHistory(current.attribution_history, {
|
|
1948
|
+
project_id: projectId,
|
|
1949
|
+
story_id: storyId,
|
|
1950
|
+
message_id: messageId,
|
|
1951
|
+
at,
|
|
1952
|
+
}),
|
|
1953
|
+
};
|
|
1954
|
+
});
|
|
1955
|
+
}
|
|
1956
|
+
printJson({
|
|
1957
|
+
ok: true,
|
|
1958
|
+
action: "resume",
|
|
1959
|
+
lane_id: laneId,
|
|
1960
|
+
resumed_from: resumedFrom,
|
|
1961
|
+
message_id: messageId ?? null,
|
|
1962
|
+
text: text ?? null,
|
|
1963
|
+
delivery: "resume",
|
|
1964
|
+
delivery_status: "worker_spawned",
|
|
1965
|
+
resumed_session_id: resumed.last_resume?.session_id ?? null,
|
|
1966
|
+
last_resume: next.last_resume ?? null,
|
|
1967
|
+
compaction: resumed.last_compaction && resumed.last_compaction.resume_at === resumed.last_resume?.at
|
|
1968
|
+
? resumed.last_compaction
|
|
1969
|
+
: null,
|
|
1970
|
+
project_id: next.project_id,
|
|
1971
|
+
story_id: next.story_id,
|
|
1972
|
+
lane: next,
|
|
1973
|
+
});
|
|
1974
|
+
}
|
|
1975
|
+
/**
|
|
1976
|
+
* Registration state of the drain hook in the lane's account home, read the
|
|
1977
|
+
* way the provider reads it. Null when the lane has no bound account yet
|
|
1978
|
+
* (queued): spawn installs the hook before the worker starts.
|
|
1979
|
+
*/
|
|
1980
|
+
function drainHookStatusFor(ctx, lane) {
|
|
1981
|
+
if (lane.account === undefined)
|
|
1982
|
+
return null;
|
|
1983
|
+
const hqRoot = ctx.hqRoot();
|
|
1984
|
+
const config = loadLanesConfig(hqRoot);
|
|
1985
|
+
const account = config.providers[lane.provider].accounts[lane.account];
|
|
1986
|
+
if (account === undefined)
|
|
1987
|
+
return null;
|
|
1988
|
+
const home = canonicalizeHome(account.home, {
|
|
1989
|
+
homedir: ctx.homedir,
|
|
1990
|
+
realpathSync: (p) => fs.realpathSync(p),
|
|
1991
|
+
existsSync: (p) => fs.existsSync(p),
|
|
1992
|
+
});
|
|
1993
|
+
const probe = probeDrainHookRegistration(lane.provider, home);
|
|
1994
|
+
return { registered: probe.registered, kind: probe.kind, path: probe.path };
|
|
1995
|
+
}
|
|
1996
|
+
/** Audit copy of a message delivered by resume. Not the inbox: nothing drains this. */
|
|
1997
|
+
function archiveResumeMessage(hqRoot, laneId, message) {
|
|
1998
|
+
const dir = path.join(dropboxDir(hqRoot, laneId), "delivered");
|
|
1999
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
2000
|
+
fs.writeFileSync(path.join(dir, `${message.message_id}.resume.jsonl`), `${JSON.stringify(message)}\n`, "utf8");
|
|
2001
|
+
}
|
|
1294
2002
|
async function runReparent(ctx, laneId, opts) {
|
|
1295
2003
|
const hqRoot = ctx.hqRoot();
|
|
1296
2004
|
if (!opts.senior) {
|
|
@@ -1368,6 +2076,12 @@ async function runReparent(ctx, laneId, opts) {
|
|
|
1368
2076
|
pending_questions: pending.length,
|
|
1369
2077
|
});
|
|
1370
2078
|
}
|
|
2079
|
+
const CANONICAL_LOG_STREAMS = [
|
|
2080
|
+
"watcher.err",
|
|
2081
|
+
"watcher.out",
|
|
2082
|
+
"worker.err",
|
|
2083
|
+
"worker.out",
|
|
2084
|
+
];
|
|
1371
2085
|
async function runLogs(ctx, laneId, opts) {
|
|
1372
2086
|
const hqRoot = ctx.hqRoot();
|
|
1373
2087
|
readLane(hqRoot, laneId);
|
|
@@ -1376,24 +2090,37 @@ async function runLogs(ctx, laneId, opts) {
|
|
|
1376
2090
|
if (tail === 0) {
|
|
1377
2091
|
throw new LanesError("invalid_integer", "--tail must be a positive integer", { flag: "--tail", value: opts.tail });
|
|
1378
2092
|
}
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
.readdirSync(logsDir)
|
|
1385
|
-
.filter((name) => !name.startsWith("."))
|
|
1386
|
-
.sort();
|
|
1387
|
-
const lines = [];
|
|
1388
|
-
for (const name of files) {
|
|
1389
|
-
const text = fs.readFileSync(path.join(logsDir, name), "utf8");
|
|
1390
|
-
for (const line of text.split("\n")) {
|
|
1391
|
-
if (line.length > 0)
|
|
1392
|
-
lines.push(line);
|
|
2093
|
+
const names = new Set(CANONICAL_LOG_STREAMS);
|
|
2094
|
+
if (fs.existsSync(logsDir)) {
|
|
2095
|
+
for (const name of fs.readdirSync(logsDir)) {
|
|
2096
|
+
if (!name.startsWith("."))
|
|
2097
|
+
names.add(name);
|
|
1393
2098
|
}
|
|
1394
2099
|
}
|
|
1395
|
-
const
|
|
1396
|
-
|
|
2100
|
+
const streams = [...names].sort().map((name) => {
|
|
2101
|
+
const filePath = path.join(logsDir, name);
|
|
2102
|
+
let bytes = 0;
|
|
2103
|
+
let text = "";
|
|
2104
|
+
try {
|
|
2105
|
+
const st = fs.statSync(filePath);
|
|
2106
|
+
if (st.isFile()) {
|
|
2107
|
+
bytes = st.size;
|
|
2108
|
+
text = fs.readFileSync(filePath, "utf8");
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
catch (err) {
|
|
2112
|
+
const code = err.code;
|
|
2113
|
+
if (code !== "ENOENT")
|
|
2114
|
+
throw err;
|
|
2115
|
+
}
|
|
2116
|
+
const lines = text.split("\n").filter((line) => line.length > 0);
|
|
2117
|
+
const sliced = tail === undefined ? lines : lines.slice(-tail);
|
|
2118
|
+
return { name, path: filePath, bytes, lines: sliced };
|
|
2119
|
+
});
|
|
2120
|
+
printJson({ ok: true, action: "logs", lane_id: laneId, streams });
|
|
2121
|
+
}
|
|
2122
|
+
export function stopCommittedState(state) {
|
|
2123
|
+
return state === "queued" ? "cancelled" : "killed";
|
|
1397
2124
|
}
|
|
1398
2125
|
async function runStop(ctx, laneId) {
|
|
1399
2126
|
const hqRoot = ctx.hqRoot();
|
|
@@ -1401,20 +2128,24 @@ async function runStop(ctx, laneId) {
|
|
|
1401
2128
|
if (isTerminalLaneState(lane.state)) {
|
|
1402
2129
|
throw new LanesError("already_terminal", `Lane ${laneId} is already ${lane.state}`, { lane_id: laneId, state: lane.state });
|
|
1403
2130
|
}
|
|
1404
|
-
const nextState = lane.state === "queued" ? "cancelled" : "killed";
|
|
1405
2131
|
const at = ctx.now().toISOString();
|
|
1406
2132
|
const updated = casUpdateLane(hqRoot, laneId, (current) => {
|
|
1407
2133
|
if (isTerminalLaneState(current.state)) {
|
|
1408
|
-
if (current.state === nextState)
|
|
1409
|
-
return current;
|
|
1410
2134
|
throw new LanesError("already_terminal", `Lane ${laneId} is already ${current.state}`, { lane_id: laneId, state: current.state });
|
|
1411
2135
|
}
|
|
1412
2136
|
return {
|
|
1413
2137
|
...current,
|
|
1414
|
-
state:
|
|
2138
|
+
state: stopCommittedState(current.state),
|
|
1415
2139
|
queued_reason: undefined,
|
|
1416
2140
|
};
|
|
1417
2141
|
});
|
|
2142
|
+
if (updated.state === "killed") {
|
|
2143
|
+
stopLaneProcesses(hqRoot, laneId, {
|
|
2144
|
+
killProcess: ctx.killProcess,
|
|
2145
|
+
isAlive: ctx.isAlive,
|
|
2146
|
+
sleepMs: ctx.stopSleepMs,
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
1418
2149
|
const cancelled = cancelPendingQuestionsForLane(hqRoot, laneId, at);
|
|
1419
2150
|
printJson({
|
|
1420
2151
|
ok: true,
|
|
@@ -1447,49 +2178,20 @@ async function runAsk(ctx, opts) {
|
|
|
1447
2178
|
if (opts.recommended !== undefined)
|
|
1448
2179
|
body.recommended = opts.recommended;
|
|
1449
2180
|
const at = ctx.now().toISOString();
|
|
1450
|
-
const store = bodyStoreForLane(lane);
|
|
1451
2181
|
let questionId;
|
|
1452
2182
|
let lastErr;
|
|
1453
|
-
const attempts =
|
|
2183
|
+
const attempts = ASK_ATTEMPTS;
|
|
1454
2184
|
for (let i = 0; i < attempts; i += 1) {
|
|
1455
2185
|
try {
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
raised_via: "cli",
|
|
1464
|
-
asked_at: at,
|
|
1465
|
-
origin: { kind: "lane", id: lane.lane_id },
|
|
1466
|
-
hop: 1,
|
|
1467
|
-
status: "pending",
|
|
1468
|
-
body: { store, ref: "", digest: "" },
|
|
1469
|
-
routing_history: [],
|
|
1470
|
-
});
|
|
1471
|
-
questionId = created.question_id;
|
|
1472
|
-
}
|
|
1473
|
-
const stored = writeQuestionBody(hqRoot, {
|
|
1474
|
-
company: lane.company,
|
|
1475
|
-
question_id: questionId,
|
|
1476
|
-
body: { store, ref: "", digest: "" },
|
|
1477
|
-
}, body);
|
|
1478
|
-
const withBody = casUpdateQuestion(hqRoot, questionId, (current) => {
|
|
1479
|
-
const asked = current.routing_history.some((entry) => entry.reason === "asked");
|
|
1480
|
-
return {
|
|
1481
|
-
...current,
|
|
1482
|
-
body: stored,
|
|
1483
|
-
routing_history: asked
|
|
1484
|
-
? current.routing_history
|
|
1485
|
-
: appendCappedHistory(current.routing_history, {
|
|
1486
|
-
hop: 1,
|
|
1487
|
-
audience: lane.senior,
|
|
1488
|
-
at,
|
|
1489
|
-
reason: "asked",
|
|
1490
|
-
}),
|
|
1491
|
-
};
|
|
2186
|
+
const withBody = raiseLaneQuestion({
|
|
2187
|
+
hqRoot,
|
|
2188
|
+
lane,
|
|
2189
|
+
body,
|
|
2190
|
+
raisedVia: "cli",
|
|
2191
|
+
at,
|
|
2192
|
+
questionId,
|
|
1492
2193
|
});
|
|
2194
|
+
questionId = withBody.question_id;
|
|
1493
2195
|
await emitLaneMeshEvent(ctx, lane.company, lane.thread_id, "lane_question", {
|
|
1494
2196
|
lane_id: lane.lane_id,
|
|
1495
2197
|
question_id: withBody.question_id,
|
|
@@ -1513,9 +2215,10 @@ async function runAsk(ctx, opts) {
|
|
|
1513
2215
|
}
|
|
1514
2216
|
catch (err) {
|
|
1515
2217
|
lastErr = err;
|
|
2218
|
+
process.stderr.write(`hq lanes ask: attempt ${i + 1}/${attempts} failed for ${lane.lane_id}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1516
2219
|
if (i === attempts - 1)
|
|
1517
2220
|
break;
|
|
1518
|
-
await ctx.sleep(ASK_BACKOFF_MS[i] ??
|
|
2221
|
+
await ctx.sleep(ASK_BACKOFF_MS[i] ?? ASK_BACKOFF_MS[ASK_BACKOFF_MS.length - 1]);
|
|
1519
2222
|
}
|
|
1520
2223
|
}
|
|
1521
2224
|
const failure = {
|
|
@@ -1556,7 +2259,7 @@ async function runQuestionsList(ctx, opts) {
|
|
|
1556
2259
|
return false;
|
|
1557
2260
|
if (opts.story && q.story_id !== opts.story)
|
|
1558
2261
|
return false;
|
|
1559
|
-
if (company && q.company
|
|
2262
|
+
if (company && !recordMatchesCompany(q.company, company)) {
|
|
1560
2263
|
return false;
|
|
1561
2264
|
}
|
|
1562
2265
|
if (forFilter) {
|
|
@@ -1569,6 +2272,7 @@ async function runQuestionsList(ctx, opts) {
|
|
|
1569
2272
|
.map((q) => ({
|
|
1570
2273
|
...q,
|
|
1571
2274
|
audience: resolveAudience(hqRoot, q, ctx),
|
|
2275
|
+
...(q.question_kind === "continue" ? { flag: "continue-loop" } : {}),
|
|
1572
2276
|
}));
|
|
1573
2277
|
printJson({ ok: true, action: "questions.list", questions });
|
|
1574
2278
|
}
|
|
@@ -1586,14 +2290,20 @@ async function runQuestionsShow(ctx, questionId) {
|
|
|
1586
2290
|
}
|
|
1587
2291
|
async function runQuestionsAnswer(ctx, questionId, opts) {
|
|
1588
2292
|
const hqRoot = ctx.hqRoot();
|
|
1589
|
-
if (!opts.choice) {
|
|
1590
|
-
throw new LanesError("missing_choice", "--choice is required", {});
|
|
1591
|
-
}
|
|
1592
2293
|
const question = readQuestion(hqRoot, questionId);
|
|
1593
2294
|
throwIfQuestionNotPending(question);
|
|
1594
2295
|
await yieldEventLoop();
|
|
1595
2296
|
const body = requireQuestionBody(hqRoot, question);
|
|
1596
|
-
|
|
2297
|
+
// A trimmed-empty --freeform is absent: it must not win over --choice and
|
|
2298
|
+
// must not become a freeform answer of "".
|
|
2299
|
+
const freeform = opts.freeform?.trim() || undefined;
|
|
2300
|
+
if (freeform !== undefined && body.options.length === 0) {
|
|
2301
|
+
throw new LanesError("freeform_not_allowed", "--freeform is only allowed for a question with options", { question_id: questionId });
|
|
2302
|
+
}
|
|
2303
|
+
if (!freeform && !opts.choice) {
|
|
2304
|
+
throw new LanesError("missing_choice", "--choice or --freeform is required", {});
|
|
2305
|
+
}
|
|
2306
|
+
const choice = freeform ?? resolveAnswerChoice(opts.choice, body.options, false);
|
|
1597
2307
|
const at = ctx.now().toISOString();
|
|
1598
2308
|
const answeredBy = sessionIdFromEnv(ctx.env)
|
|
1599
2309
|
? `session:${sessionIdFromEnv(ctx.env)}`
|
|
@@ -1616,6 +2326,7 @@ async function runQuestionsAnswer(ctx, questionId, opts) {
|
|
|
1616
2326
|
status: "answered",
|
|
1617
2327
|
answer: {
|
|
1618
2328
|
choice,
|
|
2329
|
+
answer_kind: freeform ? "freeform" : "option",
|
|
1619
2330
|
notes_ref: notesRef,
|
|
1620
2331
|
answered_by: answeredBy,
|
|
1621
2332
|
answered_at: at,
|
|
@@ -1623,7 +2334,49 @@ async function runQuestionsAnswer(ctx, questionId, opts) {
|
|
|
1623
2334
|
},
|
|
1624
2335
|
};
|
|
1625
2336
|
});
|
|
1626
|
-
|
|
2337
|
+
const delivery = deliverAnswerToAskingLane(hqRoot, updated, choice, at);
|
|
2338
|
+
printJson({
|
|
2339
|
+
ok: true,
|
|
2340
|
+
action: "questions.answer",
|
|
2341
|
+
question: updated,
|
|
2342
|
+
delivery: delivery.status,
|
|
2343
|
+
});
|
|
2344
|
+
}
|
|
2345
|
+
function writeAnswerDeliveryFailed(hqRoot, laneId, questionId, err, at) {
|
|
2346
|
+
const failure = {
|
|
2347
|
+
schema_version: "hq-lane-ask-delivery-failed.v1",
|
|
2348
|
+
at,
|
|
2349
|
+
lane_id: laneId,
|
|
2350
|
+
question_id: questionId,
|
|
2351
|
+
error: err instanceof Error
|
|
2352
|
+
? { name: err.name, message: err.message }
|
|
2353
|
+
: { name: "Error", message: String(err) },
|
|
2354
|
+
};
|
|
2355
|
+
try {
|
|
2356
|
+
writeJsonFile(askDeliveryFailedPath(hqRoot, laneId), failure);
|
|
2357
|
+
}
|
|
2358
|
+
catch (writeErr) {
|
|
2359
|
+
process.stderr.write(`hq lanes: failed to write ask_delivery_failed for ${laneId}: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}\n`);
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
/**
|
|
2363
|
+
* Record is already durable. Delivery is best-effort onto the asking lane's
|
|
2364
|
+
* drop box (spec §6.5). Target is question.lane_id, never a project/story lookup.
|
|
2365
|
+
*/
|
|
2366
|
+
function deliverAnswerToAskingLane(hqRoot, question, choice, at) {
|
|
2367
|
+
const laneId = laneIdForAnswerDelivery(question);
|
|
2368
|
+
try {
|
|
2369
|
+
const lane = readLane(hqRoot, laneId);
|
|
2370
|
+
if (!laneAcceptsDropboxDelivery(lane)) {
|
|
2371
|
+
return { status: "recorded_only" };
|
|
2372
|
+
}
|
|
2373
|
+
appendDropboxMessage(hqRoot, laneId, answerDropboxMessage(question, choice, at, mintMessageId()));
|
|
2374
|
+
return { status: "dropped" };
|
|
2375
|
+
}
|
|
2376
|
+
catch (err) {
|
|
2377
|
+
writeAnswerDeliveryFailed(hqRoot, laneId, question.question_id, err, at);
|
|
2378
|
+
return { status: "failed" };
|
|
2379
|
+
}
|
|
1627
2380
|
}
|
|
1628
2381
|
async function runQuestionsEscalate(ctx, questionId, opts) {
|
|
1629
2382
|
const hqRoot = ctx.hqRoot();
|
|
@@ -1705,10 +2458,45 @@ async function runAccountsList(ctx, opts) {
|
|
|
1705
2458
|
const providers = opts.provider
|
|
1706
2459
|
? [parseLaneProvider(opts.provider)]
|
|
1707
2460
|
: [...LANE_PROVIDERS];
|
|
1708
|
-
|
|
1709
|
-
|
|
2461
|
+
// model and effort are what a spawn on each account would start from
|
|
2462
|
+
// (spec: --model/--effort, then the account, then the provider).
|
|
2463
|
+
const quota = loadQuotaState(hqRoot);
|
|
2464
|
+
const accounts = providers.flatMap((provider) => listAccountStatuses(provider, config).map((status) => {
|
|
2465
|
+
const accountCfg = config.providers[provider].accounts[status.id];
|
|
2466
|
+
const model = resolveLaneModel({
|
|
2467
|
+
accountModel: accountCfg?.model,
|
|
2468
|
+
providerModel: config.providers[provider].model,
|
|
2469
|
+
});
|
|
2470
|
+
const effort = resolveLaneEffort({
|
|
2471
|
+
accountEffort: accountCfg?.effort,
|
|
2472
|
+
providerEffort: config.providers[provider].effort,
|
|
2473
|
+
model,
|
|
2474
|
+
});
|
|
2475
|
+
return {
|
|
2476
|
+
...status,
|
|
2477
|
+
model: accountCfg?.model ?? null,
|
|
2478
|
+
effort: accountCfg?.effort ?? null,
|
|
2479
|
+
effective_model: model ?? null,
|
|
2480
|
+
effective_effort: effort.effort ?? null,
|
|
2481
|
+
effective_effort_source: effort.source,
|
|
2482
|
+
...accountQuotaView(quota, provider, status.id),
|
|
2483
|
+
};
|
|
2484
|
+
}));
|
|
2485
|
+
const providerDefaults = providers.map((provider) => ({
|
|
2486
|
+
provider,
|
|
2487
|
+
model: config.providers[provider].model ?? null,
|
|
2488
|
+
effort: config.providers[provider].effort ?? null,
|
|
2489
|
+
compact_after_ms: providerCompactAfterMs(config.providers[provider]),
|
|
2490
|
+
}));
|
|
2491
|
+
printJson({
|
|
2492
|
+
ok: true,
|
|
2493
|
+
action: "accounts.list",
|
|
2494
|
+
providers: providerDefaults,
|
|
2495
|
+
accounts,
|
|
2496
|
+
});
|
|
1710
2497
|
}
|
|
1711
2498
|
async function runAccountsLogin(ctx, providerRaw, accountId, opts) {
|
|
2499
|
+
const callerCwd = ctx.callerCwd();
|
|
1712
2500
|
const hqRoot = ctx.hqRoot();
|
|
1713
2501
|
const provider = parseLaneProvider(providerRaw);
|
|
1714
2502
|
const config = loadLanesConfig(hqRoot);
|
|
@@ -1720,7 +2508,10 @@ async function runAccountsLogin(ctx, providerRaw, accountId, opts) {
|
|
|
1720
2508
|
(id === "a"
|
|
1721
2509
|
? DEFAULT_ACCOUNT_HOMES[provider]
|
|
1722
2510
|
: `~/.${provider}-${id}`);
|
|
1723
|
-
const
|
|
2511
|
+
const homeResolved = homeRaw.startsWith("~") || path.isAbsolute(homeRaw)
|
|
2512
|
+
? homeRaw
|
|
2513
|
+
: resolveCallerPath(callerCwd, homeRaw);
|
|
2514
|
+
const home = canonicalizeHome(homeResolved, {
|
|
1724
2515
|
homedir: ctx.homedir,
|
|
1725
2516
|
realpathSync: (p) => fs.realpathSync(p),
|
|
1726
2517
|
existsSync: (p) => fs.existsSync(p),
|
|
@@ -1734,13 +2525,18 @@ async function runAccountsLogin(ctx, providerRaw, accountId, opts) {
|
|
|
1734
2525
|
}
|
|
1735
2526
|
ensureDir(home);
|
|
1736
2527
|
const defaultId = config.providers[provider].default_account;
|
|
1737
|
-
const defaultHome =
|
|
2528
|
+
const defaultHome = (defaultId !== undefined
|
|
2529
|
+
? config.providers[provider].accounts[defaultId]?.home
|
|
2530
|
+
: undefined) ??
|
|
2531
|
+
Object.values(config.providers[provider].accounts).find((account) => account.enabled)
|
|
2532
|
+
?.home ??
|
|
1738
2533
|
canonicalizeHome(DEFAULT_ACCOUNT_HOMES[provider], {
|
|
1739
2534
|
homedir: ctx.homedir,
|
|
1740
2535
|
realpathSync: (p) => fs.realpathSync(p),
|
|
1741
2536
|
existsSync: (p) => fs.existsSync(p),
|
|
1742
2537
|
});
|
|
1743
2538
|
const copied = defaultHome !== home ? copyHookRails(defaultHome, home) : railsPresent(home);
|
|
2539
|
+
installDrainHook(provider, home);
|
|
1744
2540
|
const envVar = PROVIDER_HOME_ENV[provider];
|
|
1745
2541
|
const login = VENDOR_LOGIN[provider];
|
|
1746
2542
|
const result = await ctx.spawnLogin({
|
|
@@ -1814,7 +2610,13 @@ async function runAccountsDisable(ctx, providerRaw, accountId, opts) {
|
|
|
1814
2610
|
async function runSessionsList(ctx, opts) {
|
|
1815
2611
|
const hqRoot = ctx.hqRoot();
|
|
1816
2612
|
const state = opts.state ? parseLivenessState(opts.state) : undefined;
|
|
1817
|
-
const
|
|
2613
|
+
const livenessDeps = livenessDepsFromCtx(ctx);
|
|
2614
|
+
const presenceConnected = ctx.presenceConnected ?? readDaemonPresenceConnected(livenessDeps);
|
|
2615
|
+
const rows = listSessionLiveness(hqRoot, {
|
|
2616
|
+
nowMs: ctx.now().getTime(),
|
|
2617
|
+
presenceConnected,
|
|
2618
|
+
deps: livenessDeps,
|
|
2619
|
+
}).filter((row) => {
|
|
1818
2620
|
if (state && row.state !== state)
|
|
1819
2621
|
return false;
|
|
1820
2622
|
if (!opts.company)
|
|
@@ -1828,7 +2630,13 @@ async function runSessionsList(ctx, opts) {
|
|
|
1828
2630
|
}
|
|
1829
2631
|
async function runSessionsShow(ctx, sessionId) {
|
|
1830
2632
|
const hqRoot = ctx.hqRoot();
|
|
1831
|
-
const
|
|
2633
|
+
const livenessDeps = livenessDepsFromCtx(ctx);
|
|
2634
|
+
const presenceConnected = ctx.presenceConnected ?? readDaemonPresenceConnected(livenessDeps);
|
|
2635
|
+
const state = sessionLiveness(hqRoot, sessionId, {
|
|
2636
|
+
nowMs: ctx.now().getTime(),
|
|
2637
|
+
presenceConnected,
|
|
2638
|
+
deps: livenessDeps,
|
|
2639
|
+
});
|
|
1832
2640
|
if (state === null) {
|
|
1833
2641
|
throw new LanesError("session_not_found", `Session ${sessionId} is not in the liveness index`, { session_id: sessionId });
|
|
1834
2642
|
}
|
|
@@ -1842,21 +2650,104 @@ async function runSessionsShow(ctx, sessionId) {
|
|
|
1842
2650
|
company_slug: typeof meta?.company_slug === "string" ? meta.company_slug : null,
|
|
1843
2651
|
});
|
|
1844
2652
|
}
|
|
2653
|
+
async function runReconcile(ctx, opts) {
|
|
2654
|
+
const fromEnv = ctx.env.HQ_ROOT?.trim();
|
|
2655
|
+
const hqRoot = fromEnv && ctx.existsSync(fromEnv) ? fromEnv : ctx.hqRoot();
|
|
2656
|
+
const summary = await runReconcilePass(hqRoot, {
|
|
2657
|
+
now: ctx.now,
|
|
2658
|
+
host: reconcileHostFromCtx(ctx),
|
|
2659
|
+
dryRun: Boolean(opts.dryRun),
|
|
2660
|
+
spawnLaneDeps: {
|
|
2661
|
+
env: ctx.env,
|
|
2662
|
+
existsSync: ctx.existsSync,
|
|
2663
|
+
spawnProcess: ctx.spawnProcess,
|
|
2664
|
+
proveDetached: ctx.proveDetached,
|
|
2665
|
+
killProcess: ctx.killProcess,
|
|
2666
|
+
now: ctx.now,
|
|
2667
|
+
resolveMachineIdentity: ctx.resolveMachineIdentity,
|
|
2668
|
+
runCompaction: ctx.runCompaction,
|
|
2669
|
+
locateTranscript: ctx.locateTranscript,
|
|
2670
|
+
},
|
|
2671
|
+
throttleDeps: ctx.throttleDeps,
|
|
2672
|
+
livenessDeps: livenessDepsFromCtx(ctx),
|
|
2673
|
+
presenceConnected: ctx.presenceConnected,
|
|
2674
|
+
env: ctx.env,
|
|
2675
|
+
});
|
|
2676
|
+
printJson(summary);
|
|
2677
|
+
if (!summary.ok) {
|
|
2678
|
+
process.exit(1);
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
async function runReconcileInstall(ctx, opts) {
|
|
2682
|
+
const callerCwd = ctx.callerCwd();
|
|
2683
|
+
const systemdRootRaw = opts.systemdRoot ?? ctx.systemdRoot ?? defaultSystemdUserDir(ctx.homedir());
|
|
2684
|
+
const systemdRoot = opts.systemdRoot !== undefined
|
|
2685
|
+
? resolveCallerPath(callerCwd, opts.systemdRoot)
|
|
2686
|
+
: systemdRootRaw;
|
|
2687
|
+
const config = loadLanesConfig(ctx.hqRoot());
|
|
2688
|
+
const activeProviders = LANE_PROVIDERS.filter((provider) => Object.values(config.providers[provider].accounts).some((account) => account.enabled));
|
|
2689
|
+
const providerPath = providerPathEnv(activeProviders, ctx.env.PATH);
|
|
2690
|
+
const result = installReconcileTimer({
|
|
2691
|
+
systemdRoot,
|
|
2692
|
+
hqRoot: ctx.hqRoot(),
|
|
2693
|
+
enable: Boolean(opts.enable),
|
|
2694
|
+
runCommand: opts.enable ? ctx.runSystemctl : undefined,
|
|
2695
|
+
pathEnv: providerPath.path,
|
|
2696
|
+
});
|
|
2697
|
+
printJson({
|
|
2698
|
+
...result,
|
|
2699
|
+
providers_resolved: providerPath.resolved,
|
|
2700
|
+
providers_unresolved: providerPath.unresolved,
|
|
2701
|
+
...(providerPath.unresolved.length > 0
|
|
2702
|
+
? {
|
|
2703
|
+
warnings: providerPath.unresolved.map((provider) => `provider binary ${provider} is not on PATH; lanes for ${provider} stay queued with provider_binary_missing until it is installed and the timer is reinstalled`),
|
|
2704
|
+
}
|
|
2705
|
+
: {}),
|
|
2706
|
+
});
|
|
2707
|
+
}
|
|
2708
|
+
async function runReconcileUninstall(ctx, opts) {
|
|
2709
|
+
const callerCwd = ctx.callerCwd();
|
|
2710
|
+
const systemdRootRaw = opts.systemdRoot ?? ctx.systemdRoot ?? defaultSystemdUserDir(ctx.homedir());
|
|
2711
|
+
const systemdRoot = opts.systemdRoot !== undefined
|
|
2712
|
+
? resolveCallerPath(callerCwd, opts.systemdRoot)
|
|
2713
|
+
: systemdRootRaw;
|
|
2714
|
+
const result = uninstallReconcileTimer({
|
|
2715
|
+
systemdRoot,
|
|
2716
|
+
disable: Boolean(opts.disable),
|
|
2717
|
+
runCommand: opts.disable ? ctx.runSystemctl : undefined,
|
|
2718
|
+
});
|
|
2719
|
+
printJson(result);
|
|
2720
|
+
}
|
|
2721
|
+
function commandHasAction(cmd) {
|
|
2722
|
+
return (typeof cmd._actionHandler ===
|
|
2723
|
+
"function");
|
|
2724
|
+
}
|
|
2725
|
+
/** Spec §7: every command prints JSON. Accept `--json` as a no-op everywhere. */
|
|
2726
|
+
function attachJsonNoop(cmd) {
|
|
2727
|
+
const hasJson = (cmd.options ?? []).some((opt) => opt.long === "--json");
|
|
2728
|
+
if (commandHasAction(cmd) && !hasJson) {
|
|
2729
|
+
cmd.option("--json", "JSON on stdout (always)");
|
|
2730
|
+
}
|
|
2731
|
+
for (const child of cmd.commands)
|
|
2732
|
+
attachJsonNoop(child);
|
|
2733
|
+
}
|
|
1845
2734
|
export function registerLanesCommand(parent, deps = {}) {
|
|
1846
2735
|
const ctx = resolveDeps(deps);
|
|
1847
2736
|
const lanes = parent
|
|
1848
|
-
.command("lanes")
|
|
2737
|
+
.command("lanes", { hidden: true })
|
|
1849
2738
|
.description("Create, supervise, and steer worker lanes; routed decision queue");
|
|
1850
2739
|
lanes
|
|
1851
2740
|
.command("create")
|
|
1852
2741
|
.description("Register a lane, run admission, and return immediately")
|
|
1853
|
-
.
|
|
2742
|
+
.option("--company <slug|uid>", "Company slug or cloud uid; omit for a core/personal lane")
|
|
1854
2743
|
.requiredOption("--project <slug>", "HQ project slug")
|
|
1855
2744
|
.requiredOption("--story <id>", "Story id from the project's prd.json")
|
|
1856
2745
|
.requiredOption("--brief-file <path>", "Path to the lane brief")
|
|
1857
2746
|
.option("--worker <name>", "HQ worker id from the registry")
|
|
1858
2747
|
.option("--provider <name>", "claude | codex | grok")
|
|
1859
2748
|
.option("--account <id>", "Provider account id")
|
|
2749
|
+
.option("--model <id>", "Model id for the provider CLI")
|
|
2750
|
+
.option("--effort <level>", "Effort level; claude: low|medium|high|xhigh|max, codex: minimal|low|medium|high|xhigh|max|ultra, grok: low|medium|high|xhigh")
|
|
1860
2751
|
.option("--senior <ref>", "session:<id> | lane:<id> | user")
|
|
1861
2752
|
.option("--timeout <secs>", "Deadline in seconds (warns, does not kill)")
|
|
1862
2753
|
.option("--priority <n>", "Queue priority; higher runs first", "1")
|
|
@@ -1872,6 +2763,7 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
1872
2763
|
.option("--story <id>", "Filter by story")
|
|
1873
2764
|
.option("--state <state>", `Filter by state (${LANE_STATES.join("|")})`)
|
|
1874
2765
|
.option("--senior <ref>", "Filter by senior ref")
|
|
2766
|
+
.option("--json", "JSON on stdout (always)")
|
|
1875
2767
|
.action(wrap(async (opts) => {
|
|
1876
2768
|
await runList(ctx, opts);
|
|
1877
2769
|
}));
|
|
@@ -1889,9 +2781,21 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
1889
2781
|
.requiredOption("--text <msg>", "Message text")
|
|
1890
2782
|
.option("--project <slug>", "Re-point project attribution from this message")
|
|
1891
2783
|
.option("--story <id>", "Re-point story attribution from this message")
|
|
2784
|
+
.option("--no-compact", "Skip the resume-time compaction check for this message")
|
|
1892
2785
|
.action(wrap(async (laneId, opts) => {
|
|
1893
2786
|
await runMessage(ctx, laneId, opts);
|
|
1894
2787
|
}));
|
|
2788
|
+
lanes
|
|
2789
|
+
.command("resume")
|
|
2790
|
+
.description("Resume an interrupted, done or blocked lane on its recorded provider session")
|
|
2791
|
+
.argument("<lane-id>", "Lane id")
|
|
2792
|
+
.option("--text <msg>", "Operator message for the resumed turn")
|
|
2793
|
+
.option("--project <slug>", "Re-point project attribution from this resume")
|
|
2794
|
+
.option("--story <id>", "Re-point story attribution from this resume")
|
|
2795
|
+
.option("--no-compact", "Skip the resume-time compaction check for this resume")
|
|
2796
|
+
.action(wrap(async (laneId, opts) => {
|
|
2797
|
+
await runResume(ctx, laneId, opts);
|
|
2798
|
+
}));
|
|
1895
2799
|
lanes
|
|
1896
2800
|
.command("reparent")
|
|
1897
2801
|
.description("Rewrite a lane's senior edge")
|
|
@@ -1952,9 +2856,9 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
1952
2856
|
.command("answer")
|
|
1953
2857
|
.description("Answer a pending question")
|
|
1954
2858
|
.argument("<question-id>", "Question id")
|
|
1955
|
-
.
|
|
2859
|
+
.option("--choice <label|N>", "1-based index or option label")
|
|
1956
2860
|
.option("--notes <text>", "Free-text notes stored with the body")
|
|
1957
|
-
.option("--freeform", "
|
|
2861
|
+
.option("--freeform <text>", "Supply a free-form answer for a question with options")
|
|
1958
2862
|
.action(wrap(async (questionId, opts) => {
|
|
1959
2863
|
await runQuestionsAnswer(ctx, questionId, opts);
|
|
1960
2864
|
}));
|
|
@@ -2028,5 +2932,38 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
2028
2932
|
.action(wrap(async (sessionId) => {
|
|
2029
2933
|
await runSessionsShow(ctx, sessionId);
|
|
2030
2934
|
}));
|
|
2935
|
+
lanes
|
|
2936
|
+
.command("pickup", { hidden: true })
|
|
2937
|
+
.description("Ingest an Outpost-dispatched lane payload and start it on this host")
|
|
2938
|
+
.option("--payload-file <path>", "Local pickup payload JSON")
|
|
2939
|
+
.option("--payload-url <url>", "HTTPS URL of a staged pickup payload")
|
|
2940
|
+
.action(wrap(async (opts) => {
|
|
2941
|
+
await runPickup(ctx, opts);
|
|
2942
|
+
}));
|
|
2943
|
+
const reconcile = lanes
|
|
2944
|
+
.command("reconcile", { hidden: true })
|
|
2945
|
+
.description("Run one machine-owned reconcile pass and exit")
|
|
2946
|
+
.option("--dry-run", "Report what would change and write nothing")
|
|
2947
|
+
.action(wrap(async (opts) => {
|
|
2948
|
+
await runReconcile(ctx, opts);
|
|
2949
|
+
}));
|
|
2950
|
+
reconcile
|
|
2951
|
+
.command("install")
|
|
2952
|
+
.description("Write systemd user unit and timer files (idempotent)")
|
|
2953
|
+
.option("--systemd-root <dir>", "Directory for unit files (default ~/.config/systemd/user)")
|
|
2954
|
+
.option("--enable", "Enable the timer via systemctl --user")
|
|
2955
|
+
.action(wrap(async (opts) => {
|
|
2956
|
+
await runReconcileInstall(ctx, opts);
|
|
2957
|
+
}));
|
|
2958
|
+
reconcile
|
|
2959
|
+
.command("uninstall")
|
|
2960
|
+
.description("Remove systemd user unit and timer files (idempotent)")
|
|
2961
|
+
.option("--systemd-root <dir>", "Directory for unit files")
|
|
2962
|
+
.option("--disable", "Disable the timer via systemctl --user")
|
|
2963
|
+
.action(wrap(async (opts) => {
|
|
2964
|
+
await runReconcileUninstall(ctx, opts);
|
|
2965
|
+
}));
|
|
2966
|
+
attachJsonNoop(lanes);
|
|
2967
|
+
return lanes;
|
|
2031
2968
|
}
|
|
2032
2969
|
//# sourceMappingURL=lanes.js.map
|