@timurproko/a1 0.2.7-dev.1276 → 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.
Files changed (184) hide show
  1. package/README.md +5 -1
  2. package/dist/app/session-shell/session-shell-root.d.ts +4 -68
  3. package/dist/app/session-shell/session-shell.js +5 -11
  4. package/dist/app/session-shell/shell-host.js +2 -2
  5. package/dist/app/session-shell/shell-options.d.ts +65 -0
  6. package/dist/app/session-shell/shell-options.js +1 -0
  7. package/dist/app/session-shell/workflow-router.d.ts +3 -1
  8. package/dist/app/session-shell/workflow-router.js +12 -0
  9. package/dist/cli/dispatch.js +27 -1
  10. package/dist/cli/packages.js +18 -2
  11. package/dist/cli/session-context.js +6 -2
  12. package/dist/composition/owned-ui.js +20 -8
  13. package/dist/composition/session-naming.js +1 -1
  14. package/dist/composition/session-runtime-registry.d.ts +2 -0
  15. package/dist/composition/session-runtime-registry.js +49 -9
  16. package/dist/contracts/agent-engine/package-ports.d.ts +10 -14
  17. package/dist/contracts/agent-engine/package-ports.js +5 -9
  18. package/dist/contracts/owned-ui/session-workflows.d.ts +6 -0
  19. package/dist/contracts/presentation/index.d.ts +1 -0
  20. package/dist/contracts/presentation/progress-palette.generated.d.ts +1 -1
  21. package/dist/contracts/presentation/progress-palette.generated.js +1 -1
  22. package/dist/contracts/presentation/progress-spinner.d.ts +7 -0
  23. package/dist/contracts/presentation/progress-spinner.js +1 -0
  24. package/dist/features/launch/session-context.d.ts +8 -0
  25. package/dist/features/launch/session-context.js +19 -10
  26. package/dist/features/owned-ui/reference-screen-app.js +9 -5
  27. package/dist/features/owned-ui/session-fork-prompt.d.ts +2 -1
  28. package/dist/features/owned-ui/session-fork-prompt.js +11 -5
  29. package/dist/features/owned-ui/settings-app.d.ts +1 -1
  30. package/dist/features/owned-ui/settings-app.js +14 -7
  31. package/dist/features/owned-ui/settings-spinner-preview.d.ts +9 -0
  32. package/dist/features/owned-ui/settings-spinner-preview.js +29 -0
  33. package/dist/features/session-naming/context.d.ts +17 -0
  34. package/dist/features/session-naming/context.js +39 -0
  35. package/dist/features/session-naming/controller.d.ts +1 -5
  36. package/dist/features/session-naming/controller.js +7 -4
  37. package/dist/features/session-naming/generate.d.ts +2 -1
  38. package/dist/features/session-naming/generate.js +14 -10
  39. package/dist/features/session-naming/index.d.ts +1 -1
  40. package/dist/foundation/lifecycle/index.d.ts +3 -1
  41. package/dist/foundation/lifecycle/index.js +3 -1
  42. package/dist/foundation/lifecycle/session-claim-lock.d.ts +2 -2
  43. package/dist/foundation/lifecycle/session-claim-lock.js +36 -10
  44. package/dist/foundation/lifecycle/session-context-retry.d.ts +2 -0
  45. package/dist/foundation/lifecycle/session-context-retry.js +17 -0
  46. package/dist/foundation/lifecycle/session-repository-context.d.ts +7 -1
  47. package/dist/foundation/lifecycle/session-repository-context.js +105 -39
  48. package/dist/foundation/lifecycle/session-worktree-entry.d.ts +3 -0
  49. package/dist/foundation/lifecycle/session-worktree-entry.js +87 -0
  50. package/dist/foundation/release/automatic-update.js +2 -2
  51. package/dist/foundation/release/bootstrap.js +19 -3
  52. package/dist/foundation/release/index.d.ts +1 -1
  53. package/dist/foundation/release/index.js +1 -1
  54. package/dist/foundation/release/release-gc.js +41 -5
  55. package/dist/foundation/release/release-state.d.ts +48 -4
  56. package/dist/foundation/release/release-state.js +168 -31
  57. package/dist/foundation/release/release-use.d.ts +10 -0
  58. package/dist/foundation/release/release-use.js +86 -0
  59. package/dist/foundation/release/update-activation.d.ts +3 -1
  60. package/dist/foundation/release/update-activation.js +23 -6
  61. package/dist/foundation/release/update-recovery.js +2 -2
  62. package/dist/foundation/release/update.d.ts +3 -1
  63. package/dist/foundation/release/update.js +61 -22
  64. package/dist/foundation/release/warmup.js +8 -9
  65. package/dist/foundation/session-writer-lock/index.d.ts +2 -2
  66. package/dist/foundation/session-writer-lock/index.js +82 -12
  67. package/dist/foundation/startup/startup-descriptor.js +2 -2
  68. package/dist/foundation/terminal-cleanup/fatal-diagnostic.d.ts +2 -0
  69. package/dist/foundation/terminal-cleanup/fatal-diagnostic.js +59 -0
  70. package/dist/foundation/terminal-cleanup/fatal-exit.js +1 -29
  71. package/dist/integrations/pi/components/resources/builtin-themes.d.ts +2 -2
  72. package/dist/integrations/pi/components/resources/builtin-themes.js +2 -2
  73. package/dist/integrations/pi/components/share-operation-dialog.d.ts +2 -1
  74. package/dist/integrations/pi/components/share-operation-dialog.js +13 -5
  75. package/dist/integrations/pi/components/shell-footer-status.d.ts +2 -1
  76. package/dist/integrations/pi/components/shell-footer-status.js +15 -8
  77. package/dist/integrations/pi/components/shell-session-info-reference.js +11 -8
  78. package/dist/integrations/pi/components/upstream/adjacent/core/keybindings.d.ts +1 -1
  79. package/dist/integrations/pi/components/upstream/adjacent/core/keybindings.js +1 -1
  80. package/dist/integrations/pi/components/upstream/assets/earendil-image.json +2 -2
  81. package/dist/integrations/pi/components/upstream/components/auth-url.d.ts +1 -1
  82. package/dist/integrations/pi/components/upstream/components/auth-url.js +1 -1
  83. package/dist/integrations/pi/components/upstream/components/countdown-timer.d.ts +1 -1
  84. package/dist/integrations/pi/components/upstream/components/earendil-announcement.js +1 -1
  85. package/dist/integrations/pi/components/upstream/components/extension-editor.d.ts +1 -1
  86. package/dist/integrations/pi/components/upstream/components/extension-editor.js +1 -1
  87. package/dist/integrations/pi/components/upstream/components/extension-input.js +1 -1
  88. package/dist/integrations/pi/components/upstream/components/extension-selector.js +1 -1
  89. package/dist/integrations/pi/components/upstream/components/login-dialog.js +1 -1
  90. package/dist/integrations/pi/components/upstream/components/mermaid.js +1 -1
  91. package/dist/integrations/pi/components/upstream/components/oauth-selector.d.ts +1 -1
  92. package/dist/integrations/pi/components/upstream/components/oauth-selector.js +1 -1
  93. package/dist/integrations/pi/components/upstream/components/owned-editor.d.ts +1 -1
  94. package/dist/integrations/pi/components/upstream/components/owned-editor.js +1 -1
  95. package/dist/integrations/pi/components/upstream/components/pi-logo.js +1 -1
  96. package/dist/integrations/pi/components/upstream/components/scoped-models-selector.d.ts +1 -1
  97. package/dist/integrations/pi/components/upstream/components/session-footer.js +14 -8
  98. package/dist/integrations/pi/components/upstream/components/session-selector-search.js +1 -1
  99. package/dist/integrations/pi/components/upstream/components/session-selector.js +1 -1
  100. package/dist/integrations/pi/components/upstream/components/skill-invocation-message.d.ts +1 -1
  101. package/dist/integrations/pi/components/upstream/components/skill-invocation-message.js +1 -1
  102. package/dist/integrations/pi/components/upstream/components/status-indicator.d.ts +1 -1
  103. package/dist/integrations/pi/components/upstream/components/status-indicator.js +1 -1
  104. package/dist/integrations/pi/components/upstream/components/themed-text.d.ts +1 -1
  105. package/dist/integrations/pi/components/upstream/components/themed-text.js +1 -1
  106. package/dist/integrations/pi/components/upstream/components/thinking-selector.d.ts +1 -1
  107. package/dist/integrations/pi/components/upstream/components/thinking-selector.js +1 -1
  108. package/dist/integrations/pi/components/upstream/components/tool-execution.js +1 -1
  109. package/dist/integrations/pi/components/upstream/components/tree-selector.d.ts +1 -1
  110. package/dist/integrations/pi/components/upstream/components/tree-selector.js +1 -1
  111. package/dist/integrations/pi/components/upstream/components/trust-selector.js +1 -1
  112. package/dist/integrations/pi/components/upstream/external-editor.js +1 -1
  113. package/dist/integrations/pi/components/upstream/history/editor-core.d.ts +1 -1
  114. package/dist/integrations/pi/components/upstream/history/editor-core.js +1 -1
  115. package/dist/integrations/pi/components/upstream/history/kill-ring.d.ts +1 -1
  116. package/dist/integrations/pi/components/upstream/history/kill-ring.js +1 -1
  117. package/dist/integrations/pi/components/upstream/history/printable-key.js +1 -1
  118. package/dist/integrations/pi/components/upstream/history/text-helpers.js +1 -1
  119. package/dist/integrations/pi/components/upstream/history/undo-stack.d.ts +1 -1
  120. package/dist/integrations/pi/components/upstream/history/undo-stack.js +1 -1
  121. package/dist/integrations/pi/components/upstream/history/word-navigation.js +1 -1
  122. package/dist/integrations/pi/components/upstream/model-search.d.ts +1 -1
  123. package/dist/integrations/pi/components/upstream/program-status-reporter.d.ts +1 -1
  124. package/dist/integrations/pi/components/upstream/theme/theme.js +1 -1
  125. package/dist/integrations/pi/engine/adapter.d.ts +3 -1
  126. package/dist/integrations/pi/engine/adapter.js +24 -6
  127. package/dist/integrations/pi/engine/changelog.js +5 -2
  128. package/dist/integrations/pi/engine/delivery-retention.d.ts +7 -0
  129. package/dist/integrations/pi/engine/delivery-retention.js +66 -0
  130. package/dist/integrations/pi/engine/event-delivery.d.ts +17 -1
  131. package/dist/integrations/pi/engine/event-delivery.js +25 -3
  132. package/dist/integrations/pi/engine/extension-ui-binding.d.ts +2 -1
  133. package/dist/integrations/pi/engine/extension-ui-binding.js +45 -0
  134. package/dist/integrations/pi/engine/host-ports.d.ts +2 -0
  135. package/dist/integrations/pi/engine/host.d.ts +3 -0
  136. package/dist/integrations/pi/engine/host.js +2 -0
  137. package/dist/integrations/pi/engine/package-integration.js +19 -4
  138. package/dist/integrations/pi/engine/pending-delivery.d.ts +4 -1
  139. package/dist/integrations/pi/engine/pending-delivery.js +51 -18
  140. package/dist/integrations/pi/engine/resources/changelog.json +4 -4
  141. package/dist/integrations/pi/engine/runtime-integration.d.ts +8 -0
  142. package/dist/integrations/pi/engine/runtime-integration.js +58 -9
  143. package/dist/integrations/pi/engine/session-cwd-recovery.d.ts +11 -0
  144. package/dist/integrations/pi/engine/session-cwd-recovery.js +18 -0
  145. package/dist/integrations/pi/engine/session-runtime.d.ts +3 -0
  146. package/dist/integrations/pi/engine/session-runtime.js +2 -0
  147. package/dist/integrations/pi/engine/session-selection.d.ts +3 -2
  148. package/dist/integrations/pi/engine/session-selection.js +25 -5
  149. package/dist/integrations/pi/engine/transcript-image-assets.d.ts +3 -0
  150. package/dist/integrations/pi/engine/transcript-image-assets.js +65 -14
  151. package/dist/integrations/pi/engine/view-state.d.ts +14 -0
  152. package/dist/integrations/pi/engine/view-state.js +17 -0
  153. package/dist/integrations/pi/engine/workflow-runner.d.ts +2 -0
  154. package/dist/integrations/pi/engine/workflow-runner.js +47 -26
  155. package/dist/integrations/pi/engine/worktree-tools.d.ts +4 -0
  156. package/dist/integrations/pi/engine/worktree-tools.js +26 -0
  157. package/dist/integrations/pi/session-presenters/components.d.ts +2 -1
  158. package/dist/integrations/pi/session-presenters/components.js +5 -3
  159. package/dist/integrations/pi/session-presenters/presenters.d.ts +3 -1
  160. package/dist/integrations/pi/session-presenters/presenters.js +1 -1
  161. package/dist/integrations/pi/startup-public.d.ts +1 -1
  162. package/dist/integrations/pi/startup-public.js +16 -20
  163. package/dist/integrations/pi/startup-public.manifest.json +29 -29
  164. package/dist/integrations/pi/tui-runtime/damage-aware-terminal.d.ts +1 -1
  165. package/dist/integrations/pi/tui-runtime/damage-aware-terminal.js +1 -1
  166. package/dist/product-identity.json +1 -1
  167. package/dist/runtime-payload-inventory.json +5027 -5027
  168. package/dist/ui/components/index.d.ts +1 -1
  169. package/dist/ui/components/index.js +1 -1
  170. package/dist/ui/components/progress-status.d.ts +7 -0
  171. package/dist/ui/components/progress-status.js +21 -0
  172. package/dist/ui/components/value-menu.d.ts +1 -1
  173. package/dist/ui/components/value-menu.js +4 -2
  174. package/dist/ui/settings/declarations.d.ts +16 -4
  175. package/dist/ui/settings/declarations.js +13 -4
  176. package/docs/architecture/internal-naming.md +1 -1
  177. package/docs/architecture/project-structure.md +7 -11
  178. package/docs/features/session-exports.md +1 -1
  179. package/docs/features/session-naming.md +8 -6
  180. package/docs/features/worktrees.md +38 -0
  181. package/docs/local-worktree-cleanup.md +27 -4
  182. package/docs/openspec-archive-automation.md +30 -4
  183. package/docs/worktree-link-recovery.md +2 -2
  184. package/package.json +5 -5
