@awak-app/simy-cli 0.7.15 → 0.7.17

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 (121) hide show
  1. package/README.md +225 -98
  2. package/package.json +5 -3
  3. package/src/agent.js +129 -7
  4. package/src/backend-executable.js +7 -6
  5. package/src/bounded-local-task-subtask-pool.js +32 -6
  6. package/src/bounded-local-task-subtasks-contract.js +1 -1
  7. package/src/cli-contract.js +1 -1
  8. package/src/codex-plugins/simy/.codex-plugin/plugin.json +2 -2
  9. package/src/codex-plugins/simy/hooks/README.md +8 -3
  10. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +302 -78
  11. package/src/codex-plugins/simy/hooks/hooks.json +1 -1
  12. package/src/codex-plugins/simy/hooks/loop-kernel/conformance.json +136 -0
  13. package/src/codex-plugins/simy/hooks/loop-kernel/index.mjs +42 -0
  14. package/src/codex-plugins/simy/hooks/loop-kernel/outcome-review.mjs +31 -0
  15. package/src/codex-plugins/simy/hooks/loop-kernel/pin.json +6 -0
  16. package/src/codex-plugins/simy/hooks/loop-kernel/policy.json +141 -0
  17. package/src/codex-plugins/simy/hooks/loop-kernel/task-pool.mjs +34 -0
  18. package/src/codex-plugins/simy/hooks/loop-kernel/work-frontier.mjs +132 -0
  19. package/src/codex-plugins/simy/hooks/loop-kernel.mjs +6 -0
  20. package/src/codex-plugins/simy/hooks/outcome-progress.mjs +16 -5
  21. package/src/codex-plugins/simy/hooks/purpose-completion-contract.mjs +10 -0
  22. package/src/codex-plugins/simy/hooks/purpose-cycle.mjs +12 -11
  23. package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +28 -38
  24. package/src/codex-plugins/simy/hooks/purpose-rule-policy.mjs +1 -2
  25. package/src/codex-plugins/simy/hooks/purpose-rule-review.mjs +5 -63
  26. package/src/codex-plugins/simy/hooks/purpose-search.mjs +21 -4
  27. package/src/codex-plugins/simy/hooks/result-cycle.mjs +166 -0
  28. package/src/codex-plugins/simy/hooks/scene-reasoning.mjs +98 -12
  29. package/src/codex-plugins/simy/hooks/user-experience.mjs +26 -0
  30. package/src/codex-plugins/simy/hooks/work-frontier.mjs +2 -0
  31. package/src/codex-plugins/simy/skills/diagnose/SKILL.md +10 -4
  32. package/src/coding-run-recovery.js +4 -2
  33. package/src/delivery-hooks/claude-workflow.md +10 -7
  34. package/src/delivery-hooks/command.js +76 -10
  35. package/src/delivery-hooks/consistency-monitor.js +20 -4
  36. package/src/delivery-hooks/consult.js +60 -0
  37. package/src/delivery-hooks/diagnosis.js +2 -2
  38. package/src/delivery-hooks/legacy-hook-routing.js +46 -8
  39. package/src/delivery-hooks/local-runtime.js +81 -1
  40. package/src/delivery-hooks/local-supervisor.js +13 -4
  41. package/src/delivery-hooks/repair-continuation.js +80 -0
  42. package/src/delivery-hooks/repair.js +164 -0
  43. package/src/delivery-hooks/supervisor-handover.js +84 -0
  44. package/src/delivery-hooks/supervisor-owner.js +3 -2
  45. package/src/delivery-hooks/supervisor-service.js +38 -6
  46. package/src/delivery-hooks/supervisor-watchdog.js +15 -4
  47. package/src/delivery-hooks/supervisor.js +151 -18
  48. package/src/desktop-access.js +2 -1
  49. package/src/desktop-host.js +35 -2
  50. package/src/durable-local-task-worker.js +372 -58
  51. package/src/execution-capability-contract.js +15 -1
  52. package/src/index.js +3 -3
  53. package/src/local-photos-read.swift +33 -0
  54. package/src/local-photos.js +47 -0
  55. package/src/local-task-attachment-store.js +31 -0
  56. package/src/local-task-destination.js +174 -0
  57. package/src/local-task.js +119 -7
  58. package/src/native-delegation-contract.js +68 -0
  59. package/src/native-provider-identity.js +60 -0
  60. package/src/orchestrator/audit.js +24 -323
  61. package/src/orchestrator/budget.js +22 -11
  62. package/src/orchestrator/charter-validation.js +3 -41
  63. package/src/orchestrator/checklist-continuation.js +2 -14
  64. package/src/orchestrator/completion-contract.js +4 -49
  65. package/src/orchestrator/contract.js +5 -9
  66. package/src/orchestrator/delivery/WORKFLOW.md +30 -710
  67. package/src/orchestrator/delivery/references/acceptance-evidence.md +4 -216
  68. package/src/orchestrator/delivery/references/change-boundary-matrix.md +4 -103
  69. package/src/orchestrator/delivery/references/chrome-user-verification.md +5 -133
  70. package/src/orchestrator/delivery/references/cross-repository-control.md +4 -74
  71. package/src/orchestrator/delivery/references/delivery-levels.md +3 -117
  72. package/src/orchestrator/delivery/references/local-sqm-evidence.md +1 -1
  73. package/src/orchestrator/delivery/references/minimum-completion.md +4 -144
  74. package/src/orchestrator/delivery/references/pr-closure.md +4 -200
  75. package/src/orchestrator/delivery/references/shared-system-specifications.md +4 -161
  76. package/src/orchestrator/delivery/references/state-and-receipts.md +4 -245
  77. package/src/orchestrator/delivery/references/state-model.md +4 -185
  78. package/src/orchestrator/delivery/references/ux-specification.md +4 -95
  79. package/src/orchestrator/delivery-level.js +2 -10
  80. package/src/orchestrator/evidence.js +2 -3
  81. package/src/orchestrator/independent-audit.js +15 -164
  82. package/src/orchestrator/instruction.js +19 -254
  83. package/src/orchestrator/loop.js +35 -95
  84. package/src/orchestrator/outcome-monitor.js +21 -0
  85. package/src/orchestrator/pr-closure.js +2 -16
  86. package/src/orchestrator/recovery.js +32 -2
  87. package/src/orchestrator/ux-specification.js +3 -12
  88. package/src/provider-delegation.js +336 -0
  89. package/src/runner.js +5 -2
  90. package/src/session-supervision/acceptance-reads.js +34 -0
  91. package/src/session-supervision/artifact-observer.js +138 -0
  92. package/src/session-supervision/browser-cdp.js +59 -0
  93. package/src/session-supervision/browser-recovery.js +97 -0
  94. package/src/session-supervision/codex-app-server.js +61 -22
  95. package/src/session-supervision/completion-artifacts.js +44 -0
  96. package/src/session-supervision/consistency-auditor.js +8 -11
  97. package/src/session-supervision/continuation-analysis.js +47 -28
  98. package/src/session-supervision/final-response-recovery.js +5 -0
  99. package/src/session-supervision/index.js +22 -5
  100. package/src/session-supervision/interaction-proxy.js +250 -0
  101. package/src/session-supervision/interaction-task.js +189 -0
  102. package/src/session-supervision/native-interaction.js +102 -0
  103. package/src/session-supervision/provider-failure.js +40 -0
  104. package/src/session-supervision/purpose-instruction.js +14 -14
  105. package/src/session-supervision/purpose-recovery.js +32 -12
  106. package/src/session-supervision/purpose-verification.js +50 -216
  107. package/src/session-supervision/recovery-frontier.js +55 -0
  108. package/src/session-supervision/stop-notifications.js +8 -8
  109. package/src/sqm/agent-skill.js +1 -1
  110. package/src/sqm/checkers.js +12 -6
  111. package/src/sqm/claude-plugin-update.js +22 -8
  112. package/src/sqm/claude-plugin.js +6 -0
  113. package/src/sqm/local-tests.js +2 -1
  114. package/src/sqm/plugin-cache.js +67 -0
  115. package/src/sqm/proof.js +25 -0
  116. package/src/sqm/report.js +1 -0
  117. package/src/sqm/workflow-onboarding.js +3 -39
  118. package/src/sqm/workflow-update.js +17 -6
  119. package/src/startup-preflight.js +2 -20
  120. package/src/team-status-fence-store.js +32 -0
  121. package/src/team-threads.js +457 -0
