@catalyst-cloud/cli 0.16.43 → 0.16.44

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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.44
4
+
5
+ `catalyst runner update` now adds the log setting to a self-hosted machine that was set up before it existed. If the machine's `compose.yaml` has no `CATALYST_NODE_LOG_SHIPPING` line, the update adds the line a new setup writes and prints a notice saying so. With that line, which is on by default, Catalyst's own service containers on the machine send their logs to Catalyst, with secrets removed first, once Catalyst turns log collection on for your workspace. To keep it off, set `CATALYST_NODE_LOG_SHIPPING=off` in the runner's `.env` and run `docker compose up -d`; a value you set there always wins. An update that rolls back leaves `compose.yaml` as it was, and a file that already names the setting, or that the update does not recognise, is left alone.
6
+
7
+ A runner release that comes up while the machine is pausing work to prepare is no longer rolled back for that pause alone. An automatic update keeps the new release for up to 45 minutes, until the machine is ready, and reports it as waiting. If the machine is still not ready once the pause ends, the update rolls back as before. `catalyst runner update` also prints its outcome and releases its lock when Docker exits before reading its input, where it used to stop without a result.
8
+
3
9
  ## 0.16.43
4
10
 
5
11
  `catalyst ask accept` now waits for an answer that has not reached Catalyst yet, instead of failing. If you accept an ask a few seconds after answering it, Catalyst may not have your answer comment yet. The command used to stop with a refusal, and the same command worked when you ran it again. Now it waits as long as Catalyst asks and tries again, for up to 30 seconds. If the answer still has not arrived, it says so and tells you when to run the same command again. A refusal that will not change on retry is reported at once, with its reason.
package/dist/args.js CHANGED
@@ -125,7 +125,7 @@ export const FLAG_TABLES = {
125
125
  },
126
126
  "coding-account": {
127
127
  value: true,
128
- help: "verify this coding-account slot (catalyst accounts lists them; or CATALYST_ONBOARD_CODING_ACCOUNT)",
128
+ help: "verify this AI account identifier (catalyst accounts lists them; or CATALYST_ONBOARD_CODING_ACCOUNT)",
129
129
  },
130
130
  },
131
131
  status: {},
