@timurproko/a1 0.1.8-dev.332 → 0.1.8-dev.335

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/README.md CHANGED
@@ -160,18 +160,29 @@ a1 update --develop 0.1.8-dev.107 # install that exact full preview versio
160
160
  ### Stable
161
161
 
162
162
  ```sh
163
- npm run release -- patch # 0.1.1 -> 0.1.2
164
- npm run release -- minor # 0.1.1 -> 0.2.0
165
- npm run release -- major # 0.1.1 -> 1.0.0
166
- npm run release -- 0.4.0 # an exact version
163
+ npm run release -- patch # 0.1.8-dev -> 0.1.8; already-stable 0.1.8 -> 0.1.9
164
+ npm run release -- minor # 0.1.8-dev -> 0.2.0
165
+ npm run release -- major # 0.1.8-dev -> 1.0.0
166
+ npm run release -- 0.4.0 # an exact stable version
167
167
  ```
168
168
 
169
- The command lands the version on `develop` through a self-merging pull request,
170
- dispatches publication for that exact commit, and waits for success. CI validates
171
- the packed release on Windows, Linux, and macOS, publishes to npm `latest` with
172
- provenance, then writes the `v<version>` tag and the GitHub Release. `master`
173
- fast-forwards to the released commit, so it always points at what npm `latest`
174
- serves. A failed release leaves nothing behind: no tag, no GitHub Release, no
175
- moved branch.
169
+ Run from the repository root on a clean `develop` matching `origin/develop`.
170
+ A target is required; bare `npm run release` displays usage and releases nothing.
171
+ `patch` promotes the current prerelease rather than skipping its stable version.
172
+
173
+ The command prepares a version-only PR in an isolated detached worktree, prints
174
+ its URL, and waits for you to validate and **merge it manually**. Neither this PR
175
+ nor the next-development PR is auto-merged, and green CI alone does not advance
176
+ the release. After the stable PR merges, the command dispatches publication for
177
+ that exact authoritative commit and waits for success. CI validates the packed
178
+ release on Windows, Linux, and macOS, publishes to npm `latest` with provenance,
179
+ then writes the `v<version>` tag and GitHub Release and fast-forwards `master`.
180
+
181
+ Only after confirmed publication of `0.1.8` does the command prepare the separate
182
+ `0.1.9-dev` PR. It reports development reopened only after you manually merge that
183
+ PR too. Work added to your checkout during either wait is preserved, not reset.
184
+ If publication fails or is uncertain, no reopening PR is prepared. If publication
185
+ succeeded but reopening failed, inspect the reported phase and PR; do not republish
186
+ the immutable stable version.
176
187
 
177
188
  `docs/ci-release-runbook.md` has the full picture.
@@ -35,6 +35,8 @@ export interface SupervisorStartupAttempt extends SupervisorStartupAttemptIdenti
35
35
  readonly signal: NodeJS.Signals | null;
36
36
  }>;
37
37
  }
38
+ /** Reuse a verified owner, or join the winner if another launch starts the same cohort first. */
39
+ export declare function ensureSupervisor(release: MaterializedRelease, environment: NodeJS.ProcessEnv): Promise<void>;
38
40
  export declare function startSupervisor(release: MaterializedRelease, environment: NodeJS.ProcessEnv): Promise<SupervisorStartupAttempt>;