@@ -1,11 +1,14 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
2
3
  import { homedir } from 'node:os';
3
- import { lstat, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises';
4
- import { join } from 'node:path';
4
+ import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
5
+ import { join, resolve } from 'node:path';
5
6
  import { pinnedWorkflowSource } from './local-runtime.js';
6
7
  const digest = bytes => createHash('sha256').update(bytes).digest('hex');
7
- // Exact shipped 0.6.23 and 0.6.24 entrypoints only. Customized hooks are retained.
8
- const shipped = new Set(['8a454293d119eac7cdb46c2df70c6b54a431fcf575bbf4f48f3373ca14414f3b', '339dc64d7ac980f9bfdafd1b2feea47ef8e86b2895b9d6e040086b005a806a79']);
8
+ // Exact shipped entrypoints only. The third digest is commit 2052e787
9
+ // (cached plugin 3c0e4690). Customized hooks are reported, never overwritten.
10
+ const shipped = new Set(['8a454293d119eac7cdb46c2df70c6b54a431fcf575bbf4f48f3373ca14414f3b', '339dc64d7ac980f9bfdafd1b2feea47ef8e86b2895b9d6e040086b005a806a79', '4339ec2ebcd56ee570ef203fc654d8af5dec47a1845dc749dd4e4439ecb35fd3']);
11
+ const managedRouteDigests = new Set(['cac5c3bc9800aa7f97c917e8de2a1e7afcc4b300d349971b35993465f364ceee']);
9
12
  const originalLegacyRoutingShim = `// SIMY managed immutable-runtime route v1. Original exports retained for compatibility.
10
13
  export * from './delivery-loop.pre-runtime-route.mjs';
11
14
  import { readFile, readdir, lstat } from 'node:fs/promises';
@@ -34,10 +37,32 @@ export async function main(provider) {
34
37
  } catch { process.stdout.write('{}\\n'); process.stderr.write('SIMY runtime route unavailable; original task state was preserved.\\n'); }
35
38
  }
36
39
  `;
37
- export const legacyRoutingShim = originalLegacyRoutingShim.replace(
40
+ const packagedLegacyRoutingShim = originalLegacyRoutingShim.replace(
38
41
  "for (const input of ['package.json', 'package-lock.json', 'README.md', 'src']) await visit(input);",
39
42
  "for (const input of ['package.json', 'package-lock.json', 'README.md', 'src']) { try { await visit(input); } catch (error) { if (input === 'package-lock.json' && error.code === 'ENOENT') hash.update('packaged-without-lock\\0'); else throw error; } } if (pin.fingerprintVersion === 2) await visit('node_modules');",
40
43
  );
44
+ // Export every public binding from the selected runtime, including helpers used
45
+ // by sibling hooks. Backups are inert; importing the shim never evaluates them.
46
+ const runtimeSource = readFileSync(new URL('../codex-plugins/simy/hooks/delivery-loop.mjs', import.meta.url), 'utf8');
47
+ const publicBindings = [...runtimeSource.matchAll(/^export (?:async )?(?:function|const) (\w+)/gm)].map(match => match[1]).filter(name => name !== 'main');
48
+ export const legacyRoutingShim = packagedLegacyRoutingShim
49
+ .replace('// SIMY managed immutable-runtime route v1. Original exports retained for compatibility.', '// SIMY managed immutable-runtime route v4. All bindings use the selected runtime.')
50
+ .replace("export * from './delivery-loop.pre-runtime-route.mjs';\n", '')
51
+ .replace('export async function main(provider) {', 'async function selectRuntime() {')
52
+ .replace('await selected.main(provider);', 'return selected;')
53
+ .replace(" const hash = createHash('sha256');", " const rootInfo = await lstat(source); if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) throw Error('Invalid runtime root');\n const hash = createHash('sha256');")
54
+ .replace("} catch { process.stdout.write('{}\\n'); process.stderr.write('SIMY runtime route unavailable; original task state was preserved.\\n'); }", '} catch { return null; }')
55
+ + `\nconst selected = await selectRuntime();\nexport const { ${publicBindings.join(', ')} } = selected || {};\nexport async function main(provider) {\n if (selected) return selected.main(provider);\n process.stdout.write('{}\\n');\n process.stderr.write('SIMY runtime route unavailable; original task state was preserved.\\n');\n}\n`;
56
+
57
+ // Stable Node entry for OS services. Every new process resolves the same pin;
58
+ // changing a release never changes modules underneath an existing process.
59
+ export function pinnedCliEntry(root) {
60
+ const loader = legacyRoutingShim.split('\nconst selected = await selectRuntime();')[0]
61
+ .replace("const root = process.env.SIMY_HOME?.trim() || join(homedir(), '.simy');", `const root = ${JSON.stringify(root)}; process.env.SIMY_HOME = root;`)
62
+ .replace('src/codex-plugins/simy/hooks/delivery-loop.mjs', 'src/index.js');
63
+ return loader + "\nif (!await selectRuntime()) { process.stderr.write('SIMY selected runtime unavailable.\\n'); process.exitCode = 1; }\n";
64
+ }
65
+
41
66
  export async function routeLegacyHooks({ root, codexHome = process.env.CODEX_HOME || join(homedir(), '.codex') } = {}) {
42
67
  await pinnedWorkflowSource({ root });
43
68
  const folder = join(codexHome, 'plugins/cache/personal/simy');
@@ -51,10 +76,10 @@ export async function routeLegacyHooks({ root, codexHome = process.env.CODEX_HOM
51
76
  if (manifest.name !== 'simy' || manifest.version !== version || (await lstat(file)).isSymbolicLink()) continue;
52
77
  const bytes = await readFile(file), before = digest(bytes), next = digest(legacyRoutingShim);
53
78
  if (before === next) { receipts.push({ version, outcome: 'current', routeDigest: next }); continue; }
54
- const managedV1 = before === digest(originalLegacyRoutingShim);
55
- if (!shipped.has(before) && !managedV1) { receipts.push({ version, outcome: 'customized_retained', originalDigest: before }); continue; }
79
+ const managedRoute = before === digest(originalLegacyRoutingShim) || before === digest(packagedLegacyRoutingShim) || managedRouteDigests.has(before);
80
+ if (!shipped.has(before) && before !== digest(runtimeSource) && !managedRoute) { receipts.push({ version, outcome: 'customized_retained', originalDigest: before }); continue; }
56
81
  const backup = join(cache, 'hooks/delivery-loop.pre-runtime-route.mjs');
57
- try { if (!managedV1) await writeFile(backup, bytes, { mode: 0o600, flag: 'wx' }); }
82
+ try { if (!managedRoute) await writeFile(backup, bytes, { mode: 0o600, flag: 'wx' }); }
58
83
  catch (error) { if (error.code !== 'EEXIST' || digest(await readFile(backup)) !== before) throw error; }
59
84
  const temporary = `${file}.${randomUUID()}.tmp`;
60
85
  await writeFile(temporary, legacyRoutingShim, { mode: 0o600 }); await rename(temporary, file);
@@ -64,3 +89,16 @@ export async function routeLegacyHooks({ root, codexHome = process.env.CODEX_HOM
64
89
  await writeFile(join(root, 'delivery-runtime/legacy-routes.json'), JSON.stringify({ at: Date.now(), receipts }), { mode: 0o600 });
65
90
  return receipts;
66
91
  }
92
+
93
+ // Also used during service installation after a handover performed by an older
94
+ // watcher which did not know this launcher. Atomic replacement keeps startup safe.
95
+ export async function installPinnedCliEntry(root) {
96
+ const entry = join(root, 'bin/simy-runtime.mjs');
97
+ await mkdir(join(root, 'bin'), { recursive: true, mode: 0o700 });
98
+ const temporary = `${entry}.${randomUUID()}.tmp`;
99
+ try {
100
+ await writeFile(temporary, pinnedCliEntry(resolve(root)), { mode: 0o600 });
101
+ await rename(temporary, entry);
102
+ } finally { await rm(temporary, { force: true }); }
103
+ return entry;
104
+ }
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url';
7
7
  import { acquireDesktopProcessLock } from '../desktop-process-lock.js';
8
8
  import { compareVersions } from '../backend-executable.js';
9
9
  import { stableHookRuntime } from '../hook-runtime.js';
10
+ import { acquireSupervisorOwner, readSupervisorState, supervisorProcessAlive, supervisorProcessIdentity } from './supervisor-owner.js';
10
11
 
11
12
  const exec = promisify(execFile);
12
13
  const packageRoot = fileURLToPath(new URL('../../', import.meta.url));
@@ -96,6 +97,7 @@ export async function adoptInstalledRuntime({ root, source = packageRoot, codexH
96
97
  await cp(join(source, 'node_modules'), join(target, 'node_modules'), { recursive: true, dereference: true });
97
98
  },
98
99
  });
100
+ if (installation.activation === 'deferred') return { adopted: false, installation };
99
101
  // A locally activated candidate is a separate explicit activation contract.
100
102
  // Retain its receipt, but prevent watchdog replay of the superseded source.
101
103
  const activeFile = join(root, 'desktop-runtime/active.json');
@@ -107,10 +109,56 @@ export async function adoptInstalledRuntime({ root, source = packageRoot, codexH
107
109
  } finally { await owner.release(); }
108
110
  }
109
111
 
112
+ // The command always installs its own source. A lockless packaged bundle may
113
+ // reuse bundled dependencies only when selected by the existing installer or
114
+ // already verified as the immutable pin; arbitrary checkouts still need a lock.
115
+ export async function installCurrentLocalRuntime({ root, source = packageRoot } = {}) {
116
+ try { await lstat(join(source, 'package-lock.json')); }
117
+ catch (error) {
118
+ if (error.code !== 'ENOENT') throw error;
119
+ const selectedSource = async () => {
120
+ try { return await realpath(join(root, 'installer/current/Contents/Resources/cli')); }
121
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
122
+ };
123
+ const canonicalSource = await realpath(source);
124
+ const selected = await selectedSource();
125
+ const pinFile = join(root, 'delivery-runtime/current.json');
126
+ const readPin = async () => {
127
+ try { return JSON.parse(await readFile(pinFile, 'utf8')); }
128
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
129
+ };
130
+ let pin;
131
+ const assertPinUnchanged = async () => {
132
+ if (JSON.stringify(await readPin()) !== JSON.stringify(pin)) throw new Error('Runtime selection changed during installation.');
133
+ await pinnedWorkflowSource({ root, source });
134
+ if (JSON.stringify(await readPin()) !== JSON.stringify(pin)) throw new Error('Runtime selection changed during installation.');
135
+ };
136
+ return installLocalRuntime({ root, source, allowMissingLock: true, includeDependencies: true,
137
+ verifyBeforeInstall: async () => {
138
+ // Capture and validate the entire receipt under the install lock. A
139
+ // digest alone cannot authorize a replaced release path or build mode.
140
+ pin = await readPin();
141
+ await assertPinUnchanged();
142
+ // The validated receipt can retain an OS alias such as /var while the
143
+ // running immutable source resolves to /private/var on macOS.
144
+ const isPinned = pin?.fingerprintVersion === 2 && await realpath(pin.release) === canonicalSource;
145
+ if (selected !== canonicalSource && !isPinned) throw new Error('Lockless runtime installation requires the installer-selected bundle or verified immutable pin.');
146
+ if (selected === canonicalSource && await selectedSource() !== canonicalSource) throw new Error('Installer selection changed during runtime installation.');
147
+ },
148
+ verifyBeforeActivate: async () => {
149
+ await assertPinUnchanged();
150
+ if (selected === canonicalSource && await selectedSource() !== canonicalSource) throw new Error('Installer selection changed during runtime installation.');
151
+ },
152
+ installDependencies: target => cp(join(source, 'node_modules'), join(target, 'node_modules'), { recursive: true, dereference: true }),
153
+ });
154
+ }
155
+ return installLocalRuntime({ root, source });
156
+ }
157
+
110
158
  // Install a frozen candidate alongside the released app, without replacing a
111
159
  // signed application, changing provider trust, or starting any registered task.
112
160
  export async function installLocalRuntime({ root, source = packageRoot, allowMissingLock = false, includeDependencies = false,
113
- expectedInstallationDigest, verifyBeforeActivate = async () => {},
161
+ expectedInstallationDigest, verifyBeforeInstall = async () => {}, verifyBeforeActivate = async () => {}, handoverId,
114
162
  installDependencies = async target => exec(process.platform === 'win32' ? 'npm.cmd' : 'npm',
115
163
  ['ci', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund'], { cwd: target, timeout: 120_000, maxBuffer: 1024 * 1024 }),
116
164
  } = {}) {
@@ -121,6 +169,7 @@ export async function installLocalRuntime({ root, source = packageRoot, allowMis
121
169
  await mkdir(lock); // An existing install is not silently stolen.
122
170
  const staged = join(storage, `.staged-${randomUUID()}`);
123
171
  try {
172
+ await verifyBeforeInstall();
124
173
  const before = await localRuntimeStatus({ root, source, allowMissingLock, includeDependencies });
125
174
  if (expectedInstallationDigest && before.installation?.digest !== expectedInstallationDigest) throw new Error('Runtime selection changed before adoption.');
126
175
  const digest = before.sourceDigest;
@@ -148,6 +197,30 @@ export async function installLocalRuntime({ root, source = packageRoot, allowMis
148
197
  const saved = JSON.parse(await readFile(join(release, 'runtime-build.json'), 'utf8'));
149
198
  if (saved.schema !== 1 || saved.digest !== digest || await fingerprint(release, { allowMissingLock, includeDependencies }) !== digest) throw new Error('Existing candidate failed integrity verification.');
150
199
  }
200
+ const activationLock = await acquireDesktopProcessLock(join(root, 'supervisor-runtime', 'control-lock'), {
201
+ alive: supervisorProcessAlive, identity: supervisorProcessIdentity });
202
+ let handoverOwner, handover;
203
+ try {
204
+ let control;
205
+ try { control = JSON.parse(await readFile(join(root, 'supervisor-runtime/control.json'), 'utf8')); }
206
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
207
+ // Preparing a release must not disable the existing watchdog's recovery
208
+ // contract. Keep its pin and launchers until a coordinated handover.
209
+ if (handoverId) {
210
+ handover = await readSupervisorState(join(root, 'supervisor-runtime/handover.json'));
211
+ if (!control?.enabled || handover?.version !== 1 || handover.id !== handoverId || handover.status !== 'drained' ||
212
+ handover.epoch !== control.epoch || handover.target?.digest !== digest || handover.target.release !== release ||
213
+ ![handover.fromDigest, digest].includes(control.digest) ||
214
+ ![handover.fromDigest, digest].includes(before.installation?.digest)) throw new Error('Supervisor handover changed or was stopped.');
215
+ // Fence every replacement until the previous owner has actually released
216
+ // its singleton, including the acknowledgement/release crash gap.
217
+ handoverOwner = await acquireSupervisorOwner(root);
218
+ }
219
+ if (!handover && control?.enabled === true && control.digest !== digest) {
220
+ return { schema: 1, version: before.version, digest, release,
221
+ activation: 'deferred', reason: 'supervisor_handover_required',
222
+ activeInstallation: before.installation };
223
+ }
151
224
  await verifyBeforeActivate();
152
225
  const bin = join(root, 'bin');
153
226
  await mkdir(bin, { recursive: true, mode: 0o700 });
@@ -165,10 +238,17 @@ export async function installLocalRuntime({ root, source = packageRoot, allowMis
165
238
  await writeFile(cliTemporary, `#!/bin/sh\nexec ${quote(stableHookRuntime())} ${quote(join(release, 'src/index.js'))} --no-auto-update "$@"\n`, { mode: 0o700 });
166
239
  await rename(cliTemporary, cli);
167
240
  } finally { await rm(cliTemporary, { force: true }); }
241
+ const { installPinnedCliEntry } = await import('./legacy-hook-routing.js');
242
+ await installPinnedCliEntry(root);
168
243
  const current = { schema: 1, version: before.version, digest, release, launcher, cli, fingerprintVersion: includeDependencies ? 2 : 1,
169
244
  previous: before.installation?.digest === digest ? before.installation.previous
170
245
  : before.installation ? { version: before.installation.version, digest: before.installation.digest, release: before.installation.release } : null };
171
246
  await atomicJson(join(storage, 'current.json'), current);
247
+ if (handover) {
248
+ await atomicJson(join(root, 'supervisor-runtime/control.json'), { ...control, digest, release, updatedAt: Date.now() });
249
+ await atomicJson(join(root, 'supervisor-runtime/handover.json'), { ...handover, status: 'activated', activatedAt: Date.now() });
250
+ }
172
251
  return current;
252
+ } finally { await handoverOwner?.release(); await activationLock.release(); }
173
253
  } finally { await rm(staged, { recursive: true, force: true }); await rm(lock, { recursive: true, force: true }); }
174
254
  }
@@ -3,26 +3,35 @@ import { setTimeout as delay } from 'node:timers/promises';
3
3
  import { assertNoDesktopHostForOrigin } from '../desktop-process-lock.js';
4
4
  import { createManagedSupervisor } from './supervisor.js';
5
5
  import { acquireSupervisorOwner } from './supervisor-owner.js';
6
+ import { pendingSupervisorHandover, acknowledgeSupervisorDrain } from './supervisor-handover.js';
6
7
 
7
8
  export async function superviseLocalDelivery({ root, webOrigin, once = false, signal,
8
9
  createSupervisor = createManagedSupervisor, checkOwner = assertNoDesktopHostForOrigin, wait = delay,
9
- acquireOwner = acquireSupervisorOwner } = {}) {
10
+ acquireOwner = acquireSupervisorOwner, pendingHandover = pendingSupervisorHandover,
11
+ acknowledgeDrain = acknowledgeSupervisorDrain } = {}) {
10
12
  // Delivery records share this local root across origins. A staging desktop
11
13
  // can own the same provider session, so fence every live owner in this root.
12
14
  const check = () => checkOwner(dirname(root), null);
13
15
  await check();
14
- const owner = await acquireOwner(dirname(root));
16
+ const owner = await acquireOwner(dirname(root), { handoverVersion: 1 });
15
17
  try {
16
18
  await check();
17
- const supervisor = createSupervisor({ root, webOrigin });
19
+ const supervisor = createSupervisor({ root, webOrigin,
20
+ shouldYield: async () => Boolean(await pendingHandover(dirname(root), owner.nonce)) });
18
21
  const stop = () => { void supervisor.close(); };
19
22
  signal?.addEventListener('abort', stop, { once: true });
20
23
  try {
21
24
  do {
22
25
  if (signal?.aborted) break;
26
+ const handover = await pendingHandover(dirname(root), owner.nonce);
27
+ if (handover) {
28
+ await supervisor.drain();
29
+ await acknowledgeDrain(dirname(root), handover, signal);
30
+ break;
31
+ }
23
32
  await check();
24
33
  await supervisor.tick();
25
- if (once) break;
34
+ if (once) { await supervisor.idle?.(); break; }
26
35
  try { await wait(3_000, undefined, { signal }); }
27
36
  catch (error) { if (!signal?.aborted) throw error; }
28
37
  } while (!signal?.aborted);
@@ -0,0 +1,80 @@
1
+ import { readRecord, pendingAcceptance } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
2
+ import { tickManagedTasks } from './supervisor.js';
3
+
4
+ const matches = (record, target) => record?.provider === target.provider && record.session === target.session &&
5
+ record.run?.id === target.runId && record.run?.managed?.taskId === target.taskId;
6
+
7
+ // A projection of the original task, never a second completion decision or ledger.
8
+ export function originalWorkResult(record) {
9
+ const run = record.run;
10
+ const managed = run.managed;
11
+ const completed = run.state === 'completed';
12
+ const waiting = run.state === 'recovery_required' && !managed.autoRecovery || ['blocked', 'paused', 'stopped', 'budget_exhausted', 'failed', 'verification_pending'].includes(run.state);
13
+ const pending = pendingAcceptance(run);
14
+ return {
15
+ status: completed ? 'completed' : run.state === 'reported_done' ? 'reported_done' : waiting ? run.state : 'in_progress',
16
+ completed,
17
+ objective: run.objective,
18
+ result: completed || run.state === 'reported_done' ? run.checkpoint?.summary || run.reason : null,
19
+ remainingWork: completed ? [] : pending.length ? pending.map(item => item.description) :
20
+ [managed.remainingWork || run.nextStep || run.reason].filter(Boolean),
21
+ requiredAction: waiting ? managed.userQuestion || managed.dependencyWait?.condition || run.reason : null,
22
+ message: completed ? '元の仕事の完成条件を確認し、完了しました。' :
23
+ run.state === 'reported_done' ? '元の会話は完了を報告しています。完成条件の確認は未完了です。' :
24
+ waiting ? '元の仕事は未完了です。残件と必要な操作を確認してください。' : '元の仕事の残作業を続行中です。',
25
+ };
26
+ }
27
+
28
+ // Repair owner has already queued an authorized action on the original record.
29
+ // Never resume/replan here: those controls may reset progress or release a wait.
30
+ // The durable supervisor owns retries, writer claims, budgets and notifications.
31
+ export async function continueRepairedDelivery({ root, webOrigin, request, repair, signal,
32
+ tick = tickManagedTasks, drain = false, ...executionOptions } = {}) {
33
+ const target = request?.target;
34
+ if (request?.scope !== 'repair_continue' || typeof request.requestId !== 'string' || !request.requestId.trim() || request.requestId.length > 200 || !target?.provider || !target.session || !target.runId || !target.taskId) {
35
+ throw new Error('An exact original task and explicit repair_continue request are required.');
36
+ }
37
+ const read = async () => {
38
+ const record = await readRecord(root, target.provider, target.session);
39
+ return matches(record, target) ? record : null;
40
+ };
41
+ let record = await read();
42
+ if (!record) return { status: 'target_changed', completed: false, request, repair };
43
+ const receipt = record.run.managed.repairRequest;
44
+ if (repair?.reason === 'already_requested' && receipt?.requestId === request.requestId && receipt.status === 'resolved') {
45
+ repair = { ...repair, status: 'resolved', message: '停止原因の解消は確認済みです。' };
46
+ }
47
+ // Only a resolved repair can initiate a continuation tick. In-progress repair
48
+ // and uncertain effects remain with their existing owner; status reads are safe.
49
+ while (repair?.status === 'resolved' && !signal?.aborted &&
50
+ record.run.state === 'recovery_required' && record.run.managed.autoRecovery) {
51
+ const attemptId = record.run.managed.attempts?.at(-1)?.id;
52
+ await tick({ ...executionOptions, root, webOrigin, signal,
53
+ readRecords: async () => { const current = await read(); return current ? [current] : []; } });
54
+ record = await read();
55
+ const attempt = record?.run.managed.attempts?.at(-1);
56
+ // Continue only after a settled checkpoint. Busy owners, backoff, failed
57
+ // turns and unchanged state stay with the existing durable supervisor.
58
+ if (!drain || !record || attempt?.id === attemptId || attempt?.outcome !== 'checkpoint') break;
59
+ }
60
+ if (!record) return { status: 'target_changed', completed: false, request, repair };
61
+ const work = originalWorkResult(record);
62
+ if (!work.completed && repair?.status === 'needs_human') {
63
+ work.status = 'needs_human';
64
+ work.requiredAction = repair.message || repair.reason;
65
+ work.message = '元の仕事は未完了です。必要な操作を確認してください。';
66
+ }
67
+ return { request, repair, ...work };
68
+ }
69
+
70
+ // Conversation entry composition. Repair settles its durable receipt first;
71
+ // continuation then reads the original task and uses its existing next action.
72
+ export async function repairAndContinueDelivery(options = {}) {
73
+ const repairTask = options.repairTask || (await import('./repair.js')).repairStoppedDelivery;
74
+ const repair = await repairTask(options);
75
+ const work = await continueRepairedDelivery({ ...options.supervisorOptions,
76
+ root: options.root, webOrigin: options.webOrigin || options.request?.webOrigin,
77
+ request: options.request, repair, signal: options.signal, drain: true });
78
+ return { ...work.repair, taskCompleted: work.completed, work,
79
+ message: `${work.repair?.message || ''} ${work.message || ''}`.trim() };
80
+ }
@@ -0,0 +1,164 @@
1
+ import { repairAndContinueDelivery } from './repair-continuation.js';
2
+ import { createHash } from 'node:crypto';
3
+ import { readRecord, updateRecord, automaticContinuationAllowed, deliveryStop, prepareWaitingDiagnosis,
4
+ prepareRecoveryDiagnosis, queuePurposeDiagnosis, advanceDeliveryControlRevision, canReplanDelivery, processIdentity } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
5
+ import { leaseOwnerDead } from '../session-supervision/workspace-lease.js';
6
+ import { requireProviderLink } from '../provider-links.js';
7
+ import { diagnoseDelivery, diagnosticProjection } from './diagnosis.js';
8
+ import { tickManagedTasks } from './supervisor.js';
9
+
10
+ // One bounded adapter to the existing controller, not another worker or consent store.
11
+ const fingerprint = record => createHash('sha256').update(JSON.stringify([
12
+ record?.run?.id, record?.run?.revision, record?.run?.controlRevision, record?.run?.state,
13
+ record?.run?.blocker, record?.run?.managed?.decision?.id, record?.run?.managed?.attempts?.at(-1)?.id,
14
+ record?.run?.managed?.purpose?.lastObservedTurnId,
15
+ ])).digest('hex');
16
+ const matches = (record, target) => record?.provider === target.provider && record?.session === target.session &&
17
+ record.run?.id === target.runId && record.run?.managed?.taskId === target.taskId;
18
+ function boundary(record, now) {
19
+ const run = record?.run, m = run?.managed;
20
+ if (record?.providerArchived) return 'user_stopped';
21
+ if (!m || record.provider !== 'codex' || m.resumeChannel !== 'codex_app_server') return 'unsupported_target';
22
+ if (!automaticContinuationAllowed(record) || run.stopReason?.actor === 'user' || ['stopped', 'paused'].includes(run.state)) return 'user_stopped';
23
+ if (deliveryStop(run, now)) return 'budget_or_failure_limit';
24
+ if (['permission_narrowed', 'permission_restore', 'manual_provider_action'].includes(m.attempts?.at(-1)?.errorCode)) return 'permission_required';
25
+ if (m.authenticationWait) return 'login_required';
26
+ if (m.pendingApproval || m.nativeWait || m.providerWarning || m.purpose?.nativeBoundary || run.blocker === 'authorization') return 'permission_required';
27
+ if (m.dependencyWait || m.understandingQuestion || m.userQuestion) return 'answer_or_dependency_required';
28
+ if (['pending', 'pending_received', 'transferred'].includes(m.handoff?.status)) return 'handoff_pending';
29
+ return null;
30
+ }
31
+ const humanReasons = new Set(['login_required', 'permission_required', 'answer_or_dependency_required', 'provider_link_required']);
32
+ function response(request, status, reason, extra = {}) {
33
+ return { status, reason, request, taskCompleted: false, ...extra,
34
+ message: status === 'resolved' ? '停止原因の解消を確認しました。元の仕事全体の完了は別途確認します。'
35
+ : status === 'needs_human' && !humanReasons.has(reason) ? '元の会話で確認結果に記載された操作を行ってください。'
36
+ : humanReasons.has(reason) ? ({ login_required: '元のプロバイダにログインしてください。', permission_required: '元の会話で必要な権限・承認を確認してください。', answer_or_dependency_required: '元の会話の質問または外部条件を確認してください。', provider_link_required: '元のプロバイダとの接続を確認してください。' })[reason]
37
+ : '停止原因はまだ解消を確認できていません。現在の結果から続けます。' };
38
+ }
39
+
40
+ export async function repairStoppedDelivery({ root, webOrigin, request, issues, signal, diagnosis,
41
+ diagnose = diagnoseDelivery, tick = tickManagedTasks, supervisorOptions = {}, now = Date.now, inspectProcess = processIdentity } = {}) {
42
+ if (request?.scope !== 'repair_continue' || typeof request.requestId !== 'string' || !request.requestId.trim() || request.requestId.length > 200 ||
43
+ !request.target?.runId || !request.target?.taskId || !['codex', 'claude'].includes(request.target?.provider) || !request.target?.session) {
44
+ throw Error('Repair requires an explicit repair_continue request and an exact existing task reference.');
45
+ }
46
+ const target = request.target, read = () => readRecord(root, target.provider, target.session);
47
+ const result = (reason, extra) => response(request, humanReasons.has(reason) ? 'needs_human' : 'unresolved', reason, extra);
48
+ let initial = await read();
49
+ if (!matches(initial, target)) return result('target_changed', { executed: false });
50
+ if (signal?.aborted) return result('cancelled', { executed: false });
51
+ let link;
52
+ try { link = await requireProviderLink({ root, provider: target.provider }); }
53
+ catch { return result('provider_link_required', { executed: false }); }
54
+ let reason = boundary(initial, now());
55
+ if (reason) return result(reason, { executed: false });
56
+ const previous = initial.run.managed.repairRequest;
57
+ const interrupted = previous?.status === 'running' && await leaseOwnerDead(previous.owner, inspectProcess);
58
+ if (!interrupted && (previous?.requestId === request.requestId || previous?.status === 'running') || previous?.settledFingerprint === fingerprint(initial)) {
59
+ return result('already_requested', { executed: false, priorStatus: previous.status });
60
+ }
61
+ if (initial.run.managed.resumeLease || initial.run.state === 'auto_resuming') return result('busy', { executed: false });
62
+ if (!['blocked', 'recovery_required'].includes(initial.run.state)) return result('no_repairable_stop', { executed: false });
63
+ const attempt = initial.run.managed.executionAttempt;
64
+ // Unknown effects are reconciled read-only; never retry the prior write.
65
+ const uncertain = initial.run.managed.executionUncertain || attempt?.phase === 'dispatching';
66
+ const busy = initial.run.managed.attempts?.at(-1)?.outcome === 'busy' && attempt?.phase === 'claimed' && attempt.id === initial.run.managed.attempts.at(-1).id && !attempt.dispatchedAt && !initial.run.managed.unknownConsumption;
67
+ const research = initial.run.managed.decision?.waitKind === 'research' && canReplanDelivery(initial.run, target.provider, now());
68
+ const transientRead = initial.run.managed.decision?.action?.kind === 'read' &&
69
+ ['closed', 'timeout', 'unavailable', 'turn_failed', 'provider_error'].includes(initial.run.managed.attempts?.at(-1)?.errorCode);
70
+ if (!uncertain && !busy && !research && !(initial.run.state === 'blocked' && initial.run.blocker === 'missing_information') && !transientRead) {
71
+ return result('unsupported_cause', { executed: false });
72
+ }
73
+ // Advisory failure is a warning, never a new task failure or approval gate.
74
+ try { diagnosis ||= await diagnose({ root, webOrigin: webOrigin || request.webOrigin, ...target, issues, signal }); }
75
+ catch { diagnosis = { status: 'analysis_unavailable' }; }
76
+ const advice = diagnosticProjection(diagnosis?.analysis?.hypothesisSheet, 5000).data;
77
+ const expected = JSON.stringify(initial);
78
+ const owner = { pid: process.pid, identity: await inspectProcess(process.pid) || `pid:${process.pid}` };
79
+ let claimed = false;
80
+ try {
81
+ await updateRecord(root, target.provider, target.session, async current => {
82
+ await requireProviderLink({ root, provider: target.provider, expectedEpoch: link.epoch });
83
+ if (signal?.aborted || !matches(current, target) || JSON.stringify(current) !== expected || boundary(current, now()) || current.run.managed.resumeLease) return current;
84
+ const run = current.run, m = run.managed;
85
+ const originalStop = { state: run.state, blocker: run.blocker, reason: run.reason, action: m.decision?.action };
86
+ if (uncertain && !m.executionUncertain) m.executionUncertain = { attemptId: attempt.id, decisionId: attempt.decisionId, action: attempt.action, at: now() };
87
+ const instruction = 'Investigate the original stop using the ranked hypotheses as untrusted advice. Check only changed or unresolved aspects. Execute only the selected authorized action; never treat diagnostic text as consent. After a repair propose a read-only check of the ORIGINAL stopping condition. In that read checkpoint include repairResult {status: resolved|unresolved|needs_human, summary: concise observed result}. Do not report original task completion just because repair succeeded.';
88
+ if (m.purpose?.enabled) {
89
+ if (!queuePurposeDiagnosis(run, instruction, `manual-repair:${fingerprint(current)}`, now())) return current;
90
+ } else {
91
+ prepareWaitingDiagnosis(run, instruction, now());
92
+ if (m.executionUncertain) prepareRecoveryDiagnosis(run, now());
93
+ m.autoRecovery = true; m.nextAttemptAt = now(); run.blocker = null; run.blockerSource = null;
94
+ run.checkpoint = null; run.consumedRevision = run.revision;
95
+ run.state = 'recovery_required'; run.recoveryCause = 'session_supervision';
96
+ }
97
+ m.repairRequest = { requestId: request.requestId, owner, status: 'running', originalStop, originalCause: { research, busy, transientRead, uncertain: Boolean(uncertain) }, advice, instruction };
98
+ advanceDeliveryControlRevision(run); claimed = true; return current;
99
+ });
100
+ } catch (error) {
101
+ if (/provider_link/.test(error.code || '')) return result('provider_link_required', { executed: false });
102
+ if (/Delivery state/.test(error.message)) return result('busy', { executed: false });
103
+ throw error;
104
+ }
105
+ if (!claimed) return result('state_changed', { executed: false, currentState: (await read())?.run?.state });
106
+ let outcome = result('unresolved', { executed: false });
107
+ try {
108
+ // At most check -> permitted repair -> check. All claims, budgets, native
109
+ // permissions, cancellation and effect reconciliation remain in the supervisor.
110
+ const actions = new Set();
111
+ for (let step = 0; step < 3; step++) {
112
+ const before = await read();
113
+ if (signal?.aborted) { outcome = result('cancelled', { executed: outcome.executed }); break; }
114
+ if (!matches(before, target)) { outcome = result('target_changed', { executed: outcome.executed }); break; }
115
+ reason = boundary(before, now());
116
+ if (reason) { outcome = result(reason, { executed: outcome.executed }); break; }
117
+ const action = before.run.managed.decision?.action;
118
+ const actionKey = JSON.stringify(action);
119
+ if (!['read', 'local_edit', 'local_test'].includes(action?.kind) || actions.has(actionKey)) break;
120
+ actions.add(actionKey);
121
+ const oldAttempt = before.run.managed.attempts?.at(-1)?.id;
122
+ await tick({ ...supervisorOptions, root, webOrigin: webOrigin || request.webOrigin, signal,
123
+ authorizeDispatch: async record => {
124
+ await requireProviderLink({ root, provider: target.provider, expectedEpoch: link.epoch });
125
+ return supervisorOptions.authorizeDispatch?.(record);
126
+ },
127
+ readRecords: async () => { const current = await read(); return matches(current, target) && current.run.managed.repairRequest?.requestId === request.requestId ? [current] : []; } });
128
+ const after = await read();
129
+ if (!matches(after, target)) { outcome = result('target_changed', { executed: outcome.executed }); break; }
130
+ const latest = after.run.managed.attempts?.at(-1);
131
+ const executed = outcome.executed || Boolean(latest?.id !== oldAttempt && latest?.outcome !== 'busy');
132
+ try { await requireProviderLink({ root, provider: target.provider, expectedEpoch: link.epoch }); }
133
+ catch { outcome = result('provider_link_required', { executed }); break; }
134
+ if (signal?.aborted) { outcome = result('cancelled', { executed }); break; }
135
+ reason = boundary(after, now());
136
+ if (reason) { outcome = result(reason, { executed }); break; }
137
+ const history = after.run.managed.workHistory?.at(-1);
138
+ if (latest?.id !== oldAttempt && latest?.outcome === 'checkpoint' && history?.repairResult) {
139
+ outcome = response(request, history.repairResult.status, 'observed_check', { executed, repairResult: history.repairResult, verificationSource: 'provider_observed' }); break;
140
+ }
141
+ outcome = result(latest?.outcome === 'busy' ? 'busy' : 'unresolved', { executed });
142
+ if (uncertain || latest?.id === oldAttempt || latest?.outcome !== 'checkpoint' || history?.result?.status !== 'observed' || after.run.managed.executionUncertain) break;
143
+ }
144
+ return { ...outcome, warnings: [
145
+ ...(diagnosis?.status === 'analysis_unavailable' ? ['diagnosis_unavailable'] : []),
146
+ ...(diagnosis?.analysis?.sheetDiagnostic ? ['incomplete_advisory_sheet'] : []),
147
+ ] };
148
+ } finally {
149
+ await updateRecord(root, target.provider, target.session, current => {
150
+ if (matches(current, target) && current.run.managed.repairRequest?.requestId === request.requestId) {
151
+ const receipt = current.run.managed.repairRequest;
152
+ receipt.status = outcome.status; receipt.reason = outcome.reason; receipt.settledFingerprint = fingerprint(current);
153
+ delete receipt.advice; delete receipt.instruction;
154
+ }
155
+ return current;
156
+ });
157
+ }
158
+ }
159
+
160
+ // Keep the conversation entry stable while returning to the original work only
161
+ // after the repair receipt has settled. The repair adapter above owns diagnosis.
162
+ export async function repairDelivery(options = {}) {
163
+ return repairAndContinueDelivery({ ...options, repairTask: repairStoppedDelivery });
164
+ }
@@ -0,0 +1,84 @@
1
+ import { join, resolve } from 'node:path';
2
+ import { lstat, realpath } from 'node:fs/promises';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { acquireDesktopProcessLock } from '../desktop-process-lock.js';
5
+ import { localRuntimeStatus, installLocalRuntime } from './local-runtime.js';
6
+ import { observeSupervisorOwner, readSupervisorState, saveSupervisorState, supervisorProcessIdentity, supervisorProcessAlive } from './supervisor-owner.js';
7
+
8
+ export const handoverPath = root => join(root, 'supervisor-runtime/handover.json');
9
+ const controlPath = root => join(root, 'supervisor-runtime/control.json');
10
+ const lockControl = root => acquireDesktopProcessLock(join(root, 'supervisor-runtime/control-lock'), {
11
+ alive: supervisorProcessAlive, identity: supervisorProcessIdentity });
12
+
13
+ export async function requestSupervisorHandover({ root, release, observeOwner = observeSupervisorOwner } = {}) {
14
+ const target = resolve(release);
15
+ if ((await lstat(target)).isSymbolicLink() || await realpath(target) !== target) throw new Error('Use a canonical immutable release.');
16
+ const candidate = await localRuntimeStatus({ root, source: target });
17
+ if (target !== join(resolve(root), 'delivery-runtime/releases', candidate.sourceDigest)) throw new Error('Handover target is not a verified installed release.');
18
+ const lock = await lockControl(root);
19
+ try {
20
+ const control = await readSupervisorState(controlPath(root));
21
+ if (!control?.enabled) return { status: 'not_requested', reason: 'supervisor_disabled' };
22
+ const owner = await observeOwner(root);
23
+ const watcher = await readSupervisorState(join(root, 'supervisor-runtime/watchdog.json'));
24
+ // Never signal old/unknown owners, or mistake process liveness for support.
25
+ if (owner.state !== 'running' || owner.handoverVersion !== 1 || !Number.isFinite(owner.lastHeartbeatAt) || owner.heartbeatStale) return {
26
+ status: 'not_requested', reason: 'owner_handover_unavailable', owner };
27
+ if (watcher?.handoverVersion !== 1 || watcher.epoch !== control.epoch || !Number.isFinite(watcher.at) ||
28
+ Date.now() - watcher.at > 30_000) return { status: 'not_requested', reason: 'watcher_handover_unavailable' };
29
+ const watcherOwner = await readSupervisorState(join(root, 'supervisor-runtime/watcher/desktop-app.lock/owner.json'));
30
+ if (!watcherOwner?.nonce || watcherOwner.nonce !== watcher.watcherNonce ||
31
+ await supervisorProcessIdentity(watcherOwner.pid).catch(() => null) !== watcherOwner.startedAt) return {
32
+ status: 'not_requested', reason: 'watcher_identity_unconfirmed' };
33
+ if (control.digest === candidate.sourceDigest) return { status: 'already_selected' };
34
+ const previous = await readSupervisorState(handoverPath(root));
35
+ if (previous && ['requested', 'drained'].includes(previous.status) && previous.epoch === control.epoch) {
36
+ if (previous.target.digest === candidate.sourceDigest) {
37
+ if (previous.status === 'drained' || previous.ownerNonce === owner.nonce) return previous;
38
+ // The original owner died before acknowledging. Rebind only after a
39
+ // fresh explicit retry, retaining the original identity and request.
40
+ const rebound = { ...previous, ownerNonce: owner.nonce, previousOwnerNonce: previous.ownerNonce, reboundAt: Date.now() };
41
+ await saveSupervisorState(handoverPath(root), rebound); return rebound;
42
+ }
43
+ throw new Error('Another supervisor handover is pending.');
44
+ }
45
+ const request = { version: 1, id: randomUUID(), status: 'requested', epoch: control.epoch,
46
+ ownerNonce: owner.nonce, fromDigest: control.digest, requestedAt: Date.now(),
47
+ target: { digest: candidate.sourceDigest, release: target, version: candidate.version } };
48
+ await saveSupervisorState(handoverPath(root), request);
49
+ return request;
50
+ } finally { await lock.release(); }
51
+ }
52
+
53
+ export async function pendingSupervisorHandover(root, nonce) {
54
+ const request = await readSupervisorState(handoverPath(root));
55
+ if (request?.version !== 1 || request.status !== 'requested' || request.ownerNonce !== nonce) return null;
56
+ const control = await readSupervisorState(controlPath(root));
57
+ return control?.enabled && control.epoch === request.epoch && control.digest === request.fromDigest ? request : null;
58
+ }
59
+
60
+ export async function acknowledgeSupervisorDrain(root, request, signal) {
61
+ const lock = await lockControl(root);
62
+ try {
63
+ const current = await pendingSupervisorHandover(root, request.ownerNonce);
64
+ if (signal?.aborted || current?.id !== request.id) return false;
65
+ await saveSupervisorState(handoverPath(root), { ...current, status: 'drained', drainedAt: Date.now() });
66
+ return true;
67
+ } finally { await lock.release(); }
68
+ }
69
+
70
+ // A watcher can finish an interrupted activation from the same durable request.
71
+ // installLocalRuntime revalidates epoch, owner absence and release under locks.
72
+ export async function completeSupervisorHandover(root) {
73
+ const request = await readSupervisorState(handoverPath(root));
74
+ if (request?.version !== 1 || request.status !== 'drained') return null;
75
+ const control = await readSupervisorState(controlPath(root));
76
+ if (!control?.enabled || control.epoch !== request.epoch) return null;
77
+ // Installed bundles may omit the lockfile and include dependencies in their
78
+ // fingerprint. Keep that format when activating the already-frozen release.
79
+ const build = await readSupervisorState(join(request.target.release, 'runtime-build.json'));
80
+ const includeDependencies = build?.fingerprintVersion === 2;
81
+ const candidate = await localRuntimeStatus({ root, source: request.target.release, allowMissingLock: true, includeDependencies });
82
+ if (candidate.sourceDigest !== request.target.digest) throw new Error('Supervisor handover runtime changed before activation.');
83
+ return installLocalRuntime({ root, source: request.target.release, handoverId: request.id, allowMissingLock: true, includeDependencies });
84
+ }
@@ -47,6 +47,7 @@ export async function observeSupervisorOwner(root, { now = Date.now(), alive = s
47
47
  try { heartbeat = await readSupervisorState(join(supervisorOwnerRoot(root), 'heartbeat.json')); } catch {}
48
48
  const lastHeartbeatAt = heartbeat?.nonce === owner.nonce && Number.isFinite(heartbeat.at) ? heartbeat.at : null;
49
49
  return { state: 'running', pid: owner.pid, nonce: owner.nonce, lastHeartbeatAt,
50
+ ...(heartbeat?.nonce === owner.nonce && heartbeat.handoverVersion === 1 ? { handoverVersion: 1 } : {}),
50
51
  heartbeatStale: lastHeartbeatAt !== null && now - lastHeartbeatAt > 60_000 };
51
52
  }
52
53
 
@@ -54,14 +55,14 @@ export async function observeSupervisorOwner(root, { now = Date.now(), alive = s
54
55
  // than letting a missing identity probe reclaim a live owner. Task leases and
55
56
  // desktop ownership remain separate, and keep their existing authority checks.
56
57
  export async function acquireSupervisorOwner(root, { alive = supervisorProcessAlive,
57
- identity = supervisorProcessIdentity, heartbeatMs = 15_000 } = {}) {
58
+ identity = supervisorProcessIdentity, heartbeatMs = 15_000, handoverVersion } = {}) {
58
59
  const directory = supervisorOwnerRoot(root);
59
60
  const lock = await acquireDesktopProcessLock(directory, { alive, identity });
60
61
  const file = join(directory, 'heartbeat.json');
61
62
  let pending = Promise.resolve(), closed = false;
62
63
  const beat = () => {
63
64
  pending = pending.catch(() => {}).then(async () => {
64
- if (!closed) await saveSupervisorState(file, { version: 1, pid: process.pid, nonce: lock.nonce, at: Date.now() });
65
+ if (!closed) await saveSupervisorState(file, { version: 1, pid: process.pid, nonce: lock.nonce, at: Date.now(), ...(handoverVersion === 1 ? { handoverVersion } : {}) });
65
66
  });
66
67
  return pending;
67
68
  };