catherd-cli 1.3.0 → 1.5.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/README.md +144 -42
- package/catalog/ATTRIBUTION.md +12 -4
- package/catalog/models.json +8 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +11 -0
- package/plugin/.mcp-codex.json +10 -0
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +150 -79
- package/plugin/skills/catherd-setup/SKILL.md +24 -8
- package/src/adapters/access.ts +7 -1
- package/src/adapters/antigravity/index.ts +18 -3
- package/src/adapters/backend.ts +18 -2
- package/src/adapters/claude-code/index.ts +126 -14
- package/src/adapters/cli.ts +1 -64
- package/src/adapters/codex/events.ts +10 -0
- package/src/adapters/codex/index.ts +17 -5
- package/src/adapters/cursor/index.ts +7 -1
- package/src/adapters/grok/home.ts +75 -20
- package/src/adapters/grok/index.ts +27 -8
- package/src/adapters/opencode/index.ts +38 -1
- package/src/cli.ts +53 -19
- package/src/domain/agents.ts +3 -1
- package/src/domain/brief.ts +91 -0
- package/src/domain/calibration.ts +7 -0
- package/src/domain/catalog.ts +12 -1
- package/src/domain/changes.ts +23 -1
- package/src/domain/cost.ts +8 -3
- package/src/domain/errors.ts +4 -0
- package/src/domain/failover.ts +17 -5
- package/src/domain/gate-brief.ts +52 -0
- package/src/domain/hints.ts +13 -2
- package/src/domain/host.ts +17 -0
- package/src/domain/lane.ts +99 -3
- package/src/domain/notice.ts +14 -4
- package/src/domain/profile-rules.ts +129 -16
- package/src/domain/profile.ts +56 -5
- package/src/domain/record.ts +17 -2
- package/src/domain/reply.ts +29 -0
- package/src/domain/role-prompts.ts +56 -16
- package/src/domain/role-scope.ts +106 -0
- package/src/domain/role-tools.ts +34 -0
- package/src/domain/route.ts +73 -0
- package/src/domain/select.ts +177 -25
- package/src/domain/state.ts +5 -0
- package/src/domain/util.ts +22 -0
- package/src/domain/workspace.ts +130 -0
- package/src/entry/catalog-command.ts +18 -8
- package/src/entry/cli-kit.ts +17 -1
- package/src/entry/deps.ts +8 -3
- package/src/entry/doctor-command.ts +29 -2
- package/src/entry/host-arg.ts +56 -0
- package/src/entry/init-command.ts +20 -8
- package/src/entry/knowledge-command.ts +78 -3
- package/src/entry/lock-command.ts +111 -10
- package/src/entry/mcp/dispatch-tools.ts +3 -3
- package/src/entry/mcp/handshake.ts +8 -2
- package/src/entry/mcp/lane-tools.ts +55 -8
- package/src/entry/mcp/protocol-tools.ts +10 -4
- package/src/entry/mcp/push-tools.ts +16 -0
- package/src/entry/mcp/role-bin.ts +20 -0
- package/src/entry/mcp/role-server.ts +116 -0
- package/src/entry/mcp/run-tools.ts +25 -6
- package/src/entry/mcp/server.ts +224 -44
- package/src/entry/mcp/setup-tools.ts +11 -5
- package/src/entry/mcp/workspace-tools.ts +142 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +131 -16
- package/src/entry/role-cli-command.ts +115 -0
- package/src/entry/runs-command.ts +157 -11
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/tui/effects.ts +23 -8
- package/src/entry/tui/fixtures.ts +33 -10
- package/src/entry/tui/run.tsx +10 -4
- package/src/entry/tui/state.ts +56 -16
- package/src/entry/tui/stories.ts +11 -4
- package/src/entry/tui/views/profile-actions.ts +4 -2
- package/src/entry/tui/views/profiles.tsx +3 -3
- package/src/entry/tui/views/runs.tsx +66 -20
- package/src/entry/tui/views/save-dialog.tsx +8 -4
- package/src/entry/tui/views/status.tsx +6 -2
- package/src/entry/workspace-command.ts +44 -0
- package/src/infra/boot-lock.ts +25 -0
- package/src/infra/cli.ts +65 -0
- package/src/infra/codex-queue.ts +165 -0
- package/src/infra/delivery.ts +86 -0
- package/src/infra/dispatch-dir.ts +3 -0
- package/src/infra/env.ts +15 -2
- package/src/infra/git.ts +6 -0
- package/src/infra/heavy-lock.ts +93 -4
- package/src/infra/host-context.ts +58 -0
- package/src/infra/host-probe.ts +16 -0
- package/src/infra/lock-activity.ts +88 -0
- package/src/infra/login-env.ts +48 -0
- package/src/infra/paths.ts +35 -0
- package/src/infra/peer-inbox.ts +4 -1
- package/src/infra/role-mcp.ts +69 -0
- package/src/infra/sources/arena.ts +11 -3
- package/src/infra/sources/artificial-analysis.ts +8 -1
- package/src/infra/sources/cache.ts +2 -0
- package/src/infra/supervisor.ts +110 -18
- package/src/services/admission.ts +117 -26
- package/src/services/agent-links.ts +30 -5
- package/src/services/backends.ts +7 -0
- package/src/services/budget.ts +26 -6
- package/src/services/catalog-service.ts +20 -12
- package/src/services/credentials.ts +11 -6
- package/src/services/dispatch-service.ts +220 -22
- package/src/services/dispatches.ts +36 -3
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-backends.ts +30 -7
- package/src/services/doctor-checks.ts +20 -5
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-push.ts +69 -90
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +172 -38
- package/src/services/end-push.ts +157 -0
- package/src/services/finalize.ts +13 -3
- package/src/services/gate-env.ts +218 -0
- package/src/services/gate-service.ts +216 -45
- package/src/services/global-install.ts +46 -13
- package/src/services/lane-edit.ts +154 -0
- package/src/services/lane-service.ts +258 -52
- package/src/services/milestones.ts +137 -26
- package/src/services/notifier.ts +377 -72
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +122 -23
- package/src/services/ports.ts +39 -4
- package/src/services/preflight.ts +153 -19
- package/src/services/profile-service.ts +117 -30
- package/src/services/profile-store.ts +31 -7
- package/src/services/protocol.ts +119 -25
- package/src/services/provenance.ts +16 -12
- package/src/services/questions.ts +15 -0
- package/src/services/reconcile.ts +22 -1
- package/src/services/reentry.ts +7 -6
- package/src/services/role-access.ts +27 -0
- package/src/services/routing-service.ts +150 -37
- package/src/services/run-debug.ts +98 -4
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-service.ts +153 -19
- package/src/services/run-store.ts +119 -19
- package/src/services/runs-page.ts +45 -9
- package/src/services/scratch.ts +60 -0
- package/src/services/session-view.ts +18 -13
- package/src/services/sessions.ts +34 -16
- package/src/services/setup.ts +31 -10
- package/src/services/source-derive.ts +28 -5
- package/src/services/source-sync.ts +9 -2
- package/src/services/standins.ts +40 -6
- package/src/services/state.ts +21 -0
- package/src/services/summary.ts +86 -11
- package/src/services/treat-likes.ts +41 -12
- package/src/services/verifier-brief.ts +23 -0
- package/src/services/verifier-step.ts +48 -0
- package/src/services/workspace-admission.ts +267 -0
- package/src/services/workspace-service.ts +254 -0
- package/src/services/workspace-store.ts +95 -0
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { assertNativeHost } from "./backends.ts";
|
|
1
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
2
4
|
import { CatherdError, errorMessage, isCatherdError } from "../domain/errors.ts";
|
|
3
5
|
import { assertId, parseRung } from "../domain/ids.ts";
|
|
4
6
|
import type { RunRecord } from "../domain/record.ts";
|
|
@@ -15,8 +17,9 @@ import {
|
|
|
15
17
|
import { lockHeld, withFileLock } from "../infra/filelock.ts";
|
|
16
18
|
import { log } from "../infra/log.ts";
|
|
17
19
|
import { isAlive, isSurelyAlive, killGroup } from "../infra/proc.ts";
|
|
20
|
+
import { groupAlive, stopGroup } from "../infra/supervisor.ts";
|
|
18
21
|
import { writeJsonAtomic } from "../infra/store.ts";
|
|
19
|
-
import { admit, KILL_GRACE_MS, laneFile, launch } from "./admission.ts";
|
|
22
|
+
import { admit, KILL_GRACE_MS, laneFile, laneOwns, launch } from "./admission.ts";
|
|
20
23
|
import { standInFor } from "./backends.ts";
|
|
21
24
|
import {
|
|
22
25
|
type Dispatch,
|
|
@@ -33,8 +36,11 @@ import {
|
|
|
33
36
|
import { finalizeDispatch, finalizingElsewhere, waitForFinish } from "./finalize.ts";
|
|
34
37
|
import type { Deps } from "./ports.ts";
|
|
35
38
|
import { route } from "./lane-service.ts";
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
39
|
+
import { currentRoute } from "../domain/route.ts";
|
|
40
|
+
import { appendRoute, findRun, readRecords, readRoleRoutes, readRoutes, type Run } from "./run-store.ts";
|
|
41
|
+
import { sessionKey } from "../domain/host.ts";
|
|
42
|
+
import { claimRun, currentSession, ownsRun, runOwner, type SessionRef } from "./sessions.ts";
|
|
43
|
+
import { pinChanges, runProfile } from "./run-pin.ts";
|
|
38
44
|
import { type NotesPatch, refreshState } from "./state.ts";
|
|
39
45
|
|
|
40
46
|
export interface DispatchInput {
|
|
@@ -42,7 +48,8 @@ export interface DispatchInput {
|
|
|
42
48
|
role: Role;
|
|
43
49
|
name: string;
|
|
44
50
|
brief: string;
|
|
45
|
-
|
|
51
|
+
/** spec 1.5 plan 24: optional with `lane` (the lane's current rung); required without one */
|
|
52
|
+
rung?: string;
|
|
46
53
|
thread?: string;
|
|
47
54
|
lane?: string;
|
|
48
55
|
next?: string;
|
|
@@ -101,17 +108,21 @@ export interface Stalled {
|
|
|
101
108
|
export const stallHooks = new Set<(s: Stalled) => void>();
|
|
102
109
|
|
|
103
110
|
/** A watcher's poll: the first time the dispatch's stall.json is there, every stall hook hears of it. */
|
|
111
|
+
/** A stall's quiet time; 0 when stall.json is being written or damaged, so one bad file never blocks a notice. */
|
|
112
|
+
export function readStallQuietMs(dir: string): number {
|
|
113
|
+
try {
|
|
114
|
+
return Number(JSON.parse(readFileSync(dispatchPaths(dir).stall, "utf8")).quietMs) || 0;
|
|
115
|
+
} catch {
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
104
120
|
export function stallPoll(run: Run, d: Dispatch): () => void {
|
|
105
121
|
let seen = false;
|
|
106
122
|
return () => {
|
|
107
123
|
if (seen || !existsSync(dispatchPaths(d.dir).stall)) return;
|
|
108
124
|
seen = true;
|
|
109
|
-
|
|
110
|
-
try {
|
|
111
|
-
quietMs = Number(JSON.parse(readFileSync(dispatchPaths(d.dir).stall, "utf8")).quietMs) || 0;
|
|
112
|
-
} catch {
|
|
113
|
-
// being written: the stall is reported without its length
|
|
114
|
-
}
|
|
125
|
+
const quietMs = readStallQuietMs(d.dir);
|
|
115
126
|
for (const hook of stallHooks) {
|
|
116
127
|
try {
|
|
117
128
|
hook({ run, d, quietMs });
|
|
@@ -169,9 +180,19 @@ export async function watchersSettled(): Promise<void> {
|
|
|
169
180
|
*/
|
|
170
181
|
export function watch(deps: Deps, run: Run, d: Dispatch): void {
|
|
171
182
|
watching.add(d.admit.dispatchId);
|
|
183
|
+
// the session this watcher started under, kept apart from its live identity: an MCP connection that closes
|
|
184
|
+
// invalidates the scoped host (no session), yet the watcher still came from that session (PR #47 P1)
|
|
185
|
+
const origin = currentSession(deps);
|
|
172
186
|
const w = (async () => {
|
|
173
187
|
await waitForFinish(d, { pollMs: deps.pollMs, now: deps.now, onPoll: stallPoll(run, d) });
|
|
174
|
-
const
|
|
188
|
+
const record = await finalizeDispatch(run, d);
|
|
189
|
+
// a session that lost the run since it dispatched leaves a limit to the owner's claim (1.1 push minors):
|
|
190
|
+
// only the run's owner starts a stand-in. A process with no session (a terminal) settles as before.
|
|
191
|
+
if (record.status === "limit" && !limitIsMine(deps, run, origin)) {
|
|
192
|
+
log("info", "dispatch", { run: run.id, name: d.admit.name, limit: "left to the run's owner" });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const s = await settle(deps, run, d, record);
|
|
175
196
|
if (s.stateHints[0]) log("warn", "dispatch", { run: run.id, name: d.admit.name, hint: s.stateHints[0] });
|
|
176
197
|
})()
|
|
177
198
|
.catch((e: unknown) =>
|
|
@@ -184,6 +205,18 @@ export function watch(deps: Deps, run: Run, d: Dispatch): void {
|
|
|
184
205
|
watchers.add(w);
|
|
185
206
|
}
|
|
186
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Whether a watcher settles a limit (fails it over): a watcher with no session, then or now (a terminal, a
|
|
210
|
+
* test), always does; one that started under a session does only while that session, or its live one, owns
|
|
211
|
+
* the run.
|
|
212
|
+
*/
|
|
213
|
+
function limitIsMine(deps: Deps, run: Run, origin: SessionRef | null): boolean {
|
|
214
|
+
if (origin === null && currentSession(deps) === null) return true;
|
|
215
|
+
if (ownsRun(deps, run)) return true;
|
|
216
|
+
const owner = runOwner(run);
|
|
217
|
+
return origin !== null && owner !== null && sessionKey(owner) === sessionKey(origin);
|
|
218
|
+
}
|
|
219
|
+
|
|
187
220
|
/**
|
|
188
221
|
* Spec §3.3: a run this session has just taken over may have roles another session's server launched. This
|
|
189
222
|
* process watches each live one it is not already watching, so it settles them and its notifier announces them
|
|
@@ -209,6 +242,16 @@ export function adopt(deps: Deps, run: Run): void {
|
|
|
209
242
|
*/
|
|
210
243
|
export async function claim(deps: Deps, run: Run, o: { background?: boolean } = {}): Promise<void> {
|
|
211
244
|
if (!(await claimRun(deps, run)) && !ownsRun(deps, run)) return;
|
|
245
|
+
await recoverOwned(deps, run, o);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* What `claim` does for a run this session already owns, without taking it: watch its live roles, record and
|
|
250
|
+
* settle what finished unrecorded, settle its unsettled limits and unannounced records. A server that does not
|
|
251
|
+
* lead the boot runs it for each run its session owns (PR #47 P1: a role that finished while that server was
|
|
252
|
+
* away has no live process to watch and no record to scan).
|
|
253
|
+
*/
|
|
254
|
+
export async function recoverOwned(deps: Deps, run: Run, o: { background?: boolean } = {}): Promise<void> {
|
|
212
255
|
adopt(deps, run);
|
|
213
256
|
let r = recovering.get(run.id);
|
|
214
257
|
if (!r) {
|
|
@@ -276,24 +319,140 @@ function unannounced(run: Run): { d: Dispatch; record: RunRecord }[] {
|
|
|
276
319
|
});
|
|
277
320
|
}
|
|
278
321
|
|
|
322
|
+
/** How long a dispatch waits for another dispatch's route of the same lane (Jev may take tens of seconds). */
|
|
323
|
+
const ROUTE_LOCK_MS = 120_000;
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* What admission would refuse anyway, refused before a lane is routed, so a refused dispatch asks Jev nothing
|
|
327
|
+
* and writes no route row: the role off, the lane file missing or with no Owns:, the name or the lane already
|
|
328
|
+
* running. Admission checks them again under its lock.
|
|
329
|
+
*/
|
|
330
|
+
function refuseBeforeRouting(deps: Deps, run: Run, i: DispatchInput & { lane: string }): void {
|
|
331
|
+
const profile = runProfile(deps, run);
|
|
332
|
+
if (!profile.roles[i.role]?.enabled)
|
|
333
|
+
throw new CatherdError("E_ADMIT_RUNG", `the ${i.role} role is off in profile ${profile.name}`, {
|
|
334
|
+
fix: "skip the role, or turn it on with profile_set",
|
|
335
|
+
});
|
|
336
|
+
laneOwns(run, i.lane);
|
|
337
|
+
// live only: a finished, unrecorded dispatch is recorded by admission (finalizeFinished), not refused here
|
|
338
|
+
const pending = liveDispatches(run, deps.now());
|
|
339
|
+
const same = pending.find((d) => d.admit.name === i.name);
|
|
340
|
+
if (same)
|
|
341
|
+
throw new CatherdError("E_ADMIT_DUPLICATE", `${i.name} is already running on ${same.admit.rung}`, {
|
|
342
|
+
fix: `its record is announced when it finishes; or cancel(run, "${i.name}")`,
|
|
343
|
+
});
|
|
344
|
+
const lane = pending.find((d) => d.admit.lane === i.lane);
|
|
345
|
+
if (lane)
|
|
346
|
+
throw new CatherdError("E_ADMIT_OVERLAP", `lane ${i.lane} is already running as ${lane.admit.name}`, {
|
|
347
|
+
fix: `dispatch it after ${lane.admit.name} finishes`,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Plan 22: the thread a dispatch resumes. `"latest"` is the name's last recorded thread; any other id must be one
|
|
353
|
+
* the name's records in this run hold (`runs.jsonl`), in any case, so a wrong id is refused before a CLI starts
|
|
354
|
+
* and fails on it. Null for a fresh thread.
|
|
355
|
+
*/
|
|
356
|
+
function threadFor(run: Run, name: string, thread: string | undefined): string | null {
|
|
357
|
+
if (thread === undefined) return null;
|
|
358
|
+
assertId("role name", name);
|
|
359
|
+
const mine = readRecords(run)
|
|
360
|
+
.records.filter((r) => r.name === name && r.thread !== null)
|
|
361
|
+
.map((r) => r.thread as string);
|
|
362
|
+
const last = mine.at(-1);
|
|
363
|
+
if (thread === "latest") {
|
|
364
|
+
if (last) return last;
|
|
365
|
+
throw new CatherdError("E_ADMIT_THREAD", `${name} has no earlier thread in this run`, {
|
|
366
|
+
fix: "omit thread for a fresh thread",
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
const known = mine.findLast((t) => t.toLowerCase() === thread.toLowerCase());
|
|
370
|
+
if (known) return known;
|
|
371
|
+
throw new CatherdError("E_ADMIT_THREAD", `${thread} is not a thread of ${name} in this run`, {
|
|
372
|
+
fix: last
|
|
373
|
+
? `pass thread: "latest" for ${name}'s last thread (${last}), or omit thread for a fresh one`
|
|
374
|
+
: "omit thread for a fresh thread",
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Plan 22, resume hygiene: what an earlier turn on `thread` left running in its process group (a server, a
|
|
380
|
+
* watcher, a background command) is stopped before the thread is resumed, so it never ends the resumed CLI
|
|
381
|
+
* (exit 143). Only a dispatch whose supervisor and group leader are both gone is touched (with or without
|
|
382
|
+
* exit.json: a supervisor killed before it stopped the group writes none): a live group with no process of the
|
|
383
|
+
* leader's pid can only be that dispatch's leftovers (a pid is never reused while its group lives), and a pid
|
|
384
|
+
* that answers belongs to someone else by now. Returns a hint per group stopped.
|
|
385
|
+
*/
|
|
386
|
+
async function stopLeftovers(deps: Deps, run: Run, thread: string): Promise<string[]> {
|
|
387
|
+
const records = new Map(readRecords(run).records.map((r) => [r.dispatchId, r]));
|
|
388
|
+
const hints: string[] = [];
|
|
389
|
+
for (const d of listDispatches(run)) {
|
|
390
|
+
const on = d.admit.thread ?? records.get(d.admit.dispatchId)?.thread ?? null;
|
|
391
|
+
if (on?.toLowerCase() !== thread.toLowerCase()) continue;
|
|
392
|
+
const proc = readProc(d.dir);
|
|
393
|
+
const pgid = proc?.pgid ?? proc?.pid;
|
|
394
|
+
if (!proc || pgid === undefined || pgid !== proc.pid) continue;
|
|
395
|
+
// a live supervisor stops its own group when its worker ends; exit.json is not the test, since a supervisor
|
|
396
|
+
// killed (SIGKILL, OOM) before it stopped the group never writes one
|
|
397
|
+
if (isAlive(proc.supervisorPid, proc.supervisorStartTime)) continue;
|
|
398
|
+
if (isAlive(proc.pid, null) || !groupAlive(pgid)) continue;
|
|
399
|
+
await stopGroup(pgid, orphanLimits.killGraceMs, deps.pollMs);
|
|
400
|
+
log("info", "dispatch", { run: run.id, name: d.admit.name, thread, stoppedGroup: pgid });
|
|
401
|
+
hints.push(`resume: stopped what ${d.admit.name}'s earlier turn left running on thread ${thread}`);
|
|
402
|
+
}
|
|
403
|
+
return hints;
|
|
404
|
+
}
|
|
405
|
+
|
|
279
406
|
/**
|
|
280
407
|
* Spec §4.4, plan 9 ruling 1: admit and launch one role, start its watcher, then refresh state.md with
|
|
281
408
|
* `next` (after the launch, so nothing delays it). Returns at once; catherd announces the record (spec §3).
|
|
282
409
|
*/
|
|
283
410
|
export async function dispatch(deps: Deps, i: DispatchInput): Promise<DispatchStarted> {
|
|
284
411
|
const run = findRun(i.run);
|
|
412
|
+
// refused before the claim, so a wrong thread changes nothing
|
|
413
|
+
const thread = threadFor(run, i.name, i.thread);
|
|
285
414
|
await claim(deps, run);
|
|
286
415
|
const hints: string[] = [];
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
if (i.lane !== undefined
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
416
|
+
if (thread !== null) hints.push(...(await stopLeftovers(deps, run, thread)));
|
|
417
|
+
let rung: string;
|
|
418
|
+
if (i.lane !== undefined) {
|
|
419
|
+
const lane = i.lane;
|
|
420
|
+
assertId("lane", lane);
|
|
421
|
+
// spec 1.1 §6: a lane is routed before its first dispatch; a rung off the routed ladder starts at the routed one
|
|
422
|
+
let current = currentRoute(readRoutes(run), lane);
|
|
423
|
+
let routed: Awaited<ReturnType<typeof route>> | null = null;
|
|
424
|
+
if (!current) {
|
|
425
|
+
refuseBeforeRouting(deps, run, { ...i, lane });
|
|
426
|
+
// one route per lane: a second dispatch of the lane waits for the first's route and takes it
|
|
427
|
+
const got = await withFileLock(
|
|
428
|
+
join(run.dir, `route-${lane}`),
|
|
429
|
+
async () => {
|
|
430
|
+
const now = currentRoute(readRoutes(run), lane);
|
|
431
|
+
return now
|
|
432
|
+
? { current: now, routed: null }
|
|
433
|
+
: {
|
|
434
|
+
current: null,
|
|
435
|
+
routed: await route(deps, { run: i.run, laneFile: `lanes/${lane}.md`, role: i.role }),
|
|
436
|
+
};
|
|
437
|
+
},
|
|
438
|
+
{ timeoutMs: ROUTE_LOCK_MS },
|
|
439
|
+
);
|
|
440
|
+
current = got.current;
|
|
441
|
+
routed = got.routed;
|
|
442
|
+
}
|
|
443
|
+
// spec 1.5 plan 24: without a rung, a lane runs at its current rung (its route, after any climb)
|
|
444
|
+
rung = i.rung ?? routed?.rung ?? (current?.rung as string);
|
|
445
|
+
if (routed && i.rung !== undefined && !routed.ladder.includes(i.rung)) {
|
|
293
446
|
rung = routed.rung;
|
|
294
|
-
hints.push(`${i.rung} is not on ${
|
|
447
|
+
hints.push(`${i.rung} is not on ${lane}'s routed ladder: dispatched at ${routed.rung}`);
|
|
295
448
|
}
|
|
296
|
-
}
|
|
449
|
+
} else if (i.rung === undefined)
|
|
450
|
+
throw new CatherdError("E_INPUT_INVALID", `dispatch ${i.name}: a role outside a lane needs a rung`, {
|
|
451
|
+
fix: `route(run, role: "${i.role}") gives the role's rung; pass it as rung`,
|
|
452
|
+
});
|
|
453
|
+
else rung = i.rung;
|
|
454
|
+
// the rung that runs, after routing: an off-ladder native rung routing replaced is never launched
|
|
455
|
+
assertNativeHost(rung, deps.host.host);
|
|
297
456
|
const { d, specPath } = await admit(
|
|
298
457
|
deps,
|
|
299
458
|
run,
|
|
@@ -302,7 +461,7 @@ export async function dispatch(deps: Deps, i: DispatchInput): Promise<DispatchSt
|
|
|
302
461
|
name: i.name,
|
|
303
462
|
brief: i.brief,
|
|
304
463
|
rung,
|
|
305
|
-
thread
|
|
464
|
+
thread,
|
|
306
465
|
lane: i.lane ?? null,
|
|
307
466
|
failoverFrom: null,
|
|
308
467
|
},
|
|
@@ -317,10 +476,46 @@ export async function dispatch(deps: Deps, i: DispatchInput): Promise<DispatchSt
|
|
|
317
476
|
// a launch that failed is still watched: its record (lost, after the start grace) is announced
|
|
318
477
|
watch(deps, run, d);
|
|
319
478
|
}
|
|
320
|
-
|
|
479
|
+
if (i.lane === undefined) recordRoleDispatch(deps, run, i.role, i.name, rung);
|
|
480
|
+
// spec 1.5: a change since the run's pin is logged in state.md each time a role starts
|
|
481
|
+
const changes = pinChanges(deps, run);
|
|
482
|
+
if (changes.length)
|
|
483
|
+
hints.push(
|
|
484
|
+
`dispatched on the run's pinned values: ${changes.join("; ")}; run_pin(run) re-pins to the repo's now`,
|
|
485
|
+
);
|
|
486
|
+
await refresh(run, { ...(i.next ? { next: i.next } : {}), pinChanges: changes }, hints);
|
|
321
487
|
return { dispatched: dispatchedOf(d), hints };
|
|
322
488
|
}
|
|
323
489
|
|
|
490
|
+
/**
|
|
491
|
+
* Spec 1.5 plan 24: a lane-less dispatch's rung in routes.jsonl, so every role's decision can be audited:
|
|
492
|
+
* `route` when it is the rung the role's last `route` gave (with that ladder), else the coordinator's own pick.
|
|
493
|
+
* A failed write is logged, never a failed dispatch: the role is already running.
|
|
494
|
+
*/
|
|
495
|
+
function recordRoleDispatch(deps: Deps, run: Run, role: Role, name: string, rung: string): void {
|
|
496
|
+
try {
|
|
497
|
+
const last = readRoleRoutes(run).findLast((r) => r.role === role && r.source === "route");
|
|
498
|
+
const routed = last?.rung === rung;
|
|
499
|
+
appendRoute(run, {
|
|
500
|
+
at: new Date(deps.now()).toISOString(),
|
|
501
|
+
lane: null,
|
|
502
|
+
role,
|
|
503
|
+
name,
|
|
504
|
+
rung,
|
|
505
|
+
ladder: last?.ladder ?? [rung],
|
|
506
|
+
source: "dispatch",
|
|
507
|
+
decidedBy: routed ? last.decidedBy : "orchestrator",
|
|
508
|
+
why: routed
|
|
509
|
+
? `the rung route gave the ${role}`
|
|
510
|
+
: last
|
|
511
|
+
? `the coordinator's rung; route gave the ${role} ${last.rung}`
|
|
512
|
+
: `the coordinator's rung; the ${role} was never routed`,
|
|
513
|
+
});
|
|
514
|
+
} catch (e) {
|
|
515
|
+
log("warn", "dispatch", { run: run.id, name, routes: errorMessage(e) });
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
324
519
|
/** How long a settle waits for another process's failover of the same dispatch (a test seam). */
|
|
325
520
|
export const failoverLock = { timeoutMs: 120_000 };
|
|
326
521
|
|
|
@@ -491,8 +686,10 @@ async function failover(deps: Deps, run: Run, d: Dispatch, limited: RunRecord):
|
|
|
491
686
|
return failedOver(already.admit.rung, already);
|
|
492
687
|
}
|
|
493
688
|
// else that stand-in never ran and is over (recorded as lost, or past its start grace): admit a new one
|
|
494
|
-
const standIn = standInFor(deps
|
|
689
|
+
const standIn = standInFor(runProfile(deps, run).failover, limited.rung, run.meta.repo);
|
|
495
690
|
if (!standIn) return { hints, started: null, pause: paused };
|
|
691
|
+
// the stand-in answers to whoever owns the run now, which a claim from another host may have changed
|
|
692
|
+
assertNativeHost(standIn, runOwner(run)?.host ?? deps.host.host);
|
|
496
693
|
if (parseRung(standIn).backend === "claude") {
|
|
497
694
|
const agent = deps.profiles.agentFor(run.meta.repo, d.admit.role, standIn);
|
|
498
695
|
return {
|
|
@@ -517,6 +714,7 @@ async function failover(deps: Deps, run: Run, d: Dispatch, limited: RunRecord):
|
|
|
517
714
|
failoverOf: d.admit.dispatchId,
|
|
518
715
|
// the stand-in answers to the session that dispatched the limited role, whoever fails it over
|
|
519
716
|
sessionId: d.admit.sessionId ?? null,
|
|
717
|
+
host: d.admit.host,
|
|
520
718
|
});
|
|
521
719
|
} catch (e) {
|
|
522
720
|
if (!isCatherdError(e)) throw e;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join, relative } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
4
5
|
import { dispatchHints } from "../domain/hints.ts";
|
|
5
6
|
import { ACCESS, type RunRecord } from "../domain/record.ts";
|
|
6
7
|
import { ROLES } from "../domain/roles.ts";
|
|
@@ -18,6 +19,11 @@ const AdmitSchema = z.looseObject({
|
|
|
18
19
|
role: z.enum(ROLES),
|
|
19
20
|
lane: z.string().nullable(),
|
|
20
21
|
owns: z.array(z.string()),
|
|
22
|
+
/**
|
|
23
|
+
* spec 1.5 plan 21: a laneless writer's implicit Owns (its brief's Owns: line, else docs/** and *.md), so its
|
|
24
|
+
* edits are its own and never a concurrent lane's violation; admission never refuses an overlap with it
|
|
25
|
+
*/
|
|
26
|
+
ownsImplicit: z.array(z.string()).optional(),
|
|
21
27
|
rung: z.string(),
|
|
22
28
|
backend: z.string(),
|
|
23
29
|
thread: z.string().nullable(),
|
|
@@ -36,6 +42,7 @@ const AdmitSchema = z.looseObject({
|
|
|
36
42
|
before: z.record(z.string(), z.string()),
|
|
37
43
|
/** the Claude Code session that dispatched it (spec §3.3); absent on 1.0 dispatches and outside Claude Code */
|
|
38
44
|
sessionId: z.string().optional(),
|
|
45
|
+
host: z.enum(["claude-code", "codex"]).optional(),
|
|
39
46
|
});
|
|
40
47
|
export type Admit = z.infer<typeof AdmitSchema>;
|
|
41
48
|
|
|
@@ -53,9 +60,11 @@ export const startLimits = { graceMs: STARTING_GRACE_MS };
|
|
|
53
60
|
export const admitPath = (dir: string): string => join(dir, "admit.json");
|
|
54
61
|
export const launchPath = (dir: string): string => join(dir, "launch.json");
|
|
55
62
|
export const roleDir = (run: Run, name: string): string => join(runPaths(run.dir).roles, name);
|
|
63
|
+
/** Spec 1.5 plan 21: the role's TMPDIR, `<run>/scratch/<name>/`, removed with the run's scratch. */
|
|
64
|
+
export const scratchDir = (run: Run, name: string): string => join(runPaths(run.dir).scratch, name);
|
|
56
65
|
|
|
57
66
|
/** Every admitted dispatch of the run, oldest first; a folder whose admit.json cannot be read is skipped. */
|
|
58
|
-
export function listDispatches(run: Run): Dispatch[] {
|
|
67
|
+
export function listDispatches(run: Run, strict = false): Dispatch[] {
|
|
59
68
|
const roles = runPaths(run.dir).roles;
|
|
60
69
|
const out: Dispatch[] = [];
|
|
61
70
|
if (!existsSync(roles)) return out;
|
|
@@ -65,8 +74,10 @@ export function listDispatches(run: Run): Dispatch[] {
|
|
|
65
74
|
if (!id.isDirectory()) continue;
|
|
66
75
|
const dir = join(roles, name.name, id.name);
|
|
67
76
|
try {
|
|
68
|
-
out.push({ dir, admit: readVersioned(admitPath(dir), AdmitSchema, 1) });
|
|
77
|
+
out.push({ dir, admit: decodeAdmit(readVersioned(admitPath(dir), AdmitSchema, 1)) });
|
|
69
78
|
} catch {
|
|
79
|
+
if (strict && existsSync(admitPath(dir)))
|
|
80
|
+
throw new CatherdError("E_RUN_CORRUPT", `run ${run.id} has unreadable admission evidence: ${dir}`);
|
|
70
81
|
// admission never finished writing this folder
|
|
71
82
|
}
|
|
72
83
|
}
|
|
@@ -123,9 +134,10 @@ export function pendingDispatches(
|
|
|
123
134
|
run: Run,
|
|
124
135
|
now = Date.now(),
|
|
125
136
|
records: RunRecord[] = readRecords(run).records,
|
|
137
|
+
strict = false,
|
|
126
138
|
): LiveDispatch[] {
|
|
127
139
|
const recorded = new Set(records.map((r) => r.dispatchId));
|
|
128
|
-
return listDispatches(run)
|
|
140
|
+
return listDispatches(run, strict)
|
|
129
141
|
.filter((d) => !recorded.has(d.admit.dispatchId))
|
|
130
142
|
.map((d) => ({ ...d, state: dispatchState(d, now) }));
|
|
131
143
|
}
|
|
@@ -133,6 +145,23 @@ export function pendingDispatches(
|
|
|
133
145
|
export const liveDispatches = (run: Run, now = Date.now()): LiveDispatch[] =>
|
|
134
146
|
pendingDispatches(run, now).filter((d) => d.state !== "finished");
|
|
135
147
|
|
|
148
|
+
/** The thread the supervisor saw the role's CLI name (thread.json), else null. */
|
|
149
|
+
export function readThread(dir: string): string | null {
|
|
150
|
+
const t = readJson<{ thread?: unknown }>(dispatchPaths(dir).thread)?.thread;
|
|
151
|
+
return typeof t === "string" && t ? t : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Spec 1.5 plan 21: the role whose thread `thread` is, in this run: a recorded thread, a resumed one, or the one
|
|
156
|
+
* a live role's supervisor wrote down. Null for any other thread, the orchestrator's among them.
|
|
157
|
+
*/
|
|
158
|
+
export function roleThreadOf(run: Run, thread: string): string | null {
|
|
159
|
+
const recorded = readRecords(run).records.find((r) => r.thread === thread);
|
|
160
|
+
if (recorded) return recorded.name;
|
|
161
|
+
const d = listDispatches(run).find((x) => x.admit.thread === thread || readThread(x.dir) === thread);
|
|
162
|
+
return d ? d.admit.name : null;
|
|
163
|
+
}
|
|
164
|
+
|
|
136
165
|
/** Spec §4.1: `roles/<name>/latest` names the newest dispatch of a role. */
|
|
137
166
|
export function setLatest(run: Run, name: string, dispatchId: string): void {
|
|
138
167
|
writeTextAtomic(join(roleDir(run, name), "latest"), dispatchId);
|
|
@@ -174,3 +203,7 @@ export function readFailover(dir: string): FailoverFile | null {
|
|
|
174
203
|
export function recordHints(run: Run, d: Dispatch, r: RunRecord): string[] {
|
|
175
204
|
return readFailover(d.dir)?.hints ?? dispatchHints(r, d.admit.owns, relative(run.dir, d.dir));
|
|
176
205
|
}
|
|
206
|
+
|
|
207
|
+
function decodeAdmit(a: Admit): Admit {
|
|
208
|
+
return a.sessionId && !a.host ? { ...a, host: "claude-code" } : a;
|
|
209
|
+
}
|
|
@@ -172,9 +172,14 @@ export function workspaceWriteNetwork(profiles: Profile[]): Map<string, boolean>
|
|
|
172
172
|
|
|
173
173
|
/**
|
|
174
174
|
* The `sandbox:codex` row (which `codex sandbox` form runs) and one `access:<backend>` row per backend; a
|
|
175
|
-
* backend not in `installed` (its CLI is not on PATH) is skipped without running a probe.
|
|
175
|
+
* backend not in `installed` (its CLI is not on PATH) is skipped without running a probe. `repos`: where
|
|
176
|
+
* workers run (doctor's repo and every bound one), for a sandbox set per repo.
|
|
176
177
|
*/
|
|
177
|
-
export async function accessChecks(
|
|
178
|
+
export async function accessChecks(
|
|
179
|
+
profiles: Profile[],
|
|
180
|
+
installed: ReadonlySet<string>,
|
|
181
|
+
repos: string[] = [],
|
|
182
|
+
): Promise<Check[]> {
|
|
178
183
|
const checks: Check[] = [];
|
|
179
184
|
for (const [id, network] of workspaceWriteNetwork(profiles)) {
|
|
180
185
|
const a = adapterFor(id);
|
|
@@ -190,7 +195,7 @@ export async function accessChecks(profiles: Profile[], installed: ReadonlySet<s
|
|
|
190
195
|
continue;
|
|
191
196
|
}
|
|
192
197
|
const shell = a.accessShell
|
|
193
|
-
? await a.accessShell({ network }).catch((e: unknown) => String(e))
|
|
198
|
+
? await a.accessShell({ network, repos }).catch((e: unknown) => String(e))
|
|
194
199
|
: `no way to run a shell in ${id}'s sandbox without a model turn; the live kit (docs/dev/live-verification.md) runs the five probes as one worker turn`;
|
|
195
200
|
if (id === "codex")
|
|
196
201
|
checks.push(
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
+
import type { OrchestrationHost } from "../domain/host.ts";
|
|
1
2
|
import type { Probe } from "../adapters/backend.ts";
|
|
2
3
|
import { adapterFor } from "../adapters/registry.ts";
|
|
3
4
|
import "../adapters/all.ts";
|
|
4
5
|
import { ADAPTER_IDS, tryParseRung } from "../domain/ids.ts";
|
|
5
6
|
import type { Profile } from "../domain/profile.ts";
|
|
6
7
|
import { ROLES, type Role } from "../domain/roles.ts";
|
|
7
|
-
import { probeBackend } from "./backends.ts";
|
|
8
|
+
import { standInFor, probeBackend } from "./backends.ts";
|
|
8
9
|
import { refreshDiscovery } from "./catalog-service.ts";
|
|
9
|
-
import { type Check, errText, fixOf } from "./doctor-checks.ts";
|
|
10
|
+
import { type Check, errText, fixOf, hostFlag } from "./doctor-checks.ts";
|
|
10
11
|
|
|
11
12
|
// The backend rows of `catherd doctor` (spec §10.3) and the backends the linked profiles use.
|
|
12
13
|
|
|
@@ -22,7 +23,12 @@ export function usedBackends(profiles: Profile[]): Map<string, "role" | "failove
|
|
|
22
23
|
};
|
|
23
24
|
for (const p of profiles) {
|
|
24
25
|
for (const role of ROLES) if (p.roles[role].enabled) for (const r of p.roles[role].rungs) note(r, "role");
|
|
25
|
-
for (const
|
|
26
|
+
for (const role of ROLES)
|
|
27
|
+
if (p.roles[role].enabled)
|
|
28
|
+
for (const r of p.roles[role].rungs) {
|
|
29
|
+
const to = standInFor(p.failover, r);
|
|
30
|
+
if (to) note(to, "failover");
|
|
31
|
+
}
|
|
26
32
|
}
|
|
27
33
|
return used;
|
|
28
34
|
}
|
|
@@ -52,10 +58,16 @@ const shellWord = (w: string): string => (/^[\w./:=@,+-]+$/.test(w) ? w : `'${w.
|
|
|
52
58
|
* each linked profile, any default rung the new ladder would not hold reset first. The artist draws, which only Codex does, so
|
|
53
59
|
* off Codex it is turned off instead.
|
|
54
60
|
*/
|
|
55
|
-
export function moveRolesFix(
|
|
61
|
+
export function moveRolesFix(
|
|
62
|
+
install: string,
|
|
63
|
+
id: string,
|
|
64
|
+
to: string,
|
|
65
|
+
profiles: Profile[],
|
|
66
|
+
host: OrchestrationHost = "unknown",
|
|
67
|
+
): string {
|
|
56
68
|
const onIt = (r: string | undefined) => r !== undefined && backendOf(r) === id;
|
|
57
69
|
const set = (p: Profile, path: string, v: string) =>
|
|
58
|
-
`catherd profile set ${path} ${shellWord(v)} --profile ${shellWord(p.name)}`;
|
|
70
|
+
`catherd profile set ${path} ${shellWord(v)} --profile ${shellWord(p.name)}${hostFlag(host)}`;
|
|
59
71
|
const stuck = new Set<Role>();
|
|
60
72
|
const commands: string[] = [];
|
|
61
73
|
for (const p of profiles)
|
|
@@ -87,10 +99,21 @@ export async function backendChecks(
|
|
|
87
99
|
used: Map<string, "role" | "failover">,
|
|
88
100
|
profiles: Profile[],
|
|
89
101
|
installed?: Set<string>,
|
|
102
|
+
host: OrchestrationHost = "unknown",
|
|
90
103
|
): Promise<Check[]> {
|
|
91
104
|
const checks: Check[] = [];
|
|
92
105
|
const ready: string[] = [];
|
|
93
106
|
for (const id of ADAPTER_IDS) {
|
|
107
|
+
if (host === "codex" && id === "claude-code" && !used.has(id)) {
|
|
108
|
+
checks.push({
|
|
109
|
+
id: `backend:${id}`,
|
|
110
|
+
label: id,
|
|
111
|
+
state: "skip",
|
|
112
|
+
word: "not required",
|
|
113
|
+
detail: "not probed: the selected profile has no headless Claude role or reachable failover",
|
|
114
|
+
});
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
94
117
|
const a = adapterFor(id);
|
|
95
118
|
if (!a) continue;
|
|
96
119
|
const probe: Probe = await probeBackend(a).catch((e: unknown) => ({
|
|
@@ -124,7 +147,7 @@ export async function backendChecks(
|
|
|
124
147
|
state: "warn",
|
|
125
148
|
word: "billing",
|
|
126
149
|
detail: `${detail} · profile ${billed.name} bills ${id} as ${billed.billing[id]}, but this login is ${probe.billing}`,
|
|
127
|
-
fix: `catherd profile set billing.${id} ${probe.billing} --profile ${billed.name}`,
|
|
150
|
+
fix: `catherd profile set billing.${id} ${probe.billing} --profile ${billed.name}${hostFlag(host)}`,
|
|
128
151
|
}
|
|
129
152
|
: { id: `backend:${id}`, label: id, state: "ok", word: "ready", detail },
|
|
130
153
|
);
|
|
@@ -147,7 +170,7 @@ export async function backendChecks(
|
|
|
147
170
|
for (const c of checks) {
|
|
148
171
|
const id = c.id.slice("backend:".length);
|
|
149
172
|
if (to && c.state === "fail" && c.word === PROBLEM_WORD.E_BACKEND_MISSING && c.fix && id !== to)
|
|
150
|
-
c.fix = moveRolesFix(c.fix, id, to, profiles);
|
|
173
|
+
c.fix = moveRolesFix(c.fix, id, to, profiles, host);
|
|
151
174
|
}
|
|
152
175
|
// spec §5.2: doctor refreshes discovery; a listing that fails keeps the last one
|
|
153
176
|
let refreshed: Awaited<ReturnType<typeof refreshDiscovery>> = [];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { OrchestrationHost } from "../domain/host.ts";
|
|
1
2
|
import { readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { errorMessage, isCatherdError } from "../domain/errors.ts";
|
|
@@ -131,9 +132,13 @@ export function locksCheck(): Check {
|
|
|
131
132
|
}
|
|
132
133
|
}
|
|
133
134
|
|
|
134
|
-
export function agentsCheck(
|
|
135
|
+
export function agentsCheck(
|
|
136
|
+
host: OrchestrationHost,
|
|
137
|
+
selected?: string[],
|
|
138
|
+
relink = `catherd profile use ${activeName()}`,
|
|
139
|
+
): Check {
|
|
135
140
|
const base = { id: "agents", label: "Claude agents" };
|
|
136
|
-
const links = agentLinkState();
|
|
141
|
+
const links = agentLinkState(host, selected);
|
|
137
142
|
const broken = [...links.missing, ...links.stale];
|
|
138
143
|
if (broken.length)
|
|
139
144
|
return {
|
|
@@ -141,13 +146,19 @@ export function agentsCheck(): Check {
|
|
|
141
146
|
state: "fail",
|
|
142
147
|
word: links.missing.length ? "missing" : "stale",
|
|
143
148
|
detail: broken.join(", "),
|
|
144
|
-
fix:
|
|
149
|
+
fix: relink,
|
|
145
150
|
};
|
|
146
151
|
return links.ok.length
|
|
147
152
|
? { ...base, state: "ok", word: "ready", detail: `${links.ok.length} linked` }
|
|
148
153
|
: { ...base, state: "skip", word: "none", detail: "no profile uses a native Claude rung" };
|
|
149
154
|
}
|
|
150
155
|
|
|
156
|
+
/**
|
|
157
|
+
* ` --host <host>` for a fix doctor prints, when it judged on a known host: run from a plain terminal, the fix
|
|
158
|
+
* must judge the profile the same way (omitted architect/verifier rungs resolve per host).
|
|
159
|
+
*/
|
|
160
|
+
export const hostFlag = (host: OrchestrationHost): string => (host === "unknown" ? "" : ` --host ${host}`);
|
|
161
|
+
|
|
151
162
|
/** The browser CLI the ui-reviewer's prompt takes its screenshots with. */
|
|
152
163
|
const UI_BROWSER = "agent-browser";
|
|
153
164
|
|
|
@@ -155,7 +166,11 @@ const UI_BROWSER = "agent-browser";
|
|
|
155
166
|
* The ui-reviewer takes screenshots with agent-browser: a profile that turns it on, on a machine without it,
|
|
156
167
|
* dispatches a role that cannot do its job. Nothing to say when it is on PATH or no profile turns the role on.
|
|
157
168
|
*/
|
|
158
|
-
export function uiBrowserCheck(
|
|
169
|
+
export function uiBrowserCheck(
|
|
170
|
+
profiles: Profile[],
|
|
171
|
+
onPath: (bin: string) => string | null,
|
|
172
|
+
host: OrchestrationHost = "unknown",
|
|
173
|
+
): Check | null {
|
|
159
174
|
const on = profiles.filter((p) => p.roles["ui-reviewer"].enabled).map((p) => p.name);
|
|
160
175
|
if (!on.length || onPath(UI_BROWSER)) return null;
|
|
161
176
|
return {
|
|
@@ -167,7 +182,7 @@ export function uiBrowserCheck(profiles: Profile[], onPath: (bin: string) => str
|
|
|
167
182
|
fix: [
|
|
168
183
|
`npm i -g ${UI_BROWSER}`,
|
|
169
184
|
"or turn the role off:",
|
|
170
|
-
...on.map((n) => `catherd profile set roles.ui-reviewer.enabled false --profile ${n}`),
|
|
185
|
+
...on.map((n) => `catherd profile set roles.ui-reviewer.enabled false --profile ${n}${hostFlag(host)}`),
|
|
171
186
|
].join("\n"),
|
|
172
187
|
};
|
|
173
188
|
}
|