@@ -235,7 +235,7 @@ export const FLAG_TABLES = {
235
235
  queue: {
236
236
  team: {
237
237
  value: true,
238
- help: "team key (default: every team the tenant contract names)",
238
+ help: "team key (default: every team the workspace contract names)",
239
239
  },
240
240
  },
241
241
  watch: {
@@ -344,11 +344,11 @@ export const FLAG_TABLES = {
344
344
  },
345
345
  "gates-repo": {
346
346
  value: true,
347
- help: "raise: the --gates-pr repository as owner/name (default: the account's only repository)",
347
+ help: "raise: the --gates-pr repository as owner/name (default: the workspace's only repository)",
348
348
  },
349
349
  anyone: {
350
350
  value: false,
351
- help: "list: every open ask in the tenant, not only the ones assigned to you",
351
+ help: "list: every open ask in the workspace, not only the ones assigned to you",
352
352
  },
353
353
  "ask-key": { value: true, help: "raise: idempotency key" },
354
354
  answer: { value: true, help: "accept: the answering comment id" },
@@ -433,7 +433,7 @@ export const FLAG_TABLES = {
433
433
  var: {
434
434
  repo: {
435
435
  value: true,
436
- help: "repository scope (owner/name); omit for tenant scope",
436
+ help: "repository scope (owner/name); omit for workspace scope",
437
437
  },
438
438
  names: {
439
439
  value: true,
@@ -554,7 +554,7 @@ export const FLAG_TABLES = {
554
554
  "codex-env": {
555
555
  print: {
556
556
  value: false,
557
- help: "print the OTEL_RESOURCE_ATTRIBUTES value a Codex launch should carry: the attributes already set, plus the coding account's slot and this machine's short name",
557
+ help: "print the OTEL_RESOURCE_ATTRIBUTES value a Codex launch should carry: the attributes already set, plus the AI account identifier and this computer's short name",
558
558
  },
559
559
  apply: {
560
560
  value: false,
@@ -562,7 +562,7 @@ export const FLAG_TABLES = {
562
562
  },
563
563
  refresh: {
564
564
  value: false,
565
- help: "fetch this workspace's coding accounts now and update the cache --print and --apply read",
565
+ help: "fetch this workspace's AI accounts now and update the cache --print and --apply read",
566
566
  },
567
567
  "codex-home": { value: true, help: "print: the Codex home to read (default: $CODEX_HOME, else ~/.codex)" },
568
568
  },
@@ -655,7 +655,7 @@ export const VERB_USAGE = {
655
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)",
656
656
  project: "project <list [--json] | wip-limit <get|set <n>|set default> [--team K] [--json]>",
657
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)",
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)",
658
+ "codex-env": "codex-env --print [--codex-home <dir>] | --apply | --refresh [--json] (the telemetry attributes naming the AI account and computer a Codex launch runs as; only --refresh waits on the network)",
659
659
  work: "work available [--team K] [--json] (per team: tickets nobody holds, with the last holder and progress, and open pull requests no live lease covers, drafts included)",
660
660
  repo: "repo <status|pause <owner/name> --reason <text>|resume <owner/name> [--reason <text>]|agents-block <path> [--write]|agent-setup <path> [--apply] [--with-check]> [--json] (status: visible workspace repositories; agents-block/agent-setup: local checkout)",
661
661
  legacy: "legacy [--remove [--data] [--yes]] [--json]",
@@ -48,8 +48,8 @@ export const CAPABILITIES = [
48
48
  { verb: "secret set|import", does: "enter a repository secret's value from this terminal, never echoed", needs: "admin", routes: [], since: "0.8.0" },
49
49
  { verb: "var set|import", does: "enter plain environment variable values from this terminal, never echoed", needs: "admin", routes: [], since: "0.13.2" },
50
50
  { verb: "env import", does: "store this computer's values for the names .catalyst/catalyst.toml declares, never printed", needs: "admin", routes: [], since: "0.16.20" },
51
- { verb: "accounts", does: "read the workspace's coding accounts and which can take work", needs: "member", routes: [], since: "0.5.0" },
52
- { verb: "codex-env", does: "name the coding account and the machine on every Codex launch's telemetry: print the attributes, set them for apps on macOS, or refresh the account cache", needs: "member", routes: [], since: "0.16.33" },
51
+ { verb: "accounts", does: "read the workspace's AI accounts and which can take work", needs: "member", routes: [], since: "0.5.0" },
52
+ { verb: "codex-env", does: "name the AI account and the computer on every Codex launch's telemetry: print the attributes, set them for apps on macOS, or refresh the AI account cache", needs: "member", routes: [], since: "0.16.33" },
53
53
  { verb: "setup --remove-codex-attribution", does: "take out the Codex launch hooks setup adds: the codex function in the shell rc files, and on macOS the login agent and the attributes it set", needs: "member", routes: [], since: "0.16.34" },
54
54
  { verb: "explain|running|queue|history", does: "read why a ticket runs or waits, what runs now, and what is queued", needs: "member", routes: [], since: "0.1.0" },
55
55
  { verb: "write comment|state|label|create|reaction|attachment|session", does: "act on a ticket as the person, through the agent proxy", needs: "member", routes: [agent("POST", "issue-comment"), agent("POST", "issue-state"), agent("POST", "issue-label"), agent("POST", "issue-create")], since: "0.1.0" },
package/dist/cli.js CHANGED
@@ -110,7 +110,7 @@ export function usageText() {
110
110
  " catalyst write <comment|state|label|create|reaction|attachment|session> ...",
111
111
  " catalyst ask <raise|accept|answer --proxy|proxy [status|on|off]|disown|list> ...",
112
112
  " catalyst release <ticket> --because <what changed> [--retry-unchanged] [--dry-run] | release --class <c> --team <K> ...",
113
- " catalyst environment [read] | environment propose --file <path>|--stdin [--approve] | environment approve (your account; needs login)",
113
+ " catalyst environment [read] | environment propose --file <path>|--stdin [--approve] | environment approve (your workspace; needs login)",
114
114
  " catalyst env <inventory [path] | check <file> | migrate [catalyst.env.json] | draft [--root DIR] [--diff] [--write] | shell [--root DIR] [--env-file FILE]... [--image REF] [--check]> [--json] (this repository; no login — shell needs Docker and downloads Catalyst's image once)",
115
115
  " catalyst secret set <NAME> --repo <owner/name> [--command '<cmd>'] | secret import <file> --repo <owner/name> [--names NAME[,NAME...]]",
116
116
  " catalyst var set <NAME> | var import <file> [--repo <owner/name>] [--names NAME[,NAME...]] (plain environment variables)",
@@ -125,7 +125,7 @@ export function usageText() {
125
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)",
126
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)",
127
127
  " catalyst work available [--team K] [--json] (tickets nobody holds and open pull requests no lease covers)",
128
- " catalyst codex-env --print [--codex-home <dir>] | --apply | --refresh (the telemetry attributes naming the coding account and machine a Codex launch runs as)",
128
+ " catalyst codex-env --print [--codex-home <dir>] | --apply | --refresh (the telemetry attributes naming the AI account and computer a Codex launch runs as)",
129
129
  " catalyst legacy [--remove [--data] [--yes]] (leftovers of the old local Catalyst runtime: list them; remove them only on a yes)",
130
130
  " catalyst identity linear <status|options|set> [<linearUserId>] [--json]",
131
131
  " catalyst messages read --ticket CTC-N [--since SEQUENCE] [--json] (messages sent to a ticket on your teams)",
package/dist/execution.js CHANGED
@@ -35,7 +35,7 @@ export const EXCLUSION_REASONS = {
35
35
  runner_image_breaker: "fleet-wide: the live runner image is failing every phase at startup; dispatch resumes when the pin moves",
36
36
  no_branch_to_remediate: "a remediate round is queued on a ticket with no recorded branch",
37
37
  retry_backoff: "the failed phase is retrying in place and waiting out its backoff",
38
- routing_unavailable: "the unit was claimed and refused at kickoff (no route, no eligible coding-account slot, or the provider is unavailable)",
38
+ routing_unavailable: "the unit was claimed and refused at kickoff (no route, no eligible AI account, or the provider is unavailable)",
39
39
  repo_paused: "an operator paused the repository",
40
40
  remediate_parked: "the remediate phase is parked, so the failing phase has nowhere to be repaired; `catalyst release <ticket>` releases the park once its cause is fixed",
41
41
  phase_parked: "the offered phase is parked after repeated failures or the repair-round cap; `catalyst release <ticket>` releases it once its cause is fixed",
@@ -374,7 +374,7 @@ export async function cmdAccounts(args, ctx) {
374
374
  accept: [404],
375
375
  });
376
376
  if (res.status === 404)
377
- throw needsNewerCloud("coding-account status", cfg);
377
+ throw needsNewerCloud("AI account status", cfg);
378
378
  const accounts = Array.isArray(res.body) ? res.body : (res.body?.accounts ?? []);
379
379
  // CTC-6204: removed accounts are history, so they are left out unless --all asks for them. Listed,
380
380
  // they read as accounts to repair, and agents kept trying to revive them.
@@ -388,7 +388,7 @@ export async function cmdAccounts(args, ctx) {
388
388
  const addAt = `${normalizeBaseUrl(cfg.baseUrl)}/settings/coding-accounts`;
389
389
  const hiddenLine = `${hidden} removed account${hidden === 1 ? " is" : "s are"} kept for history and take no work; catalyst accounts --all lists ${hidden === 1 ? "it" : "them"}.`;
390
390
  if (accounts.length === 0) {
391
- ctx.stdout(`No coding accounts are enrolled in this workspace. A workspace owner or admin adds one at ${addAt}; there is no command for that yet.`);
391
+ ctx.stdout(`No AI accounts are enrolled in this workspace. A workspace owner or admin adds one at ${addAt}; there is no command for that yet.`);
392
392
  return 0;
393
393
  }
394
394
  if (shown.length === 0) {
@@ -96,6 +96,9 @@ export const spawnDocker = (args, options) => new Promise((resolve, reject) => {
96
96
  });
97
97
  child.on("error", reject);
98
98
  child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
99
+ // Listen before writing (src/watch.ts:96-121): a docker that exits before reading its stdin
100
+ // raises EPIPE here, and the exit code from `close` is still the verdict.
101
+ child.stdin.on("error", () => { });
99
102
  child.stdin.end(options.input ?? "");
100
103
  });
101
104
  /** Compose's own error lines, reduced to short printable text a person can act on. */
@@ -193,7 +193,7 @@ export async function cmdRunner(args, ctx, deps = {}) {
193
193
  : await runRunnerUpdate(engine, {
194
194
  dir: dir(), check,
195
195
  ...(ownerWindows ? { window: windowBounds(ctx.now().getTime(), ownerWindows) } : {}),
196
- ...(auto && !check ? { drain: true, held: () => imageHold(ctx), ...(args.json ? {} : { onPass: (pass) => ctx.stdout(autoLine(pass, ctx.now())) }) } : {}),
196
+ ...(auto && !check ? { scheduled: true, drain: true, held: () => imageHold(ctx), ...(args.json ? {} : { onPass: (pass) => ctx.stdout(autoLine(pass, ctx.now())) }) } : {}),
197
197
  });
198
198
  // `runner update --check` also lists every installed component against the approved release, as
199
199
  // `catalyst update --check` does. The image fields stay where scripts already read them.
@@ -1,4 +1,4 @@
1
- import { closeSync, existsSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, closeSync, existsSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { readManifest } from "./config.js";
@@ -44,6 +44,11 @@ const LOCK_FILE = "update.lock";
44
44
  const PENDING_REMOVAL_KEPT = 20;
45
45
  const READY_DEADLINE_MS = 5 * 60_000;
46
46
  const READY_POLL_MS = 5_000;
47
+ /** CTC-8745: the supervisor is up and its one unmet prerequisite is its own preparation stop. */
48
+ const WAITING_PREPARATION_STOP = "waiting_preparation_stop";
49
+ /** How long a release is kept through a preparation stop. Stops that end by a quiet period lift at 30
50
+ * minutes; a workspace stop can hold forever, and the release can be what holds it. */
51
+ const PREPARATION_STOP_WAIT_MS = 45 * 60_000;
47
52
  const ATTEMPT_HOST_LABEL = "dev.catalystcloud.attempt.host-id";
48
53
  const ATTEMPT_ID_LABEL = "dev.catalystcloud.attempt.id";
49
54
  const DESIRED_RELEASE_PATH = "/api/v1/hosts/desired-release";
@@ -173,7 +178,7 @@ export function decideRelease(appliedGeneration, read) {
173
178
  return { action: "update", release };
174
179
  }
175
180
  export function readReleaseState(dir) {
176
- const empty = { version: 1, generation: null, releaseId: null, appliedAtMs: null, previous: null, journal: null, pendingRemoval: [], windowSettledMs: null, deferral: null, drain: null, last: null };
181
+ const empty = { version: 1, kept: null, generation: null, releaseId: null, appliedAtMs: null, previous: null, journal: null, pendingRemoval: [], windowSettledMs: null, deferral: null, drain: null, last: null };
177
182
  try {
178
183
  const parsed = JSON.parse(readFileSync(join(dir, RELEASE_STATE_FILE), "utf8"));
179
184
  if (!isRecord(parsed) || parsed.version !== 1)
@@ -191,6 +196,8 @@ export function readReleaseState(dir) {
191
196
  ? parsed.pendingRemoval.filter((ref) => typeof ref === "string").slice(0, PENDING_REMOVAL_KEPT)
192
197
  : [],
193
198
  windowSettledMs: posInt(parsed.windowSettledMs) ? parsed.windowSettledMs : null,
199
+ kept: isRecord(parsed.kept) && posInt(parsed.kept.generation) && posInt(parsed.kept.sinceMs)
200
+ ? { generation: parsed.kept.generation, sinceMs: parsed.kept.sinceMs } : null,
194
201
  deferral: isRecord(parsed.deferral) && posInt(parsed.deferral.sinceMs) && posInt(parsed.deferral.offered) && posInt(parsed.deferral.offeredSinceMs)
195
202
  ? { sinceMs: parsed.deferral.sinceMs, offered: parsed.deferral.offered, offeredSinceMs: parsed.deferral.offeredSinceMs } : null,
196
203
  drain: isRecord(parsed.drain) && typeof parsed.drain.requestId === "string" && UUID.test(parsed.drain.requestId) &&
@@ -210,9 +217,34 @@ export function writePrivateAtomic(path, text) {
210
217
  writeFileSync(temp, text, { mode: 0o600 });
211
218
  renameSync(temp, path);
212
219
  }
220
+ /** Write a file by rename with the mode it already has, so an edit keeps what setup chose. */
221
+ function writeKeepingMode(path, text) {
222
+ const mode = statSync(path).mode & 0o777;
223
+ const temp = `${path}.${process.pid}.tmp`;
224
+ writeFileSync(temp, text, { mode });
225
+ chmodSync(temp, mode);
226
+ renameSync(temp, path);
227
+ }
213
228
  export function writeReleaseState(dir, state) {
214
229
  writePrivateAtomic(join(dir, RELEASE_STATE_FILE), `${JSON.stringify(state, null, 2)}\n`);
215
230
  }
231
+ const NODE_LOG_SETTING = /^\s*CATALYST_NODE_LOG_SHIPPING\s*:/m;
232
+ const NODE_LOG_LINE = " CATALYST_NODE_LOG_SHIPPING: ${CATALYST_NODE_LOG_SHIPPING-on}\n";
233
+ const NODE_LOG_LINE_FOLLOWS = /^ {6}CATALYST_GIT_CLONE_TIMEOUT_MS: \$\{CATALYST_GIT_CLONE_TIMEOUT_MS:-\}\n/m;
234
+ export const NODE_LOG_NOTICE = "Catalyst's own service containers on this machine now send their logs to Catalyst, with secrets removed first. To stop it, set CATALYST_NODE_LOG_SHIPPING=off in this runner's .env and run docker compose up -d.";
235
+ /** CTC-8674: an update replaces image references and nothing else, so a machine set up earlier has no
236
+ * log setting in its Compose file and ships nothing. This adds the one line, exactly as setup writes
237
+ * it. Null when there is nothing to add: the setting is already there, or the file is not one we
238
+ * recognise. `.env` is never touched here, so a value the owner set there always wins. */
239
+ export function withNodeLogSetting(composeText) {
240
+ if (NODE_LOG_SETTING.test(composeText))
241
+ return null;
242
+ const follows = NODE_LOG_LINE_FOLLOWS.exec(composeText);
243
+ if (follows === null)
244
+ return null;
245
+ const at = follows.index + follows[0].length;
246
+ return `${composeText.slice(0, at)}${NODE_LOG_LINE}${composeText.slice(at)}`;
247
+ }
216
248
  /** `.env` with each `KEY=` line for `values` replaced in place (or appended), every other line kept. */
217
249
  export function rewriteEnvText(text, values) {
218
250
  const pending = new Map(Object.entries(values));
@@ -419,6 +451,40 @@ async function running(engine, dir, images, signal) {
419
451
  return false;
420
452
  return engine.supervisorReadySinceStart(dir, signal);
421
453
  }
454
+ /** CTC-8745: these images are running and only a preparation stop keeps them from being ready. A
455
+ * stop survives a restart and lifts by itself, so going back would not help. */
456
+ async function inPreparationStop(engine, dir, images, signal) {
457
+ const live = await engine.services(dir, signal);
458
+ if (!live?.supervisor?.running || live.supervisor.image !== images.supervisor)
459
+ return false;
460
+ if (!live.watchdog?.running || live.watchdog.image !== images.watchdog)
461
+ return false;
462
+ return engine.supervisorInPreparationStop(dir, signal);
463
+ }
464
+ /** CTC-8745: whether the latest readiness line in this log names a preparation stop and nothing
465
+ * else, from a supervisor that writes a line whenever what is failing changes. */
466
+ export function preparationStopOnly(logs) {
467
+ let latest = null;
468
+ for (const line of logs.split("\n")) {
469
+ if (!line.includes('"event":"host_'))
470
+ continue;
471
+ try {
472
+ const parsed = JSON.parse(line);
473
+ if (isRecord(parsed) && (parsed.event === "host_ready" || parsed.event === "host_not_ready"))
474
+ latest = parsed;
475
+ }
476
+ catch {
477
+ // Not a log line this reads.
478
+ }
479
+ }
480
+ // An older supervisor writes one line per flip, so its latest line can be stale: it does not count.
481
+ if (latest?.event !== "host_not_ready" || latest.linePerChange !== true)
482
+ return false;
483
+ if (!Array.isArray(latest.failing) || latest.failing.length !== 1)
484
+ return false;
485
+ const [only] = latest.failing;
486
+ return isRecord(only) && only.id === "host.preparation-failed" && only.reason === "failed";
487
+ }
422
488
  const REPORT_DETAIL = /[^a-z0-9_:,.-]/g;
423
489
  /** The previous run's outcome as the release read reports it (CTC-6924), or undefined before the
424
490
  * first run. Only the fixed outcome vocabulary leaves the machine: anything outside the cloud's
@@ -557,6 +623,7 @@ async function update(engine, options, now, fresh = false, onHostId) {
557
623
  const { dir, signal } = options;
558
624
  const envPath = join(dir, ".env");
559
625
  let state = readReleaseState(dir);
626
+ const notices = [];
560
627
  const done = (outcome, detail, extra = {}) => ({ outcome, detail, generation: state.generation, exitCode: 0, ...extra });
561
628
  const read = await engine.desiredRelease(dir, signal, options.check ? undefined : updateReport(state, readManifest().version), fresh);
562
629
  const hostId = read.kind === "no_credential" ? "" : read.hostId;
@@ -565,6 +632,10 @@ async function update(engine, options, now, fresh = false, onHostId) {
565
632
  const offered = read.kind === "answered" && read.status === 200 && isRecord(read.body) && read.body.ok === true
566
633
  ? parseDesiredRelease(read.body.release) : null;
567
634
  if (decision.action === "retain") {
635
+ // CTC-8745: a kept release ends on local files alone, so a read that offers no release (a hold,
636
+ // an outage, an older generation) cannot keep it on past its wait.
637
+ if (!options.check && state.kept !== null && (options.scheduled !== true || now() - state.kept.sinceMs >= PREPARATION_STOP_WAIT_MS))
638
+ return returnFromKept(engine, options, state, hostId, now);
568
639
  if (!options.check && state.drain?.acknowledgedAtMs != null && decision.reason !== "not_newer")
569
640
  return drainingResult(dir, state, now(), null, undefined, `read:${decision.reason}`);
570
641
  return decision.reason === "not_newer"
@@ -615,18 +686,37 @@ async function update(engine, options, now, fresh = false, onHostId) {
615
686
  const record = (outcome, detail, previous, unremoved = []) => {
616
687
  const journal = release.journalFormat === null ? null : { format: release.journalFormat, supervisor: target.supervisor };
617
688
  const pendingRemoval = [...new Set([...state.pendingRemoval, ...unremoved])].slice(-PENDING_REMOVAL_KEPT);
618
- writeReleaseState(dir, { ...state, generation: release.generation, releaseId: release.releaseId, appliedAtMs: now(), previous, journal, pendingRemoval });
619
- return { outcome, detail, generation: release.generation, exitCode: 0, ...tagged };
689
+ writeReleaseState(dir, { ...state, generation: release.generation, releaseId: release.releaseId, appliedAtMs: now(), previous, kept: null, journal, pendingRemoval });
690
+ return { outcome, detail, generation: release.generation, exitCode: 0, ...tagged, ...(notices.length > 0 ? { notices } : {}) };
620
691
  };
621
692
  if (options.check)
622
693
  return done("planned", changed.length > 0 ? `would_update:${changed.join(",")}` : "already_configured", { ...tagged, generation: release.generation });
623
694
  // `.env` naming the release proves nothing on its own: an earlier run may have stopped after writing
624
695
  // it. Only the running containers, ready, prove the release is applied.
625
- if (changed.length === 0 && (await running(engine, dir, target, signal)))
626
- return record("recorded", "already_running_release", state.previous);
696
+ if (changed.length === 0 && (await running(engine, dir, target, signal))) {
697
+ if (state.kept === null)
698
+ return record("recorded", "already_running_release", state.previous);
699
+ // CTC-8745: the stop lifted and the kept release came up ready, so what it replaced can go now.
700
+ const kept = existsSync(join(dir, PREVIOUS_ENV_FILE)) ? imagesOf(parseEnvText(readFileSync(join(dir, PREVIOUS_ENV_FILE), "utf8"))) : null;
701
+ const replaced = RELEASE_COMPONENTS.filter((c) => kept !== null && kept[c] !== "" && kept[c] !== target[c]).map((c) => kept[c]);
702
+ return record("recorded", "kept_through_preparation_stop", kept ?? state.previous, replaced.length > 0 ? await engine.removeImages(replaced, signal) : []);
703
+ }
704
+ // CTC-8745: a kept release has until its deadline, counted from its first wait. Past that it gets
705
+ // one restart and the normal deadline, and then goes back, whatever the stop says. A run no schedule
706
+ // follows has nothing to end a wait, so it goes back at the normal deadline.
707
+ const waitOver = options.scheduled !== true || (state.kept?.generation === release.generation && now() - state.kept.sinceMs >= PREPARATION_STOP_WAIT_MS);
708
+ const keepWaiting = () => {
709
+ if (state.kept?.generation !== release.generation)
710
+ writeReleaseState(dir, { ...state, kept: { generation: release.generation, sinceMs: now() } });
711
+ return done("retained", WAITING_PREPARATION_STOP, tagged);
712
+ };
713
+ // Starting a kept release again inside its wait would change nothing.
714
+ if (changed.length === 0 && !waitOver && (await inPreparationStop(engine, dir, target, signal)))
715
+ return keepWaiting();
627
716
  // What a failure goes back to: today's file, or for a half-applied file the one saved before it.
717
+ // CTC-8745: a kept release is unproven, so a newer offer goes back past it too.
628
718
  let rollbackText = envText;
629
- if (changed.length === 0) {
719
+ if (changed.length === 0 || state.kept !== null) {
630
720
  try {
631
721
  rollbackText = readFileSync(join(dir, PREVIOUS_ENV_FILE), "utf8");
632
722
  }
@@ -686,26 +776,106 @@ async function update(engine, options, now, fresh = false, onHostId) {
686
776
  : deferBusy(dir, state, release, now(), busyAfterStop, done, tagged);
687
777
  }
688
778
  // From here the machine changes. The previous file is the rollback.
689
- if (changed.length > 0)
779
+ if (changed.length > 0 && state.kept === null)
690
780
  writePrivateAtomic(join(dir, PREVIOUS_ENV_FILE), envText);
781
+ // The log setting goes in with the release and comes out with it: a rollback puts the file back as
782
+ // it was, so the run whose release stays adds the line and says so.
783
+ const composePath = join(dir, "compose.yaml");
784
+ let composeBefore = null;
785
+ try {
786
+ const text = readFileSync(composePath, "utf8");
787
+ const withSetting = withNodeLogSetting(text);
788
+ if (withSetting !== null) {
789
+ writeKeepingMode(composePath, withSetting);
790
+ composeBefore = text;
791
+ }
792
+ }
793
+ catch {
794
+ // The setting is not worth a failed update: with no line the machine ships nothing, and the next one tries again.
795
+ }
796
+ /** True when the line could not be taken out, so the rollback runs with it and must say so. */
797
+ const undoSetting = () => {
798
+ if (composeBefore === null)
799
+ return false;
800
+ try {
801
+ writeKeepingMode(composePath, composeBefore);
802
+ return false;
803
+ }
804
+ catch {
805
+ return true;
806
+ }
807
+ };
691
808
  writePrivateAtomic(envPath, rewriteEnvText(envText, Object.fromEntries(RELEASE_COMPONENTS.map((c) => [RELEASE_ENV_KEYS[c], target[c]]))));
692
809
  if ((await engine.composeUp(dir, signal)) && (await waitReady(engine, dir, target, options, now))) {
693
810
  // Only once the new release is running and ready: the replaced images free their disk (a runner
694
811
  // image is several GB, against a free-space floor that decides whether the machine takes work).
695
812
  // A rollback or failure never reaches here, so the previous images stay for it.
696
813
  // An image still in use, such as the runner image under phases left running, is kept for a later run.
697
- const unremoved = changed.length > 0
698
- ? await engine.removeImages(changed.map((c) => current[c]).filter((ref) => ref !== ""), signal)
699
- : [];
814
+ // CTC-8745: so do the proven images a kept release left in place for its rollback.
815
+ const proven = state.kept === null ? null : rollbackImages;
816
+ const replaced = [...new Set([...changed.map((c) => current[c]), ...(proven === null ? [] : RELEASE_COMPONENTS.map((c) => proven[c]))])]
817
+ .filter((ref) => ref !== "" && !RELEASE_COMPONENTS.some((c) => target[c] === ref));
818
+ const unremoved = replaced.length > 0 ? await engine.removeImages(replaced, signal) : [];
700
819
  const applied = changed.length > 0 ? `updated:${changed.join(",")}` : "reconciled";
701
- return record("updated", inPlace ? `${applied}:in_place` : applied, changed.length > 0 ? current : state.previous, unremoved);
820
+ if (composeBefore !== null)
821
+ notices.push(NODE_LOG_NOTICE);
822
+ return record("updated", inPlace ? `${applied}:in_place` : applied, proven ?? (changed.length > 0 ? current : state.previous), unremoved);
702
823
  }
824
+ // CTC-8745: not recorded as applied, and the previous images stay, until it comes up ready.
825
+ // The new supervisor is up with the line, so a release kept waiting keeps it and says so.
826
+ if (!waitOver && (await inPreparationStop(engine, dir, target, signal)))
827
+ return composeBefore === null ? keepWaiting() : { ...keepWaiting(), notices: [NODE_LOG_NOTICE] };
703
828
  if (rollbackText === null || rollbackImages === null)
704
- return done("failed", "new_supervisor_not_ready_no_previous", { ...tagged, exitCode: 1 });
829
+ return done("failed", "new_supervisor_not_ready_no_previous", { ...tagged, exitCode: 1, ...(composeBefore === null ? {} : { notices: [NODE_LOG_NOTICE] }) });
705
830
  writePrivateAtomic(envPath, rollbackText);
831
+ const settingStays = undoSetting();
706
832
  const custodyRefused = await restoreCustody(rollbackImages);
707
- const restored = (await engine.composeUp(dir, signal)) && (await waitReady(engine, dir, rollbackImages, options, now));
708
- return done(restored && custodyRefused === null ? "rolled_back" : "failed", !restored ? "rollback_not_ready" : custodyRefused === null ? "new_supervisor_not_ready" : `custody_restore_failed:${custodyRefused}`, { ...tagged, exitCode: 1 });
833
+ // CTC-8745: a stop the release did not cause holds the release it went back to as well. That is a
834
+ // rollback that worked, not a failed one.
835
+ const restored = (await engine.composeUp(dir, signal))
836
+ && ((await waitReady(engine, dir, rollbackImages, options, now)) || (await inPreparationStop(engine, dir, rollbackImages, signal)));
837
+ writeReleaseState(dir, { ...state, kept: null });
838
+ return done(restored && custodyRefused === null ? "rolled_back" : "failed", !restored ? "rollback_not_ready" : custodyRefused === null ? "new_supervisor_not_ready" : `custody_restore_failed:${custodyRefused}`, { ...tagged, exitCode: 1, ...(settingStays ? { notices: [NODE_LOG_NOTICE] } : {}) });
839
+ }
840
+ /** CTC-8745: puts back the proven release a kept one replaced, with no release read. The phase
841
+ * boundary and the outcome are the update's own: stop the supervisor, go back only if no phase is
842
+ * running, and report it as the rollback it is. */
843
+ async function returnFromKept(engine, options, state, hostId, now) {
844
+ const { dir, signal } = options;
845
+ const envPath = join(dir, ".env");
846
+ const result = (outcome, detail, exitCode) => ({ outcome, detail, generation: state.generation, exitCode });
847
+ let rollbackText;
848
+ try {
849
+ rollbackText = readFileSync(join(dir, PREVIOUS_ENV_FILE), "utf8");
850
+ }
851
+ catch {
852
+ return result("failed", "new_supervisor_not_ready_no_previous", 1);
853
+ }
854
+ const rollbackImages = imagesOf(parseEnvText(rollbackText));
855
+ const env = readEnvFile(envPath) ?? {};
856
+ const restoreCustody = async () => {
857
+ if (persistedCustody(dir, env) !== "installed")
858
+ return null;
859
+ const attested = await engine.custody(dir, rollbackImages, signal);
860
+ if ("refused" in attested)
861
+ return attested.refused;
862
+ return attested.authority === env.CATALYST_THOUGHTS_CUSTODY_AUTHORITY ? null : "authority_changed";
863
+ };
864
+ if (!(await engine.stopSupervisor(dir, signal))) {
865
+ await engine.composeUp(dir, signal);
866
+ return result("retained", "supervisor_stop_failed", 0);
867
+ }
868
+ const busy = await engine.attemptContainers(dir, hostId, signal);
869
+ if (busy !== 0) {
870
+ await engine.composeUp(dir, signal);
871
+ return result("retained", busy === null ? "docker_unavailable" : "phases_running", 0);
872
+ }
873
+ writePrivateAtomic(envPath, rollbackText);
874
+ const custodyRefused = await restoreCustody();
875
+ const restored = (await engine.composeUp(dir, signal))
876
+ && ((await waitReady(engine, dir, rollbackImages, options, now)) || (await inPreparationStop(engine, dir, rollbackImages, signal)));
877
+ writeReleaseState(dir, { ...state, kept: null });
878
+ return result(restored && custodyRefused === null ? "rolled_back" : "failed", !restored ? "rollback_not_ready" : custodyRefused === null ? "new_supervisor_not_ready" : `custody_restore_failed:${custodyRefused}`, 1);
709
879
  }
710
880
  async function waitReady(engine, dir, images, options, now) {
711
881
  const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
@@ -853,6 +1023,20 @@ export function dockerRunnerUpdateEngine(deps = {}) {
853
1023
  return { code: 1, stdout: "" };
854
1024
  }
855
1025
  };
1026
+ /** The supervisor container's log since it last started, or null when that cannot be read. */
1027
+ const logsSinceStart = async (dir, signal) => {
1028
+ const compose = runnerComposeArgs(dir);
1029
+ if (!compose)
1030
+ return null;
1031
+ const id = (await docker([...compose, "ps", "--quiet", "supervisor"], { cwd: dir, signal })).stdout.trim();
1032
+ if (!/^[0-9a-f]{12,64}$/.test(id))
1033
+ return null;
1034
+ const started = (await docker(["inspect", "--format", "{{.State.StartedAt}}", id], { signal })).stdout.trim();
1035
+ if (!Number.isFinite(Date.parse(started)))
1036
+ return null;
1037
+ const logs = await docker(["logs", "--since", started, id], { signal, timeoutMs: 30_000 });
1038
+ return `${logs.stdout}\n${logs.stderr ?? ""}`;
1039
+ };
856
1040
  return {
857
1041
  async desiredRelease(dir, signal, report) {
858
1042
  const compose = runnerComposeArgs(dir);
@@ -943,17 +1127,12 @@ export function dockerRunnerUpdateEngine(deps = {}) {
943
1127
  return kept;
944
1128
  },
945
1129
  async supervisorReadySinceStart(dir, signal) {
946
- const compose = runnerComposeArgs(dir);
947
- if (!compose)
948
- return false;
949
- const id = (await docker([...compose, "ps", "--quiet", "supervisor"], { cwd: dir, signal })).stdout.trim();
950
- if (!/^[0-9a-f]{12,64}$/.test(id))
951
- return false;
952
- const started = (await docker(["inspect", "--format", "{{.State.StartedAt}}", id], { signal })).stdout.trim();
953
- if (!Number.isFinite(Date.parse(started)))
954
- return false;
955
- const logs = await docker(["logs", "--since", started, id], { signal, timeoutMs: 30_000 });
956
- return `${logs.stdout}\n${logs.stderr ?? ""}`.includes('"event":"host_ready"');
1130
+ const logs = await logsSinceStart(dir, signal);
1131
+ return logs !== null && logs.includes('"event":"host_ready"');
1132
+ },
1133
+ async supervisorInPreparationStop(dir, signal) {
1134
+ const logs = await logsSinceStart(dir, signal);
1135
+ return logs !== null && preparationStopOnly(logs);
957
1136
  },
958
1137
  };
959
1138
  }
@@ -1000,8 +1179,11 @@ export const CUSTODY_UNATTESTED_LINE = "Private thoughts storage on this Mac is
1000
1179
  "If Catalyst had stopped sending this machine new phases for an update, it still sends none. To repair storage: catalyst onboard --runner";
1001
1180
  export function updateResultLines(result) {
1002
1181
  return [
1003
- OUTCOME_TEXT[result.outcome],
1182
+ result.detail === WAITING_PREPARATION_STOP
1183
+ ? "The new release is running. This machine stopped asking for phases after their preparation kept failing, so the update finishes once it asks again, or goes back to the previous release after 45 minutes."
1184
+ : OUTCOME_TEXT[result.outcome],
1004
1185
  `Release: ${result.releaseId ?? "none"}, generation ${result.generation ?? "none"} (${result.detail}).`,
1005
1186
  ...(custodyUnattested(result) ? [CUSTODY_UNATTESTED_LINE] : []),
1187
+ ...(result.notices ?? []),
1006
1188
  ];
1007
1189
  }
package/dist/runner.js CHANGED
@@ -51,6 +51,8 @@ const spawnCommand = (file, args, options = {}) => new Promise((resolve) => {
51
51
  code: error ? (typeof error.code === "number" ? error.code : 127) : 0,
52
52
  stdout: String(stdout), stderr: String(stderr),
53
53
  }));
54
+ // As in spawnDocker: a broken pipe is not the verdict, the exit code is.
55
+ child.stdin?.on("error", () => { });
54
56
  child.stdin?.end(options.input ?? "");
55
57
  });
56
58
  function owned(path, uid, kind) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catalyst-cloud/cli",
3
- "version": "0.16.43",
3
+ "version": "0.16.44",
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.43 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.44 — 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.43 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.44 — 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.43 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.44 — 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.43 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.44 — 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.43 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.44 — 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.43 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.44 — 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.43 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.44 — 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.43 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.44 — written in this repository for customer accounts -->
8
8
 
9
9
  # What's happening
10
10