@catalyst-cloud/cli 0.16.39 → 0.16.40

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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.40
4
+
5
+ `catalyst flow --repo <owner/name> --period 2w --granularity day` prints a repository's open pull request counts by the day, or by the hour with `--granularity hour`. `--json` prints the same buckets the SDK's `flow.series()` returns.
6
+
7
+ A supervisor fleet can now update itself on a schedule: `catalyst runner auto-update on --fleet --dir <fleet directory>` installs a timer that runs `catalyst runner update --fleet` for that directory, and `catalyst runner auto-update status` shows that it is a fleet schedule and which directory it updates.
8
+
9
+ After `catalyst runner update --fleet` applies a release, it checks the fleet again 10 minutes later. If the supervisor is not running, or the release was granted no work while three requests for work got no response, it restores the previous release and does not apply that release again until a newer one is approved. An idle fleet with nothing to do keeps the new release. `catalyst runner update --check --fleet` names the last check's result.
10
+
3
11
  ## 0.16.39
4
12
 
5
13
  `catalyst query pulls` now sends `--state`, `--repo` and `--draft` to Catalyst, so `--state open --all` reads only the workspace's open pull requests instead of paging through every pull request it has ever had. `--state` takes `open`, `closed` or `merged`, and `--draft` takes `draft` or `ready`. Any other value is refused before a request is made. `open` and `closed` leave out merged pull requests, and the repository and state match in any letter case. Against an older Catalyst that ignores these filters, the CLI applies them to the rows it gets back, so the result is the same. A filtered read of the local replica now checks every pull request it holds, so an older pull request linked to a ticket is still found.
package/README.md CHANGED
@@ -240,6 +240,32 @@ Paused rows show who paused the repository (their email, or the key or operator
240
240
 
241
241
  `--json` preserves the list response for status and prints the refreshed repository row for a write. An older cloud reports that repository status is not served yet and needs a newer cloud. Agents can use `catalyst capabilities --json` to check route availability against the cached cloud contract.
242
242
 
243
+ ## Open pull requests over time
244
+
245
+ Print how many pull requests a repository had open, one row per day or per hour:
246
+
247
+ ```sh
248
+ catalyst flow --repo acme/app --period 2w --granularity day
249
+ catalyst flow --repo acme/app --period 24h --granularity hour --json
250
+ catalyst flow
251
+ ```
252
+
253
+ ```text
254
+ $ catalyst flow --repo acme/app --period 2w --granularity day
255
+ 2026-10-12 27
256
+ 2026-10-13 31
257
+ ...
258
+ 2026-10-25 24 (now)
259
+ ```
260
+
261
+ Each row is one bucket: its start, then the number of pull requests open at its end, drafts included. Daily rows show the date; hourly rows show the time with its UTC offset. Days start at midnight in your workspace's time zone, as the cloud reports it. The last row ends now and is marked `(now)`. A row reads `unavailable` when your workspace cannot attribute pull requests to that repository.
262
+
263
+ `--period` is one of `24h`, `2d`, `3d`, `1w`, `2w`, `4w`, `12w` or `26w`, and defaults to two weeks. `--granularity` is `hour` or `day`; left out, the period picks the width: one, two or three hours for `24h`, `2d` and `3d`, and a day for the week periods. Hourly rows over a long period can be too much for one answer; the command then exits 2 and suggests daily rows or a shorter period.
264
+
265
+ Without `--repo`, the command reads this checkout's GitHub origin and uses it when it is a repository of your workspace. Anywhere else it exits 2 and asks for `--repo owner/name`. Any workspace member can read it.
266
+
267
+ `--json` prints one JSON array with one entry per bucket, exactly as the SDK's `flow.series()` returns them in `series.columns`: `startMs` and `endMs` in epoch milliseconds, `label`, `partial`, and `pr` with `open` and the bucket's other pull request counts.
268
+
243
269
  ## What has to be running
244
270
 
245
271
  Nothing, by default. After `login`, every read, write, ask and explanation goes to the cloud's origin-fresh API with the config file and the cached contract on disk. Two optional processes exist for people who want them:
@@ -13,14 +13,13 @@ import { dirname, join } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { compareVersions, exactVersion } from "./approved-release.js";
15
15
  import { LEGACY_PACKAGE_NAME, readManifest } from "./config.js";
16
- import { AUTO_UPDATE_LABEL, custodyNode, defaultRun, enableAutoUpdate, launchAgentPath, scheduledPath, systemdUnitPaths, writeFileAtomic, } from "./runner-auto-update.js";
16
+ import { AUTO_UPDATE_LABEL, custodyNode, defaultRun, enableAutoUpdate, launchAgentPath, scheduledPath, systemdUnitPaths, unitWords, unxml, writeFileAtomic, } from "./runner-auto-update.js";
17
17
  import { CLAUDE_PATH_ENV, resolveClaude } from "./update-plugin.js";
18
18
  /** Long enough for an in-place update (under a minute on a Mac measured 2026-10-24), well inside the
19
19
  * installer's 600-second budget for the whole npm install. */
20
20
  export const BUSY_WAIT_MS = 240_000;
21
21
  const BUSY_POLL_MS = 5_000;
22
22
  const blockingSleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
23
- const unxml = (value) => value.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&amp;/g, "&");
24
23
  /** The agent's command, from a plist this CLI wrote (launchAgentPlist). Anything else is unrecognized. */
25
24
  function plistProgram(text) {
26
25
  const array = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(text)?.[1];
@@ -33,10 +32,6 @@ function plistProgram(text) {
33
32
  };
34
33
  return programOf(args, env("PATH"), env(CLAUDE_PATH_ENV));
35
34
  }
36
- /** systemd's quoted words, as systemdUnits writes them. */
37
- function unitWords(line) {
38
- return [...line.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => (m[1] ?? "").replace(/\\(.)/g, "$1").replace(/%%/g, "%"));
39
- }
40
35
  function unitProgram(text) {
41
36
  const lines = text.split("\n");
42
37
  const exec = lines.find((l) => l.startsWith("ExecStart="));
package/dist/args.js CHANGED
@@ -25,7 +25,8 @@ export const FLAG_TABLES = {
25
25
  check: { value: false, help: "read the approved release and say what would change, without changing it" },
26
26
  auto: { value: false, help: "one-line output for the scheduled run" },
27
27
  dir: { value: true, help: "the runner directory (default: the one setup created)" },
28
- fleet: { value: false, help: "update a multi-team supervisor fleet's compose.json in --dir" },
28
+ fleet: { value: false, help: "update a multi-team supervisor fleet's compose.json in --dir (with auto-update on: schedule that update)" },
29
+ "post-roll-check": { value: false, help: "with --fleet and --dir: the check a fleet update arms for 10 minutes after it applies (run by that timer)" },
29
30
  yes: { value: false, help: "uninstall: confirm without a terminal" },
30
31
  "delete-checkouts": { value: false, help: "uninstall: also delete the repository and thoughts checkouts" },
31
32
  },
@@ -440,6 +441,11 @@ export const FLAG_TABLES = {
440
441
  help: "import: only send these names (repeatable or comma-separated)",
441
442
  },
442
443
  },
