@catalyst-cloud/cli 0.16.41 → 0.16.43

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.43
4
+
5
+ `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.
6
+
7
+ ## 0.16.42
8
+
9
+ A busy machine now takes a waiting release after one day. Before, a busy machine could keep putting off a release that cannot take over from running work in place. Now the next check asks Catalyst to send the machine no new work, waits for the running work to finish, then moves to the release and takes work again. `catalyst runner update --check` says when a machine is waiting like this, and `catalyst runner auto-update off` ends the wait. If the update cannot restore the machine's private thoughts storage, the machine keeps taking no new work, and `catalyst runner update` tells you what to do. This release also makes updates on Catalyst's own machines more reliable.
10
+
3
11
  ## 0.16.41
4
12
 
5
13
  A runner's automatic update schedule now applies a newly approved runner release at its next check, instead of waiting for one of four set times of day. An owner who wants set times can still choose them with `catalyst update policy windows <HH:MM,...>`, and `catalyst update policy windows default` goes back to applying at the next check. `catalyst runner update` still applies a release at once, and `catalyst update policy` shows which setting is in effect.
package/README.md CHANGED
@@ -346,7 +346,7 @@ The skill files themselves are written by whichever install command you ran, in
346
346
 
347
347
  The new CLI is installed beside the old one, and your `catalyst` command runs it at the same path. A running local replica restarts on it and keeps its synced data; a stopped one stays stopped. CLI, SDK and image updates keep their existing policy against moving to an older version. Ordinary CLI startup never replaces skills with its bundled copy.
348
348
 
349
- On a machine checking for releases every 15 minutes (`catalyst runner auto-update on`), that check applies the same component policies. Otherwise components move when you run `catalyst update`. That check moves the runner images to a newer release as soon as it finds one, with no set times of day. An update does not stop work that is already running: that work finishes on the image it started with. If you want updates only at set times, `catalyst update policy windows 06:00,18:00 --time-zone America/Chicago` sets your own times, `catalyst update policy windows default` goes back to none, and `catalyst runner update` or `catalyst update` applies a waiting release at once. Each component is on by default; turn one off with `catalyst update policy <component> off` (for example `cli`), and `catalyst update policy` lists every setting. If you turned the installer's daily update off, the CLI and both skills packs start held, and `catalyst update policy cli on` turns the CLI back on.
349
+ On a machine checking for releases every 15 minutes (`catalyst runner auto-update on`), that check applies the same component policies. Otherwise components move when you run `catalyst update`. That check moves the runner images to a newer release as soon as it finds one, with no set times of day. An update does not stop work that is already running: that work finishes on the image it started with. When work is running and the new release cannot take over from it in place, the check leaves the release waiting. Once a release has waited a day, the next check asks Catalyst to send the machine no new work. It waits for the running work to finish, however long that takes, then moves to the release and takes work again. `catalyst runner update --check` says when a machine is waiting like this, and `catalyst runner auto-update off` ends the wait. If you want updates only at set times, `catalyst update policy windows 06:00,18:00 --time-zone America/Chicago` sets your own times, `catalyst update policy windows default` goes back to none, and `catalyst runner update` or `catalyst update` applies a waiting release at once. Each component is on by default; turn one off with `catalyst update policy <component> off` (for example `cli`), and `catalyst update policy` lists every setting. If you turned the installer's daily update off, the CLI and both skills packs start held, and `catalyst update policy cli on` turns the CLI back on.
350
350
 
351
351
  The installer's daily job runs the same release check, so it moves the CLI and the Cloud skills only to the approved release and keeps your holds. Until the development skills join the release, it refreshes them from their repository.
352
352
 
package/dist/ask.js CHANGED
@@ -9,7 +9,7 @@ import { CliError, UsageError } from "./errors.js";
9
9
  import { apiClient } from "./transport.js";
10
10
  import { fetchAllPages } from "./pagination.js";
11
11
  import { fetchWorkflowStates, postAgent, resolveIssue } from "./write.js";
12
- export async function cmdAsk(args, ctx) {
12
+ export async function cmdAsk(args, ctx, deps = {}) {
13
13
  const [sub, ...rest] = positionals(args);
14
14
  if (!sub)
15
15
  throw new UsageError("ask needs a subcommand: raise | accept | answer | proxy | disown | list");
@@ -33,7 +33,7 @@ export async function cmdAsk(args, ctx) {
33
33
  case "raise":
34
34
  return raise(args, ctx, doc, api);
35
35
  case "accept":
36
- return accept(args, ctx, doc, api, rest);
36
+ return accept(args, ctx, doc, api, rest, deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))));
37
37
  case "answer":
38
38
  return answerByProxy(args, ctx, doc, api, proxyBody);
39
39
  case "list":
@@ -128,20 +128,36 @@ function askGates(args, options, doc) {
128
128
  }
129
129
  return { repo, pr: pr.length === 1 ? pr[0] : pr, labels: labels.length ? labels : ["hold"], releasedBy: released };
130
130
  }
131
- async function accept(args, ctx, doc, api, rest) {
131
+ /** The most `ask accept` waits, in total, for an answer comment the cloud has not copied yet. */
132
+ export const ASK_ACCEPT_RETRY_BUDGET_MS = 30_000;
133
+ async function accept(args, ctx, doc, api, rest, sleep) {
132
134
  const askTicket = rest[0];
133
135
  const answer = flagString(args, "answer");
134
136
  const role = flagString(args, "role");
135
137
  if (!askTicket || !answer || !role)
136
138
  throw new UsageError("ask accept needs <askTicket> --answer <commentId> --role <role>");
137
139
  const ask = await resolveIssue(api, askTicket);
138
- const result = await postAgent(api, doc, "ask-accept", {
139
- askIssueId: ask.id,
140
- answerCommentId: answer,
141
- acceptedByRole: role,
142
- });
143
- ctx.stdout(args.json ? JSON.stringify(result) : `ask ${ask.identifier}: answer ${answer} recorded by ${role}`);
144
- return 0;
140
+ const body = { askIssueId: ask.id, answerCommentId: answer, acceptedByRole: role };
141
+ // An answer posted seconds ago may not have reached the cloud yet. The cloud refuses that one case
142
+ // as retryable and says how long to wait; every other refusal is final and is not retried.
143
+ let waited = 0;
144
+ for (;;) {
145
+ const result = await postAgent(api, doc, "ask-accept", body, { accept: [409] });
146
+ if (result.outcome !== "refused") {
147
+ ctx.stdout(args.json ? JSON.stringify(result) : `ask ${ask.identifier}: answer ${answer} recorded by ${role}`);
148
+ return 0;
149
+ }
150
+ const waitMs = Math.max(1, result.retryAfterSeconds ?? 10) * 1000;
151
+ if (result.retryable === true && waited + waitMs <= ASK_ACCEPT_RETRY_BUDGET_MS) {
152
+ await sleep(waitMs);
153
+ waited += waitMs;
154
+ continue;
155
+ }
156
+ const reason = result.reason ?? "refused";
157
+ throw new CliError(result.retryable === true
158
+ ? `ask ${ask.identifier}: answer ${answer} has not reached the cloud yet (${reason}) after ${waited / 1000}s of retries; run the same command again in ${result.retryAfterSeconds ?? 10}s`
159
+ : `ask ${ask.identifier}: accept refused (${reason})`, result.retryable === true ? "ask-accept-retry" : "ask-accept-refused", 2, 409);
160
+ }
145
161
  }
146
162
  /** The cloud's bounds on an answer by proxy, checked here first so a refusal never costs a write. */
147
163
  const QUOTE_MAX = 4000;
package/dist/cli.js CHANGED
@@ -531,7 +531,7 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
531
531
  case "write":
532
532
  return await cmdWrite(args, ctx, deps.write);
533
533
  case "ask":
534
- return await cmdAsk(args, ctx);
534
+ return await cmdAsk(args, ctx, { sleep: deps.sleep });
535
535
  case "ready":
536
536
  return await cmdReady(args, ctx, {
537
537
  skillNames: CUSTOMER_SKILLS,
@@ -1,11 +1,11 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { realpathSync } from "node:fs";
2
+ import { existsSync, realpathSync } from "node:fs";
3
3
  import { isAbsolute, resolve } from "node:path";
4
4
  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
- import { dockerRunnerUpdateEngine, readReleaseState, RELEASE_COMPONENTS, runnerDir, runRunnerUpdate, updateResultLines, lastProblem, lastProblemLines, } from "./runner-update.js";
8
- import { dockerFleetUpdateEngine, postRollLines, readPostRollResult, runFleetPostRollCheck, runFleetUpdate } from "./runner-update-fleet.js";
7
+ import { dockerRunnerUpdateEngine, readReleaseState, RELEASE_COMPONENTS, runnerDir, runRunnerUpdate, updateResultLines, lastProblem, lastProblemLines, drainLines, custodyUnattested, CUSTODY_UNATTESTED_LINE, releaseUpdateDrain, } from "./runner-update.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";
@@ -69,6 +69,18 @@ function statusLine(status) {
69
69
  return "Automatic runner updates need your systemd user manager, which is not running for you. Run: sudo loginctl enable-linger $USER, then catalyst runner auto-update on";
70
70
  return `Automatic runner updates are not available on this machine (${status.detail}). Run catalyst runner update yourself.`;
71
71
  }
72
+ function autoLine(result, at) {
73
+ return `${at.toISOString()} ${result.outcome} ${result.detail} generation=${result.generation ?? "none"}`;
74
+ }
75
+ function drainReleaseLine(result) {
76
+ if (result === "released")
77
+ return "Catalyst can send this machine new phases again: it had stopped sending them so the machine could update.";
78
+ if (result === "pending")
79
+ return "The update that was waiting for running phases ends within a few seconds, and Catalyst can then send this machine new phases again.";
80
+ if (result === "failed")
81
+ return "Catalyst may still be holding new phases back from this machine. Run catalyst runner auto-update off again to retry.";
82
+ return null;
83
+ }
72
84
  /** Why this machine's images must not move now: an owner's hold, or settings that cannot be read
73
85
  * (which never count as "everything on"). Null when every image may move. */
74
86
  function imageHold(ctx) {
@@ -145,7 +157,9 @@ export async function cmdRunner(args, ctx, deps = {}) {
145
157
  if (args.json)
146
158
  ctx.stdout(JSON.stringify(extra ? { ...result, components: extra.rows } : result));
147
159
  else if (auto) {
148
- ctx.stdout(`${ctx.now().toISOString()} ${result.outcome} ${result.detail} generation=${result.generation ?? "none"}`);
160
+ ctx.stdout(autoLine(result, ctx.now()));
161
+ if (custodyUnattested(result))
162
+ ctx.stdout(CUSTODY_UNATTESTED_LINE);
149
163
  if (extra)
150
164
  ctx.stdout(extra.line);
151
165
  }
@@ -160,13 +174,27 @@ export async function cmdRunner(args, ctx, deps = {}) {
160
174
  // windows; then only in a window. An owner's own run applies at once (CTC-7465, update-windows.ts).
161
175
  if (hold !== null) {
162
176
  const retained = { outcome: "retained", detail: hold, generation: readReleaseState(dir()).generation, exitCode: hold === "owner_setting" ? 0 : 1 };
177
+ if (hold === "owner_setting" && !fleet) {
178
+ const released = await releaseUpdateDrain(engine, dir(), { cloud: false });
179
+ if (released === "failed" && !auto && !args.json)
180
+ ctx.stdout(drainReleaseLine(released));
181
+ }
163
182
  // Unreadable settings move nothing; a hold leaves the other components to the release check.
164
183
  return print(retained, hold === "owner_setting" ? await components(undefined) : null);
165
184
  }
166
185
  const ownerWindows = auto && !check && !fleet ? readUpdatePolicy(ctx.home).windows : undefined;
186
+ // A fleet supervisor that misses its qualification deadline names each scope it had not qualified,
187
+ // and why, on stderr before the restore, so stdout keeps its one line or JSON object (CTC-8163).
167
188
  const result = fleet
168
- ? await runFleetUpdate(deps.fleetEngine ?? dockerFleetUpdateEngine({ env: ctx.env }), { dir: dir(), check })
169
- : await runRunnerUpdate(engine, { dir: dir(), check, ...(ownerWindows ? { window: windowBounds(ctx.now().getTime(), ownerWindows) } : {}) });
189
+ ? await runFleetUpdate(deps.fleetEngine ?? dockerFleetUpdateEngine({ env: ctx.env }), {
190
+ dir: dir(), check, ...deps.fleetClock,
191
+ onQualificationPending: (line) => ctx.stderr(`catalyst: ${line}`),
192
+ })
193
+ : await runRunnerUpdate(engine, {
194
+ dir: dir(), check,
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())) }) } : {}),
197
+ });
170
198
  // `runner update --check` also lists every installed component against the approved release, as
171
199
  // `catalyst update --check` does. The image fields stay where scripts already read them.
172
200
  if (check && !fleet && !auto) {
@@ -190,10 +218,10 @@ export async function cmdRunner(args, ctx, deps = {}) {
190
218
  const state = readReleaseState(dir());
191
219
  if (args.json) {
192
220
  const { schemaVersion, release, components, projects } = updateReportJson(report);
193
- ctx.stdout(JSON.stringify({ ...result, schemaVersion, release, components, projects, lastProblem: lastProblem(state) }));
221
+ ctx.stdout(JSON.stringify({ ...result, schemaVersion, release, components, projects, lastProblem: lastProblem(state), drain: state.drain === null ? null : { generation: state.drain.generation, acknowledgedAtMs: state.drain.acknowledgedAtMs }, deferral: state.deferral }));
194
222
  }
195
223
  else
196
- for (const line of [...updateResultLines(result), ...lastProblemLines(state), ...updateReportLines(report)])
224
+ for (const line of [...updateResultLines(result), ...lastProblemLines(state), ...drainLines(state), ...updateReportLines(report)])
197
225
  ctx.stdout(line);
198
226
  return result.exitCode;
199
227
  }
@@ -229,12 +257,25 @@ export async function cmdRunner(args, ctx, deps = {}) {
229
257
  if (mode === "off" || (mode === "on" && status.state === "on"))
230
258
  for (const id of RELEASE_COMPONENTS)
231
259
  setComponentPolicy(ctx.home, id, mode === "on");
260
+ let updateDrain = "none";
261
+ if (mode === "off" && !fleet) {
262
+ let runner = null;
263
+ try {
264
+ runner = dir();
265
+ }
266
+ catch { /* No runner directory to release. */ }
267
+ if (runner !== null && existsSync(resolve(runner, ".env")) && existsSync(resolve(runner, "compose.yaml")))
268
+ updateDrain = await releaseUpdateDrain(deps.engine ?? dockerRunnerUpdateEngine({ env: ctx.env }), runner, { cloud: true });
269
+ }
232
270
  if (args.json) {
233
271
  const state = mode === "status" && dirFlag !== undefined ? readReleaseState(dirFlag) : undefined;
234
- ctx.stdout(JSON.stringify({ ...status, ...(state ? { release: state } : {}) }));
272
+ ctx.stdout(JSON.stringify({ ...status, ...(state ? { release: state } : {}), ...(mode === "off" ? { updateDrain } : {}) }));
235
273
  }
236
274
  else {
237
275
  ctx.stdout(statusLine(status));
276
+ const line = mode === "off" ? drainReleaseLine(updateDrain) : null;
277
+ if (line !== null)
278
+ ctx.stdout(line);
238
279
  // A single runner's schedule prints what it always has; a fleet's says so, and for which directory.
239
280
  if (status.state === "on" && status.fleet)
240
281
  ctx.stdout(`This is a supervisor fleet's schedule: it updates the compose.json in ${status.dir}.`);