39
41
  /**
40
42
  * Hand a release the key set its own build reads. A retained pre-cutover release is
@@ -10,6 +10,8 @@ import { assertLaunchProfileId, createSupervisorStartupAttempt, readSupervisorSt
10
10
  import { encodeFrame, LineFrameDecoder } from "../protocol/index.js";
11
11
  import { cleanupProvenIdleOwner, processIsAlive } from "./process-cleanup.js";
12
12
  import { sweepDeadEndpoints } from "./endpoints.js";
13
+ import { UpdateTransactionStore } from "./update-transaction.js";
14
+ import { selectUpdateLaunchRelease } from "./update-launch.js";
13
15
  import { consumeMaterializationProof, materializeRelease, readCertifiedReleaseManifest, readMaterializedRelease, resolveReleaseEntryPoint, verifyMaterializedRelease } from "./release-store.js";
14
16
  import { scheduleReleaseCleanup } from "./release-gc.js";
15
17
  import { createRestartSeal, readRestartCertifiedRelease, releaseCertificationDocument } from "./restart-certification.js";
@@ -29,6 +31,21 @@ export async function runBootstrap(options) {
29
31
  await mkdir(paths.runtimeDir, { recursive: true, mode: 0o700 });
30
32
  const stateStore = new CohortStateStore(paths.dataDir);
31
33
  let state = await stateStore.read();
34
+ const launchDuringUpdate = async () => {
35
+ // Concurrency: read the journal before touching npm's mutable tree, including package.json.
36
+ const transaction = await new UpdateTransactionStore(paths.dataDir).read();
37
+ if (!transaction || transaction.status === "completed")
38
+ return null;
39
+ const prior = selectUpdateLaunchRelease(await stateStore.read(), transaction);
40
+ if (!prior)
41
+ return null;
42
+ const retained = await readCertifiedReleaseManifest(prior, resolve(paths.dataDir, "releases"));
43
+ await ensureSupervisor(retained, environment);
44
+ return await launchUi(retained, environment, sessionArgs);
45
+ };
46
+ const duringUpdate = await launchDuringUpdate();
47
+ if (duringUpdate !== null)
48
+ return duringUpdate;
32
49
  // Invariant: records left by cohorts whose processes are gone say nothing about ownership, and there
33
50
  // can now be several of them. Clearing them first keeps the decision below about what is
34
51
  // actually running.
@@ -80,10 +97,8 @@ export async function runBootstrap(options) {
80
97
  return verified;
81
98
  });
82
99
  await markStartupPhase(environment, "durable-validation-complete");
83
- const retainedPaths = resolveCohortEndpoint(paths, retained.releaseId, environment);
84
100
  await markStartupPhase(environment, "replacement-supervisor-start");
85
- const startup = await startSupervisor(retained, environment);
86
- await waitForVerifiedEndpoint(retainedPaths.endpointMetadataPath, retained, 8_000, startup);
101
+ await ensureSupervisor(retained, environment);
87
102
  await markStartupPhase(environment, "replacement-supervisor-ready");
88
103
  return await launchUi(retained, environment, sessionArgs);
89
104
  }
@@ -102,6 +117,10 @@ export async function runBootstrap(options) {
102
117
  throw error;
103
118
  return fallback;
104
119
  }
120
+ // Concurrency: an update may have begun while this launch was reading the installed payload.
121
+ const afterMaterialization = await launchDuringUpdate();
122
+ if (afterMaterialization !== null)
123
+ return afterMaterialization;
105
124
  await stateStore.recordCandidate(candidate);
106
125
  state = await stateStore.read();
107
126
  if (!state.references.active) {
@@ -192,8 +211,7 @@ export async function runBootstrap(options) {
192
211
  else {
193
212
  selected = await readMaterializedRelease(decision.releaseRoot);
194
213
  }
195
- const startup = await startSupervisor(selected, environment);
196
- await waitForVerifiedEndpoint(resolveCohortEndpoint(paths, selected.releaseId, environment).endpointMetadataPath, selected, 8_000, startup);
214
+ await ensureSupervisor(selected, environment);
197
215
  return await launchUi(selected, environment, sessionArgs);
198
216
  }
199
217
  /**
@@ -209,14 +227,7 @@ async function launchRetainedActive(state, paths, environment, sessionArgs, outp
209
227
  return null;
210
228
  try {
211
229
  const retained = await readCertifiedReleaseManifest(active, resolve(paths.dataDir, "releases"));
212
- const retainedPaths = resolveCohortEndpoint(paths, retained.releaseId, environment);
213
- const endpoint = await readEndpointMetadata(retainedPaths.endpointMetadataPath);
214
- if (!endpoint || await probeOwnership(endpoint) !== "live-verified") {
215
- if (endpoint)
216
- await removeEndpointArtifacts(retainedPaths.endpointMetadataPath, retainedPaths.endpoint);
217
- const startup = await startSupervisor(retained, environment);
218
- await waitForVerifiedEndpoint(retainedPaths.endpointMetadataPath, retained, 8_000, startup);
219
- }
230
+ await ensureSupervisor(retained, environment);
220
231
  output.write(`${PRODUCT_TEXT.diagnostic(`installation is being replaced; starting the retained release ${retained.packageVersion}`)}\n`);
221
232
  return await launchUi(retained, environment, sessionArgs);
222
233
  }
@@ -246,6 +257,25 @@ export async function recordParentCertifiedRelease(release, dataDir) {
246
257
  await chmod(path, 0o400);
247
258
  return path;
248
259
  }
260
+ /** Reuse a verified owner, or join the winner if another launch starts the same cohort first. */
261
+ export async function ensureSupervisor(release, environment) {
262
+ const paths = resolveCohortEndpoint(resolveProductPaths(environment), release.releaseId, environment);
263
+ const metadata = await readEndpointMetadata(paths.endpointMetadataPath);
264
+ if (metadata) {
265
+ const probe = await probeOwnership(metadata);
266
+ if (probe !== "dead") {
267
+ if (probe === "live-verified" && endpointMatchesRelease(metadata, release))
268
+ return;
269
+ throw new Error(PRODUCT_TEXT.diagnostic(`refused duplicate supervisor startup: existing ownership is ${probe}`));
270
+ }
271
+ }
272
+ const startup = await startSupervisor(release, environment);
273
+ await waitForVerifiedEndpoint(paths.endpointMetadataPath, release, 8_000, startup);
274
+ }
275
+ function endpointMatchesRelease(metadata, release) {
276
+ return metadata.releaseId === release.releaseId && metadata.releaseRoot === release.releaseRoot
277
+ && metadata.contentDigest === release.contentDigest;
278
+ }
249
279
  export async function startSupervisor(release, environment) {
250
280
  const entry = await resolveReleaseEntryPoint(release, "bin/supervisor.js");
251
281
  const paths = resolveProductPaths(environment);
@@ -296,10 +326,11 @@ export function releaseEnvironment(environment, release, profile) {
296
326
  export async function waitForVerifiedEndpoint(path, release, timeoutMs, startup) {
297
327
  const deadline = Date.now() + timeoutMs;
298
328
  let childOutcome = null;
329
+ let collision = null;
299
330
  void startup?.childOutcome.then(outcome => { childOutcome = outcome; });
300
331
  while (Date.now() < deadline) {
301
332
  const metadata = await readEndpointMetadata(path);
302
- if (metadata && metadata.releaseId === release.releaseId && await probeOwnership(metadata) === "live-verified") {
333
+ if (metadata && endpointMatchesRelease(metadata, release) && await probeOwnership(metadata) === "live-verified") {
303
334
  if (startup)
304
335
  await rm(startup.resultPath, { force: true });
305
336
  return;
@@ -307,15 +338,21 @@ export async function waitForVerifiedEndpoint(path, release, timeoutMs, startup)
307
338
  if (startup) {
308
339
  const result = await readSupervisorStartupResult(startup.resultPath, startup.attemptId, startup.releaseId);
309
340
  if (result?.outcome === "failure") {
310
- throw Object.assign(new Error(PRODUCT_TEXT.diagnostic(`supervisor startup failed at ${result.stage}: ${result.message}`)), {
341
+ const error = Object.assign(new Error(PRODUCT_TEXT.diagnostic(`supervisor startup failed at ${result.stage}: ${result.message}`)), {
311
342
  code: result.code ?? "SUPERVISOR_STARTUP_FAILED",
312
343
  });
344
+ if (result.code !== "EADDRINUSE")
345
+ throw error;
346
+ // Concurrency: the winning process may not have published its authenticated metadata yet.
347
+ collision = error;
313
348
  }
314
349
  }
315
- if (childOutcome)
350
+ if (childOutcome && !collision)
316
351
  throw new Error(PRODUCT_TEXT.diagnostic(`supervisor exited before readiness: ${JSON.stringify(childOutcome)}`));
317
352
  await new Promise(resolvePromise => setTimeout(resolvePromise, 40));
318
353
  }
354
+ if (collision)
355
+ throw collision;
319
356
  throw new Error(PRODUCT_TEXT.diagnostic(`supervisor did not publish verified endpoint metadata within ${timeoutMs}ms`));
320
357
  }
321
358
  export async function readEndpointMetadata(path) {
@@ -410,8 +447,7 @@ async function activatePendingAfterBlockerExit(candidate, stateStore, paths, env
410
447
  return;
411
448
  await removeEndpointArtifacts(paths.endpointMetadataPath, paths.endpoint);
412
449
  await stateStore.activate(candidate.releaseId);
413
- const startup = await startSupervisor(candidate, environment);
414
- await waitForVerifiedEndpoint(resolveCohortEndpoint(paths, candidate.releaseId, environment).endpointMetadataPath, candidate, 8_000, startup);
450
+ await ensureSupervisor(candidate, environment);
415
451
  }
416
452
  export async function releaseVerifiedIdleOwner(metadata, dataDir, operations = {}) {
417
453
  try {
@@ -10,6 +10,7 @@ export * from "./release-store.js";
10
10
  export * from "./restart-certification.js";
11
11
  export * from "./stable-release.js";
12
12
  export * from "./update.js";
13
+ export * from "./update-launch.js";
13
14
  export * from "./update-recovery.js";
14
15
  export * from "./update-transaction.js";
15
16
  export * from "./warmup.js";
@@ -10,6 +10,7 @@ export * from "./release-store.js";
10
10
  export * from "./restart-certification.js";
11
11
  export * from "./stable-release.js";
12
12
  export * from "./update.js";
13
+ export * from "./update-launch.js";
13
14
  export * from "./update-recovery.js";
14
15
  export * from "./update-transaction.js";
15
16
  export * from "./warmup.js";
@@ -0,0 +1,6 @@
1
+ import type { CohortState, ReleaseRecord } from "./cohort-state.js";
2
+ import type { UpdateTransaction } from "./update-transaction.js";
3
+ /** Keep interactive launches on the last successful release until the update commits success. */
4
+ export declare function selectUpdateLaunchRelease(state: CohortState, transaction: UpdateTransaction | null): ReleaseRecord | null;
5
+ /** Pause cohort retirement during preparation; afterward only the successful launch release admits new sessions. */
6
+ export declare function selectSupervisorLaunchReleaseId(state: CohortState, transaction: UpdateTransaction | null): string | null;
@@ -0,0 +1,17 @@
1
+ /** Keep interactive launches on the last successful release until the update commits success. */
2
+ export function selectUpdateLaunchRelease(state, transaction) {
3
+ if (transaction === null || transaction.status === "completed")
4
+ return null;
5
+ const prior = transaction.priorActiveReleaseId === null ? undefined : state.releases[transaction.priorActiveReleaseId];
6
+ if (!prior || prior.approval !== "approved") {
7
+ throw new Error("unfinished update has no verified previous release available for launch; resume the update");
8
+ }
9
+ return prior;
10
+ }
11
+ /** Pause cohort retirement during preparation; afterward only the successful launch release admits new sessions. */
12
+ export function selectSupervisorLaunchReleaseId(state, transaction) {
13
+ // Concurrency: both the previous cohort and the warming candidate must remain available until cutover.
14
+ if (transaction?.status === "active")
15
+ return null;
16
+ return selectUpdateLaunchRelease(state, transaction)?.releaseId ?? state.references.active;
17
+ }
@@ -112,5 +112,7 @@ export type UpdateOwnershipAction = "clean-dead-record" | "leave-running" | "end
112
112
  */
113
113
  export declare function planUpdateOwnership(ownership: "live-verified" | "dead", runsFromRetainedRelease: boolean): UpdateOwnershipAction;
114
114
  export declare function createUpdateLifecycleCoordinator(environment?: NodeJS.ProcessEnv, fileSystem?: UpdateFileSystem, output?: UpdateOutput): UpdateLifecycleCoordinator;
115
+ /** Renders the terminal-only update meter; the visual preview shares this exact frame. */
116
+ export declare function renderUpdateProgressBar(percent: number): string;
115
117
  export declare function runSelfUpdate(options: SelfUpdateOptions): Promise<number>;
116
118
  export declare function assertUpdatePerformanceBudget(evidence: UpdatePerformanceEvidence, maximumPostNpmDurationMs?: number): void;
@@ -5,7 +5,7 @@ import { isAbsolute, relative, resolve, sep } from "node:path";
5
5
  import crossSpawn from "cross-spawn";
6
6
  import { valid as validSemver } from "semver";
7
7
  import { PRODUCT_IDENTITY, PRODUCT_TEXT } from "../../product-identity.js";
8
- import { certifyMaterializedRelease, probeOwnership, readEndpointMetadata, removeEndpointArtifacts, startSupervisor, waitForProcessExit, waitForVerifiedEndpoint, } from "./bootstrap.js";
8
+ import { certifyMaterializedRelease, probeOwnership, readEndpointMetadata, removeEndpointArtifacts, ensureSupervisor, waitForProcessExit, } from "./bootstrap.js";
9
9
  import { resolveCohortEndpoint, resolveProductPaths } from "../lifecycle/index.js";
10
10
  import { encodeFrame, LineFrameDecoder } from "../protocol/index.js";
11
11
  import { CohortStateStore } from "./cohort-state.js";
@@ -163,17 +163,17 @@ export function createUpdateLifecycleCoordinator(environment = process.env, file
163
163
  const diagnostics = await certifyMaterializedRelease(candidate, paths.dataDir);
164
164
  await stateStore.approve(candidate.releaseId, diagnostics);
165
165
  await phase("certified");
166
- await stateStore.activate(candidate.releaseId);
167
- await phase("active-reference-committed");
168
166
  onWarmup?.("started");
169
167
  await warmMaterializedRelease(candidate, environment);
170
168
  onWarmup?.("completed");
171
- const startup = await startSupervisor(candidate, environment);
172
- await waitForVerifiedEndpoint(resolveCohortEndpoint(paths, candidate.releaseId, environment).endpointMetadataPath, candidate, 8_000, startup);
169
+ await ensureSupervisor(candidate, environment);
170
+ // Invariant: warmup and authenticated readiness precede changing the active reference.
171
+ await stateStore.activate(candidate.releaseId);
172
+ await phase("active-reference-committed");
173
173
  },
174
174
  };
175
175
  }
176
- const PROGRESS_BAR_WIDTH = 39;
176
+ const PROGRESS_BAR_WIDTH = 40;
177
177
  const PROGRESS_TICK_MS = 200;
178
178
  /**
179
179
  * Copying the release owns 78–92 and is reported file by file, so the bar crosses
@@ -186,10 +186,15 @@ const ACTIVATION_PROGRESS = {
186
186
  certified: { at: 94, creepTo: 96 },
187
187
  "active-reference-committed": { at: 96, creepTo: 99 },
188
188
  };
189
- function renderProgressBar(percent) {
189
+ /** Renders the terminal-only update meter; the visual preview shares this exact frame. */
190
+ export function renderUpdateProgressBar(percent) {
190
191
  const bounded = Math.min(100, Math.max(0, Math.round(percent)));
191
192
  const filled = Math.round((bounded / 100) * PROGRESS_BAR_WIDTH);
192
- return `${"█".repeat(filled)}${"░".repeat(PROGRESS_BAR_WIDTH - filled)} ${bounded}%`;
193
+ // Rationale: a gray line, a darker gray track, and one space before the percentage.
194
+ // Explicit RGB keeps both grays neutral even when the terminal remaps its ANSI palette.
195
+ const gray = "\u001b[38;2;128;128;128m";
196
+ const track = "\u001b[38;2;102;102;102m";
197
+ return `${gray}${"━".repeat(filled)}${track}${"─".repeat(PROGRESS_BAR_WIDTH - filled)}${gray} ${bounded}%\u001b[39m`;
193
198
  }
194
199
  function createUpdateProgress(output, enabled) {
195
200
  let visible = false;
@@ -202,7 +207,7 @@ function createUpdateProgress(output, enabled) {
202
207
  return;
203
208
  shown = rounded;
204
209
  visible = true;
205
- output.stdout(`\r${renderProgressBar(rounded)}`);
210
+ output.stdout(`\r${renderUpdateProgressBar(rounded)}`);
206
211
  };
207
212
  const stopCreep = () => {
208
213
  if (timer !== null) {
@@ -375,11 +380,7 @@ export async function runSelfUpdate(options) {
375
380
  : async () => await scheduleReleaseCleanup(paths.dataDir, paths));
376
381
  let transaction = await transactionStore.read();
377
382
  try {
378
- if (await lifecycle.targetIsActive(targetVersion)) {
379
- if (transaction?.status === "active") {
380
- await transactionStore.advance("supervisor-verified");
381
- await transactionStore.finish("completed");
382
- }
383
+ if ((!transaction || transaction.status === "completed") && await lifecycle.targetIsActive(targetVersion)) {
383
384
  await maintenance();
384
385
  await transactionStore.clearCompleted();
385
386
  output.stdout(`${PRODUCT_TEXT.commandName} is up to date — no update needed.\n`);
@@ -510,13 +511,14 @@ export async function runSelfUpdate(options) {
510
511
  options.onPhaseTiming?.({ phase: "supervisor-verified", durationMs: Math.max(0, now() - activationPhaseStartedAt) });
511
512
  const transactionStartedAt = now();
512
513
  await transactionStore.advance("supervisor-verified");
513
- await transactionStore.finish("completed");
514
514
  if (transaction.recovery?.capsulePath)
515
515
  await removeUpdateRecoveryCapsule(paths.dataDir, transaction.transactionId);
516
516
  // Invariant: successful output follows the durable cleanup disposition. Slow recursive
517
517
  // removal belongs to the detached worker started by this maintenance coordinator.
518
518
  await maintenance();
519
- await transactionStore.clearCompleted();
519
+ // Concurrency: this is the launch cutover. No failure-prone update work follows success.
520
+ await transactionStore.finish("completed");
521
+ await transactionStore.clearCompleted().catch(() => undefined);
520
522
  options.onPhaseTiming?.({ phase: "transaction-complete", durationMs: Math.max(0, now() - transactionStartedAt) });
521
523
  progress.finish();
522
524
  output.stdout(`${PRODUCT_TEXT.commandName} updated successfully: ${targetVersion}\n`);
@@ -609,11 +611,8 @@ async function rollbackPriorCohort(dataDir, environment, priorReleaseId) {
609
611
  throw new Error(`prior release ${priorReleaseId} is not the recorded rollback cohort`);
610
612
  }
611
613
  const release = await readMaterializedRelease(prior.releaseRoot);
612
- const paths = resolveProductPaths(environment);
613
- // Invariant: rollback re-points the active reference and starts the prior cohort on its own endpoint;
614
- // a cohort that survived the update keeps serving the work it already had.
615
- const startup = await startSupervisor(release, environment);
616
- await waitForVerifiedEndpoint(resolveCohortEndpoint(paths, release.releaseId, environment).endpointMetadataPath, release, 8_000, startup);
614
+ // Invariant: rollback reuses a surviving prior cohort rather than racing its occupied endpoint.
615
+ await ensureSupervisor(release, environment);
617
616
  return "rolled back";
618
617
  }
619
618
  function phaseBefore(current, target) {
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { appendFileSync, mkdirSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
- import { assertImmutableExecutionRoot, CohortStateStore, readCertifiedReleaseManifest, recordParentCertifiedRelease } from "../release/index.js";
4
+ import { assertImmutableExecutionRoot, CohortStateStore, readCertifiedReleaseManifest, recordParentCertifiedRelease, selectSupervisorLaunchReleaseId, UpdateTransactionStore } from "../release/index.js";
5
5
  import { publishSupervisorStartupResult, supervisorStartupFailure, supervisorStartupReady, supervisorStartupResultPath } from "../lifecycle/index.js";
6
6
  import { ControlStore } from "../storage/index.js";
7
7
  import { resolveCohortEndpoint, resolveProductPaths } from "./paths.js";
@@ -38,7 +38,10 @@ export async function runSupervisor(arguments_ = []) {
38
38
  const bootNonce = randomUUID();
39
39
  const store = new ControlStore(paths.databasePath, bootNonce);
40
40
  const cohortState = new CohortStateStore(paths.dataDir);
41
- server = new SupervisorServer(store, paths, release, bootNonce, undefined, undefined, undefined, async () => (await cohortState.read()).references.active);
41
+ server = new SupervisorServer(store, paths, release, bootNonce, undefined, undefined, undefined, async () => {
42
+ const transaction = await new UpdateTransactionStore(paths.dataDir).read();
43
+ return selectSupervisorLaunchReleaseId(await cohortState.read(), transaction);
44
+ });
42
45
  stage = "endpoint-listen";
43
46
  await server.listen();
44
47
  if (resultPath && attemptId)
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { chmod, lstat, mkdir, rename, rm, writeFile } from "node:fs/promises";
2
+ import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { createConnection, createServer } from "node:net";
4
4
  import { platform } from "node:os";
5
5
  import { dirname } from "node:path";
@@ -54,6 +54,7 @@ export class SupervisorServer {
54
54
  startedAt = new Date().toISOString();
55
55
  paths;
56
56
  #server = null;
57
+ #ownsEndpoint = false;
57
58
  #instances = new Map();
58
59
  #instanceOwners = new Map();
59
60
  #clientIds = new Map();
@@ -104,15 +105,24 @@ export class SupervisorServer {
104
105
  if (platform() !== "win32") {
105
106
  if (this.paths.endpointDirectory)
106
107
  await ensureManagedEndpointDirectory(dirname(this.paths.endpoint));
107
- if (await endpointIsLive(this.paths.endpoint))
108
- throw new Error(PRODUCT_TEXT.diagnostic(`supervisor already owns ${this.paths.endpoint}`));
109
- await rm(this.paths.endpoint, { force: true });
108
+ const existing = await lstat(this.paths.endpoint).catch(error => {
109
+ if (error.code === "ENOENT")
110
+ return null;
111
+ throw error;
112
+ });
113
+ if (existing) {
114
+ if (await endpointIsLive(this.paths.endpoint)) {
115
+ throw Object.assign(new Error(PRODUCT_TEXT.diagnostic(`supervisor already owns ${this.paths.endpoint}`)), { code: "EADDRINUSE" });
116
+ }
117
+ await rm(this.paths.endpoint, { force: true });
118
+ }
110
119
  }
111
120
  const server = createServer(socket => this.#attach(socket));
112
121
  this.#server = server;
113
122
  await new Promise((resolve, reject) => {
114
123
  server.once("error", reject);
115
124
  server.listen(this.paths.endpoint, () => {
125
+ this.#ownsEndpoint = true;
116
126
  server.off("error", reject);
117
127
  resolve();
118
128
  });
@@ -146,7 +156,9 @@ export class SupervisorServer {
146
156
  await this.close(false);
147
157
  }
148
158
  async close(stopAgents = false) {
149
- if (stopAgents && !await this.#drainInstances("update", this.shutdownDeadlineMs)) {
159
+ if (this.#closing)
160
+ return;
161
+ if (stopAgents && this.#ownsEndpoint && !await this.#drainInstances("update", this.shutdownDeadlineMs)) {
150
162
  throw new Error("active launch instances did not release ownership within the shutdown deadline");
151
163
  }
152
164
  this.#closing = true;
@@ -155,14 +167,27 @@ export class SupervisorServer {
155
167
  this.#supersededPoll = null;
156
168
  for (const client of this.#clients)
157
169
  client.destroy();
158
- if (this.#server)
159
- await new Promise(resolve => this.#server?.close(() => resolve()));
160
- this.#server = null;
161
- this.store.close();
162
- await this.#metadataWrites;
163
- await rm(this.paths.endpointMetadataPath, { force: true });
164
- if (platform() !== "win32")
165
- await rm(this.paths.endpoint, { force: true });
170
+ try {
171
+ await this.#metadataWrites.catch(() => undefined);
172
+ if (this.#ownsEndpoint) {
173
+ // Concurrency: remove only our publication, before releasing the listening endpoint.
174
+ // A contender that failed to bind must never erase the successful owner's discovery file.
175
+ const metadata = await readFile(this.paths.endpointMetadataPath, "utf8").then(source => JSON.parse(source)).catch(() => null);
176
+ if (metadata?.supervisorId === this.id && metadata.bootNonce === this.bootNonce) {
177
+ await rm(this.paths.endpointMetadataPath, { force: true });
178
+ }
179
+ // Platform: Node unlinks its Unix socket when the server closes; unlinking it early would
180
+ // let a replacement bind before the old server finishes releasing that path.
181
+ }
182
+ }
183
+ finally {
184
+ // Platform: a metadata sharing error must not leave a bound but undiscoverable listener.
185
+ if (this.#server)
186
+ await new Promise(resolve => this.#server?.close(() => resolve()));
187
+ this.#server = null;
188
+ this.#ownsEndpoint = false;
189
+ this.store.close();
190
+ }
166
191
  }
167
192
  async closeForReleaseReplacement(stopAgents) {
168
193
  await this.close(stopAgents);
@@ -473,6 +498,8 @@ export class SupervisorServer {
473
498
  return [...this.#instances.keys()];
474
499
  }
475
500
  #writeEndpointMetadata() {
501
+ if (this.#closing)
502
+ return Promise.resolve();
476
503
  const liveInstanceIds = this.#liveInstanceIds();
477
504
  const metadata = {
478
505
  schema: PRODUCT_IDENTITY.protocol.supervisorSchema,
@@ -5,7 +5,7 @@
5
5
  "platform": "darwin",
6
6
  "architecture": "arm64",
7
7
  "capability": "supported",
8
- "builtAt": "2026-09-12T13:59:03.140Z",
8
+ "builtAt": "2026-09-12T16:36:02.575Z",
9
9
  "artifact": {
10
10
  "filename": "process-guardian",
11
11
  "sha256": "9db1726bbe3fc2e8292f2ead1e7e9d0fd4d7d0dc9217b372582bf354b0f565dc",
@@ -5,7 +5,7 @@
5
5
  "platform": "linux",
6
6
  "architecture": "x64",
7
7
  "capability": "supported",
8
- "builtAt": "2026-09-12T13:58:54.058Z",
8
+ "builtAt": "2026-09-12T16:36:05.106Z",
9
9
  "artifact": {
10
10
  "filename": "process-guardian",
11
11
  "sha256": "d8cda6b0c7cb36c0cc41e90802aceebf6c02eaebbc08a83d7d963d2e918dbd7a",
@@ -5,10 +5,10 @@
5
5
  "platform": "win32",
6
6
  "architecture": "x64",
7
7
  "capability": "supported",
8
- "builtAt": "2026-09-12T13:59:34.009Z",
8
+ "builtAt": "2026-09-12T16:36:30.210Z",
9
9
  "artifact": {
10
10
  "filename": "process-guardian.exe",
11
- "sha256": "15e00144629345d7f00b110494775b80c65e6e575e6758d88b533c25b57d35d5",
11
+ "sha256": "0bc42bc571ebdcf244d60f42d03472a2dd57da4984b1d39931f9a2ad3787cde5",
12
12
  "size": 177664
13
13
  },
14
14
  "provenance": {
@@ -71,7 +71,7 @@ Presentation acceptance is the reader comparing `a1 pi` with pinned Pi. `node sc
71
71
 
72
72
  ## Publication
73
73
 
74
- One workflow publishes both channels: `.github/workflows/release.yml`. Pushes do not publish. Nightly development verification runs at `03:17 UTC`; `npm run develop` explicitly requests a numbered preview and `npm run release` explicitly requests stable publication after its version pull request merges. A preview is stamped as `-dev.<merged pull-request number>` and uses npm `next` only as an internal dist-tag. New candidates pack once and are validated on Windows, Linux, and macOS; a repeated nightly verifies the exact immutable registry tarball. All publication uses provenance from the `npm-publish` environment, a preview never changes `latest`, and the stable tag, GitHub Release, and `master` are written only after npm has the package. One global non-cancelling concurrency group serializes the final registry check.
74
+ One workflow publishes both channels: `.github/workflows/release.yml`. Pushes do not publish. Nightly development verification runs at `03:17 UTC`; `npm run develop` explicitly requests a numbered preview and `npm run release -- patch` explicitly requests stable publication after its version pull request is manually merged; it promotes a development version to its stable core. The following development-version pull request also requires manual merge. A preview is stamped as `-dev.<merged pull-request number>` and uses npm `next` only as an internal dist-tag. New candidates pack once and are validated on Windows, Linux, and macOS; a repeated nightly verifies the exact immutable registry tarball. All publication uses provenance from the `npm-publish` environment, a preview never changes `latest`, and the stable tag, GitHub Release, and `master` are written only after npm has the package. One global non-cancelling concurrency group serializes the final registry check.
75
75
 
76
76
  `docs/ci-release-runbook.md` is the operational reference.
77
77
 
@@ -166,15 +166,40 @@ work.
166
166
 
167
167
  ## Cutting a stable release
168
168
 
169
- From a clean `develop` matching its remote:
169
+ From the repository root on clean `develop` matching `origin/develop`:
170
170
 
171
171
  ```sh
172
- npm run release -- patch # or minor, major, or an exact x.y.z
172
+ npm run release -- patch # 0.1.8-dev -> 0.1.8; already-stable 0.1.8 -> 0.1.9
173
+ npm run release -- minor # 0.1.8-dev -> 0.2.0
174
+ npm run release -- major # 0.1.8-dev -> 1.0.0
175
+ npm run release -- 0.4.0 # exact stable target
173
176
  ```
174
177
 
175
- The command lands `x.y.z` through its version pull request, explicitly dispatches
176
- stable publication for that exact current `origin/develop` commit, and waits. Only
177
- after success does it land `x.y.(z+1)-dev`.
178
+ A target is required: `npm run release` alone is a mutation-free usage error.
179
+ `patch` preserves prerelease-aware semantics, including `0.1.8-dev.123 -> 0.1.8`.
180
+ The command reports its source, stable target, and prospective reopening before
181
+ preparing anything.
182
+
183
+ 1. The stable-version edit is committed in an owned detached worktree beneath
184
+ `.worktrees/`. Only this package's manifest and root lockfile version change.
185
+ 2. Follow the printed PR URL, wait for required CI, perform local validation, and
186
+ **merge manually after acceptance**. The helper does not merge PRs or enable
187
+ auto-merge. It polls for actual merge with a bounded 30-minute wait.
188
+ 3. The helper verifies the merged version and source SHA against authoritative
189
+ develop, then explicitly dispatches stable publication for that exact source.
190
+ A changed source is an error, not permission to substitute a newer commit.
191
+ 4. Only after verified publication of `0.1.8` does it prepare a separate PR for
192
+ `0.1.9-dev`. Validate and manually merge that PR as well. Until then the helper
193
+ reports development reopening as incomplete.
194
+
195
+ Closed PRs, timeout, cancellation, and query failures retain identifiable phase
196
+ work for inspection. Conflicting existing branches/PRs are not overwritten;
197
+ matching pending PRs can be observed again without replacing them. Worktrees
198
+ retained by an earlier attempt are not removed by a later invocation. Cleanup
199
+ only removes clean, owned, confirmed-merged phase worktrees and unchanged remote
200
+ phase branches. The caller is never hard-reset: a final fast-forward is attempted
201
+ only when its branch, original HEAD, and cleanliness remain unchanged. Otherwise
202
+ preserve local work and synchronize manually with the reported remote state.
178
203
 
179
204
  Stable publication builds the process guardian on all supported platforms, packs
180
205
  once, runs the complete suite against those exact bytes on Windows, Linux, and
@@ -200,8 +225,21 @@ Rules that do not bend:
200
225
  path, and use the confirmed apply mode only for an accepted mutable policy change.
201
226
  - **Nightly documentation review fails:** inspect the reported paths and rules, identify the introducing merge from the nightly interval, and repair the invariant before unrelated work proceeds.
202
227
  - **Development publication fails:** fix the cause and rerun `npm run develop`; an npm version that already exists is never overwritten.
203
- - **Stable publication fails before npm accepts bytes:** no tag, release, or moved branch exists. Fix the cause and release the next version.
204
- - **Stable publication is uncertain after npm accepted bytes:** stop and inspect registry version, digest, tag, and release. Never republish immutable bytes.
228
+ - **Stable preparation stops before dispatch:** inspect the reported version PR/worktree. After preserving work, an exact matching pending PR can be observed again. If develop already declares the prepared stable version, use its exact target (for example `npm run release -- 0.1.8`) only after verifying that source's merged PR and confirming the registry/tag guards still permit it. Do not use `patch` from stable develop to retry the same version: that deliberately selects the next patch.
229
+ - **Stable publication fails or is uncertain:** inspect the workflow, registry version/digest, tag, and release before choosing recovery. No reopening PR is prepared. Never republish immutable bytes or move a release tag.
230
+ - **Stable publication succeeded but reopening stops:** the stable version is already published. Inspect and finish the reported next-development PR manually; do not repeat stable publication. If no reopening PR was created, prepare the next-development version through a separately validated manual PR after inspecting remote state.
231
+
232
+ ## Safe release-command validation
233
+
234
+ Use the isolated harness rather than a live release to validate changes to this command:
235
+
236
+ ```sh
237
+ npm exec --no -- vitest run test/repository-governance/release-target.test.ts test/repository-governance/release-command.test.ts --maxWorkers=1 --minWorkers=1
238
+ ```
239
+
240
+ It uses disposable local Git repositories and fake GitHub, registry, and publication
241
+ services. It does not publish a package or create production version PRs. A real
242
+ release remains a separate deliberate operation.
205
243
 
206
244
  ## Branch protection rationale
207
245
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timurproko/a1",
3
- "version": "0.1.8-dev.332",
3
+ "version": "0.1.8-dev.335",
4
4
  "description": "Standalone terminal workspace for supervised native and managed agents",
5
5
  "type": "module",
6
6
  "privateLaunchContract": "neutral-launch-v1",