@timurproko/a1 0.2.7-dev.1285 → 0.2.7-dev.1286

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.
@@ -1,7 +1,7 @@
1
1
  export { certifyMaterializedRelease, recordParentCertifiedRelease, releaseEnvironment, runBootstrap, } from "./bootstrap.js";
2
2
  export type { BootstrapOptions } from "./bootstrap.js";
3
3
  export { selectOrdinaryLaunchReleaseId } from "./ordinary-launch-selection.js";
4
- export { ReleaseStateStore, RELEASE_STATE_SCHEMA, RELEASE_RETENTION_MS, emptyState, planProtectedReleases, withinRetention } from "./release-state.js";
4
+ export { ReleaseStateStore, ReleaseStateNewerError, isReleaseStateNewerError, RELEASE_STATE_SCHEMA, RELEASE_STATE_LEVEL, RELEASE_RETENTION_MS, emptyState, planProtectedReleases, withinRetention } from "./release-state.js";
5
5
  export type { ActiveReleaseTransactionReference, ReleaseState, OrphanReleaseDisposition, ProtectedReleaseInputs, ProtectedReleasePlan, ReleaseApproval, ReleaseCleanupDiagnostic, ReleaseCleanupDisposition, ReleaseCleanupStage, ReleaseCleanupState, ReleaseCleanupWorkerRun, ReleaseCleanupWorkerStatus, ReleaseCleanupWorkerSummary, ReleaseRecord, ReleaseReferences, ReleaseRetentionReconciliation, } from "./release-state.js";
6
6
  export { DEPENDENCY_LAYER_MANIFEST, DEPENDENCY_LAYER_SCHEMA, RUNTIME_PAYLOAD_INVENTORY, dependencyLayerCertificationPath, dependencyReference, generateDependencyRuntimePayload, legacyDependencyLayerCertificationPath, materializeDependencyLayer, readCertifiedDependencyLayer, selectDependencyRuntimePayload, selectPublishedDependencyRuntimePayload, verifyDependencyLayer, } from "./dependency-layer.js";
7
7
  export type { DependencyLayerIdentity, DependencyLayerOperationEvent, DependencyLayerReference, GeneratedRuntimePayload, MaterializeDependencyLayerOptions, MaterializedDependencyLayer, PublishedRuntimeFileClassification, PublishedRuntimePayload, ReadCertifiedDependencyLayerOptions, RuntimePayloadExclusion, RuntimePayloadInventory, SelectedRuntimePayload, } from "./dependency-layer.js";
@@ -1,6 +1,6 @@
1
1
  export { certifyMaterializedRelease, recordParentCertifiedRelease, releaseEnvironment, runBootstrap, } from "./bootstrap.js";
2
2
  export { selectOrdinaryLaunchReleaseId } from "./ordinary-launch-selection.js";
3
- export { ReleaseStateStore, RELEASE_STATE_SCHEMA, RELEASE_RETENTION_MS, emptyState, planProtectedReleases, withinRetention } from "./release-state.js";
3
+ export { ReleaseStateStore, ReleaseStateNewerError, isReleaseStateNewerError, RELEASE_STATE_SCHEMA, RELEASE_STATE_LEVEL, RELEASE_RETENTION_MS, emptyState, planProtectedReleases, withinRetention } from "./release-state.js";
4
4
  export { DEPENDENCY_LAYER_MANIFEST, DEPENDENCY_LAYER_SCHEMA, RUNTIME_PAYLOAD_INVENTORY, dependencyLayerCertificationPath, dependencyReference, generateDependencyRuntimePayload, legacyDependencyLayerCertificationPath, materializeDependencyLayer, readCertifiedDependencyLayer, selectDependencyRuntimePayload, selectPublishedDependencyRuntimePayload, verifyDependencyLayer, } from "./dependency-layer.js";
5
5
  export { processIsAlive } from "./process-liveness.js";
6
6
  export { PRODUCT_PACKAGE_NAME, createReleaseIdentity, deriveReleaseIdentity, digestManifestFiles, discoverReleasePayload, packageRootFromModule, releaseFileIdentity, resolveWithin, } from "./release.js";
@@ -3,7 +3,7 @@ import { spawnReleaseEntry } from "./release-use.js";
3
3
  import { lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat } from "node:fs/promises";