444
+ flow: {
445
+ repo: { value: true, help: "the repository (owner/name); default: this checkout's GitHub origin, when it is a repository of your workspace" },
446
+ period: { value: true, help: "24h | 2d | 3d | 1w | 2w | 4w | 12w | 26w (default: 2w)" },
447
+ granularity: { value: true, help: "hour | day (default: the period picks it: 1h for 24h, 2h for 2d, 3h for 3d, a day for weeks)" },
448
+ },
443
449
  team: {
444
450
  all: { value: false, help: "check every team, one request at a time" },
445
451
  stage: {
@@ -613,7 +619,7 @@ export const FLAG_TABLES = {
613
619
  };
614
620
  export const VERB_USAGE = {
615
621
  hosts: "hosts [list] | hosts remove <name-or-id> [--yes] | hosts rename <name-or-id> <new-name> [--json]",
616
- runner: "runner [status] | runner stop | runner start | runner uninstall [--yes] [--delete-checkouts] | runner update [--check] [--dir <runner directory>] [--fleet] | runner auto-update [status|on|off] [--json]",
622
+ runner: "runner [status] | runner stop | runner start | runner uninstall [--yes] [--delete-checkouts] | runner update [--check] [--dir <runner directory>] [--fleet] | runner auto-update [status|on|off] [--fleet --dir <fleet directory>] [--json]",
617
623
  update: "update [--check] [--project <absolute path>] [--dir <runner directory>] | update sdk --project <absolute path> | update policy [<component> on|off] | update policy sdk on|off --project <absolute path> | update policy windows <HH:MM,...>|default [--time-zone <zone>] [--json] (components: runner, supervisor, watchdog, cli, skills-cloud, skills-dev, replica)",
618
624
  setup: "setup [onboard options] (sets up Catalyst step by step; same as catalyst onboard) | setup --engine <file> --engine-sha256 <hex> [-- <install options>] (run by install.sh; install.sh --help lists the options)",
619
625
  login: "login [--base-url <url>] [--start-replica] (keyless; or --key <personal-key> / CATALYST_CLOUD_TOKEN)",
@@ -646,6 +652,7 @@ export const VERB_USAGE = {
646
652
  identity: "identity linear <status|options|set> [<linearUserId>] [--json]",
647
653
  messages: "messages read --ticket CTC-N [--since SEQUENCE] [--json]",
648
654
  capabilities: "capabilities [--json]",
655
+ flow: "flow [--repo <owner/name>] [--period 24h|2d|3d|1w|2w|4w|12w|26w] [--granularity hour|day] [--json] (a repository's open pull requests over time, one row per hour or day; needs login)",
649
656
  project: "project <list [--json] | wip-limit <get|set <n>|set default> [--team K] [--json]>",
650
657
  lease: "lease <take <ticket> --agent <seat> [--ttl <minutes>] | renew <ticket> (--commit <sha>|--artifact <path or id>|--finding <id>|--pr <number>) [--note <text>] [--agent <seat>] [--generation <n>] | release <ticket> [--agent <seat>] [--generation <n>] | show <ticket>> [--json] (a seat's ticket lease: take, renew with new progress, release, read; needs login)",
651
658
  "codex-env": "codex-env --print [--codex-home <dir>] | --apply | --refresh [--json] (the telemetry attributes naming the coding account and machine a Codex launch runs as; only --refresh waits on the network)",
@@ -35,6 +35,7 @@ export const CAPABILITIES = [
35
35
  { verb: "project wip-limit set", does: "set a project's new-start WIP limit (0 to 9999, or back to the workspace default)", needs: "admin", routes: [agent("POST", "team-wip-limit")], since: "0.13.1" },
36
36
  { verb: "repo status", does: "list visible repositories with their state, pause details and projects", needs: "member", routes: [agent("GET", "repos")], since: "0.16.27" },
37
37
  { verb: "repo pause|resume", does: "pause or resume a repository as a workspace owner or admin", needs: "admin", routes: [agent("GET", "repos"), agent("POST", "repos/pause"), agent("POST", "repos/resume")], since: "0.16.27" },
38
+ { verb: "flow", does: "print a repository's open pull request counts over time, one row per hour or per day", needs: "member", routes: [], since: "0.16.40" },
38
39
  { verb: "lease take|renew|release|show", does: "hold the ticket a seat works through a ticket lease that lapses without new progress, instead of a label", needs: "member", routes: [agent("POST", "ticket-lease/take"), agent("POST", "ticket-lease/renew"), agent("POST", "ticket-lease/release"), agent("GET", "ticket-lease")], since: "0.16.30" },
39
40
  { verb: "work available", does: "list tickets nobody holds, with the last holder and progress, and open pull requests no lease covers", needs: "member", routes: [], since: "0.16.30" },
40
41
  { verb: "repo agents-block", does: "add or refresh the one Catalyst block in a checkout's AGENTS.md, in the working tree, for the person to commit and open as a pull request", needs: "member", routes: [], since: "0.13.1" },
package/dist/cli.js CHANGED
@@ -53,6 +53,7 @@ import { cmdLegacy } from "./legacy.js";
53
53
  import { cmdCodexEnv } from "./codex-env.js";
54
54
  import { codexAttributionAfterSetup, codexAttributionStatusLine, onlyCodexAttribution, removeCodexAttribution, } from "./codex-attribution.js";
55
55
  import { cmdIdentity } from "./identity.js";
56
+ import { cmdFlow } from "./flow.js";
56
57
  import { cmdMessages } from "./messages.js";
57
58
  import { cmdConnections } from "./connections.js";
58
59
  import { cmdOnboard, onboardErrorJournal, onboardStatePath, readOnboardJournal, } from "./onboard.js";
@@ -120,6 +121,7 @@ export function usageText() {
120
121
  " catalyst project wip-limit <get|set <n>|set default> [--team K] (a project's new-start WIP limit; members read, owners and admins set)",
121
122
  " catalyst repo pause <owner/name> --reason <text> | repo resume <owner/name> [--reason <text>] (owner or admin; reason: 1 to 500 trimmed characters)",
122
123
  " catalyst repo status [--json] (visible workspace repositories and their paused state; needs login)",
124
+ " catalyst flow [--repo <owner/name>] [--period 2w] [--granularity hour|day] [--json] (a repository's open pull requests over time; needs login)",
123
125
  " catalyst repo <agents-block <path> [--write]|agent-setup <path> [--apply] [--with-check]> (a checkout's AGENTS.md block and portable agent layout; working tree only)",
124
126
  " catalyst lease <take <ticket> --agent <seat> [--ttl <minutes>]|renew <ticket> --commit|--artifact|--finding|--pr <ref> [--note <text>]|release <ticket>|show <ticket>> [--json] (a seat's ticket lease; needs login)",
125
127
  " catalyst work available [--team K] [--json] (tickets nobody holds and open pull requests no lease covers)",
@@ -582,6 +584,8 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
582
584
  return await cmdUpdate(args, ctx, deps.update);
583
585
  case "connections":
584
586
  return await cmdConnections(args, ctx, deps.connections ?? {});
587
+ case "flow":
588
+ return await cmdFlow(args, ctx, deps.flow ?? {});
585
589
  default:
586
590
  ctx.stderr(`unknown command: ${args.command}`);
587
591
  ctx.stderr(usageText());
@@ -646,6 +650,7 @@ const VERB_HELP_KNOWN = Object.fromEntries([
646
650
  "capabilities",
647
651
  "project",
648
652
  "repo",
653
+ "flow",
649
654
  "lease",
650
655
  "work",
651
656
  "legacy",
package/dist/flow.js ADDED
@@ -0,0 +1,104 @@
1
+ import { resolve } from "node:path";
2
+ import { flagString, positionals } from "./args.js";
3
+ import { requireConfig } from "./config.js";
4
+ import { loadContract } from "./contract.js";
5
+ import { CliError, UsageError } from "./errors.js";
6
+ import { readLocalRepositoryName } from "./onboard-settings-context.js";
7
+ import { bearerFor } from "./oauth.js";
8
+ import { loadHttpSdk } from "./sdk.js";
9
+ const REPO_RE = /^[^/\s]+\/[^/\s]+$/;
10
+ function repoRequired(why) {
11
+ return new CliError(`flow needs a repository: pass --repo owner/name (${why})`, "repo-required", 2);
12
+ }
13
+ /** The zone the cloud cut the days in, from the answer's `boundary`; UTC when it names none. */
14
+ function zoneOf(series) {
15
+ const b = series["boundary"];
16
+ if (b !== null && typeof b === "object" && "kind" in b) {
17
+ if (b.kind === "iana" && "timeZone" in b && typeof b.timeZone === "string") {
18
+ try {
19
+ new Intl.DateTimeFormat("en-CA", { timeZone: b.timeZone });
20
+ return { timeZone: b.timeZone };
21
+ }
22
+ catch {
23
+ // An unknown zone name prints in UTC rather than failing the whole read.
24
+ }
25
+ }
26
+ if (b.kind === "fixed-offset" && "offsetMinutes" in b && typeof b.offsetMinutes === "number" && Number.isFinite(b.offsetMinutes)) {
27
+ return { offsetMinutes: b.offsetMinutes };
28
+ }
29
+ }
30
+ return { timeZone: "UTC" };
31
+ }
32
+ /** One wall-clock formatter per series; a fixed-offset zone reads through shifted UTC. */
33
+ function formatterFor(zone) {
34
+ return new Intl.DateTimeFormat("en-CA", {
35
+ timeZone: "timeZone" in zone ? zone.timeZone : "UTC",
36
+ year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23",
37
+ timeZoneName: "longOffset",
38
+ });
39
+ }
40
+ /** `±HH:MM` for a fixed offset's minutes. */
41
+ function offsetText(minutes) {
42
+ const abs = Math.abs(minutes);
43
+ return `${minutes < 0 ? "-" : "+"}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`;
44
+ }
45
+ function wallClock(ms, zone, fmt) {
46
+ const shifted = "offsetMinutes" in zone ? ms + zone.offsetMinutes * 60_000 : ms;
47
+ const parts = fmt.formatToParts(shifted);
48
+ const part = (type) => parts.find((p) => p.type === type)?.value ?? "";
49
+ const offset = "offsetMinutes" in zone
50
+ ? offsetText(zone.offsetMinutes)
51
+ : part("timeZoneName").replace(/^GMT$/, "GMT+00:00").replace(/^GMT/, "");
52
+ return { date: `${part("year")}-${part("month")}-${part("day")}`, time: `${part("hour")}:${part("minute")}`, offset };
53
+ }
54
+ /** One line per bucket: the start in the boundary's zone (a date when every bucket starts at local
55
+ * midnight, a local time with its offset otherwise), the open count, and `(now)` on the partial one. */
56
+ function rows(series) {
57
+ const zone = zoneOf(series);
58
+ const fmt = formatterFor(zone);
59
+ const starts = series.columns.map((c) => wallClock(c.startMs, zone, fmt));
60
+ const daily = starts.every((w) => w.time === "00:00");
61
+ const when = starts.map((w) => (daily ? w.date : `${w.date}T${w.time}${w.offset}`));
62
+ const counts = series.columns.map((c) => (c.pr === null ? "unavailable" : String(c.pr.open)));
63
+ const width = Math.max(0, ...counts.map((c) => c.length));
64
+ return series.columns.map((c, i) => `${when[i]} ${counts[i].padStart(width)}${c.partial ? " (now)" : ""}`);
65
+ }
66
+ export async function cmdFlow(args, ctx, deps = {}) {
67
+ if (positionals(args).length > 0)
68
+ throw new UsageError("flow takes no positional arguments; name the repository with --repo owner/name");
69
+ const sdk = await loadHttpSdk();
70
+ const rawPeriod = flagString(args, "period");
71
+ const period = rawPeriod === undefined ? undefined : sdk.FLOW_PERIODS.find((p) => p === rawPeriod);
72
+ if (rawPeriod !== undefined && period === undefined)
73
+ throw new UsageError(`--period must be one of ${sdk.FLOW_PERIODS.join(", ")} (got "${rawPeriod}")`);
74
+ const rawGranularity = flagString(args, "granularity");
75
+ const granularity = rawGranularity === undefined ? undefined : sdk.FLOW_GRANULARITIES.find((g) => g === rawGranularity);
76
+ if (rawGranularity !== undefined && granularity === undefined)
77
+ throw new UsageError(`--granularity must be ${sdk.FLOW_GRANULARITIES.join(" or ")} (got "${rawGranularity}")`);
78
+ const named = flagString(args, "repo");
79
+ if (named !== undefined && !REPO_RE.test(named))
80
+ throw new UsageError(`--repo must be owner/name (got "${named}")`);
81
+ const cfg = requireConfig(ctx);
82
+ let repo = named;
83
+ if (repo === undefined) {
84
+ const local = await (deps.repoName ?? readLocalRepositoryName)(resolve("."));
85
+ if (local === null)
86
+ throw repoRequired("this directory is not a checkout with a GitHub origin");
87
+ const repositories = await (deps.repositories ?? (async () => (await loadContract(ctx, cfg)).doc.merge.repositories))();
88
+ const match = repositories.find((r) => `${r.owner}/${r.name}`.toLowerCase() === local);
89
+ if (match === undefined)
90
+ throw repoRequired(`this checkout's origin, ${local}, is not a repository of this workspace`);
91
+ repo = `${match.owner}/${match.name}`;
92
+ }
93
+ const createClient = deps.createClient ?? ((options) => sdk.createTenantClient(options));
94
+ const client = await createClient({ key: await bearerFor(ctx, cfg), baseUrl: cfg.baseUrl, fetch: ctx.fetch });
95
+ const result = await client.flow.series({ repo, period, granularity });
96
+ if (result.outcome !== "ok")
97
+ throw new CliError(`flow for ${repo}: ${result.reason}`, result.outcome, 2, "status" in result ? result.status : undefined);
98
+ if (args.json)
99
+ ctx.stdout(JSON.stringify(result.series.columns));
100
+ else
101
+ for (const line of rows(result.series))
102
+ ctx.stdout(line);
103
+ return 0;
104
+ }
@@ -25,8 +25,16 @@ export const defaultRun = (cmd, args) => {
25
25
  return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.error ? r.error.message : (r.stderr ?? "") };
26
26
  };
27
27
  export const xml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
28
+ export const unxml = (value) => value.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&amp;/g, "&");
28
29
  /** systemd's own quoting for one ExecStart/Environment word. */
29
30
  const unitWord = (value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`;
31
+ /** systemd's quoted words, as systemdUnits writes them. */
32
+ export function unitWords(line) {
33
+ return [...line.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => (m[1] ?? "").replace(/\\(.)/g, "$1").replace(/%%/g, "%"));
34
+ }
35
+ /** The command the schedule runs. CTC-6384: a supervisor fleet's schedule adds --fleet, so each run
36
+ * updates the fleet's compose.json in `dir` instead of a single runner's .env. */
37
+ const updateArgs = (dir, fleet) => ["runner", "update", "--auto", ...(fleet ? ["--fleet"] : []), "--dir", dir];
30
38
  export function launchAgentPath(home) {
31
39
  return join(home, "Library", "LaunchAgents", `${AUTO_UPDATE_LABEL}.plist`);
32
40
  }
@@ -34,8 +42,8 @@ export function systemdUnitPaths(home) {
34
42
  const base = join(home, ".config", "systemd", "user");
35
43
  return { service: join(base, `${AUTO_UPDATE_UNIT}.service`), timer: join(base, `${AUTO_UPDATE_UNIT}.timer`) };
36
44
  }
37
- export function launchAgentPlist(deps, dir) {
38
- const args = [...(deps.node ? [deps.node] : []), deps.cli, "runner", "update", "--auto", "--dir", dir];
45
+ export function launchAgentPlist(deps, dir, fleet = false) {
46
+ const args = [...(deps.node ? [deps.node] : []), deps.cli, ...updateArgs(dir, fleet)];
39
47
  return [
40
48
  '<?xml version="1.0" encoding="UTF-8"?>',
41
49
  '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
@@ -54,7 +62,7 @@ export function launchAgentPlist(deps, dir) {
54
62
  "",
55
63
  ].join("\n");
56
64
  }
57
- export function systemdUnits(deps, dir) {
65
+ export function systemdUnits(deps, dir, fleet = false) {
58
66
  return {
59
67
  service: [
60
68
  "[Unit]",
@@ -64,7 +72,7 @@ export function systemdUnits(deps, dir) {
64
72
  "Type=oneshot",
65
73
  `Environment=${unitWord(`PATH=${deps.path}`)}`,
66
74
  ...(deps.claude ? [`Environment=${unitWord(`${CLAUDE_PATH_ENV}=${deps.claude}`)}`] : []),
67
- `ExecStart=${[deps.cli, "runner", "update", "--auto", "--dir", dir].map(unitWord).join(" ")}`,
75
+ `ExecStart=${[deps.cli, ...updateArgs(dir, fleet)].map(unitWord).join(" ")}`,
68
76
  "",
69
77
  ].join("\n"),
70
78
  timer: [
@@ -107,15 +115,41 @@ export function writeFileAtomic(path, text) {
107
115
  writeFileSync(temp, text, { mode: 0o644 });
108
116
  renameSync(temp, path);
109
117
  }
118
+ /** The installed schedule's own command, read back from the file this CLI wrote. Null when the file
119
+ * is missing or names no update directory. */
120
+ function installedTarget(platform, home) {
121
+ let args;
122
+ try {
123
+ if (platform === "darwin") {
124
+ const array = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(readFileSync(launchAgentPath(home), "utf8"))?.[1];
125
+ args = array === undefined ? [] : [...array.matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => unxml(m[1] ?? ""));
126
+ }
127
+ else {
128
+ const exec = readFileSync(systemdUnitPaths(home).service, "utf8").split("\n").find((l) => l.startsWith("ExecStart="));
129
+ args = exec === undefined ? [] : unitWords(exec);
130
+ }
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ const at = args.indexOf("--dir");
136
+ const dir = at === -1 ? undefined : args[at + 1];
137
+ if (!args.includes("update") || dir === undefined || !dir.startsWith("/"))
138
+ return null;
139
+ return { fleet: args.includes("--fleet"), dir };
140
+ }
110
141
  export function autoUpdateStatus(deps) {
111
142
  const platform = deps.platform ?? process.platform;
112
143
  const run = deps.run ?? defaultRun;
144
+ const target = () => installedTarget(platform, deps.home) ?? {};
113
145
  if (platform === "darwin") {
114
146
  const path = launchAgentPath(deps.home);
115
147
  if (!existsSync(path))
116
148
  return { state: "off", detail: "no_launch_agent" };
117
149
  const loaded = run("launchctl", ["print", `gui/${deps.uid ?? process.getuid?.() ?? 0}/${AUTO_UPDATE_LABEL}`]).status === 0;
118
- return loaded ? { state: "on", detail: "launch_agent_loaded", path } : { state: "off", detail: "launch_agent_not_loaded" };
150
+ return loaded
151
+ ? { state: "on", detail: "launch_agent_loaded", path, ...target() }
152
+ : { state: "off", detail: "launch_agent_not_loaded", ...target() };
119
153
  }
120
154
  if (platform === "linux") {
121
155
  const { timer } = systemdUnitPaths(deps.home);
@@ -123,9 +157,9 @@ export function autoUpdateStatus(deps) {
123
157
  return { state: "off", detail: "no_timer" };
124
158
  const enabled = run("systemctl", ["--user", "is-enabled", `${AUTO_UPDATE_UNIT}.timer`]);
125
159
  if (enabled.status !== 0)
126
- return { state: "off", detail: "timer_not_enabled" };
160
+ return { state: "off", detail: "timer_not_enabled", ...target() };
127
161
  const linger = lingering(run, deps.uid ?? process.getuid?.() ?? 0);
128
- return { state: "on", detail: linger ? "timer_enabled" : "timer_enabled_without_linger", path: timer, linger };
162
+ return { state: "on", detail: linger ? "timer_enabled" : "timer_enabled_without_linger", path: timer, linger, ...target() };
129
163
  }
130
164
  return { state: "unsupported", detail: `platform_${platform}` };
131
165
  }
@@ -146,32 +180,34 @@ export function custodyNode(dir) {
146
180
  return null;
147
181
  }
148
182
  }
149
- /** Install and start the schedule. Rewrites the files every time, so a moved CLI or PATH is picked up. */
150
- export function enableAutoUpdate(deps, dir) {
183
+ /** Install and start the schedule. Rewrites the files every time, so a moved CLI or PATH is picked up.
184
+ * `fleet` schedules a supervisor fleet's update of the compose.json in `dir` (CTC-6384). */
185
+ export function enableAutoUpdate(deps, dir, { fleet = false } = {}) {
151
186
  const platform = deps.platform ?? process.platform;
152
187
  const run = deps.run ?? defaultRun;
188
+ const target = { fleet, dir };
153
189
  if (platform === "darwin") {
154
190
  const uid = deps.uid ?? process.getuid?.() ?? 0;
155
191
  const path = launchAgentPath(deps.home);
156
- const text = launchAgentPlist({ ...deps, node: deps.node === undefined ? custodyNode(dir) : deps.node }, dir);
192
+ const text = launchAgentPlist({ ...deps, node: deps.node === undefined ? custodyNode(dir) : deps.node }, dir, fleet);
157
193
  const unchanged = existsSync(path) && readFileSync(path, "utf8") === text;
158
194
  const domain = `gui/${uid}`;
159
195
  const loaded = run("launchctl", ["print", `${domain}/${AUTO_UPDATE_LABEL}`]).status === 0;
160
196
  if (unchanged && loaded)
161
- return { state: "on", detail: "launch_agent_loaded", path };
197
+ return { state: "on", detail: "launch_agent_loaded", path, ...target };
162
198
  if (loaded)
163
199
  run("launchctl", ["bootout", `${domain}/${AUTO_UPDATE_LABEL}`]);
164
200
  writeFileAtomic(path, text);
165
201
  const boot = run("launchctl", ["bootstrap", domain, path]);
166
202
  return boot.status === 0
167
- ? { state: "on", detail: "launch_agent_loaded", path }
203
+ ? { state: "on", detail: "launch_agent_loaded", path, ...target }
168
204
  : { state: "off", detail: `launchctl_bootstrap_failed:${boot.stderr.trim().slice(0, 160)}` };
169
205
  }
170
206
  if (platform === "linux") {
171
207
  if (run("systemctl", ["--user", "show-environment"]).status !== 0)
172
208
  return { state: "unsupported", detail: "no_systemd_user_manager" };
173
209
  const paths = systemdUnitPaths(deps.home);
174
- const units = systemdUnits(deps, dir);
210
+ const units = systemdUnits(deps, dir, fleet);
175
211
  writeFileAtomic(paths.service, units.service);
176
212
  writeFileAtomic(paths.timer, units.timer);
177
213
  run("systemctl", ["--user", "daemon-reload"]);
@@ -180,7 +216,7 @@ export function enableAutoUpdate(deps, dir) {
180
216
  return { state: "off", detail: `systemctl_enable_failed:${enabled.stderr.trim().slice(0, 160)}` };
181
217
  const { linger, turnedOn } = ensureLingering(run, deps.uid ?? process.getuid?.() ?? 0);
182
218
  return { state: "on", detail: linger ? "timer_enabled" : "timer_enabled_without_linger", path: paths.timer, linger,
183
- ...(turnedOn ? { lingerTurnedOn: true } : {}) };
219
+ ...(turnedOn ? { lingerTurnedOn: true } : {}), ...target };
184
220
  }
185
221
  return { state: "unsupported", detail: `platform_${platform}` };
186
222
  }
@@ -5,7 +5,7 @@ import { flagBool, flagString, positionals } from "./args.js";
5
5
  import { CliError, UsageError } from "./errors.js";
6
6
  import { autoUpdateStatus, custodyNode, disableAutoUpdate, enableAutoUpdate, scheduledPath, } from "./runner-auto-update.js";
7
7
  import { dockerRunnerUpdateEngine, readReleaseState, RELEASE_COMPONENTS, runnerDir, runRunnerUpdate, updateResultLines, lastProblem, lastProblemLines, } from "./runner-update.js";
8
- import { dockerFleetUpdateEngine, runFleetUpdate } from "./runner-update-fleet.js";
8
+ import { dockerFleetUpdateEngine, postRollLines, readPostRollResult, runFleetPostRollCheck, runFleetUpdate } from "./runner-update-fleet.js";
9
9
  import { runUpdate, sharedReleaseRead, updateReportJson, updateReportLines } from "./update.js";
10
10
  import { effectivePolicy, readUpdatePolicy, setComponentPolicy } from "./update-policy.js";
11
11
  import { resolveClaude } from "./update-plugin.js";
@@ -37,8 +37,8 @@ function custodyRerun(ctx, deps, dir) {
37
37
  const run = deps.run ?? ((command, args, env) => spawnSync(command, [...args], { stdio: "inherit", env }).status ?? 1);
38
38
  return run(node, [script, ...rest], { ...ctx.env, [CUSTODY_RERUN_ENV]: "1" });
39
39
  }
40
- const USAGE = "catalyst runner [status] | stop | start | uninstall [--yes] [--delete-checkouts] | runner update [--check] [--dir <runner directory>] [--fleet] | runner auto-update [status|on|off]";
41
- const UPDATE_FLAGS = ["check", "auto", "dir", "fleet"];
40
+ const USAGE = "catalyst runner [status] | stop | start | uninstall [--yes] [--delete-checkouts] | runner update [--check] [--dir <runner directory>] [--fleet] | runner auto-update [status|on|off] [--fleet --dir <fleet directory>]";
41
+ const UPDATE_FLAGS = ["check", "auto", "dir", "fleet", "post-roll-check"];
42
42
  function schedulerDeps(ctx, deps) {
43
43
  const cli = deps.scheduler?.cli ?? resolve(process.argv[1] ?? "catalyst");
44
44
  return {
@@ -89,7 +89,7 @@ export async function cmdRunner(args, ctx, deps = {}) {
89
89
  const action = parts[0];
90
90
  if (action === undefined || RUNNER_LIFECYCLE_ACTIONS.includes(action)) {
91
91
  if (UPDATE_FLAGS.some(flag => args.flags[flag] !== undefined))
92
- throw new UsageError("--check, --auto, --dir and --fleet are only valid with runner update or auto-update");
92
+ throw new UsageError("--check, --auto, --dir, --fleet and --post-roll-check are only valid with runner update or auto-update");
93
93
  return cmdRunnerLifecycle(args, ctx, deps);
94
94
  }
95
95
  if (args.flags.yes !== undefined || args.flags["delete-checkouts"] !== undefined)
@@ -104,6 +104,18 @@ export async function cmdRunner(args, ctx, deps = {}) {
104
104
  const fleet = flagBool(args, "fleet");
105
105
  if (fleet && dirFlag === undefined)
106
106
  throw new UsageError("--fleet needs --dir, the directory that holds compose.json");
107
+ // The check a fleet update arms on the host 10 minutes after it applies (CTC-6564); only that timer passes it.
108
+ if (flagBool(args, "post-roll-check")) {
109
+ if (!fleet || dirFlag === undefined || check || auto)
110
+ throw new UsageError("--post-roll-check is run by the timer a fleet update arms: runner update --fleet --dir <dir> --post-roll-check");
111
+ const outcome = await runFleetPostRollCheck(deps.fleetEngine ?? dockerFleetUpdateEngine({ env: ctx.env }), { dir: dirFlag });
112
+ if (args.json)
113
+ ctx.stdout(JSON.stringify(outcome));
114
+ else
115
+ for (const line of outcome.result ? postRollLines(outcome.result) : [`Post-roll check: ${outcome.detail}.`])
116
+ ctx.stdout(line);
117
+ return outcome.exitCode;
118
+ }
107
119
  if (!fleet && !check) {
108
120
  const rerun = custodyRerun(ctx, deps.custody ?? {}, dir());
109
121
  if (rerun !== null)
@@ -184,14 +196,27 @@ export async function cmdRunner(args, ctx, deps = {}) {
184
196
  ctx.stdout(line);
185
197
  return result.exitCode;
186
198
  }
199
+ // A fleet check also names the last post-roll verdict (CTC-6564).
200
+ if (check && fleet && !auto) {
201
+ const verdict = readPostRollResult(dir());
202
+ if (args.json)
203
+ ctx.stdout(JSON.stringify({ ...result, lastPostRoll: verdict }));
204
+ else
205
+ for (const line of [...updateResultLines(result), ...postRollLines(verdict)])
206
+ ctx.stdout(line);
207
+ return result.exitCode;
208
+ }
187
209
  return print(result, await components(result));
188
210
  }
189
211
  if (action === "auto-update" && parts.length <= 2) {
190
212
  const mode = parts[1] ?? "status";
213
+ const fleet = flagBool(args, "fleet");
214
+ if (mode === "on" && fleet && dirFlag === undefined)
215
+ throw new UsageError("--fleet needs --dir, the directory that holds compose.json");
191
216
  const scheduler = schedulerDeps(ctx, deps);
192
217
  let status;
193
218
  if (mode === "on")
194
- status = enableAutoUpdate(scheduler, dir());
219
+ status = enableAutoUpdate(scheduler, dir(), { fleet });
195
220
  else if (mode === "off")
196
221
  status = disableAutoUpdate(scheduler);
197
222
  else if (mode === "status")
@@ -207,8 +232,12 @@ export async function cmdRunner(args, ctx, deps = {}) {
207
232
  const state = mode === "status" && dirFlag !== undefined ? readReleaseState(dirFlag) : undefined;
208
233
  ctx.stdout(JSON.stringify({ ...status, ...(state ? { release: state } : {}) }));
209
234
  }
210
- else
235
+ else {
211
236
  ctx.stdout(statusLine(status));
237
+ // A single runner's schedule prints what it always has; a fleet's says so, and for which directory.
238
+ if (status.state === "on" && status.fleet)
239
+ ctx.stdout(`This is a supervisor fleet's schedule: it updates the compose.json in ${status.dir}.`);
240
+ }
212
241
  return status.state === "on" || mode !== "on" ? 0 : 1;
213
242
  }
214
243
  throw new UsageError(USAGE);
@@ -1,6 +1,8 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { existsSync, readFileSync, unlinkSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
+ import { dirname, join, resolve } from "node:path";
3
4
  import { dockerRunnerEngine, prepareRunnerImages, spawnDocker } from "./onboard-runner.js";
5
+ import { defaultRun } from "./runner-auto-update.js";
4
6
  import { decideRelease, DESIRED_RELEASE_SCRIPT, HOST_ID, isRecord, readReleaseState, releaseLock, takeLock, writePrivateAtomic, writeReleaseState, } from "./runner-update.js";
5
7
  // `catalyst runner update --fleet <dir>`: the same approved release, applied to a multi-team
6
8
  // supervisor fleet (CTC-6530). One supervisor container serves several scopes from a Compose file
@@ -20,6 +22,12 @@ import { decideRelease, DESIRED_RELEASE_SCRIPT, HOST_ID, isRecord, readReleaseSt
20
22
  // no restart and log no `host_not_ready` for those scopes. Otherwise the previous `compose.json` comes
21
23
  // back and the supervisor is recreated on it. That scope set is saved before the recreate and removed
22
24
  // once it qualifies or rolls back, so a run killed in between is finished by the next one.
25
+ //
26
+ // A release can qualify and then stop receiving work minutes later (CTC-6564). A successful apply
27
+ // arms a check on the host, outside the calling process, for 10 minutes later: it reads the supervisor's
28
+ // log since the roll began and, when the release was granted no work and the fleet's acquires got no
29
+ // answer, restores the saved previous `compose.json`. `runner update --fleet --post-roll-check` is
30
+ // that check; only the armed timer runs it.
23
31
  export const FLEET_COMPOSE_FILE = "compose.json";
24
32
  export const PREVIOUS_COMPOSE_FILE = "compose.release-previous.json";
25
33
  const CANDIDATE_COMPOSE_FILE = "compose.release-candidate.json";
@@ -34,6 +42,14 @@ const QUALIFY_MIN_MS = 90_000;
34
42
  const QUALIFY_DEADLINE_MS = 240_000;
35
43
  const QUALIFY_POLL_MS = 10_000;
36
44
  const CHILD_NAME_PREFIX = "catalyst-pa_";
45
+ /** What a successful apply leaves for the check: the bytes it wrote and when the roll began. */
46
+ export const POST_ROLL_ARMED_FILE = "post-roll-armed.json";
47
+ /** The check's last verdict, beside `release-state.json`. */
48
+ export const POST_ROLL_RESULT_FILE = "post-roll-result.json";
49
+ export const POST_ROLL_DELAY_MS = 10 * 60_000;
50
+ const POST_ROLL_UNIT = "catalyst-fleet-post-roll";
51
+ /** No-response acquire retries, with no grants, that count a release as failing. */
52
+ const POST_ROLL_MAX_NO_RESPONSE = 3;
37
53
  /** The two image fields in a fleet `compose.json`, or null when the file is not a fleet layout. */
38
54
  export function fleetImages(compose) {
39
55
  const supervisor = isRecord(compose) && isRecord(compose.services) ? compose.services.supervisor : null;
@@ -104,6 +120,112 @@ function clearInFlight(dir) {
104
120
  // Already gone.
105
121
  }
106
122
  }
123
+ const removeFile = (path) => {
124
+ try {
125
+ unlinkSync(path);
126
+ }
127
+ catch {
128
+ // Already gone.
129
+ }
130
+ };
131
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
132
+ const POST_ROLL_VERDICTS = ["PASS", "STANDS_IDLE", "ROLLED_BACK", "ROLLBACK_REFUSED", "ROLLBACK_FAILED"];
133
+ const numberOrNull = (value) => (typeof value === "number" && Number.isSafeInteger(value) ? value : null);
134
+ const stringOrNull = (value) => (typeof value === "string" ? value : null);
135
+ function readPostRollArmed(dir) {
136
+ try {
137
+ const parsed = JSON.parse(readFileSync(join(dir, POST_ROLL_ARMED_FILE), "utf8"));
138
+ if (!isRecord(parsed) || parsed.version !== 1)
139
+ return null;
140
+ const sinceMs = numberOrNull(parsed.sinceMs);
141
+ if (sinceMs === null || typeof parsed.composeSha256 !== "string")
142
+ return null;
143
+ return {
144
+ version: 1,
145
+ sinceMs,
146
+ composeSha256: parsed.composeSha256,
147
+ generation: numberOrNull(parsed.generation),
148
+ releaseId: stringOrNull(parsed.releaseId),
149
+ fromGeneration: numberOrNull(parsed.fromGeneration),
150
+ armedAtMs: numberOrNull(parsed.armedAtMs) ?? sinceMs,
151
+ };
152
+ }
153
+ catch {
154
+ return null;
155
+ }
156
+ }
157
+ /** The last post-roll check's record, or null when there is none. */
158
+ export function readPostRollResult(dir) {
159
+ try {
160
+ const parsed = JSON.parse(readFileSync(join(dir, POST_ROLL_RESULT_FILE), "utf8"));
161
+ if (!isRecord(parsed) || parsed.version !== 1 || typeof parsed.verdict !== "string" || !POST_ROLL_VERDICTS.includes(parsed.verdict))
162
+ return null;
163
+ if (typeof parsed.at !== "string" || typeof parsed.since !== "string")
164
+ return null;
165
+ return {
166
+ version: 1,
167
+ at: parsed.at,
168
+ since: parsed.since,
169
+ granted: numberOrNull(parsed.granted) ?? 0,
170
+ noResponse: numberOrNull(parsed.noResponse) ?? 0,
171
+ answered: numberOrNull(parsed.answered) ?? 0,
172
+ verdict: parsed.verdict,
173
+ generation: numberOrNull(parsed.generation),
174
+ releaseId: stringOrNull(parsed.releaseId),
175
+ ...(parsed.rolledBackFrom !== undefined ? { rolledBackFrom: numberOrNull(parsed.rolledBackFrom) } : {}),
176
+ ...(parsed.rolledBackTo !== undefined ? { rolledBackTo: numberOrNull(parsed.rolledBackTo) } : {}),
177
+ ...(typeof parsed.reason === "string" ? { reason: parsed.reason } : {}),
178
+ };
179
+ }
180
+ catch {
181
+ return null;
182
+ }
183
+ }
184
+ const VERDICT_TEXT = {
185
+ PASS: "passed: the release was granted work",
186
+ STANDS_IDLE: "left the release in place: no work was granted and nothing showed a failure",
187
+ ROLLED_BACK: "rolled the release back: it was granted no work or its supervisor was not running",
188
+ ROLLBACK_REFUSED: "found no work granted and did not roll back",
189
+ ROLLBACK_FAILED: "tried to roll back and could not confirm the supervisor",
190
+ };
191
+ /** One line naming the last post-roll verdict, for `runner update --check --fleet`. */
192
+ export function postRollLines(result) {
193
+ if (result === null)
194
+ return [];
195
+ const detail = [
196
+ ...(result.verdict === "ROLLED_BACK" ? [`back to generation ${result.rolledBackTo ?? "none"}`] : result.reason ? [result.reason] : []),
197
+ `generation ${result.generation ?? "none"}`,
198
+ `since ${result.since}: ${result.granted} granted, ${result.noResponse} no response, ${result.answered} answered`,
199
+ ];
200
+ return [`The last post-roll check, at ${result.at}, ${VERDICT_TEXT[result.verdict]} (${detail.join("; ")}).`];
201
+ }
202
+ const eventLines = (text, event) => text.split("\n").filter((line) => new RegExp(`"event"\\s*:\\s*"${event}"`).test(line));
203
+ /** The counts the post-roll rule reads from the supervisor's log since the roll began. `answered` is
204
+ * informational: an idle supervisor whose acquire gets an empty queue may log nothing at all. */
205
+ export function postRollCounts(text) {
206
+ const granted = eventLines(text, "work_granted").length;
207
+ const refused = eventLines(text, "acquire_refused").length;
208
+ const none = eventLines(text, "acquire_none").length;
209
+ const noResponse = text.split("\n").filter((line) => line.includes("acquire_retry") && line.includes("(no response)")).length;
210
+ return { granted, noResponse, answered: granted + refused + none };
211
+ }
212
+ /** Whether a release with these counts stopped receiving work: nothing granted since the roll began and
213
+ * at least three acquire retries that got no response. Silence alone is not a failure, because a healthy
214
+ * idle host logs nothing; a supervisor that is not running is judged separately, from the container. */
215
+ export const postRollFailing = (counts) => counts.granted === 0 && counts.noResponse >= POST_ROLL_MAX_NO_RESPONSE;
216
+ /** Record what the apply wrote and ask the engine for the check. The note joins the result's detail
217
+ * when the check could not be armed; the apply itself still succeeded. */
218
+ async function armPostRollCheck(engine, dir, armed, signal) {
219
+ try {
220
+ writePrivateAtomic(join(dir, POST_ROLL_ARMED_FILE), `${JSON.stringify(armed, null, 2)}\n`);
221
+ // The verdict on an earlier release says nothing about this one.
222
+ removeFile(join(dir, POST_ROLL_RESULT_FILE));
223
+ return (await engine.armPostRollCheck(dir, POST_ROLL_DELAY_MS, signal)) ? "" : ";post_roll_check_not_armed";
224
+ }
225
+ catch {
226
+ return ";post_roll_check_not_armed";
227
+ }
228
+ }
107
229
  export async function runFleetUpdate(engine, options) {
108
230
  const { dir } = options;
109
231
  const now = options.now ?? Date.now;
@@ -152,6 +274,11 @@ async function update(engine, options, now) {
152
274
  const target = { runner: release.images.runner.reference, supervisor: release.images.supervisor.reference };
153
275
  const changed = ["runner", "supervisor"].filter((c) => current[c] !== target[c]);
154
276
  const tagged = { releaseId: release.releaseId };
277
+ // A release the post-roll check rolled back stays rolled back while it is the approved one; a newer
278
+ // generation is applied normally (CTC-6564).
279
+ const rolled = readPostRollResult(dir);
280
+ if (changed.length > 0 && rolled?.verdict === "ROLLED_BACK" && rolled.releaseId === release.releaseId && rolled.generation === release.generation)
281
+ return done("rolled_back", `post_roll_rolled_back:granted=${rolled.granted},no_response=${rolled.noResponse},answered=${rolled.answered}`, { ...tagged, generation: release.generation, exitCode: 1 });
155
282
  const live = await engine.supervisor(dir, project, signal);
156
283
  if (live === undefined)
157
284
  return done("retained", "docker_unavailable", tagged);
@@ -223,6 +350,7 @@ async function update(engine, options, now) {
223
350
  if (childrenBefore === null)
224
351
  return done("retained", "docker_unavailable", tagged);
225
352
  // From here the machine changes. The previous file is the rollback.
353
+ const rollStartMs = now();
226
354
  if (changed.length > 0)
227
355
  writePrivateAtomic(join(dir, PREVIOUS_COMPOSE_FILE), composeText);
228
356
  writePrivateAtomic(join(dir, IN_FLIGHT_FILE), `${JSON.stringify({ version: 1, scopes: [...scopes].sort(), atMs: now() })}\n`);
@@ -239,7 +367,11 @@ async function update(engine, options, now) {
239
367
  const after = childrenAfter[id];
240
368
  return before.running && after?.running === true && after.fingerprint !== before.fingerprint;
241
369
  }).length;
242
- const detail = changed.length > 0 ? `updated:${changed.join(",")}` : "reconciled";
370
+ const armed = {
371
+ version: 1, sinceMs: rollStartMs, composeSha256: sha256(candidateText), generation: release.generation,
372
+ releaseId: release.releaseId, fromGeneration: state.generation, armedAtMs: now(),
373
+ };
374
+ const detail = (changed.length > 0 ? `updated:${changed.join(",")}` : "reconciled") + await armPostRollCheck(engine, dir, armed, signal);
243
375
  const previous = changed.length > 0 ? current : null;
244
376
  return disturbed === 0
245
377
  ? record("updated", detail, previous)
@@ -247,14 +379,104 @@ async function update(engine, options, now) {
247
379
  }
248
380
  if (rollbackText === null || rollbackImages === null)
249
381
  return done("failed", "new_supervisor_not_qualified_no_previous", { ...tagged, exitCode: 1 });
250
- writePrivateAtomic(composePath, rollbackText);
251
- const restored = (await engine.recreate(dir, project, signal)) &&
382
+ const restored = (await restoreCompose(engine, dir, project, rollbackText, signal)) &&
252
383
  (await qualify(engine, dir, project, rollbackImages, scopes, options, now));
253
384
  // A rollback that did not qualify keeps the baseline for the next run.
254
385
  if (restored)
255
386
  clearInFlight(dir);
256
387
  return done(restored ? "rolled_back" : "failed", restored ? "new_supervisor_not_qualified" : "rollback_not_qualified", { ...tagged, exitCode: 1 });
257
388
  }
389
+ /** Put `text` back as `compose.json` and recreate the supervisor alone on it. */
390
+ async function restoreCompose(engine, dir, project, text, signal) {
391
+ writePrivateAtomic(join(dir, FLEET_COMPOSE_FILE), text);
392
+ return engine.recreate(dir, project, signal);
393
+ }
394
+ /** The check a successful apply armed, run on the host 10 minutes later (CTC-6564). A release whose
395
+ * supervisor is not running, or that was granted no work while three acquire retries got no response,
396
+ * goes back to the saved previous `compose.json`, but only if `compose.json` still holds the bytes the
397
+ * apply wrote. A running supervisor with no grants and fewer no-response retries stands. */
398
+ export async function runFleetPostRollCheck(engine, options) {
399
+ const { dir, signal } = options;
400
+ const now = options.now ?? Date.now;
401
+ const stay = (detail, exitCode = 0) => ({ detail, result: null, exitCode });
402
+ if (!existsSync(join(dir, FLEET_COMPOSE_FILE)))
403
+ return stay("fleet_not_installed", 1);
404
+ const armed = readPostRollArmed(dir);
405
+ if (armed === null)
406
+ return stay("no_post_roll_check_armed");
407
+ const lock = takeLock(dir, now());
408
+ if (lock === null)
409
+ return stay("update_running");
410
+ try {
411
+ const composeText = readFileSync(join(dir, FLEET_COMPOSE_FILE), "utf8");
412
+ let compose;
413
+ try {
414
+ compose = JSON.parse(composeText);
415
+ }
416
+ catch {
417
+ compose = null;
418
+ }
419
+ const project = isRecord(compose) && typeof compose.name === "string" ? compose.name : "";
420
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(project))
421
+ return stay("fleet_compose_unrecognized", 1);
422
+ const live = await engine.supervisor(dir, project, signal);
423
+ if (live === undefined)
424
+ return stay("docker_unavailable", 1);
425
+ const since = new Date(armed.sinceMs).toISOString();
426
+ // A missing or stopped supervisor is a failure on its own; its log is not read.
427
+ const notRunning = live === null || !live.running;
428
+ let counts = { granted: 0, noResponse: 0, answered: 0 };
429
+ if (!notRunning) {
430
+ const text = await engine.logs(live.id, since, signal);
431
+ if (text === null)
432
+ return stay("docker_unavailable", 1);
433
+ counts = postRollCounts(text);
434
+ }
435
+ const finish = (verdict, extra = {}, exitCode = 0) => {
436
+ const result = {
437
+ version: 1, at: new Date(now()).toISOString(), since, ...counts, verdict,
438
+ generation: armed.generation, releaseId: armed.releaseId, ...extra,
439
+ };
440
+ writePrivateAtomic(join(dir, POST_ROLL_RESULT_FILE), `${JSON.stringify(result, null, 2)}\n`);
441
+ removeFile(join(dir, POST_ROLL_ARMED_FILE));
442
+ return { detail: verdict, result, exitCode };
443
+ };
444
+ if (!notRunning) {
445
+ if (counts.granted > 0)
446
+ return finish("PASS");
447
+ if (!postRollFailing(counts))
448
+ return finish("STANDS_IDLE");
449
+ }
450
+ const refuse = (reason) => finish("ROLLBACK_REFUSED", { reason }, 1);
451
+ if (sha256(composeText) !== armed.composeSha256)
452
+ return refuse("compose_changed_since_roll");
453
+ let previousText;
454
+ let previous;
455
+ try {
456
+ previousText = readFileSync(join(dir, PREVIOUS_COMPOSE_FILE), "utf8");
457
+ previous = fleetImages(JSON.parse(previousText));
458
+ }
459
+ catch {
460
+ return refuse("no_previous_compose");
461
+ }
462
+ if (previous === null)
463
+ return refuse("no_previous_compose");
464
+ // The apply removed the images it replaced, so the previous ones may need pulling again.
465
+ const unprepared = await engine.prepare([previous.runner, previous.supervisor], signal);
466
+ if (unprepared !== null)
467
+ return finish("ROLLBACK_FAILED", { reason: unprepared }, 1);
468
+ const restored = await restoreCompose(engine, dir, project, previousText, signal);
469
+ const after = restored ? await engine.supervisor(dir, project, signal) : null;
470
+ const running = !!after && after.running && after.image === previous.supervisor && after.runnerImage === previous.runner;
471
+ const moved = { rolledBackFrom: armed.generation, rolledBackTo: armed.fromGeneration };
472
+ return running
473
+ ? finish("ROLLED_BACK", { ...moved, reason: notRunning ? "supervisor_not_running" : "no_work_granted" }, 1)
474
+ : finish("ROLLBACK_FAILED", { ...moved, reason: restored ? "supervisor_not_running" : "recreate_failed" }, 1);
475
+ }
476
+ finally {
477
+ releaseLock(dir, lock);
478
+ }
479
+ }
258
480
  /** The recreated supervisor runs `images`, stays up with no restart, logs no `host_not_ready` for a
259
481
  * scope it must serve, and qualifies every one of `scopes`, held for the minimum window. */
260
482
  async function qualify(engine, dir, project, images, scopes, options, now) {
@@ -438,5 +660,28 @@ export function dockerFleetUpdateEngine(deps = {}) {
438
660
  for (const ref of images)
439
661
  await docker(["image", "rm", ref], { signal, timeoutMs: 120_000 });
440
662
  },
663
+ // A transient systemd timer outlives this process and the operator's session. Root's runs under the
664
+ // system manager; anyone else's runs under their user manager, which needs lingering to outlive a logout.
665
+ async armPostRollCheck(dir, delayMs) {
666
+ const timer = deps.timer ?? {};
667
+ if ((timer.platform ?? process.platform) !== "linux")
668
+ return false;
669
+ const command = timer.command ?? (process.argv[1] ? [process.execPath, resolve(process.argv[1])] : null);
670
+ if (command === null)
671
+ return false;
672
+ const run = timer.run ?? defaultRun;
673
+ const manager = (timer.uid ?? process.getuid?.()) === 0 ? [] : ["--user"];
674
+ const source = deps.env ?? process.env;
675
+ const carried = ["PATH", "HOME"].flatMap((key) => (source[key] ? [`--setenv=${key}=${source[key]}`] : []));
676
+ try {
677
+ // A timer armed by an earlier apply is replaced, not duplicated.
678
+ run("systemctl", [...manager, "stop", `${POST_ROLL_UNIT}.timer`, `${POST_ROLL_UNIT}.service`]);
679
+ return run("systemd-run", [...manager, "--quiet", "--collect", `--unit=${POST_ROLL_UNIT}`, `--on-active=${Math.ceil(delayMs / 1000)}s`,
680
+ "--description=Catalyst fleet post-roll check", ...carried, "--", ...command, "runner", "update", "--fleet", "--dir", dir, "--post-roll-check"]).status === 0;
681
+ }
682
+ catch {
683
+ return false;
684
+ }
685
+ },
441
686
  };
442
687
  }
@@ -601,7 +601,7 @@ const OUTCOME_TEXT = {
601
601
  deferred_busy: "A phase is running here, so the update waits for the next check.",
602
602
  waiting_window: "An approved release waits for this machine's next update window.",
603
603
  retained: "Kept the current images.",
604
- rolled_back: "The new supervisor did not become ready, so the previous images were restored.",
604
+ rolled_back: "The new release was rolled back, so the previous images were restored.",
605
605
  failed: "The update could not finish. Look at the runner with docker compose ps.",
606
606
  };
607
607
  /** The last run's record when it ended badly: a failed or rolled-back update a person should know
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catalyst-cloud/cli",
3
- "version": "0.16.39",
3
+ "version": "0.16.40",
4
4
  "description": "Set up Catalyst Cloud, check your tickets and manage your account from the command line.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -4,7 +4,7 @@ description: >-
4
4
  Catalyst's GitHub: show a ticket's pull request with its checks, reviews and review threads, say whether it is mergeable under this repository's policy, and explain what a PR accumulates as the ticket moves (the branch, the draft, the rewrite, the force-pushes, the labels, the queue). Use when someone asks "show me the PR", "what are the checks saying", "why hasn't it merged", "what does the queue need", "what does that label mean", or wants the branch and merge conventions Catalyst follows.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.39 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.40 — written in this repository for customer accounts -->
8
8
 
9
9
  # Catalyst's GitHub
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Catalyst's view of Linear on the customer's own cloud account. Reads a ticket with its comments, relations, labels, linked pull requests and agent sessions inline, from the cloud API by default, with local replica reads available as an explicit opt-in, always naming the source; searches tickets, PRs, projects and initiatives; writes comments, card moves, labels and new tickets through the account's agent proxy as the app actor, with every route, stage id, label id and marker read from the account's contract. Knows what a ticket accumulates as Catalyst works it (phase-outcome comments, the document per phase, the agent session, the labels, the single blocks relation, the bookkeeping marker, the eyes acknowledgement). Use when a person says "show me the ticket", "what did Catalyst write on it", "comment on it", "move it", "label it" or "file a ticket". Not for raising a decision for a human (what-needs-me) and not for pull requests (catalyst-github).
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.39 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.40 — written in this repository for customer accounts -->
8
8
 
9
9
  # Catalyst Linear
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Set up Catalyst Cloud and say whether setup is working. Walks a person from nothing to their first ticket running, one step at a time: the coding account, the project, the integrations and connected accounts, each repository's settings file, then one real ticket moving. Also logs this machine in, reads the readiness verdict with who can fix each failure, and checks the optional local replica. Use when someone says "set me up", "onboard me", "I just signed up", "get me started", "what do I do first", "log me in", "which account is this machine on", "am I set up", "what is missing", "why does nothing happen" or "is the replica running", when a login expired, or when a skill script exits 2 saying this machine is not connected. Does every step a key can do through the catalyst CLI, hands over the exact page for the steps only a browser can do, and never claims a step it did not watch succeed.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.39 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.40 — written in this repository for customer accounts -->
8
8
 
9
9
  # Onboard me
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Own one Catalyst Cloud project end to end until it closes. Use when the person says "run this project for me", "own this until it ships", "keep this moving", or hands you a project id or a set of tickets to drive. Subscribes to the account's event stream for the scope through the catalyst CLI, reacts to each change in the same turn, makes tickets ready and moves them to dispatch, parks what should stop, chases stalls, escalates inward, and keeps one status summary current. Writes to Linear as the app actor; never polls.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.39 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.40 — written in this repository for customer accounts -->
8
8
 
9
9
  # Run this project
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Get a stuck Catalyst Cloud ticket moving again. Use when the person asks "why is this parked and can you release it?", "unpark this", "get things flowing again", "the outage is over, retry what failed", or hands you a ticket that is not moving. Reads why nothing runs and what holds the ticket through the catalyst CLI, decides whether the recorded cause is fixed, previews the release, releases every governor holding the ticket the right way (or one failure class across a team), and raises an ask only for what a person has to do. Never releases a cause it cannot show changed without saying so.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.39 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.40 — written in this repository for customer accounts -->
8
8
 
9
9
  # Unstick
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  The human's decision inbox on Catalyst Cloud, and the one way an agent raises a decision on their behalf. Use when the person asks "what needs me?", "what am I blocking?", or when active work is gated on a choice only they can make. Lists open asks ranked by how much open work each one holds, files an ask through the cloud's ask route with the account's own template, and records the answer so the held work releases. Never answers as the human, never duplicates an open ask.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.39 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.40 — written in this repository for customer accounts -->
8
8
 
9
9
  # What needs me
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  What environment variable names does this repository need, and where does each one come from? Scans the repository offline (no login, no network) and lists the names in three groups (build/test, deploy-only, bindings), each with where it was found, what uses it, and where a local value would come from. Never reads a value or prints one. Also checks the environment variable table in `.catalyst/catalyst.toml` offline. Use when someone asks "what does catalyst.env.json mean", "what env vars does this repo need", "why does the container need this", or before reviewing or writing the declaration.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.39 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.40 — written in this repository for customer accounts -->
8
8
 
9
9
  # What this repository needs
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Use for "what's happening?", "what machines do we have?", "how much capacity do we have?", "where are we?", "why is that stuck?", "what closed?", "what's next?", "how does this work?", "why did it do that?", "how does it prioritise?" and "what does this setting do, where is it set?" in Catalyst Cloud, or asks for something to be done rather than known. Reads the contract via catalyst CLI. Read-only: route work/decisions, never answer as the human.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.39 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.40 — written in this repository for customer accounts -->
8
8
 
9
9
  # What's happening
10
10