@catalyst-cloud/cli 0.16.41 → 0.16.42

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,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.42
4
+
5
+ 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.
6
+
3
7
  ## 0.16.41
4
8
 
5
9
  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
 
@@ -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}.`);
@@ -23,23 +23,45 @@ import { decideRelease, DESIRED_RELEASE_SCRIPT, HOST_ID, isRecord, readReleaseSt
23
23
  // back and the supervisor is recreated on it. That scope set is saved before the recreate and removed
24
24
  // once it qualifies or rolls back, so a run killed in between is finished by the next one.
25
25
  //
26
+ // A busy host re-adopts its live sessions before it qualifies, which can take longer than four minutes
27
+ // (CTC-8163). Each recreated supervisor, the new release's or the restored previous one's, gets four
28
+ // minutes, extended to 150 seconds past each new step it logs: fleet tenure, then a saved scope
29
+ // re-adopted, then that scope ready. No supervisor gets more than seven minutes. A restart, another
30
+ // image or `host_not_ready` still ends the wait at the next poll. At the deadline each saved scope it
31
+ // has not qualified is reported, with the gate condition its latest log line shows failing.
32
+ //
33
+ // A team can stop being delegated while an update is in flight (CTC-7569): no supervisor, new or old,
34
+ // starts its scope again. The previous release, restored, is the control. When it misses exactly the
35
+ // scopes the new supervisor missed and qualifies the rest, those scopes are dropped from the saved set
36
+ // and the new release is applied again against what is left. A later run that finds a saved set beside
37
+ // the previous release's running supervisor drops the saved scopes that supervisor no longer qualifies.
38
+ // Either way the result names each dropped scope; the new release's own log never drops one.
39
+ //
26
40
  // A release can qualify and then stop receiving work minutes later (CTC-6564). A successful apply
27
41
  // arms a check on the host, outside the calling process, for 10 minutes later: it reads the supervisor's
28
42
  // log since the roll began and, when the release was granted no work and the fleet's acquires got no
29
43
  // answer, restores the saved previous `compose.json`. `runner update --fleet --post-roll-check` is
30
44
  // that check; only the armed timer runs it.
45
+ // A check that is due while an update holds the lock judges nothing; that update arms it again
46
+ // for 5 minutes after it ends (CTC-7570).
31
47
  export const FLEET_COMPOSE_FILE = "compose.json";
32
48
  export const PREVIOUS_COMPOSE_FILE = "compose.release-previous.json";
33
49
  const CANDIDATE_COMPOSE_FILE = "compose.release-candidate.json";
34
50
  /** The scopes the supervisor served before a recreate, kept until that recreate is qualified or rolled
35
51
  * back. A run that was killed in between finds it and qualifies against it, never against the new
36
- * container's own log, which would read an unqualified supervisor as serving. */
52
+ * container's own log, which would read an unqualified supervisor as serving. Only the previous
53
+ * release's supervisor can take a scope out of it (CTC-7569). */
37
54
  export const IN_FLIGHT_FILE = "fleet-update-in-flight.json";
38
55
  /** How far back a qualification counts as current before the update. The supervisor repeats it
39
56
  * on every loop while a scope is ready, so a ready scope logs it many times in this window. */
40
57
  const QUALIFIED_LOOKBACK_MS = 5 * 60_000;
41
58
  const QUALIFY_MIN_MS = 90_000;
59
+ /** How long a recreated supervisor that shows no progress has to qualify every saved scope. */
42
60
  const QUALIFY_DEADLINE_MS = 240_000;
61
+ /** How long past each new step of progress a recreated supervisor has to qualify (CTC-8163). */
62
+ const QUALIFY_PROGRESS_MS = 150_000;
63
+ /** The most one recreated supervisor is given, however it progresses (CTC-8163). */
64
+ const QUALIFY_CEILING_MS = 420_000;
43
65
  const QUALIFY_POLL_MS = 10_000;
44
66
  const CHILD_NAME_PREFIX = "catalyst-pa_";
45
67
  /** What a successful apply leaves for the check: the bytes it wrote and when the roll began. */
@@ -47,6 +69,8 @@ export const POST_ROLL_ARMED_FILE = "post-roll-armed.json";
47
69
  /** The check's last verdict, beside `release-state.json`. */
48
70
  export const POST_ROLL_RESULT_FILE = "post-roll-result.json";
49
71
  export const POST_ROLL_DELAY_MS = 10 * 60_000;
72
+ /** How long after an update run lets go of the lock a check it kept from judging runs again (CTC-7570). */
73
+ export const POST_ROLL_RETRY_DELAY_MS = 5 * 60_000;
50
74
  const POST_ROLL_UNIT = "catalyst-fleet-post-roll";
51
75
  /** No-response acquire retries, with no grants, that count a release as failing. */
52
76
  const POST_ROLL_MAX_NO_RESPONSE = 3;
@@ -91,14 +115,83 @@ export function qualifiedScopes(text) {
91
115
  /** A `host_not_ready` line for one of `scopes`, or one that names no scope. Other scopes may be
92
116
  * unready for their own reasons (a team with no repository yet) and do not stop an update. */
93
117
  export function notReadyScope(text, scopes) {
118
+ return readSupervisorLog(text, scopes).notReady;
119
+ }
120
+ const GATE_CODES = [
121
+ ["resourcesReady", "resources_ready"],
122
+ ["scopeEligible", "scope_eligible"],
123
+ ["readyUntilValid", "ready_until"],
124
+ ["sessionsReadopted", "sessions_readopted"],
125
+ ];
126
+ /** One pass over the log text for everything qualify() reads. Lines for other scopes, and lines that
127
+ * are not JSON or lack a field, are skipped. */
128
+ function readSupervisorLog(text, scopes) {
129
+ const log = { notReady: null, qualified: new Set(), tenure: false, readopted: new Set(), ready: new Set(), gates: new Map() };
94
130
  for (const event of logEvents(text)) {
95
- if (event.event !== "host_not_ready")
96
- continue;
97
131
  const key = scopeKey(event);
98
- if (key === null || scopes.has(key))
99
- return key ?? "unscoped";
132
+ if (event.event === "host_not_ready" && log.notReady === null && (key === null || scopes.has(key)))
133
+ log.notReady = key ?? "unscoped";
134
+ if (event.event === "supervisor_fleet_tenure")
135
+ log.tenure = true;
136
+ if (key === null || !scopes.has(key))
137
+ continue;
138
+ if (event.event === "supervisor_scope_qualified")
139
+ log.qualified.add(key);
140
+ else if (event.event === "supervisor_readopted")
141
+ log.readopted.add(key);
142
+ else if (event.event === "host_ready")
143
+ log.ready.add(key);
144
+ else if (event.event === "supervisor_scope_qualification_state") {
145
+ const { resourcesReady, scopeEligible, readyUntilValid, sessionsReadopted } = event;
146
+ if (typeof resourcesReady === "boolean" && typeof scopeEligible === "boolean" && typeof readyUntilValid === "boolean" && typeof sessionsReadopted === "boolean")
147
+ log.gates.set(key, { resourcesReady, scopeEligible, readyUntilValid, sessionsReadopted });
148
+ }
149
+ }
150
+ return log;
151
+ }
152
+ /** Count the steps `log` shows that `budget` has not counted yet: fleet tenure, a scope re-adopted, and
153
+ * a re-adopted scope ready. A scope ready before its re-adoption shows counts once that does. Any new
154
+ * step moves the deadline to `QUALIFY_PROGRESS_MS` past `atMs`, never earlier and never past the ceiling. */
155
+ function countProgress(budget, log, atMs) {
156
+ let progressed = false;
157
+ if (log.tenure && !budget.tenure) {
158
+ budget.tenure = true;
159
+ progressed = true;
160
+ }
161
+ for (const scope of log.readopted) {
162
+ if (budget.readopted.has(scope))
163
+ continue;
164
+ budget.readopted.add(scope);
165
+ progressed = true;
166
+ }
167
+ for (const scope of log.ready) {
168
+ if (!budget.readopted.has(scope) || budget.ready.has(scope))
169
+ continue;
170
+ budget.ready.add(scope);
171
+ progressed = true;
100
172
  }
101
- return null;
173
+ if (progressed)
174
+ budget.deadline = Math.min(budget.ceiling, Math.max(budget.deadline, atMs + QUALIFY_PROGRESS_MS));
175
+ }
176
+ /** The longest one recreated supervisor is given to qualify, however it progresses. */
177
+ const qualifyCeilingMs = (options) => Math.max(QUALIFY_CEILING_MS, options.qualifyDeadlineMs ?? QUALIFY_DEADLINE_MS);
178
+ /** A scope as printed: anything outside printable ASCII, and the backslash that starts an escape,
179
+ * escaped as `\uXXXX`, and at most 120 characters. */
180
+ const printableScope = (scope) => {
181
+ const escaped = scope.replace(/[^\x21-\x5b\x5d-\x7e]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
182
+ return escaped.length > 120 ? `${escaped.slice(0, 120)}...` : escaped;
183
+ };
184
+ /** The gate conditions a missing scope's snapshot shows failing, sorted; `diagnostic_unavailable`
185
+ * without one, and `qualification_event_missing` when every condition held. */
186
+ function failedGates(snapshot) {
187
+ if (snapshot === undefined)
188
+ return ["diagnostic_unavailable"];
189
+ const failed = GATE_CODES.filter(([field]) => !snapshot[field]).map(([, code]) => code).sort();
190
+ return failed.length > 0 ? failed : ["qualification_event_missing"];
191
+ }
192
+ /** One line per scope in `missing`, sorted by scope. `gates` null: the log could not be read. */
193
+ function qualificationPendingLines(roll, missing, gates) {
194
+ return [...missing].sort().map((scope) => `fleet qualification pending: roll=${roll} scope=${printableScope(scope)} failed=${failedGates(gates?.get(scope)).join(",")}`);
102
195
  }
103
196
  function readInFlight(dir) {
104
197
  try {
@@ -112,6 +205,10 @@ function readInFlight(dir) {
112
205
  return null;
113
206
  }
114
207
  }
208
+ /** Save the scopes the next recreate has to qualify, replacing any saved before. */
209
+ function saveInFlight(dir, scopes, atMs) {
210
+ writePrivateAtomic(join(dir, IN_FLIGHT_FILE), `${JSON.stringify({ version: 1, scopes: [...scopes].sort(), atMs })}\n`);
211
+ }
115
212
  function clearInFlight(dir) {
116
213
  try {
117
214
  unlinkSync(join(dir, IN_FLIGHT_FILE));
@@ -120,6 +217,17 @@ function clearInFlight(dir) {
120
217
  // Already gone.
121
218
  }
122
219
  }
220
+ /** The images a saved `compose.json` names, or null when there is none or it is not a fleet layout. */
221
+ function savedImages(path) {
222
+ try {
223
+ return fleetImages(JSON.parse(readFileSync(path, "utf8")));
224
+ }
225
+ catch {
226
+ return null;
227
+ }
228
+ }
229
+ /** The detail token naming scopes dropped from the requirement, sorted; empty when there are none. */
230
+ const droppedDetail = (scopes) => scopes.length > 0 ? `;dropped_scope:${[...scopes].sort().join(",")}` : "";
123
231
  const removeFile = (path) => {
124
232
  try {
125
233
  unlinkSync(path);
@@ -226,6 +334,23 @@ async function armPostRollCheck(engine, dir, armed, signal) {
226
334
  return ";post_roll_check_not_armed";
227
335
  }
228
336
  }
337
+ /** The armed check runs once, from a one-shot timer. When it was due while an update held the lock, or
338
+ * Docker could not be read, it judged nothing and left the armed file; a reboot loses the timer the same
339
+ * way (CTC-7570). The update holding the lock arms it again for a few minutes later, before it lets go:
340
+ * while the lock is held no check is part-way through a judgment, so the arm's stop of the check's unit
341
+ * interrupts nothing. The check cannot arm itself: that stop would end its own unit. A check this run
342
+ * armed is not due yet and is left alone. */
343
+ async function rearmOverdueCheck(engine, dir, now, signal) {
344
+ const armed = readPostRollArmed(dir);
345
+ if (armed === null || now() < armed.armedAtMs + POST_ROLL_DELAY_MS)
346
+ return;
347
+ try {
348
+ await engine.armPostRollCheck(dir, POST_ROLL_RETRY_DELAY_MS, signal);
349
+ }
350
+ catch {
351
+ // The next run tries again.
352
+ }
353
+ }
229
354
  export async function runFleetUpdate(engine, options) {
230
355
  const { dir } = options;
231
356
  const now = options.now ?? Date.now;
@@ -243,8 +368,10 @@ export async function runFleetUpdate(engine, options) {
243
368
  return result;
244
369
  }
245
370
  finally {
246
- if (lock !== null)
371
+ if (lock !== null) {
372
+ await rearmOverdueCheck(engine, dir, now, options.signal);
247
373
  releaseLock(dir, lock);
374
+ }
248
375
  }
249
376
  }
250
377
  async function update(engine, options, now) {
@@ -291,6 +418,8 @@ async function update(engine, options, now) {
291
418
  // The scopes the running supervisor qualifies are what the new one has to qualify again, unless an
292
419
  // earlier run stopped mid-recreate: then the scopes it saved are the baseline.
293
420
  let scopes = inFlight;
421
+ /** Saved scopes the previous release's supervisor no longer qualifies, named in the result (CTC-7569). */
422
+ let dropped = [];
294
423
  if (scopes === null) {
295
424
  if (!live?.running)
296
425
  return done("retained", "supervisor_not_running", { ...tagged, exitCode: 1 });
@@ -301,6 +430,25 @@ async function update(engine, options, now) {
301
430
  if (scopes.size === 0)
302
431
  return done("retained", "no_qualified_scope", { ...tagged, exitCode: 1 });
303
432
  }
433
+ else {
434
+ // An earlier run that ended back on the previous release, its rollback unqualified, left its scopes
435
+ // saved. Only when compose.json and the running supervisor are that previous release, up long enough
436
+ // to have qualified every scope it serves, does its log trim the saved set: a saved scope it no
437
+ // longer qualifies stopped being delegated. A `host_not_ready` for a saved scope is a scope still
438
+ // started, not one dropped. Anything less keeps the saved set whole. Long enough is the most any
439
+ // recreated supervisor is given, since this run cannot see how it progressed (CTC-8163).
440
+ const previous = savedImages(join(dir, PREVIOUS_COMPOSE_FILE));
441
+ const onPrevious = previous !== null && current.runner === previous.runner && current.supervisor === previous.supervisor && runs(previous);
442
+ if (live && onPrevious && now() - Date.parse(live.startedAt) >= qualifyCeilingMs(options)) {
443
+ const recent = await engine.logs(live.id, new Date(now() - QUALIFIED_LOOKBACK_MS).toISOString(), signal);
444
+ const serving = recent === null || notReadyScope(recent, scopes) !== null ? new Set() : qualifiedScopes(recent);
445
+ const kept = [...scopes].filter((scope) => serving.has(scope));
446
+ if (kept.length > 0 && kept.length < scopes.size) {
447
+ dropped = [...scopes].filter((scope) => !serving.has(scope));
448
+ scopes = new Set(kept);
449
+ }
450
+ }
451
+ }
304
452
  const record = (outcome, detail, previous, exitCode = 0) => {
305
453
  writeReleaseState(dir, {
306
454
  ...state,
@@ -353,9 +501,15 @@ async function update(engine, options, now) {
353
501
  const rollStartMs = now();
354
502
  if (changed.length > 0)
355
503
  writePrivateAtomic(join(dir, PREVIOUS_COMPOSE_FILE), composeText);
356
- writePrivateAtomic(join(dir, IN_FLIGHT_FILE), `${JSON.stringify({ version: 1, scopes: [...scopes].sort(), atMs: now() })}\n`);
357
- writePrivateAtomic(composePath, candidateText);
358
- if ((await engine.recreate(dir, project, signal)) && (await qualify(engine, dir, project, target, scopes, options, now))) {
504
+ saveInFlight(dir, scopes, now());
505
+ /** Write `text` as compose.json, recreate the supervisor alone on it and qualify it against `required`. */
506
+ const roll = async (which, text, images, required) => {
507
+ writePrivateAtomic(composePath, text);
508
+ if (!(await engine.recreate(dir, project, signal)))
509
+ return { ok: false, reason: "recreate_failed" };
510
+ return qualify(engine, dir, project, images, required, options, now, which);
511
+ };
512
+ const succeed = async (droppedScopes) => {
359
513
  clearInFlight(dir);
360
514
  if (changed.length > 0)
361
515
  await engine.removeImages(changed.map((c) => current[c]), signal);
@@ -371,20 +525,34 @@ async function update(engine, options, now) {
371
525
  version: 1, sinceMs: rollStartMs, composeSha256: sha256(candidateText), generation: release.generation,
372
526
  releaseId: release.releaseId, fromGeneration: state.generation, armedAtMs: now(),
373
527
  };
374
- const detail = (changed.length > 0 ? `updated:${changed.join(",")}` : "reconciled") + await armPostRollCheck(engine, dir, armed, signal);
528
+ const detail = (changed.length > 0 ? `updated:${changed.join(",")}` : "reconciled") + droppedDetail(droppedScopes) +
529
+ await armPostRollCheck(engine, dir, armed, signal);
375
530
  const previous = changed.length > 0 ? current : null;
376
531
  return disturbed === 0
377
532
  ? record("updated", detail, previous)
378
533
  : record("updated", `${detail};${disturbed < 0 ? "children_unreadable" : `children_changed:${disturbed}`}`, previous, 1);
379
- }
534
+ };
535
+ const candidate = await roll("candidate", candidateText, target, scopes);
536
+ if (candidate.ok)
537
+ return succeed(dropped);
380
538
  if (rollbackText === null || rollbackImages === null)
381
- return done("failed", "new_supervisor_not_qualified_no_previous", { ...tagged, exitCode: 1 });
382
- const restored = (await restoreCompose(engine, dir, project, rollbackText, signal)) &&
383
- (await qualify(engine, dir, project, rollbackImages, scopes, options, now));
539
+ return done("failed", `new_supervisor_not_qualified_no_previous${droppedDetail(dropped)}`, { ...tagged, exitCode: 1 });
540
+ let restored = await roll("rollback", rollbackText, rollbackImages, scopes);
541
+ // The previous release is the control (CTC-7569). Missing exactly the scopes the candidate missed while
542
+ // qualifying the rest, it shows those scopes stopped being delegated, not that the candidate broke them:
543
+ // the candidate is applied again without them, and recorded only once it qualifies what is left.
544
+ const gone = lostDelegation(candidate, restored, scopes);
545
+ if (gone !== null) {
546
+ const kept = new Set([...scopes].filter((scope) => !gone.has(scope)));
547
+ saveInFlight(dir, kept, now());
548
+ if ((await roll("candidate", candidateText, target, kept)).ok)
549
+ return succeed([...dropped, ...gone]);
550
+ restored = await roll("rollback", rollbackText, rollbackImages, kept);
551
+ }
384
552
  // A rollback that did not qualify keeps the baseline for the next run.
385
- if (restored)
553
+ if (restored.ok)
386
554
  clearInFlight(dir);
387
- return done(restored ? "rolled_back" : "failed", restored ? "new_supervisor_not_qualified" : "rollback_not_qualified", { ...tagged, exitCode: 1 });
555
+ return done(restored.ok ? "rolled_back" : "failed", `${restored.ok ? "new_supervisor_not_qualified" : "rollback_not_qualified"}${droppedDetail(dropped)}`, { ...tagged, exitCode: 1 });
388
556
  }
389
557
  /** Put `text` back as `compose.json` and recreate the supervisor alone on it. */
390
558
  async function restoreCompose(engine, dir, project, text, signal) {
@@ -461,6 +629,11 @@ export async function runFleetPostRollCheck(engine, options) {
461
629
  }
462
630
  if (previous === null)
463
631
  return refuse("no_previous_compose");
632
+ // An update that started from this release and did not finish saved this release's own file as the
633
+ // previous one (CTC-7570). Restoring it would move nothing and record a rollback that never happened.
634
+ const current = fleetImages(compose);
635
+ if (current !== null && previous.runner === current.runner && previous.supervisor === current.supervisor)
636
+ return refuse("previous_compose_is_this_release");
464
637
  // The apply removed the images it replaced, so the previous ones may need pulling again.
465
638
  const unprepared = await engine.prepare([previous.runner, previous.supervisor], signal);
466
639
  if (unprepared !== null)
@@ -478,37 +651,69 @@ export async function runFleetPostRollCheck(engine, options) {
478
651
  }
479
652
  }
480
653
  /** The recreated supervisor runs `images`, stays up with no restart, logs no `host_not_ready` for a
481
- * scope it must serve, and qualifies every one of `scopes`, held for the minimum window. */
482
- async function qualify(engine, dir, project, images, scopes, options, now) {
654
+ * scope it must serve, and qualifies every one of `scopes`, held for the minimum window. Its deadline
655
+ * moves with the progress it logs (CTC-8163); the restart, image and `host_not_ready` exits come first. */
656
+ async function qualify(engine, dir, project, images, scopes, options, now, roll) {
483
657
  const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
484
658
  const start = now();
485
- const deadline = start + (options.qualifyDeadlineMs ?? QUALIFY_DEADLINE_MS);
659
+ const budget = {
660
+ deadline: start + (options.qualifyDeadlineMs ?? QUALIFY_DEADLINE_MS),
661
+ ceiling: start + qualifyCeilingMs(options),
662
+ tenure: false,
663
+ readopted: new Set(),
664
+ ready: new Set(),
665
+ };
486
666
  const minimum = start + (options.qualifyMinMs ?? QUALIFY_MIN_MS);
487
667
  let id = null;
488
668
  for (;;) {
489
669
  if (options.signal?.aborted)
490
- return false;
670
+ return { ok: false, reason: "aborted" };
491
671
  const live = await engine.supervisor(dir, project, options.signal);
672
+ // What this poll's log has not qualified yet, and its gate lines; null when the supervisor or its
673
+ // log could not be read.
674
+ let missing = null;
675
+ let gates = null;
492
676
  if (live) {
493
677
  id ??= live.id;
494
678
  if (live.id !== id || !live.running || live.restartCount !== 0)
495
- return false;
679
+ return { ok: false, reason: "restarted" };
496
680
  if (live.image !== images.supervisor || live.runnerImage !== images.runner)
497
- return false;
681
+ return { ok: false, reason: "wrong_image" };
498
682
  const text = await engine.logs(live.id, live.startedAt, options.signal);
499
683
  if (text !== null) {
500
- if (notReadyScope(text, scopes) !== null)
501
- return false;
502
- const seen = qualifiedScopes(text);
503
- if (now() >= minimum && [...scopes].every((scope) => seen.has(scope)))
504
- return true;
684
+ const log = readSupervisorLog(text, scopes);
685
+ if (log.notReady !== null)
686
+ return { ok: false, reason: "host_not_ready" };
687
+ missing = new Set([...scopes].filter((scope) => !log.qualified.has(scope)));
688
+ if (now() >= minimum && missing.size === 0)
689
+ return { ok: true };
690
+ countProgress(budget, log, now());
691
+ gates = log.gates;
692
+ }
693
+ }
694
+ if (now() >= budget.deadline) {
695
+ for (const line of qualificationPendingLines(roll, missing ?? scopes, gates)) {
696
+ try {
697
+ options.onQualificationPending?.(line);
698
+ }
699
+ catch {
700
+ // A line that could not be written never holds up the other lines or the restore.
701
+ }
505
702
  }
703
+ return missing !== null && missing.size > 0 ? { ok: false, reason: "missing", missing } : { ok: false, reason: "deadline" };
506
704
  }
507
- if (now() >= deadline)
508
- return false;
509
705
  await sleep(QUALIFY_POLL_MS);
510
706
  }
511
707
  }
708
+ /** The scopes to drop when the candidate and the previous release, its control, both failed only by
709
+ * missing scopes, the same ones, and the control qualified at least one other `required` scope. Null
710
+ * otherwise: then the candidate is what failed, or nothing shows which. */
711
+ function lostDelegation(candidate, control, required) {
712
+ if (candidate.ok || control.ok || candidate.reason !== "missing" || control.reason !== "missing")
713
+ return null;
714
+ const same = candidate.missing.size === control.missing.size && [...candidate.missing].every((scope) => control.missing.has(scope));
715
+ return same && control.missing.size < required.size ? control.missing : null;
716
+ }
512
717
  const CONTAINER_ID = /^[0-9a-f]{12,64}$/;
513
718
  /** The one volume or bind in `service.volumes` that holds `file`, as a read-only `--mount` value. */
514
719
  export function credentialMount(compose, service, file) {