4
4
  import { dirname, resolve, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { ReleaseStateStore, releaseUseIsLive, } from "./release-state.js";
6
+ import { ReleaseStateStore, releaseUseIsLive, isReleaseStateNewerError, } from "./release-state.js";
7
7
  import { resolveProductPaths } from "../lifecycle/index.js";
8
8
  import { RELEASE_MANIFEST_FILENAME } from "./release-store.js";
9
9
  import { DEPENDENCY_LAYER_MANIFEST, readCertifiedDependencyLayer } from "./dependency-layer.js";
@@ -130,6 +130,16 @@ export async function runBoundedReleaseCleanup(dataDir, paths, options = {}) {
130
130
  * potentially slow recursive deletion. A later launch/update resumes work if spawning fails.
131
131
  */
132
132
  export async function scheduleReleaseCleanup(dataDir, paths, options = {}) {
133
+ try {
134
+ await scheduleOwnedReleaseCleanup(dataDir, paths, options);
135
+ }
136
+ catch (error) {
137
+ // Compatibility: a newer release owns state this code may not write; its own maintenance cleans up.
138
+ if (!isReleaseStateNewerError(error))
139
+ throw error;
140
+ }
141
+ }
142
+ async function scheduleOwnedReleaseCleanup(dataDir, paths, options) {
133
143
  const { store } = await prepareReleaseCleanup(dataDir, paths, options);
134
144
  const runId = randomUUID();
135
145
  await store.recordCleanupWorkerScheduled(runId);
@@ -152,6 +162,17 @@ export async function scheduleReleaseCleanup(dataDir, paths, options = {}) {
152
162
  }
153
163
  /** Entry used by the private cleanup executable shipped in every immutable release. */
154
164
  export async function runReleaseCleanupWorker(environment = process.env, workerOptions = {}) {
165
+ try {
166
+ return await runOwnedReleaseCleanupWorker(environment, workerOptions);
167
+ }
168
+ catch (error) {
169
+ // Compatibility: a worker started by an older release stands down once a newer release owns the state.
170
+ if (!isReleaseStateNewerError(error))
171
+ throw error;
172
+ return { planned: 0, attempted: 0, completed: 0, remaining: 0, durationMs: 0 };
173
+ }
174
+ }
175
+ async function runOwnedReleaseCleanupWorker(environment, workerOptions) {
155
176
  const paths = resolveProductPaths(environment);
156
177
  const store = new ReleaseStateStore(paths.dataDir);
157
178
  const runId = environment[WORKER_RUN_ID_ENV] ?? randomUUID();
@@ -1,5 +1,17 @@
1
1
  import type { MaterializedRelease } from "./release-store.js";
2
+ /**
3
+ * Protocol: the control-state family identifier. It is frozen: changing it locks every
4
+ * already-published release out of the state, which is what broke the 1276 → 1285 update.
5
+ * Evolve the format through {@link RELEASE_STATE_LEVEL} instead.
6
+ */
2
7
  export declare const RELEASE_STATE_SCHEMA: string;
8
+ /**
9
+ * Protocol: the compatibility level this code reads and writes. State records the lowest
10
+ * level that may read it and the lowest that may write it; additive fields raise neither,
11
+ * because older code preserves fields it does not know. Raise `read` only when older code would
12
+ * misread the state, and `write` only when older writes would corrupt it.
13
+ */
14
+ export declare const RELEASE_STATE_LEVEL = 1;
3
15
  /** Minimum age retention; live, uncertain, and legacy ownership independently overrides age. */
4
16
  export declare const RELEASE_RETENTION_MS: number;
5
17
  export type ReleaseApproval = "candidate" | "approved" | "rejected";
@@ -81,6 +93,27 @@ export interface ReleaseState {
81
93
  readonly references: ReleaseReferences;
82
94
  readonly cleanup: ReleaseCleanupState;
83
95
  readonly uses?: Readonly<Record<string, ReleaseUseClaim>>;
96
+ /** Lowest compatibility levels that may read and write this state; absent means level 1. */
97
+ readonly format?: ReleaseStateFormat;
98
+ }
99
+ export interface ReleaseStateFormat {
100
+ readonly read: number;
101
+ readonly write: number;
102
+ }
103
+ /**
104
+ * State written by a newer release that this code may not read or write. Callers doing
105
+ * housekeeping treat it as "a newer release owns this state" and skip their work.
106
+ */
107
+ export declare class ReleaseStateNewerError extends Error {
108
+ readonly access: "read" | "write";
109
+ readonly required: number;
110
+ constructor(access: "read" | "write", required: number);
111
+ }
112
+ export declare function isReleaseStateNewerError(error: unknown): error is ReleaseStateNewerError;
113
+ export interface ReleaseStateStoreOptions {
114
+ /** Replaces the atomic rename; tests inject transient sharing failures. */
115
+ readonly rename?: (from: string, to: string) => Promise<void>;
116
+ readonly platform?: NodeJS.Platform;
84
117
  }
85
118
  export interface ProtectedReleaseInputs {
86
119
  /** Releases the reconciling command itself runs from; they are never collected under it. */
@@ -121,8 +154,10 @@ export declare function planProtectedReleases(state: ReleaseState, inputs?: Prot
121
154
  export declare function withinRetention(release: ReleaseRecord, now: number, retentionMs?: number): boolean;
122
155
  /** Serializes validated release-state revisions under a lock and commits each update atomically. */
123
156
  export declare class ReleaseStateStore {
157
+ #private;
124
158
  readonly path: string;
125
- constructor(dataDir: string);
159
+ constructor(dataDir: string, options?: ReleaseStateStoreOptions);
160
+ private renameWithRetry;
126
161
  read(): Promise<ReleaseState>;
127
162
  update(operation: (current: ReleaseState) => ReleaseState | Promise<ReleaseState>): Promise<ReleaseState>;
128
163
  recordCandidate(release: MaterializedRelease): Promise<ReleaseState>;
@@ -1,12 +1,45 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { mkdir, open, readFile, rename, rm } from "node:fs/promises";
2
+ import { mkdir, open, readdir, readFile, rename, rm } from "node:fs/promises";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { selectOrdinaryLaunchReleaseId } from "./ordinary-launch-selection.js";
5
5
  import { PRODUCT_IDENTITY } from "../../product-identity.js";
6
+ /**
7
+ * Protocol: the control-state family identifier. It is frozen: changing it locks every
8
+ * already-published release out of the state, which is what broke the 1276 → 1285 update.
9
+ * Evolve the format through {@link RELEASE_STATE_LEVEL} instead.
10
+ */
6
11
  export const RELEASE_STATE_SCHEMA = PRODUCT_IDENTITY.protocol.releaseCohortSchema;
12
+ /** Compatibility: earlier family identifier, normalized on read. */
13
+ const LEGACY_RELEASE_STATE_SCHEMA = "a1-release-cohort-v1";
14
+ /**
15
+ * Protocol: the compatibility level this code reads and writes. State records the lowest
16
+ * level that may read it and the lowest that may write it; additive fields raise neither,
17
+ * because older code preserves fields it does not know. Raise `read` only when older code would
18
+ * misread the state, and `write` only when older writes would corrupt it.
19
+ */
20
+ export const RELEASE_STATE_LEVEL = 1;
21
+ const RELEASE_STATE_TEMPORARY_PATTERN = /^release-state\.json\.(\d+)\.[0-9a-f-]{36}\.tmp$/;
22
+ const RENAME_RETRY_MS = 2_000;
7
23
  const RELEASE_ID_PATTERN = /^[0-9A-Za-z.+_-]+-[a-f0-9]{20}$/;
8
24
  /** Minimum age retention; live, uncertain, and legacy ownership independently overrides age. */
9
25
  export const RELEASE_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000;
26
+ /**
27
+ * State written by a newer release that this code may not read or write. Callers doing
28
+ * housekeeping treat it as "a newer release owns this state" and skip their work.
29
+ */
30
+ export class ReleaseStateNewerError extends Error {
31
+ access;
32
+ required;
33
+ constructor(access, required) {
34
+ super(`release state requires ${access} level ${required}; this release supports level ${RELEASE_STATE_LEVEL}. A newer release owns it`);
35
+ this.name = "ReleaseStateNewerError";
36
+ this.access = access;
37
+ this.required = required;
38
+ }
39
+ }
40
+ export function isReleaseStateNewerError(error) {
41
+ return error instanceof ReleaseStateNewerError;
42
+ }
10
43
  /**
11
44
  * Age-based retention. A release stays while a selector names it (active, its rollback, or a
12
45
  * candidate still being certified), while an unfinished update may return to it, while the
@@ -56,12 +89,37 @@ export function withinRetention(release, now, retentionMs = RELEASE_RETENTION_MS
56
89
  /** Serializes validated release-state revisions under a lock and commits each update atomically. */
57
90
  export class ReleaseStateStore {
58
91
  path;
59
- constructor(dataDir) {
92
+ #rename;
93
+ #platform;
94
+ constructor(dataDir, options = {}) {
60
95
  this.path = resolve(dataDir, "release-state.json");
96
+ this.#rename = options.rename ?? rename;
97
+ this.#platform = options.platform ?? process.platform;
98
+ }
99
+ async renameWithRetry(temporary) {
100
+ const deadline = Date.now() + RENAME_RETRY_MS;
101
+ for (let delay = 10;; delay = Math.min(delay * 2, 200)) {
102
+ try {
103
+ await this.#rename(temporary, this.path);
104
+ return;
105
+ }
106
+ catch (error) {
107
+ const code = error instanceof Error && "code" in error ? error.code : undefined;
108
+ // Platform: Windows refuses to replace a file another process has open for reading.
109
+ // Readers hold it only briefly, so the replacement is retried rather than lost.
110
+ const transient = this.#platform === "win32" && (code === "EPERM" || code === "EACCES" || code === "EBUSY");
111
+ if (!transient || Date.now() >= deadline)
112
+ throw error;
113
+ await new Promise(resolvePromise => setTimeout(resolvePromise, delay));
114
+ }
115
+ }
61
116
  }
62
117
  async read() {
63
118
  try {
64
119
  const state = normalizeState(JSON.parse(await readFile(this.path, "utf8")));
120
+ const format = stateFormat(state);
121
+ if (format.read > RELEASE_STATE_LEVEL)
122
+ throw new ReleaseStateNewerError("read", format.read);
65
123
  validateState(state);
66
124
  return state;
67
125
  }
@@ -77,19 +135,30 @@ export class ReleaseStateStore {
77
135
  const lock = await acquireLock(lockPath);
78
136
  try {
79
137
  const current = await this.read();
138
+ const format = stateFormat(current);
139
+ if (format.write > RELEASE_STATE_LEVEL)
140
+ throw new ReleaseStateNewerError("write", format.write);
80
141
  const next = await operation(current);
81
142
  validateTransition(current, next);
82
- const committed = { ...next, revision: current.revision + 1 };
143
+ // Invariant: levels only rise; an older writer never weakens what a newer one required.
144
+ const committed = { ...next, revision: current.revision + 1, format };
83
145
  const temporary = `${this.path}.${process.pid}.${randomUUID()}.tmp`;
84
146
  const file = await open(temporary, "wx", 0o600);
85
147
  try {
86
- await file.writeFile(JSON.stringify(committed, null, 2));
87
- await file.sync();
148
+ try {
149
+ await file.writeFile(JSON.stringify(committed, null, 2));
150
+ await file.sync();
151
+ }
152
+ finally {
153
+ await file.close();
154
+ }
155
+ await this.renameWithRetry(temporary);
88
156
  }
89
- finally {
90
- await file.close();
157
+ catch (error) {
158
+ await rm(temporary, { force: true }).catch(() => undefined);
159
+ throw error;
91
160
  }
92
- await rename(temporary, this.path);
161
+ await reclaimOrphanedTemporaries(dirname(this.path));
93
162
  return committed;
94
163
  }
95
164
  finally {
@@ -425,6 +494,43 @@ async function reclaimAbandonedLock(path) {
425
494
  await rm(gate, { recursive: true, force: true });
426
495
  }
427
496
  }
497
+ function stateFormat(state) {
498
+ const format = state?.format;
499
+ if (format === undefined)
500
+ return { read: RELEASE_STATE_LEVEL, write: RELEASE_STATE_LEVEL };
501
+ const { read, write } = (format ?? {});
502
+ if (!Number.isSafeInteger(read) || !Number.isSafeInteger(write) || read < 1 || write < 1) {
503
+ throw new Error("invalid release state format");
504
+ }
505
+ return { read: read, write: write };
506
+ }
507
+ /**
508
+ * A write that died between creating and renaming its temporary file leaves it behind.
509
+ * Only files whose writer process is gone are removed; a running writer's file is never touched.
510
+ */
511
+ async function reclaimOrphanedTemporaries(directory) {
512
+ const names = await readdir(directory).catch(() => []);
513
+ for (const name of names) {
514
+ const match = RELEASE_STATE_TEMPORARY_PATTERN.exec(name);
515
+ if (!match)
516
+ continue;
517
+ const pid = Number(match[1]);
518
+ if (pid === process.pid || processIsAlive(pid))
519
+ continue;
520
+ await rm(resolve(directory, name), { force: true }).catch(() => undefined);
521
+ }
522
+ }
523
+ function processIsAlive(pid) {
524
+ if (!Number.isSafeInteger(pid) || pid <= 0)
525
+ return true;
526
+ try {
527
+ process.kill(pid, 0);
528
+ return true;
529
+ }
530
+ catch (error) {
531
+ return !(error instanceof Error && "code" in error && error.code === "ESRCH");
532
+ }
533
+ }
428
534
  function normalizeState(value) {
429
535
  if (!value || typeof value !== "object")
430
536
  return value;
@@ -435,7 +541,7 @@ function normalizeState(value) {
435
541
  return {
436
542
  ...state,
437
543
  // Compatibility: one-way control-state upgrade fences pre-claim cleanup workers; session files are untouched.
438
- ...(state.schema === "a1-release-cohort-v1" ? { schema: RELEASE_STATE_SCHEMA } : {}),
544
+ ...(state.schema === LEGACY_RELEASE_STATE_SCHEMA ? { schema: RELEASE_STATE_SCHEMA } : {}),
439
545
  cleanup: cleanup
440
546
  ? { ...cleanup, workerRuns: cleanup.workerRuns ?? [] }
441
547
  : { pending: {}, diagnostics: [], workerRuns: [] },
@@ -3,7 +3,7 @@ import { realpath } from "node:fs/promises";
3
3
  import { spawn } from "node:child_process";
4
4
  import { basename, dirname, resolve } from "node:path";
5
5
  import { resolveProductPaths } from "../lifecycle/index.js";
6
- import { ReleaseStateStore, releaseUseIsLive } from "./release-state.js";
6
+ import { ReleaseStateStore, isReleaseStateNewerError, releaseUseIsLive } from "./release-state.js";
7
7
  /** Mutable installed entries are not GC-managed; immutable helper processes need their own claim. */
8
8
  async function claimReleaseEntry(entry, environment) {
9
9
  const dataDir = resolveProductPaths(environment).dataDir;
@@ -11,7 +11,16 @@ async function claimReleaseEntry(entry, environment) {
11
11
  const storeRoot = await realpath(resolve(dataDir, "releases")).catch(() => undefined);
12
12
  if (dirname(root) !== storeRoot)
13
13
  return undefined;
14
- return acquireReleaseUse(dataDir, basename(root));
14
+ try {
15
+ return await acquireReleaseUse(dataDir, basename(root));
16
+ }
17
+ catch (error) {
18
+ // Compatibility: a newer release owns state this code may not write. It retains releases it
19
+ // cannot prove unused, so the launch proceeds without a claim rather than failing.
20
+ if (isReleaseStateNewerError(error))
21
+ return undefined;
22
+ throw error;
23
+ }
15
24
  }
16
25
  export async function spawnReleaseEntry(entry, args, options) {
17
26
  const use = await claimReleaseEntry(entry, options.env ?? process.env);
@@ -59,11 +68,18 @@ export async function acquireReleaseUse(dataDir, releaseId) {
59
68
  async release() {
60
69
  if (released)
61
70
  return;
62
- await store.update(state => {
63
- const uses = { ...state.uses };
64
- delete uses[token];
65
- return { ...state, uses };
66
- });
71
+ try {
72
+ await store.update(state => {
73
+ const uses = { ...state.uses };
74
+ delete uses[token];
75
+ return { ...state, uses };
76
+ });
77
+ }
78
+ catch (error) {
79
+ // Compatibility: once a newer release owns the state, it settles this claim by process liveness.
80
+ if (!isReleaseStateNewerError(error))
81
+ throw error;
82
+ }
67
83
  released = true;
68
84
  },
69
85
  };
@@ -1,3 +1,4 @@
1
+ import { type ProductPaths } from "../lifecycle/index.js";
1
2
  /**
2
3
  * The one activation contract an installed tree can offer the updater that installed it.
3
4
  * The updater knows nothing else about the tree: the manifest names the contracts the tree
@@ -69,4 +70,5 @@ export declare function delegateActivation(request: UpdateActivationRequest, cal
69
70
  */
70
71
  export declare function runActivationEntry(argv: readonly string[], entryUrl: string, io?: {
71
72
  write(line: string): void;
72
- }, environment?: NodeJS.ProcessEnv): Promise<number>;
73
+ diagnostic?(line: string): void;
74
+ }, environment?: NodeJS.ProcessEnv, maintenance?: (dataDir: string, paths: ProductPaths) => Promise<void>): Promise<number>;
@@ -6,6 +6,7 @@ import { PRODUCT_TEXT } from "../../product-identity.js";
6
6
  import { certifyMaterializedRelease } from "./bootstrap.js";
7
7
  import { ReleaseStateStore } from "./release-state.js";
8
8
  import { resolveProductPaths } from "../lifecycle/index.js";
9
+ import { scheduleReleaseCleanup } from "./release-gc.js";
9
10
  import { materializeRelease } from "./release-store.js";
10
11
  import { warmMaterializedRelease } from "./warmup.js";
11
12
  import { errorMessage } from "./managed-path.js";
@@ -113,6 +114,9 @@ export async function delegateActivation(request, callbacks) {
113
114
  for (const event of events) {
114
115
  relay = relay.then(async () => {
115
116
  const parsed = parseActivationEvent(event);
117
+ // Compatibility: a newer tree may report events this updater does not know; they carry no verdict.
118
+ if (parsed === "unknown")
119
+ return;
116
120
  if (!parsed) {
117
121
  failure ??= new Error(`unrecognized activation event ${JSON.stringify(event).slice(0, 200)}`);
118
122
  return;
@@ -146,14 +150,19 @@ export async function delegateActivation(request, callbacks) {
146
150
  * Body of the installed tree's activation entry: activate the tree this code ships in,
147
151
  * writing one progress event per line so the updater that started it can follow along.
148
152
  */
149
- export async function runActivationEntry(argv, entryUrl, io = { write: line => process.stdout.write(line) }, environment = process.env) {
153
+ export async function runActivationEntry(argv, entryUrl, io = {
154
+ write: line => process.stdout.write(line),
155
+ diagnostic: line => process.stderr.write(line),
156
+ }, environment = process.env, maintenance = scheduleReleaseCleanup) {
150
157
  const emit = (event) => io.write(`${JSON.stringify(event)}\n`);
151
158
  try {
152
159
  const arguments_ = parseEntryArguments(argv);
153
160
  const packageRoot = resolve(fileURLToPath(new URL("..", entryUrl)));
161
+ const paths = resolveProductPaths(environment);
162
+ const dataDir = arguments_.dataDir ?? paths.dataDir;
154
163
  await activateInstalledRelease({
155
164
  packageRoot,
156
- dataDir: arguments_.dataDir ?? resolveProductPaths(environment).dataDir,
165
+ dataDir,
157
166
  targetVersion: arguments_.targetVersion,
158
167
  environment,
159
168
  }, {
@@ -161,6 +170,12 @@ export async function runActivationEntry(argv, entryUrl, io = { write: line => p
161
170
  onMaterializing: progress => emit({ event: "materializing", ...progress }),
162
171
  onWarmup: state => emit({ event: "warmup", state }),
163
172
  });
173
+ // Invariant: the release that now owns the control state performs its own maintenance. The
174
+ // updater that started this entry may be older and must not interpret state this code wrote.
175
+ // The activation is committed, so a maintenance failure is a diagnostic, never a verdict.
176
+ await maintenance(dataDir, { ...paths, dataDir }).catch((error) => {
177
+ io.diagnostic?.(`release maintenance after activation failed: ${errorMessage(error)}\n`);
178
+ });
164
179
  emit({ event: "completed" });
165
180
  return 0;
166
181
  }
@@ -197,7 +212,9 @@ function parseActivationEvent(value) {
197
212
  case "materializing":
198
213
  return typeof record.completed === "number" && typeof record.total === "number" ? { event: "materializing", completed: record.completed, total: record.total } : null;
199
214
  case "phase":
200
- return record.phase === "materialized" || record.phase === "certified" || record.phase === "active-reference-committed" ? { event: "phase", phase: record.phase } : null;
215
+ if (record.phase === "materialized" || record.phase === "certified" || record.phase === "active-reference-committed")
216
+ return { event: "phase", phase: record.phase };
217
+ return typeof record.phase === "string" && record.phase.length > 0 ? "unknown" : null;
201
218
  case "warmup":
202
219
  return record.state === "started" || record.state === "completed" ? { event: "warmup", state: record.state } : null;
203
220
  case "completed":
@@ -205,7 +222,7 @@ function parseActivationEvent(value) {
205
222
  case "failed":
206
223
  return typeof record.message === "string" ? { event: "failed", message: record.message } : null;
207
224
  default:
208
- return null;
225
+ return typeof record.event === "string" && record.event.length > 0 ? "unknown" : null;
209
226
  }
210
227
  }
211
228
  function summarize(text) {
@@ -87,8 +87,10 @@ export type { UpdateActivationPhase, UpdateMaterializationProgress } from "./upd
87
87
  export interface UpdateLifecycleCoordinator {
88
88
  targetIsActive(targetVersion: string): Promise<boolean>;
89
89
  verifyPackageUnlocked(packageRoot: string): Promise<void>;
90
- activateInstalled(packageRoot: string, targetVersion: string, phase: (phase: UpdateActivationPhase) => Promise<void>, onMaterializing?: (progress: UpdateMaterializationProgress) => void, onWarmup?: (state: "started" | "completed") => void): Promise<void>;
90
+ activateInstalled(packageRoot: string, targetVersion: string, phase: (phase: UpdateActivationPhase) => Promise<void>, onMaterializing?: (progress: UpdateMaterializationProgress) => void, onWarmup?: (state: "started" | "completed") => void): Promise<UpdateActivationMode | void>;
91
91
  }
92
+ /** Whether the installed tree activated itself (and owns its maintenance) or this updater activated it. */
93
+ export type UpdateActivationMode = "delegated" | "in-process";
92
94
  export interface UpdateTransactionJournal {
93
95
  readonly path: string;
94
96
  read(): Promise<UpdateTransaction | null>;
@@ -136,10 +136,12 @@ export function createUpdateLifecycleCoordinator(environment = process.env, file
136
136
  // its own to know. A tree that serves the contract activates itself with its own code;
137
137
  // a tree from before the contract is activated here, with the layout it had then.
138
138
  const contracts = await readActivationContracts(packageRoot, fileSystem.readFile);
139
- if (contracts.includes(UPDATE_ACTIVATION_CONTRACT))
139
+ if (contracts.includes(UPDATE_ACTIVATION_CONTRACT)) {
140
140
  await delegateActivation(request, callbacks);
141
- else
142
- await activateInstalledRelease(request, callbacks);
141
+ return "delegated";
142
+ }
143
+ await activateInstalledRelease(request, callbacks);
144
+ return "in-process";
143
145
  },
144
146
  };
145
147
  }
@@ -416,13 +418,22 @@ async function withUpdateExecutionLease(input, options, output, installer, opera
416
418
  // back to the release that ran before it, so this request proceeds as an ordinary update of its
417
419
  // own target instead of reporting a dead attempt as in progress.
418
420
  const interrupted = lease.owner.targetVersion;
419
- const rollback = await installer.rollback(lease.transaction.priorActiveReleaseId)
420
- .catch(error => `rollback failed: ${errorMessage(error)}`);
421
421
  const store = new UpdateTransactionStore(input.dataDir);
422
- await store.finish("rolled-back", `interrupted ${interrupted} update settled by ${input.targetVersion}; ${rollback}`);
423
- await store.clearCompleted().catch(() => undefined);
424
- await reclaimDeadLease(input.dataDir, lease.owner);
425
- output.stdout(`${PRODUCT_TEXT.commandName} rolled back the interrupted ${interrupted} update: ${rollback}\n`);
422
+ if (lease.transaction.phase === "active-reference-committed") {
423
+ // Invariant: the interrupted update already committed its target; agents may run it, so it stays.
424
+ await store.finish("completed", `interrupted ${interrupted} update was already active; settled by ${input.targetVersion}`);
425
+ await store.clearCompleted().catch(() => undefined);
426
+ await reclaimDeadLease(input.dataDir, lease.owner);
427
+ output.stdout(`${PRODUCT_TEXT.commandName} settled the interrupted ${interrupted} update, which was already active\n`);
428
+ }
429
+ else {
430
+ const rollback = await installer.rollback(lease.transaction.priorActiveReleaseId)
431
+ .catch(error => `rollback failed: ${errorMessage(error)}`);
432
+ await store.finish("rolled-back", `interrupted ${interrupted} update settled by ${input.targetVersion}; ${rollback}`);
433
+ await store.clearCompleted().catch(() => undefined);
434
+ await reclaimDeadLease(input.dataDir, lease.owner);
435
+ output.stdout(`${PRODUCT_TEXT.commandName} rolled back the interrupted ${interrupted} update: ${rollback}\n`);
436
+ }
426
437
  lease = await acquireUpdateExecutionLease(input);
427
438
  }
428
439
  if (lease.status !== "acquired") {
@@ -533,7 +544,9 @@ async function runUpdateTransaction(plan) {
533
544
  const { packageRoot, npmCliRoot } = plan.installation;
534
545
  const run = { transaction: await transactionStore.read() };
535
546
  try {
536
- if ((!run.transaction || run.transaction.status === "completed") && await lifecycle.targetIsActive(targetVersion)) {
547
+ // Rationale: a settled transaction, even one reported as failed after its commit, leaves nothing
548
+ // to install when its target is already active; reinstalling the same release would only churn.
549
+ if ((!run.transaction || run.transaction.status !== "active") && await lifecycle.targetIsActive(targetVersion)) {
537
550
  await installer.maintenance();
538
551
  await transactionStore.clearCompleted();
539
552
  output.stdout(`${PRODUCT_TEXT.commandName} is up to date — no update needed.\n`);
@@ -610,7 +623,7 @@ async function replaceUpdatePackage(plan, run, protection, npmCli) {
610
623
  }
611
624
  /** Activate the installed package, commit maintenance, and only then report success. */
612
625
  async function activateUpdate(plan, run) {
613
- const { output, progress, transactionStore, now, onPhaseTiming, paths } = plan;
626
+ const { progress, transactionStore, now, onPhaseTiming } = plan;
614
627
  progress.set(70, MATERIALIZE_PROGRESS.from);
615
628
  let activationPhaseStartedAt = now();
616
629
  const recordActivationPhase = async (phase) => {
@@ -619,7 +632,7 @@ async function activateUpdate(plan, run) {
619
632
  progress.set(ACTIVATION_PROGRESS[phase].at, ACTIVATION_PROGRESS[phase].creepTo);
620
633
  activationPhaseStartedAt = now();
621
634
  };
622
- await plan.lifecycle.activateInstalled(plan.installation.packageRoot, plan.targetVersion, recordActivationPhase, ({ completed, total }) => progress.set(MATERIALIZE_PROGRESS.from
635
+ const activation = await plan.lifecycle.activateInstalled(plan.installation.packageRoot, plan.targetVersion, recordActivationPhase, ({ completed, total }) => progress.set(MATERIALIZE_PROGRESS.from
623
636
  + (MATERIALIZE_PROGRESS.to - MATERIALIZE_PROGRESS.from) * (total > 0 ? Math.min(1, completed / total) : 0)), state => {
624
637
  if (state === "started") {
625
638
  activationPhaseStartedAt = now();
@@ -632,19 +645,39 @@ async function activateUpdate(plan, run) {
632
645
  }
633
646
  });
634
647
  const transactionStartedAt = now();
635
- const transaction = run.transaction;
636
- if (transaction?.recovery?.capsulePath)
637
- await removeUpdateRecoveryCapsule(paths.dataDir, transaction.transactionId);
638
- // Invariant: successful output follows the durable cleanup disposition. Slow recursive
639
- // removal belongs to the detached worker started by this maintenance coordinator.
640
- await plan.installer.maintenance();
648
+ const warnings = [];
649
+ // Invariant: a delegated tree owns the control state it just wrote and has already scheduled its
650
+ // own maintenance; this updater may be older and must not interpret that state. An in-process
651
+ // activation ran this updater's own code, so the updater remains the owner of its maintenance.
652
+ if (activation !== "delegated") {
653
+ // Rationale: the activation is committed, so cleanup trouble is retried later, never a failure.
654
+ await plan.installer.maintenance().catch(error => { warnings.push(`release cleanup will retry: ${errorMessage(error)}`); });
655
+ }
656
+ await completeCommittedUpdate(plan, run.transaction, warnings);
657
+ onPhaseTiming?.({ phase: "transaction-complete", durationMs: Math.max(0, now() - transactionStartedAt) });
658
+ return 0;
659
+ }
660
+ /**
661
+ * The active reference names the target, so the update has happened. Everything here is
662
+ * best-effort bookkeeping: it may warn, but it never fails the update and never rolls back.
663
+ */
664
+ async function completeCommittedUpdate(plan, transaction, warnings) {
665
+ const { output, progress, transactionStore, paths } = plan;
666
+ if (transaction?.recovery?.capsulePath) {
667
+ await removeUpdateRecoveryCapsule(paths.dataDir, transaction.transactionId)
668
+ .catch(error => { warnings.push(`recovery evidence will be removed later: ${errorMessage(error)}`); });
669
+ }
641
670
  // Concurrency: this is the launch cutover. No failure-prone update work follows success.
642
- await transactionStore.finish("completed");
671
+ await transactionStore.finish("completed", warnings.length === 0 ? null : warnings.join("; ").slice(0, 2_000))
672
+ .catch(error => { warnings.push(`update journal could not be settled: ${errorMessage(error)}`); });
643
673
  await Promise.all([transactionStore.clearCompleted(), invalidateReleaseCheckCache(paths.configDir)]).catch(() => undefined);
644
- onPhaseTiming?.({ phase: "transaction-complete", durationMs: Math.max(0, now() - transactionStartedAt) });
645
674
  progress.finish();
646
675
  output.stdout(`${PRODUCT_TEXT.commandName} updated successfully to ${plan.targetVersion}\n`);
647
- return 0;
676
+ for (const warning of warnings)
677
+ output.stderr(`${PRODUCT_TEXT.diagnostic(`warning: ${singleLine(warning)}`)}\n`);
678
+ }
679
+ function singleLine(text) {
680
+ return text.replace(/[\r\n]+/gu, " ").slice(0, 400);
648
681
  }
649
682
  async function settleUpdateFailure(plan, transaction, error) {
650
683
  const { output, transactionStore } = plan;
@@ -654,6 +687,12 @@ async function settleUpdateFailure(plan, transaction, error) {
654
687
  output.stderr(`${PRODUCT_TEXT.diagnostic(`update could not start safely: ${message}. Nothing was changed. Retry with npm x -y -- ${PRODUCT_PACKAGE}-install`)}\n`);
655
688
  return 1;
656
689
  }
690
+ if (transaction?.status === "active" && transaction.phase === "active-reference-committed") {
691
+ // Invariant: once the active reference names the target, running agents may already use it.
692
+ // Rolling back would move the reference under them, so a later failure only warns.
693
+ await completeCommittedUpdate(plan, transaction, [`post-activation step failed: ${message}`]);
694
+ return 0;
695
+ }
657
696
  const rollback = await plan.installer.rollback(transaction?.priorActiveReleaseId ?? null)
658
697
  .catch(rollbackError => `rollback failed: ${errorMessage(rollbackError)}`);
659
698
  if (transaction)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timurproko/a1",
3
- "version": "0.2.7-dev.1285",
3
+ "version": "0.2.7-dev.1286",
4
4
  "description": "Standalone single-agent terminal UI on pinned Pi",
5
5
  "type": "module",
6
6
  "privateLaunchContract": "neutral-launch-v1",
@@ -113,7 +113,7 @@
113
113
  "@mariozechner/clipboard": "^0.3.9"
114
114
  },
115
115
  "publication": {
116
- "source": "70c8ebe762cd38cfbdc1f87fe82e674b236671c3",
117
- "pullRequest": 912
116
+ "source": "81969ce6924469b3081347067fd0d0190359b132",
117
+ "pullRequest": 913
118
118
  }
119
119
  }