catherd-cli 1.4.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 +93 -30
- 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 +1 -1
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +110 -45
- package/plugin/skills/catherd-setup/SKILL.md +2 -2
- 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/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 +5 -0
- 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/lane.ts +99 -3
- package/src/domain/notice.ts +9 -2
- package/src/domain/profile-rules.ts +112 -16
- package/src/domain/profile.ts +24 -0
- package/src/domain/record.ts +16 -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 +14 -6
- package/src/entry/cli-kit.ts +17 -1
- package/src/entry/deps.ts +2 -0
- package/src/entry/doctor-command.ts +20 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +2 -1
- package/src/entry/knowledge-command.ts +78 -3
- package/src/entry/lock-command.ts +104 -8
- package/src/entry/mcp/dispatch-tools.ts +3 -3
- package/src/entry/mcp/handshake.ts +6 -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 +20 -4
- package/src/entry/mcp/server.ts +98 -7
- package/src/entry/mcp/setup-tools.ts +4 -3
- package/src/entry/mcp/workspace-tools.ts +142 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +23 -4
- package/src/entry/role-cli-command.ts +115 -0
- package/src/entry/runs-command.ts +78 -8
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/tui/effects.ts +4 -0
- package/src/entry/tui/state.ts +37 -10
- package/src/entry/tui/views/profiles.tsx +1 -1
- package/src/entry/tui/views/runs.tsx +66 -20
- package/src/entry/tui/views/save-dialog.tsx +6 -2
- 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/delivery.ts +3 -0
- package/src/infra/dispatch-dir.ts +2 -0
- package/src/infra/env.ts +11 -1
- package/src/infra/git.ts +6 -0
- package/src/infra/heavy-lock.ts +93 -4
- 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/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 +107 -16
- package/src/services/admission.ts +110 -23
- 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 +204 -16
- package/src/services/dispatches.ts +30 -2
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +68 -6
- package/src/services/end-push.ts +157 -0
- package/src/services/finalize.ts +12 -2
- 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 +257 -52
- package/src/services/milestones.ts +137 -26
- package/src/services/notifier.ts +56 -3
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +79 -17
- package/src/services/ports.ts +31 -2
- package/src/services/preflight.ts +153 -19
- package/src/services/profile-service.ts +1 -0
- package/src/services/profile-store.ts +10 -3
- 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 +144 -36
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-service.ts +151 -19
- package/src/services/run-store.ts +109 -15
- package/src/services/runs-page.ts +42 -7
- package/src/services/scratch.ts +60 -0
- package/src/services/sessions.ts +3 -1
- 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 +49 -9
- package/src/services/treat-likes.ts +19 -3
- 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,19 +1,21 @@
|
|
|
1
|
-
import { existsSync, lstatSync, mkdirSync, readdirSync, realpathSync } from "node:fs";
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, renameSync } from "node:fs";
|
|
2
2
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { CatherdError } from "../domain/errors.ts";
|
|
5
5
|
import { assertId } from "../domain/ids.ts";
|
|
6
6
|
import { type RunRecord, RunRecordSchema } from "../domain/record.ts";
|
|
7
|
-
import type { OutcomeRow, RouteRow } from "../domain/route.ts";
|
|
7
|
+
import type { OutcomeRouteRow, OutcomeRow, RoleRouteRow, RouteRow } from "../domain/route.ts";
|
|
8
8
|
import { cell, slug } from "../domain/util.ts";
|
|
9
9
|
import { withFileLock } from "../infra/filelock.ts";
|
|
10
|
-
import {
|
|
10
|
+
import { gitOrigin } from "../infra/git.ts";
|
|
11
|
+
import { dataDir, originDir, repoDir, runsDir } from "../infra/paths.ts";
|
|
11
12
|
import {
|
|
12
13
|
appendJsonl,
|
|
13
14
|
ensureJsonlHeader,
|
|
14
15
|
ensurePrivateDir,
|
|
15
16
|
PRIVATE_DIR,
|
|
16
17
|
appendPrivate,
|
|
18
|
+
nonBlankLines,
|
|
17
19
|
readJsonl,
|
|
18
20
|
readVersioned,
|
|
19
21
|
writeJsonAtomic,
|
|
@@ -38,6 +40,9 @@ const RunMetaSchema = z.looseObject({
|
|
|
38
40
|
createdAt: z.string(),
|
|
39
41
|
catherdVersion: z.string(),
|
|
40
42
|
startedBy: StartedBySchema.nullable().optional(),
|
|
43
|
+
workspace: z.strictObject({ id: z.string(), step: z.string() }).optional(),
|
|
44
|
+
/** the repo's HEAD when run_start ran: where the first milestone's commit range starts (absent before 1.5) */
|
|
45
|
+
startHead: z.string().optional(),
|
|
41
46
|
});
|
|
42
47
|
type RunMeta = z.infer<typeof RunMetaSchema>;
|
|
43
48
|
|
|
@@ -66,6 +71,8 @@ export function runPaths(dir: string) {
|
|
|
66
71
|
sessions: join(dir, "sessions.jsonl"),
|
|
67
72
|
roles: join(dir, "roles"),
|
|
68
73
|
shots: join(dir, "shots"),
|
|
74
|
+
/** per dispatch name, the role's TMPDIR (spec 1.5 plan 21): `scratch/<name>/` */
|
|
75
|
+
scratch: join(dir, "scratch"),
|
|
69
76
|
/** the admission lock's target: `admission.lock` guards dispatch admission */
|
|
70
77
|
admission: join(dir, "admission"),
|
|
71
78
|
};
|
|
@@ -83,6 +90,9 @@ export function createRun(o: {
|
|
|
83
90
|
aLines: string[];
|
|
84
91
|
version: string;
|
|
85
92
|
now?: Date;
|
|
93
|
+
workspace?: { id: string; step: string };
|
|
94
|
+
/** the repo's full HEAD at the start, when it has a commit */
|
|
95
|
+
startHead?: string | null;
|
|
86
96
|
startedBy?: {
|
|
87
97
|
host?: StartedBy["host"];
|
|
88
98
|
sessionId: string;
|
|
@@ -90,6 +100,9 @@ export function createRun(o: {
|
|
|
90
100
|
name: string | null;
|
|
91
101
|
} | null;
|
|
92
102
|
}): Run {
|
|
103
|
+
const workspace = o.workspace
|
|
104
|
+
? { id: assertId("workspace", o.workspace.id), step: assertId("workspace step", o.workspace.step) }
|
|
105
|
+
: undefined;
|
|
93
106
|
const now = o.now ?? new Date();
|
|
94
107
|
const root = runsDir(o.repo);
|
|
95
108
|
ensurePrivateDir(root);
|
|
@@ -130,6 +143,8 @@ export function createRun(o: {
|
|
|
130
143
|
createdAt: now.toISOString(),
|
|
131
144
|
catherdVersion: o.version,
|
|
132
145
|
...(o.startedBy ? { startedBy: StartedBySchema.parse(o.startedBy) } : {}),
|
|
146
|
+
...(workspace ? { workspace } : {}),
|
|
147
|
+
...(o.startHead ? { startHead: o.startHead } : {}),
|
|
133
148
|
};
|
|
134
149
|
writeJsonAtomic(p.meta, meta);
|
|
135
150
|
return { id, dir, meta };
|
|
@@ -141,14 +156,18 @@ export interface RunListing {
|
|
|
141
156
|
corrupt: { id: string; dir: string; reason: string }[];
|
|
142
157
|
}
|
|
143
158
|
|
|
144
|
-
/** Every run
|
|
145
|
-
export function listRuns(): RunListing {
|
|
159
|
+
/** Every run, newest first; an explicit repo scope avoids reading unrelated histories. */
|
|
160
|
+
export function listRuns(repos?: readonly string[]): RunListing {
|
|
146
161
|
const root = join(dataDir(), "repos");
|
|
147
162
|
const out: RunListing = { runs: [], corrupt: [] };
|
|
148
163
|
if (!existsSync(root)) return out;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
164
|
+
const directories = repos
|
|
165
|
+
? [...new Set(repos.map(runsDir))]
|
|
166
|
+
: readdirSync(root, { withFileTypes: true })
|
|
167
|
+
.filter((repo) => repo.isDirectory())
|
|
168
|
+
.map((repo) => join(root, repo.name, "runs"));
|
|
169
|
+
for (const runs of directories) {
|
|
170
|
+
if (!existsSync(runs)) continue;
|
|
152
171
|
for (const e of readdirSync(runs, { withFileTypes: true })) {
|
|
153
172
|
if (!e.isDirectory()) continue;
|
|
154
173
|
const dir = join(runs, e.name);
|
|
@@ -220,13 +239,32 @@ export function appendRecord(run: Run, r: RunRecord): Promise<RunRecord> {
|
|
|
220
239
|
});
|
|
221
240
|
}
|
|
222
241
|
|
|
242
|
+
/** The run's lane rows (routes and climbs); a role's rows outside a lane are left out (`readRoleRoutes`). */
|
|
223
243
|
export function readRoutes(run: Run): RouteRow[] {
|
|
224
244
|
return readJsonl<RouteRow>(runPaths(run.dir).routes).rows.filter(
|
|
225
|
-
(r) =>
|
|
245
|
+
(r) =>
|
|
246
|
+
typeof r?.lane === "string" &&
|
|
247
|
+
(r.source === "route" || r.source === "climb") &&
|
|
248
|
+
Array.isArray(r.ladder) &&
|
|
249
|
+
typeof r.rung === "string",
|
|
226
250
|
);
|
|
227
251
|
}
|
|
228
252
|
|
|
229
|
-
|
|
253
|
+
/** Spec 1.5 plan 24: the lanes' outcome rows of routes.jsonl, oldest first (the last per lane wins). */
|
|
254
|
+
export function readOutcomeRoutes(run: Run): OutcomeRouteRow[] {
|
|
255
|
+
return readJsonl<OutcomeRouteRow>(runPaths(run.dir).routes).rows.filter(
|
|
256
|
+
(r) => typeof r?.lane === "string" && r.source === "outcome",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Spec 1.5 plan 24: the run's role decisions outside a lane, oldest first. */
|
|
261
|
+
export function readRoleRoutes(run: Run): RoleRouteRow[] {
|
|
262
|
+
return readJsonl<RoleRouteRow>(runPaths(run.dir).routes).rows.filter(
|
|
263
|
+
(r) => r?.lane === null && typeof r.role === "string" && typeof r.rung === "string",
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function appendRoute(run: Run, row: RouteRow | RoleRouteRow | OutcomeRouteRow): void {
|
|
230
268
|
const file = runPaths(run.dir).routes;
|
|
231
269
|
ensureJsonlHeader(file, "routes");
|
|
232
270
|
appendJsonl(file, row);
|
|
@@ -255,13 +293,22 @@ const AgentRunSchema = z.looseObject({
|
|
|
255
293
|
status: z.enum(["ok", "failed", "cancelled"]),
|
|
256
294
|
/** the lane the subagent worked, so catalog timings count it under that lane's kind; rows before it lack it */
|
|
257
295
|
lane: z.string().nullable().optional(),
|
|
296
|
+
/** plan 23: a native verifier's verdict line (VERDICT: BLOCKED: environment — <probe>); rows before it lack it */
|
|
297
|
+
verdict: z.string().optional(),
|
|
298
|
+
/** plan 23: the STATUS word of the subagent's reply (a reviewer's partial is no review); rows before it lack it */
|
|
299
|
+
replyStatus: z.enum(["complete", "partial", "blocked", "refused", "flaky"]).optional(),
|
|
258
300
|
});
|
|
259
301
|
export type AgentRun = z.infer<typeof AgentRunSchema>;
|
|
260
302
|
|
|
261
303
|
/** Native Claude subagent runs, as the orchestrator reported them through record_agent_run. */
|
|
262
|
-
export function readAgentRuns(run: Run): AgentRun[] {
|
|
263
|
-
|
|
304
|
+
export function readAgentRuns(run: Run, strict = false): AgentRun[] {
|
|
305
|
+
const { rows, corrupt } = readJsonl<unknown>(runPaths(run.dir).agents);
|
|
306
|
+
if (strict && corrupt)
|
|
307
|
+
throw new CatherdError("E_RUN_CORRUPT", `run ${run.id} has unreadable native cost records`);
|
|
308
|
+
return rows.flatMap((row) => {
|
|
264
309
|
const r = AgentRunSchema.safeParse(row);
|
|
310
|
+
if (strict && !r.success)
|
|
311
|
+
throw new CatherdError("E_RUN_CORRUPT", `run ${run.id} has invalid native cost records`);
|
|
265
312
|
return r.success ? [r.data] : [];
|
|
266
313
|
});
|
|
267
314
|
}
|
|
@@ -276,20 +323,67 @@ export function appendLedger(run: Run, row: string): void {
|
|
|
276
323
|
appendPrivate(runPaths(run.dir).ledger, `${row}\n`);
|
|
277
324
|
}
|
|
278
325
|
|
|
279
|
-
/** Spec §4.7: what past runs of a repo learned, keyed by its git toplevel. */
|
|
326
|
+
/** Spec §4.7: what past runs of a repo learned, keyed by its git toplevel (before 1.5, and with no origin). */
|
|
280
327
|
export const knowledgeFile = (toplevel: string): string => join(repoDir(toplevel), "knowledge.md");
|
|
281
328
|
|
|
329
|
+
/**
|
|
330
|
+
* Spec 1.5 "Knowledge keyed by git origin": the knowledge.md every worktree and clone of one repository
|
|
331
|
+
* shares, keyed by its `origin` remote, else by its toplevel. A toplevel-keyed file this worktree left before
|
|
332
|
+
* is migrated on read: its lines not there yet are appended, and it is renamed `knowledge.md.migrated`.
|
|
333
|
+
*/
|
|
334
|
+
export async function knowledgeFor(toplevel: string): Promise<string> {
|
|
335
|
+
const origin = await gitOrigin(toplevel);
|
|
336
|
+
const legacy = knowledgeFile(toplevel);
|
|
337
|
+
if (!origin) return legacy;
|
|
338
|
+
const file = join(originDir(origin), "knowledge.md");
|
|
339
|
+
if (!existsSync(legacy)) return file;
|
|
340
|
+
ensurePrivateDir(dirname(file));
|
|
341
|
+
await withFileLock(file, () => {
|
|
342
|
+
if (!existsSync(legacy)) return;
|
|
343
|
+
const have = new Set(existsSync(file) ? nonBlankLines(file) : []);
|
|
344
|
+
const add = nonBlankLines(legacy).filter((l) => !have.has(l));
|
|
345
|
+
if (add.length) appendPrivate(file, `${add.join("\n")}\n`);
|
|
346
|
+
renameSync(legacy, `${legacy}.migrated`);
|
|
347
|
+
});
|
|
348
|
+
return file;
|
|
349
|
+
}
|
|
350
|
+
|
|
282
351
|
/**
|
|
283
352
|
* Appends one line to the repo's knowledge.md, `- <date> <source>: <text>` with `text` on one line, and returns
|
|
284
353
|
* it. The single writer of knowledge.md: `land`'s `learned` and `catherd knowledge add` both come through here.
|
|
285
354
|
*/
|
|
286
|
-
export function appendKnowledge(
|
|
355
|
+
export async function appendKnowledge(
|
|
356
|
+
toplevel: string,
|
|
357
|
+
at: Date,
|
|
358
|
+
source: string,
|
|
359
|
+
text: string,
|
|
360
|
+
): Promise<string> {
|
|
287
361
|
const line = `- ${at.toISOString().slice(0, 10)} ${source}: ${cell(text)}`;
|
|
288
|
-
appendPrivate(
|
|
362
|
+
appendPrivate(await knowledgeFor(toplevel), `${line}\n`);
|
|
289
363
|
return line;
|
|
290
364
|
}
|
|
291
365
|
|
|
366
|
+
/** Spec 1.5 "runs supersede": the run that took over a closed one, and when. */
|
|
367
|
+
const SupersededSchema = z.looseObject({ schema: z.literal(1), by: z.string(), at: z.string() });
|
|
368
|
+
export type Superseded = z.infer<typeof SupersededSchema>;
|
|
369
|
+
|
|
370
|
+
export const supersededFile = (run: Run): string => join(run.dir, "superseded.json");
|
|
371
|
+
|
|
372
|
+
/** The pointer `runs supersede` left, or null: an open run, or a pointer that cannot be read. */
|
|
373
|
+
export function supersededBy(run: Run): Superseded | null {
|
|
374
|
+
const file = supersededFile(run);
|
|
375
|
+
if (!existsSync(file)) return null;
|
|
376
|
+
try {
|
|
377
|
+
return readVersioned(file, SupersededSchema, 1);
|
|
378
|
+
} catch {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
292
383
|
const SERVER_OWNED = new Set([
|
|
384
|
+
"superseded.json",
|
|
385
|
+
"pin.json",
|
|
386
|
+
"workspace-contract.md",
|
|
293
387
|
"meta.json",
|
|
294
388
|
"state.md",
|
|
295
389
|
"state.json",
|
|
@@ -12,6 +12,7 @@ import { lastActivity } from "./finalize.ts";
|
|
|
12
12
|
import type { Deps } from "./ports.ts";
|
|
13
13
|
import { findRun, listRuns, readRecords, type Run, runPaths } from "./run-store.ts";
|
|
14
14
|
import { groupRuns, type SessionGroup } from "./session-view.ts";
|
|
15
|
+
import { orchestratorWait, type OrchestratorWait } from "./orchestrator-wait.ts";
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Spec §4: what the Runs tab shows. The top level is the sessions; a session's screen holds its runs, each with its
|
|
@@ -51,11 +52,12 @@ export interface RoleRow {
|
|
|
51
52
|
export interface Milestone {
|
|
52
53
|
name: string;
|
|
53
54
|
landed: boolean;
|
|
54
|
-
/** the ledger's "what" once landed */
|
|
55
|
+
/** the ledger's "what" once landed; before that, plan.md's line for the milestone ("" when it has none) */
|
|
55
56
|
what: string;
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
export interface SessionRun {
|
|
60
|
+
waiting?: OrchestratorWait | null;
|
|
59
61
|
id: string;
|
|
60
62
|
title: string;
|
|
61
63
|
repo: string;
|
|
@@ -126,18 +128,50 @@ export function sessionRows(deps: Deps): { rows: SessionRow[]; warnings: string[
|
|
|
126
128
|
};
|
|
127
129
|
}
|
|
128
130
|
|
|
129
|
-
/**
|
|
131
|
+
/**
|
|
132
|
+
* A milestone's line in plan.md: a heading, list item or plain line that opens with its id and a separator
|
|
133
|
+
* (`## M1 — the jobs list`, `- **M2:** export`). The id stops at the separator, so `M1.L1 — …` is a lane's.
|
|
134
|
+
*/
|
|
135
|
+
const PLAN_LINE =
|
|
136
|
+
/^\s*(?:#{1,6}\s+|[-*]\s+|\d+[.)]\s+)?(?:\*\*)?([A-Za-z0-9_-]+)(?::\*\*|(?:\*\*)?\s*(?:—|–|:|-))\s*(.+?)\s*$/;
|
|
137
|
+
|
|
138
|
+
/** The description plan.md gives each of `names`, from the first line that opens with it; none for the rest. */
|
|
139
|
+
function planWhat(run: Run, names: string[]): Map<string, string> {
|
|
140
|
+
const out = new Map<string, string>();
|
|
141
|
+
const file = runPaths(run.dir).plan;
|
|
142
|
+
if (names.length === 0 || !existsSync(file)) return out;
|
|
143
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
144
|
+
const m = PLAN_LINE.exec(line);
|
|
145
|
+
const name = m?.[1];
|
|
146
|
+
if (m && name && names.includes(name) && !out.has(name)) out.set(name, (m[2] ?? "").trim());
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const byNumber = (a: string, b: string) => a.localeCompare(b, "en", { numeric: true });
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The ledger's landed milestones, then each milestone a lane file names that has not landed, whatever its id:
|
|
155
|
+
* lane `<milestone>.<lane>` belongs to the milestone before its first dot, as protocol.ts reads it. An open
|
|
156
|
+
* milestone's description is plan.md's line for it, when there is one.
|
|
157
|
+
*/
|
|
130
158
|
function milestonesOf(run: Run): Milestone[] {
|
|
131
159
|
const landed = nonBlankLines(runPaths(run.dir).ledger)
|
|
132
160
|
.slice(1)
|
|
133
161
|
.map((l) => l.split(" | "))
|
|
134
162
|
.map(([name, what]) => ({ name: (name ?? "").trim(), landed: true, what: (what ?? "").trim() }));
|
|
135
163
|
const lanes = existsSync(runPaths(run.dir).lanes) ? readdirSync(runPaths(run.dir).lanes) : [];
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
164
|
+
const names = [
|
|
165
|
+
...new Set(
|
|
166
|
+
lanes
|
|
167
|
+
.filter((f) => f.endsWith(".md") && f.slice(0, -".md".length).includes("."))
|
|
168
|
+
.map((f) => f.split(".")[0] as string),
|
|
169
|
+
),
|
|
170
|
+
]
|
|
171
|
+
.filter((m) => m !== "" && !landed.some((l) => l.name === m))
|
|
172
|
+
.sort(byNumber);
|
|
173
|
+
const what = planWhat(run, names);
|
|
174
|
+
return [...landed, ...names.map((name) => ({ name, landed: false, what: what.get(name) ?? "" }))];
|
|
141
175
|
}
|
|
142
176
|
|
|
143
177
|
/** Each role's latest dispatch, live ones first, then the newest finished first. */
|
|
@@ -183,6 +217,7 @@ function sessionRun(deps: Deps, x: SessionGroup["runs"][number]): SessionRun {
|
|
|
183
217
|
}
|
|
184
218
|
return {
|
|
185
219
|
id: run.id,
|
|
220
|
+
waiting: orchestratorWait(run, deps.now()),
|
|
186
221
|
title: run.meta.title,
|
|
187
222
|
repo: run.meta.repo,
|
|
188
223
|
createdAt: run.meta.createdAt,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tryLock } from "../infra/filelock.ts";
|
|
4
|
+
import { liveDispatches } from "./dispatches.ts";
|
|
5
|
+
import { findRun, listRuns, type Run, runPaths } from "./run-store.ts";
|
|
6
|
+
|
|
7
|
+
export interface ScratchCleaned {
|
|
8
|
+
/** the runs whose scratch folder was removed, with what it held */
|
|
9
|
+
removed: { run: string; bytes: number }[];
|
|
10
|
+
/** the runs left alone, and why: a role still writes there */
|
|
11
|
+
kept: { run: string; why: string }[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The bytes under `dir`, links not followed; 0 for what cannot be read. */
|
|
15
|
+
function sizeOf(dir: string): number {
|
|
16
|
+
let total = 0;
|
|
17
|
+
try {
|
|
18
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
19
|
+
const p = join(dir, e.name);
|
|
20
|
+
total += e.isDirectory() ? sizeOf(p) : (lstatSync(p, { throwIfNoEntry: false })?.size ?? 0);
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
// gone or unreadable: counts as nothing
|
|
24
|
+
}
|
|
25
|
+
return total;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Spec 1.5 plan 21: `catherd runs clean [run]` removes the roles' scratch folders (`<run>/scratch/`, each role's
|
|
30
|
+
* TMPDIR) of a run with no live role, or of every such run. The run's own files, records and replies stay. A run
|
|
31
|
+
* whose admission is in progress is kept: that admission may have just created a role's scratch.
|
|
32
|
+
*/
|
|
33
|
+
export function cleanScratch(o: { run?: string; now?: number } = {}): ScratchCleaned {
|
|
34
|
+
const runs: Run[] = o.run ? [findRun(o.run)] : listRuns().runs;
|
|
35
|
+
const out: ScratchCleaned = { removed: [], kept: [] };
|
|
36
|
+
for (const run of runs) {
|
|
37
|
+
const dir = runPaths(run.dir).scratch;
|
|
38
|
+
if (!existsSync(dir)) continue;
|
|
39
|
+
// admission creates a role's scratch and admits its dispatch under this lock, so while cleanup holds it no
|
|
40
|
+
// scratch is half-admitted, and one an admission holds now belongs to a dispatch about to go live
|
|
41
|
+
const release = tryLock(runPaths(run.dir).admission);
|
|
42
|
+
if (!release) {
|
|
43
|
+
out.kept.push({ run: run.id, why: "a dispatch is being admitted" });
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const live = liveDispatches(run, o.now ?? Date.now());
|
|
48
|
+
if (live.length) {
|
|
49
|
+
out.kept.push({ run: run.id, why: `${live.map((d) => d.admit.name).join(", ")} still running` });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const bytes = sizeOf(dir);
|
|
53
|
+
rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
out.removed.push({ run: run.id, bytes });
|
|
55
|
+
} finally {
|
|
56
|
+
release();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
package/src/services/sessions.ts
CHANGED
|
@@ -69,9 +69,11 @@ export function readSessionRows(run: Run): SessionRow[] {
|
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
71
|
* Spec §3.3: `run_start`, `dispatch` and `peek` make the calling session the run's owner when it is not, and
|
|
72
|
-
* append it to `R/sessions.jsonl`: that is how a run moves when it is continued from another session. Without host session identity it changes nothing. Returns whether the owner changed.
|
|
72
|
+
* append it to `R/sessions.jsonl`: that is how a run moves when it is continued from another session. Without host session identity it changes nothing, and neither does a role's process (`deps.role`, spec 1.5 plan 21). Returns whether the owner changed.
|
|
73
73
|
*/
|
|
74
74
|
export async function claimRun(deps: Deps, run: Run): Promise<boolean> {
|
|
75
|
+
// spec 1.5 plan 21: a role's process never becomes the run's owner, whatever host identity it has
|
|
76
|
+
if (deps.role) return false;
|
|
75
77
|
const me = currentSession(deps);
|
|
76
78
|
if (!me) return false;
|
|
77
79
|
const p = runPaths(run.dir);
|
|
@@ -8,9 +8,11 @@ import {
|
|
|
8
8
|
type ModelsFile,
|
|
9
9
|
outranks,
|
|
10
10
|
type Score,
|
|
11
|
+
ScoreSchema,
|
|
11
12
|
type ScoresFile,
|
|
12
13
|
} from "../domain/catalog.ts";
|
|
13
14
|
import {
|
|
15
|
+
EFFORT_ORDER,
|
|
14
16
|
defaultEffortOf,
|
|
15
17
|
type Derived,
|
|
16
18
|
familyEfforts,
|
|
@@ -55,6 +57,18 @@ interface Keyed {
|
|
|
55
57
|
row: SourceRow;
|
|
56
58
|
}
|
|
57
59
|
|
|
60
|
+
/**
|
|
61
|
+
* (1.2 minor) the fields where less is better: when two of a source's rows land on one rung, the lower one
|
|
62
|
+
* stands for it. Every other field keeps the higher.
|
|
63
|
+
*/
|
|
64
|
+
export const LOWER_IS_BETTER: ReadonlySet<string> = new Set([
|
|
65
|
+
"cost_per_task",
|
|
66
|
+
"median_time_to_first_token_seconds",
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/** Whether `a` should stand for a rung over `b` on `field`. */
|
|
70
|
+
const better = (field: string, a: number, b: number): boolean => (LOWER_IS_BETTER.has(field) ? a < b : a > b);
|
|
71
|
+
|
|
58
72
|
/** The score sources' rows, by source (spec 1.2 §3.3). */
|
|
59
73
|
function scoreRows(raw: RawAnswers): [SourceId, SourceRow[]][] {
|
|
60
74
|
const out: [SourceId, SourceRow[]][] = [];
|
|
@@ -93,7 +107,7 @@ export function derive(raw: RawAnswers, ctx: DeriveContext): Derived {
|
|
|
93
107
|
const at = table.get(field) ?? new Map<string, Keyed>();
|
|
94
108
|
table.set(field, at);
|
|
95
109
|
const had = at.get(key);
|
|
96
|
-
if (!had || row.value
|
|
110
|
+
if (!had || better(row.field, row.value, had.row.value))
|
|
97
111
|
at.set(key, {
|
|
98
112
|
key,
|
|
99
113
|
family,
|
|
@@ -171,15 +185,19 @@ export function derive(raw: RawAnswers, ctx: DeriveContext): Derived {
|
|
|
171
185
|
const family = map.family(id);
|
|
172
186
|
if (family && effort !== null) shipped.add(`${family.id}#${effort}|${s.dim}`);
|
|
173
187
|
}
|
|
188
|
+
// (1.2 minor) a sync carries a value only up to a stronger effort: a low effort never takes a high one's
|
|
174
189
|
scores.push(
|
|
175
|
-
...adjacent(ctx.models.families, scores, shipped, ctx.now, (f) => defaultEffortOf(ctx.sources, f)
|
|
190
|
+
...adjacent(ctx.models.families, scores, shipped, ctx.now, (f) => defaultEffortOf(ctx.sources, f), {
|
|
191
|
+
upwardOnly: true,
|
|
192
|
+
}),
|
|
176
193
|
);
|
|
177
194
|
|
|
178
195
|
const { facts, warnings } = factsOf(raw, ctx, map);
|
|
179
196
|
return {
|
|
180
197
|
schema: 1,
|
|
181
198
|
builtAt: new Date(ctx.now).toISOString(),
|
|
182
|
-
|
|
199
|
+
// (1.2 minor) readDerived validates every score: one bad row would make the whole file unreadable
|
|
200
|
+
scores: scores.filter((s) => ScoreSchema.safeParse(s).success),
|
|
183
201
|
facts,
|
|
184
202
|
fits,
|
|
185
203
|
unmatched: Object.fromEntries(Object.entries(unmatched).map(([s, ids]) => [s, [...ids].sort()])),
|
|
@@ -212,7 +230,8 @@ function aaFeatures(table: Map<string, Map<string, Keyed>>): Derived["features"]
|
|
|
212
230
|
/**
|
|
213
231
|
* Spec 1.2 §4.3 `adjacent`: for each family effort a dimension has no value at (neither one in `direct` nor
|
|
214
232
|
* one `shipped` names, as `<family>#<effort>|<dim>`), the best value in `direct` at the nearest effort that
|
|
215
|
-
* has one (the weaker on a tie)
|
|
233
|
+
* has one (the weaker on a tie); with `upwardOnly` (a sync's values, 1.2 minor), only a weaker effort's value
|
|
234
|
+
* is carried, so Luna none never takes Luna max's. A sync spreads only its own values, and never onto a value the shipped file
|
|
216
235
|
* carries; `rebuildShipped` spreads the shipped file's published values, with an empty `shipped`. A family that
|
|
217
236
|
* one backend runs without an effort (Cursor's bare slug, spec 1.3 §7.1) also gets `#default`, carried from the
|
|
218
237
|
* value nearest `defaultEffort(family)`.
|
|
@@ -223,7 +242,9 @@ export function adjacent(
|
|
|
223
242
|
shipped: ReadonlySet<string>,
|
|
224
243
|
now: number,
|
|
225
244
|
defaultEffort: (f: Family) => string = () => "high",
|
|
245
|
+
o: { upwardOnly?: boolean } = {},
|
|
226
246
|
): Score[] {
|
|
247
|
+
const rank = (e: string) => (EFFORT_ORDER as readonly string[]).indexOf(e);
|
|
227
248
|
const out: Score[] = [];
|
|
228
249
|
for (const f of families)
|
|
229
250
|
for (const dim of DIMS) {
|
|
@@ -239,7 +260,9 @@ export function adjacent(
|
|
|
239
260
|
const bare = efforts.length > 0 && Object.values(f.on).some((o) => o && o.efforts.length === 0);
|
|
240
261
|
for (const e of bare ? [...efforts, "default"] : efforts) {
|
|
241
262
|
if (byEffort.has(e) || shipped.has(`${f.id}#${e}|${dim}`)) continue;
|
|
242
|
-
const
|
|
263
|
+
const target = e === "default" ? defaultEffort(f) : e;
|
|
264
|
+
const from_ = [...byEffort.keys()].filter((x) => !o.upwardOnly || rank(x) <= rank(target));
|
|
265
|
+
const near = nearestEffort(target, from_);
|
|
243
266
|
const from = near ? byEffort.get(near) : undefined;
|
|
244
267
|
if (!near || !from) continue;
|
|
245
268
|
out.push({
|
|
@@ -49,6 +49,8 @@ export interface SyncOptions {
|
|
|
49
49
|
force?: boolean;
|
|
50
50
|
/** the MCP server's boot sync: skips when another sync runs, and gives a failing source an hour's rest */
|
|
51
51
|
background?: boolean;
|
|
52
|
+
/** false: answer `busy` at once when another sync holds the lock (the catalog_sync tool), never wait */
|
|
53
|
+
wait?: boolean;
|
|
52
54
|
/** how every source is fetched; tests replace it */
|
|
53
55
|
transport?: SourceTransport;
|
|
54
56
|
/** the Artificial Analysis key; default: the env's, else the saved one */
|
|
@@ -129,7 +131,7 @@ export function cachedAnswers(): RawAnswers {
|
|
|
129
131
|
*/
|
|
130
132
|
export async function syncSources(o: SyncOptions = {}): Promise<SyncReport> {
|
|
131
133
|
const now = o.now ?? Date.now;
|
|
132
|
-
const release = await takeLock(o.background === true);
|
|
134
|
+
const release = await takeLock(o.background === true || o.wait === false);
|
|
133
135
|
if (!release)
|
|
134
136
|
return {
|
|
135
137
|
busy: true,
|
|
@@ -177,16 +179,19 @@ export async function syncSources(o: SyncOptions = {}): Promise<SyncReport> {
|
|
|
177
179
|
lastAttemptAt: iso(at),
|
|
178
180
|
error: null,
|
|
179
181
|
rateLimitRemaining: limit,
|
|
182
|
+
rateLimitAt: limit === null ? null : iso(at),
|
|
180
183
|
};
|
|
181
184
|
outcomes.set(id, { source: id, state: "fetched", fetchedAt: iso(at) });
|
|
182
185
|
} catch (e) {
|
|
183
186
|
const error = errorMessage(e);
|
|
184
187
|
const limit = e instanceof SourceError ? rateLimitRemaining(e.headers) : null;
|
|
188
|
+
// (1.2 minor) a failure with no header keeps the last count with the time that count was read
|
|
185
189
|
state.sources[id] = {
|
|
186
190
|
fetchedAt: prev?.fetchedAt ?? null,
|
|
187
191
|
lastAttemptAt: iso(at),
|
|
188
192
|
error,
|
|
189
193
|
rateLimitRemaining: limit ?? prev?.rateLimitRemaining ?? null,
|
|
194
|
+
rateLimitAt: limit === null ? (prev?.rateLimitAt ?? null) : iso(at),
|
|
190
195
|
};
|
|
191
196
|
outcomes.set(id, { source: id, state: "failed", fetchedAt: prev?.fetchedAt ?? null, error });
|
|
192
197
|
log(o.background ? "debug" : "warn", "sources", { source: id, error });
|
|
@@ -300,6 +305,8 @@ export function sourcesStatus(): {
|
|
|
300
305
|
})),
|
|
301
306
|
aaKey: aaKey() !== null,
|
|
302
307
|
rateLimitRemaining: aa?.rateLimitRemaining ?? null,
|
|
303
|
-
|
|
308
|
+
// a state written before 1.5 has no rateLimitAt: its last attempt is the best guess
|
|
309
|
+
rateLimitAt:
|
|
310
|
+
aa?.rateLimitRemaining === null || aa === undefined ? null : (aa.rateLimitAt ?? aa.lastAttemptAt),
|
|
304
311
|
};
|
|
305
312
|
}
|
package/src/services/standins.ts
CHANGED
|
@@ -22,6 +22,13 @@ export type Features = Record<string, number | string>;
|
|
|
22
22
|
/** Spec 1.2 §6.3: a pair sharing fewer features than this is not suggested. */
|
|
23
23
|
export const MIN_SHARED_FEATURES = 3;
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Spec 1.5 plan 24: a rung stands in only when it has values of its own on at least this many dimensions. A
|
|
27
|
+
* sparse row (GPT-6.1 Sol's one honesty figure) ranks near everything, for it has few features to differ on,
|
|
28
|
+
* and would lend that one value to unrelated rungs.
|
|
29
|
+
*/
|
|
30
|
+
export const MIN_STANDIN_DIMS = 3;
|
|
31
|
+
|
|
25
32
|
export interface Suggestion {
|
|
26
33
|
/** the canonical rung that would stand in */
|
|
27
34
|
like: string;
|
|
@@ -132,7 +139,11 @@ export function missingDims(c: Catalog, canonical: string, dims: readonly Dim[]
|
|
|
132
139
|
return dims.filter((d) => !own[d] && !lent[d]);
|
|
133
140
|
}
|
|
134
141
|
|
|
135
|
-
/**
|
|
142
|
+
/**
|
|
143
|
+
* Every dimension some bar of the catalog (the defaults, with the user's override) has a threshold on: the
|
|
144
|
+
* dimensions a stand-in fills. No shipped bar uses `steer`, so it is never inferred by default; a user's steer
|
|
145
|
+
* bar makes it one (plan 14 Ruling 7, as the code has always read it; spec 1.5 plan 24 aligns the words).
|
|
146
|
+
*/
|
|
136
147
|
export function barDimsIn(c: Catalog): Dim[] {
|
|
137
148
|
const used = new Set<string>();
|
|
138
149
|
for (const kind of Object.values(c.bars))
|
|
@@ -148,13 +159,17 @@ class Ranker {
|
|
|
148
159
|
for (const r of canonicalRungs(c)) this.all.set(r, featuresOf(c, r));
|
|
149
160
|
this.z = scales([...this.all.values()]);
|
|
150
161
|
}
|
|
151
|
-
/**
|
|
162
|
+
/**
|
|
163
|
+
* The rungs with an own value on any of `dims` (and on at least MIN_STANDIN_DIMS dimensions in all), nearest
|
|
164
|
+
* first; each with what it would lend.
|
|
165
|
+
*/
|
|
152
166
|
rank(canonical: string, dims: readonly Dim[]): Suggestion[] {
|
|
153
167
|
const mine = this.all.get(canonical) ?? featuresOf(this.c, canonical);
|
|
154
168
|
const out: Suggestion[] = [];
|
|
155
169
|
for (const [other, f] of this.all) {
|
|
156
170
|
if (other === canonical) continue;
|
|
157
171
|
const own = ownValues(this.c, other);
|
|
172
|
+
if (Object.keys(own).length < MIN_STANDIN_DIMS) continue;
|
|
158
173
|
const lends = dims.filter((d) => own[d] !== undefined);
|
|
159
174
|
if (lends.length === 0) continue;
|
|
160
175
|
const got = distance(mine, f, this.z);
|
|
@@ -173,10 +188,24 @@ export function suggestStandIns(c: Catalog, canonical: string, limit = 3): Sugge
|
|
|
173
188
|
return new Ranker(c).rank(canonical, lacking.length ? lacking : DIMS).slice(0, limit);
|
|
174
189
|
}
|
|
175
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Spec 1.5 plan 24: the same effort of the family's predecessor (`Family.predecessor`), when it has a value on
|
|
193
|
+
* `d` of its own; null otherwise.
|
|
194
|
+
*/
|
|
195
|
+
function predecessorOf(c: Catalog, canonical: string, d: Dim): { like: string; value: number } | null {
|
|
196
|
+
const pred = family(c, canonical)?.predecessor;
|
|
197
|
+
if (!pred) return null;
|
|
198
|
+
const like = `${pred}${canonical.slice(canonical.lastIndexOf("#"))}`;
|
|
199
|
+
const s = c.scores[like]?.[d];
|
|
200
|
+
return s ? { like, value: s.value } : null;
|
|
201
|
+
}
|
|
202
|
+
|
|
176
203
|
/**
|
|
177
204
|
* Spec 1.2 §6.1: for every rung the catalog can name, per dimension the bars use that it has no value for
|
|
178
205
|
* (of its own or through a treat-like), its nearest stand-in with a value there. A rung no rung is near
|
|
179
|
-
* enough to (fewer than MIN_SHARED_FEATURES shared) gets none on that dimension.
|
|
206
|
+
* enough to (fewer than MIN_SHARED_FEATURES shared) gets none on that dimension. Spec 1.5 plan 24: a new
|
|
207
|
+
* release of a family line takes at least its predecessor's value at the same effort, so a guess never puts it
|
|
208
|
+
* below the model it replaces (the identity run's GPT-6.1 Sol at 37.2, under GPT-6 Luna).
|
|
180
209
|
*/
|
|
181
210
|
export function inferStandIns(c: Catalog): Catalog["inferred"] {
|
|
182
211
|
const dims = barDimsIn(c);
|
|
@@ -186,9 +215,14 @@ export function inferStandIns(c: Catalog): Catalog["inferred"] {
|
|
|
186
215
|
const lacking = missingDims(c, canonical, dims);
|
|
187
216
|
for (const d of lacking) {
|
|
188
217
|
const best = ranker.rank(canonical, [d])[0];
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
218
|
+
const pred = predecessorOf(c, canonical, d);
|
|
219
|
+
const nearest = best ? c.scores[best.like]?.[d]?.value : undefined;
|
|
220
|
+
let entry: InferredStandIn | null = best
|
|
221
|
+
? { like: best.like, distance: best.distance, features: best.features }
|
|
222
|
+
: null;
|
|
223
|
+
if (pred && (nearest === undefined || pred.value >= nearest))
|
|
224
|
+
entry = { like: pred.like, distance: 0, features: ["predecessor"] };
|
|
225
|
+
if (entry) (out[canonical] ??= {})[d] = entry;
|
|
192
226
|
}
|
|
193
227
|
}
|
|
194
228
|
return out;
|
package/src/services/state.ts
CHANGED
|
@@ -19,6 +19,8 @@ const NotesSchema = z.looseObject({
|
|
|
19
19
|
lastLandedAt: z.string().nullable(),
|
|
20
20
|
/** spec 1.1 §8: the milestones waiting on the owner */
|
|
21
21
|
parked: z.array(z.string()).optional(),
|
|
22
|
+
/** spec 1.5: what changed since the run's pin, as dispatch last saw it */
|
|
23
|
+
pinChanges: z.array(z.string()).optional(),
|
|
22
24
|
});
|
|
23
25
|
export type Notes = z.infer<typeof NotesSchema>;
|
|
24
26
|
export type NotesPatch = Partial<Omit<Notes, "schema">>;
|
|
@@ -68,6 +70,7 @@ export function updateState(run: Run, change: NotesPatch | ((n: Notes) => NotesP
|
|
|
68
70
|
brief: relative(run.dir, dispatchPaths(d.dir).brief),
|
|
69
71
|
})),
|
|
70
72
|
lastCheck: next.lastCheck,
|
|
73
|
+
pinChanges: next.pinChanges ?? [],
|
|
71
74
|
next: next.next,
|
|
72
75
|
protocol: protocolNext(run, next.parked ?? []),
|
|
73
76
|
});
|
|
@@ -76,6 +79,24 @@ export function updateState(run: Run, change: NotesPatch | ((n: Notes) => NotesP
|
|
|
76
79
|
});
|
|
77
80
|
}
|
|
78
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Writes state.json's notes only, with no git, under the state lock; for a writer that must not wait on git
|
|
84
|
+
* while it holds another lock (`land` in a workspace, #43 finding 5). It waits out a refresh's git (15 s).
|
|
85
|
+
*/
|
|
86
|
+
export function patchNotes(run: Run, change: (n: Notes) => NotesPatch): Promise<Notes> {
|
|
87
|
+
const stateJson = runPaths(run.dir).stateJson;
|
|
88
|
+
return withFileLock(
|
|
89
|
+
stateJson,
|
|
90
|
+
() => {
|
|
91
|
+
const notes = readNotes(run);
|
|
92
|
+
const next = parkedNext({ ...notes, ...change(notes) });
|
|
93
|
+
writeJsonAtomic(stateJson, next);
|
|
94
|
+
return next;
|
|
95
|
+
},
|
|
96
|
+
{ timeoutMs: 30_000 },
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
79
100
|
/**
|
|
80
101
|
* Ruling (b): a failed state.md refresh (git broken) never fails the call. The notes still go to state.json
|
|
81
102
|
* under the state lock (state.md stays as it was), and the message comes back as a hint.
|