@@ -1,6 +1,18 @@
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;
3
- /** How long a release stays on disk after its approval, so processes started from it keep their files. */
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;
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";
6
18
  export interface ReleaseRecord {
@@ -9,6 +21,8 @@ export interface ReleaseRecord {
9
21
  readonly packageVersion: string;
10
22
  readonly contentDigest: string;
11
23
  readonly launchContract?: string;
24
+ /** Only binaries implementing launch claims may become age-collectible. */
25
+ readonly lifetimeClaims?: true;
12
26
  readonly approval: ReleaseApproval;
13
27
  readonly materializedAt: string;
14
28
  /** When the release was approved; retention measures its age from this instant. */
@@ -66,12 +80,40 @@ export interface ReleaseCleanupState {
66
80
  readonly diagnostics: readonly ReleaseCleanupDiagnostic[];
67
81
  readonly workerRuns: readonly ReleaseCleanupWorkerRun[];
68
82
  }
83
+ export interface ReleaseUseClaim {
84
+ readonly releaseId: string;
85
+ readonly pid: number;
86
+ /** An interrupted handoff is uncertain and must remain protected. */
87
+ readonly phase: "launching" | "running";
88
+ }
69
89
  export interface ReleaseState {
70
90
  readonly schema: typeof RELEASE_STATE_SCHEMA;
71
91
  readonly revision: number;
72
92
  readonly releases: Readonly<Record<string, ReleaseRecord>>;
73
93
  readonly references: ReleaseReferences;
74
94
  readonly cleanup: ReleaseCleanupState;
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;
75
117
  }
76
118
  export interface ProtectedReleaseInputs {
77
119
  /** Releases the reconciling command itself runs from; they are never collected under it. */
@@ -101,8 +143,7 @@ export interface ReleaseRetentionReconciliation {
101
143
  * Age-based retention. A release stays while a selector names it (active, its rollback, or a
102
144
  * candidate still being certified), while an unfinished update may return to it, while the
103
145
  * reconciling command runs from it, or while its approval is younger than the retention window.
104
- * Nothing asks which processes are running: a session that outlives the window and two updates
105
- * can lose its release files, which is the documented trade-off.
146
+ * Process-lifetime claims override age; legacy approved binaries remain conservatively retained.
106
147
  */
107
148
  export declare function planProtectedReleases(state: ReleaseState, inputs?: ProtectedReleaseInputs): ProtectedReleasePlan;
108
149
  /**
@@ -113,8 +154,10 @@ export declare function planProtectedReleases(state: ReleaseState, inputs?: Prot
113
154
  export declare function withinRetention(release: ReleaseRecord, now: number, retentionMs?: number): boolean;
114
155
  /** Serializes validated release-state revisions under a lock and commits each update atomically. */
115
156
  export declare class ReleaseStateStore {
157
+ #private;
116
158
  readonly path: string;
117
- constructor(dataDir: string);
159
+ constructor(dataDir: string, options?: ReleaseStateStoreOptions);
160
+ private renameWithRetry;
118
161
  read(): Promise<ReleaseState>;
119
162
  update(operation: (current: ReleaseState) => ReleaseState | Promise<ReleaseState>): Promise<ReleaseState>;
120
163
  recordCandidate(release: MaterializedRelease): Promise<ReleaseState>;
@@ -134,3 +177,4 @@ export declare class ReleaseStateStore {
134
177
  recordCleanupWorkerFinished(runId: string, status: Exclude<ReleaseCleanupWorkerStatus, "scheduled" | "running">, summary: ReleaseCleanupWorkerSummary, error?: unknown): Promise<ReleaseState>;
135
178
  }
136
179
  export declare function emptyState(): ReleaseState;
180
+ export declare function releaseUseIsLive(claim: ReleaseUseClaim): boolean;
@@ -1,21 +1,56 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { mkdir, open, readFile, rename, rm, stat } 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
- /** How long a release stays on disk after its approval, so processes started from it keep their files. */
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
13
46
  * reconciling command runs from it, or while its approval is younger than the retention window.
14
- * Nothing asks which processes are running: a session that outlives the window and two updates
15
- * can lose its release files, which is the documented trade-off.
47
+ * Process-lifetime claims override age; legacy approved binaries remain conservatively retained.
16
48
  */
17
49
  export function planProtectedReleases(state, inputs = {}) {
18
50
  const protectedIds = new Set();
51
+ for (const claim of Object.values(state.uses ?? {}))
52
+ if (releaseUseIsLive(claim))
53
+ protectedIds.add(claim.releaseId);
19
54
  for (const id of [state.references.active, state.references.pending, state.references.approved, state.references.rollback]) {
20
55
  if (id !== null)
21
56
  protectedIds.add(id);
@@ -28,7 +63,7 @@ export function planProtectedReleases(state, inputs = {}) {
28
63
  const now = inputs.now ?? Date.now();
29
64
  const window = inputs.retentionMs ?? RELEASE_RETENTION_MS;
30
65
  for (const release of Object.values(state.releases)) {
31
- if (withinRetention(release, now, window))
66
+ if (withinRetention(release, now, window) || (release.approval === "approved" && release.lifetimeClaims !== true))
32
67
  protectedIds.add(release.releaseId);
33
68
  }
34
69
  const protectedReleaseIds = [...protectedIds].sort();
@@ -54,12 +89,37 @@ export function withinRetention(release, now, retentionMs = RELEASE_RETENTION_MS
54
89
  /** Serializes validated release-state revisions under a lock and commits each update atomically. */
55
90
  export class ReleaseStateStore {
56
91
  path;
57
- constructor(dataDir) {
92
+ #rename;
93
+ #platform;
94
+ constructor(dataDir, options = {}) {
58
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
+ }
59
116
  }
60
117
  async read() {
61
118
  try {
62
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);
63
123
  validateState(state);
64
124
  return state;
65
125
  }
@@ -75,19 +135,30 @@ export class ReleaseStateStore {
75
135
  const lock = await acquireLock(lockPath);
76
136
  try {
77
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);
78
141
  const next = await operation(current);
79
142
  validateTransition(current, next);
80
- 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 };
81
145
  const temporary = `${this.path}.${process.pid}.${randomUUID()}.tmp`;
82
146
  const file = await open(temporary, "wx", 0o600);
83
147
  try {
84
- await file.writeFile(JSON.stringify(committed, null, 2));
85
- 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);
86
156
  }
87
- finally {
88
- await file.close();
157
+ catch (error) {
158
+ await rm(temporary, { force: true }).catch(() => undefined);
159
+ throw error;
89
160
  }
90
- await rename(temporary, this.path);
161
+ await reclaimOrphanedTemporaries(dirname(this.path));
91
162
  return committed;
92
163
  }
93
164
  finally {
@@ -104,6 +175,7 @@ export class ReleaseStateStore {
104
175
  packageVersion: release.packageVersion,
105
176
  contentDigest: release.contentDigest,
106
177
  ...(release.launchContract === undefined ? {} : { launchContract: release.launchContract }),
178
+ ...(release.files.some(file => file.path === "dist/foundation/release/release-use.js") ? { lifetimeClaims: true } : {}),
107
179
  approval: "candidate",
108
180
  materializedAt: new Date().toISOString(),
109
181
  certifiedAt: null,
@@ -363,7 +435,7 @@ async function acquireLock(path) {
363
435
  while (true) {
364
436
  try {
365
437
  const lock = await open(path, "wx", 0o600);
366
- await lock.writeFile(JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }));
438
+ await lock.writeFile(JSON.stringify({ pid: process.pid, token: randomUUID(), createdAt: new Date().toISOString() }));
367
439
  await lock.sync();
368
440
  return lock;
369
441
  }
@@ -382,31 +454,35 @@ async function acquireLock(path) {
382
454
  }
383
455
  }
384
456
  async function reclaimAbandonedLock(path) {
385
- let abandoned = false;
457
+ let owner;
386
458
  try {
387
- const value = JSON.parse(await readFile(path, "utf8"));
388
- if (typeof value.pid === "number") {
389
- try {
390
- process.kill(value.pid, 0);
391
- }
392
- catch (error) {
393
- abandoned = !(error instanceof Error && "code" in error && error.code === "EPERM");
394
- }
459
+ owner = await readFile(path, "utf8");
460
+ const value = JSON.parse(owner);
461
+ if (!Number.isSafeInteger(value.pid) || value.pid <= 0)
462
+ return false;
463
+ try {
464
+ process.kill(value.pid, 0);
465
+ return false;
466
+ }
467
+ catch (error) {
468
+ if (!(error instanceof Error && "code" in error && error.code === "ESRCH"))
469
+ return false;
395
470
  }
396
471
  }
397
472
  catch {
398
- const metadata = await stat(path).catch(() => null);
399
- abandoned = metadata !== null && Date.now() - metadata.mtimeMs > 10_000;
473
+ return false;
474
+ } // Security: unknown ownership never becomes safe merely through age.
475
+ const gate = `${path}.reclaim`;
476
+ try {
477
+ await mkdir(gate);
400
478
  }
401
- if (!abandoned)
479
+ catch {
402
480
  return false;
403
- const quarantine = `${path}.abandoned`;
404
- const oldQuarantine = await stat(quarantine).catch(() => null);
405
- if (oldQuarantine && Date.now() - oldQuarantine.mtimeMs > 10_000)
406
- await rm(quarantine, { force: true });
481
+ }
407
482
  try {
408
- // Concurrency: the fixed destination lets exactly one waiter claim the stale lock;
409
- // another waiter cannot accidentally rename a newly acquired replacement.
483
+ if (await readFile(path, "utf8") !== owner)
484
+ return false;
485
+ const quarantine = `${path}.abandoned-${randomUUID()}`;
410
486
  await rename(path, quarantine);
411
487
  await rm(quarantine, { force: true });
412
488
  return true;
@@ -414,6 +490,46 @@ async function reclaimAbandonedLock(path) {
414
490
  catch {
415
491
  return false;
416
492
  }
493
+ finally {
494
+ await rm(gate, { recursive: true, force: true });
495
+ }
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
+ }
417
533
  }
418
534
  function normalizeState(value) {
419
535
  if (!value || typeof value !== "object")
@@ -424,6 +540,8 @@ function normalizeState(value) {
424
540
  const cleanup = state.cleanup;
425
541
  return {
426
542
  ...state,
543
+ // Compatibility: one-way control-state upgrade fences pre-claim cleanup workers; session files are untouched.
544
+ ...(state.schema === LEGACY_RELEASE_STATE_SCHEMA ? { schema: RELEASE_STATE_SCHEMA } : {}),
427
545
  cleanup: cleanup
428
546
  ? { ...cleanup, workerRuns: cleanup.workerRuns ?? [] }
429
547
  : { pending: {}, diagnostics: [], workerRuns: [] },
@@ -436,6 +554,13 @@ function validateState(state) {
436
554
  || !Array.isArray(state.cleanup.workerRuns)) {
437
555
  throw new Error("invalid release state");
438
556
  }
557
+ if (state.uses !== undefined && (state.uses === null || typeof state.uses !== "object" || Array.isArray(state.uses)))
558
+ throw new Error("invalid release use claims");
559
+ for (const [token, claim] of Object.entries(state.uses ?? {})) {
560
+ if (!/^[a-f0-9-]{36}$/.test(token) || !claim || !RELEASE_ID_PATTERN.test(claim.releaseId)
561
+ || !Number.isSafeInteger(claim.pid) || claim.pid <= 0 || !["launching", "running"].includes(claim.phase))
562
+ throw new Error("invalid release use claim");
563
+ }
439
564
  for (const run of state.cleanup.workerRuns) {
440
565
  if (!run || typeof run.runId !== "string" || run.runId.length === 0
441
566
  || !["scheduled", "running", "completed", "blocked", "continued", "failed", "skipped"].includes(run.status)
@@ -473,6 +598,18 @@ function validateTransition(current, next) {
473
598
  if (next.revision !== current.revision)
474
599
  throw new Error("release revision is controlled by the state store");
475
600
  }
601
+ export function releaseUseIsLive(claim) {
602
+ if (claim.phase === "launching")
603
+ return true;
604
+ // Security: PID reuse only over-retains: no live/uncertain process identity ever proves a claim dead.
605
+ try {
606
+ process.kill(claim.pid, 0);
607
+ return true;
608
+ }
609
+ catch (error) {
610
+ return !(error instanceof Error && "code" in error && error.code === "ESRCH");
611
+ }
612
+ }
476
613
  function boundedError(error) {
477
614
  return (error instanceof Error ? error.message : String(error)).replace(/[\r\n]+/g, " ").slice(0, 1_000);
478
615
  }
@@ -0,0 +1,10 @@
1
+ import { type ChildProcess, type SpawnOptions } from "node:child_process";
2
+ export declare function spawnReleaseEntry(entry: string, args: readonly string[], options: SpawnOptions): Promise<ChildProcess & {
3
+ releaseSettled: Promise<void>;
4
+ }>;
5
+ /** A durable launch reservation closes selection/collection and spawn/handoff races. */
6
+ export declare function acquireReleaseUse(dataDir: string, releaseId: string): Promise<{
7
+ track(child: ChildProcess): Promise<void>;
8
+ started(pid: number): Promise<void>;
9
+ release(): Promise<void>;
10
+ }>;
@@ -0,0 +1,86 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { realpath } from "node:fs/promises";
3
+ import { spawn } from "node:child_process";
4
+ import { basename, dirname, resolve } from "node:path";
5
+ import { resolveProductPaths } from "../lifecycle/index.js";
6
+ import { ReleaseStateStore, isReleaseStateNewerError, releaseUseIsLive } from "./release-state.js";
7
+ /** Mutable installed entries are not GC-managed; immutable helper processes need their own claim. */
8
+ async function claimReleaseEntry(entry, environment) {
9
+ const dataDir = resolveProductPaths(environment).dataDir;
10
+ const root = await realpath(dirname(dirname(resolve(entry))));
11
+ const storeRoot = await realpath(resolve(dataDir, "releases")).catch(() => undefined);
12
+ if (dirname(root) !== storeRoot)
13
+ return undefined;
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
+ }
24
+ }
25
+ export async function spawnReleaseEntry(entry, args, options) {
26
+ const use = await claimReleaseEntry(entry, options.env ?? process.env);
27
+ try {
28
+ const child = spawn(process.execPath, [entry, ...args], options);
29
+ const releaseSettled = use?.track(child) ?? Promise.resolve();
30
+ // Concurrency: detached callers need no waiter; awaited callers still observe release failures.
31
+ void releaseSettled.catch(() => undefined);
32
+ return Object.assign(child, { releaseSettled });
33
+ }
34
+ catch (error) {
35
+ await use?.release();
36
+ throw error;
37
+ }
38
+ }
39
+ /** A durable launch reservation closes selection/collection and spawn/handoff races. */
40
+ export async function acquireReleaseUse(dataDir, releaseId) {
41
+ const store = new ReleaseStateStore(dataDir);
42
+ const token = randomUUID();
43
+ await store.update(state => {
44
+ if (!state.releases[releaseId])
45
+ throw new Error("Selected release is no longer available for launch");
46
+ const uses = Object.fromEntries(Object.entries(state.uses ?? {}).filter(([, claim]) => releaseUseIsLive(claim)));
47
+ return { ...state, uses: { ...uses, [token]: { releaseId, pid: process.pid, phase: "launching" } } };
48
+ });
49
+ let released = false;
50
+ return {
51
+ track(child) {
52
+ let handoff = Promise.resolve();
53
+ child.once("spawn", () => { handoff = this.started(child.pid).catch(() => undefined); });
54
+ return new Promise((resolve, reject) => {
55
+ child.once("close", () => { void handoff.then(() => this.release()).then(resolve, reject); });
56
+ });
57
+ },
58
+ async started(pid) {
59
+ if (!Number.isSafeInteger(pid) || pid <= 0 || released)
60
+ throw new Error("Invalid release process handoff");
61
+ await store.update(state => {
62
+ if (!state.uses?.[token])
63
+ throw new Error("Release launch reservation was lost");
64
+ const claim = { releaseId, pid, phase: "running" };
65
+ return { ...state, uses: { ...state.uses, [token]: claim } };
66
+ });
67
+ },
68
+ async release() {
69
+ if (released)
70
+ return;
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
+ }
83
+ released = true;
84
+ },
85
+ };
86
+ }
@@ -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>;
@@ -1,4 +1,4 @@
1
- import { spawn } from "node:child_process";
1
+ import { spawnReleaseEntry } from "./release-use.js";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
@@ -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";
@@ -77,7 +78,7 @@ export async function readActivationContracts(packageRoot, read = path => readFi
77
78
  */
78
79
  export async function delegateActivation(request, callbacks) {
79
80
  const entry = resolve(request.packageRoot, UPDATE_ACTIVATION_ENTRY);
80
- const child = spawn(process.execPath, [entry, "--data-dir", request.dataDir, "--target-version", request.targetVersion], {
81
+ const child = await spawnReleaseEntry(entry, ["--data-dir", request.dataDir, "--target-version", request.targetVersion], {
81
82
  env: request.environment,
82
83
  stdio: ["ignore", "pipe", "pipe"],
83
84
  windowsHide: true,
@@ -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) {
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { spawn } from "node:child_process";
2
+ import { spawnReleaseEntry } from "./release-use.js";
3
3
  import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
4
4
  import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -303,7 +303,7 @@ export async function cleanupUpdateRecoveryCapsules(dataDir) {
303
303
  }
304
304
  }
305
305
  async function spawnRecoveryWorker(entry, manifestPath, environment) {
306
- const child = spawn(process.execPath, [entry, "--worker", manifestPath], { detached: true, stdio: "ignore", windowsHide: true, env: environment });
306
+ const child = await spawnReleaseEntry(entry, ["--worker", manifestPath], { detached: true, stdio: "ignore", windowsHide: true, env: environment });
307
307
  await new Promise((resolvePromise, rejectPromise) => {
308
308
  child.once("spawn", resolvePromise);
309
309
  child.once("error", rejectPromise);
@@ -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>;