@awak-app/simy-cli 0.7.14 → 0.7.16

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 (68) hide show
  1. package/README.md +45 -72
  2. package/package.json +3 -2
  3. package/src/agent.js +1 -0
  4. package/src/backend-executable.js +7 -6
  5. package/src/cli-contract.js +1 -1
  6. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +43 -13
  7. package/src/codex-plugins/simy/hooks/outcome-progress.mjs +1 -2
  8. package/src/codex-plugins/simy/hooks/purpose-cycle.mjs +12 -11
  9. package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +21 -33
  10. package/src/codex-plugins/simy/hooks/purpose-rule-policy.mjs +1 -2
  11. package/src/codex-plugins/simy/hooks/purpose-rule-review.mjs +5 -63
  12. package/src/codex-plugins/simy/hooks/purpose-search.mjs +21 -4
  13. package/src/codex-plugins/simy/hooks/result-cycle.mjs +140 -0
  14. package/src/codex-plugins/simy/hooks/scene-reasoning.mjs +98 -12
  15. package/src/codex-plugins/simy/hooks/user-experience.mjs +26 -0
  16. package/src/delivery-hooks/claude-workflow.md +1 -1
  17. package/src/delivery-hooks/command.js +31 -0
  18. package/src/delivery-hooks/consistency-monitor.js +20 -4
  19. package/src/delivery-hooks/local-runtime.js +32 -1
  20. package/src/delivery-hooks/local-supervisor.js +12 -3
  21. package/src/delivery-hooks/supervisor-handover.js +84 -0
  22. package/src/delivery-hooks/supervisor-owner.js +3 -2
  23. package/src/delivery-hooks/supervisor-service.js +36 -6
  24. package/src/delivery-hooks/supervisor-watchdog.js +15 -4
  25. package/src/delivery-hooks/supervisor.js +66 -9
  26. package/src/orchestrator/audit.js +24 -323
  27. package/src/orchestrator/charter-validation.js +3 -41
  28. package/src/orchestrator/checklist-continuation.js +2 -14
  29. package/src/orchestrator/completion-contract.js +4 -49
  30. package/src/orchestrator/contract.js +5 -9
  31. package/src/orchestrator/delivery/WORKFLOW.md +30 -710
  32. package/src/orchestrator/delivery/references/acceptance-evidence.md +4 -216
  33. package/src/orchestrator/delivery/references/change-boundary-matrix.md +4 -103
  34. package/src/orchestrator/delivery/references/chrome-user-verification.md +5 -133
  35. package/src/orchestrator/delivery/references/cross-repository-control.md +4 -74
  36. package/src/orchestrator/delivery/references/delivery-levels.md +3 -117
  37. package/src/orchestrator/delivery/references/local-sqm-evidence.md +1 -1
  38. package/src/orchestrator/delivery/references/minimum-completion.md +4 -144
  39. package/src/orchestrator/delivery/references/pr-closure.md +4 -200
  40. package/src/orchestrator/delivery/references/shared-system-specifications.md +4 -161
  41. package/src/orchestrator/delivery/references/state-and-receipts.md +4 -245
  42. package/src/orchestrator/delivery/references/state-model.md +4 -185
  43. package/src/orchestrator/delivery/references/ux-specification.md +4 -95
  44. package/src/orchestrator/delivery-level.js +2 -10
  45. package/src/orchestrator/evidence.js +2 -3
  46. package/src/orchestrator/independent-audit.js +7 -147
  47. package/src/orchestrator/instruction.js +19 -254
  48. package/src/orchestrator/loop.js +8 -36
  49. package/src/orchestrator/outcome-monitor.js +21 -0
  50. package/src/orchestrator/pr-closure.js +2 -16
  51. package/src/orchestrator/ux-specification.js +3 -12
  52. package/src/runner.js +3 -1
  53. package/src/session-supervision/acceptance-reads.js +34 -0
  54. package/src/session-supervision/browser-cdp.js +59 -0
  55. package/src/session-supervision/browser-recovery.js +97 -0
  56. package/src/session-supervision/codex-app-server.js +42 -17
  57. package/src/session-supervision/continuation-analysis.js +2 -1
  58. package/src/session-supervision/final-response-recovery.js +3 -0
  59. package/src/session-supervision/interaction-proxy.js +250 -0
  60. package/src/session-supervision/interaction-task.js +189 -0
  61. package/src/session-supervision/native-interaction.js +102 -0
  62. package/src/session-supervision/provider-failure.js +40 -0
  63. package/src/session-supervision/purpose-instruction.js +14 -14
  64. package/src/session-supervision/purpose-recovery.js +20 -9
  65. package/src/session-supervision/purpose-verification.js +43 -161
  66. package/src/sqm/claude-plugin.js +2 -0
  67. package/src/sqm/local-tests.js +2 -1
  68. package/src/startup-preflight.js +2 -20
@@ -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');
@@ -110,7 +112,7 @@ export async function adoptInstalledRuntime({ root, source = packageRoot, codexH
110
112
  // Install a frozen candidate alongside the released app, without replacing a
111
113
  // signed application, changing provider trust, or starting any registered task.
112
114
  export async function installLocalRuntime({ root, source = packageRoot, allowMissingLock = false, includeDependencies = false,
113
- expectedInstallationDigest, verifyBeforeActivate = async () => {},
115
+ expectedInstallationDigest, verifyBeforeActivate = async () => {}, handoverId,
114
116
  installDependencies = async target => exec(process.platform === 'win32' ? 'npm.cmd' : 'npm',
115
117
  ['ci', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund'], { cwd: target, timeout: 120_000, maxBuffer: 1024 * 1024 }),
116
118
  } = {}) {
@@ -148,6 +150,30 @@ export async function installLocalRuntime({ root, source = packageRoot, allowMis
148
150
  const saved = JSON.parse(await readFile(join(release, 'runtime-build.json'), 'utf8'));
149
151
  if (saved.schema !== 1 || saved.digest !== digest || await fingerprint(release, { allowMissingLock, includeDependencies }) !== digest) throw new Error('Existing candidate failed integrity verification.');
150
152
  }
153
+ const activationLock = await acquireDesktopProcessLock(join(root, 'supervisor-runtime', 'control-lock'), {
154
+ alive: supervisorProcessAlive, identity: supervisorProcessIdentity });
155
+ let handoverOwner, handover;
156
+ try {
157
+ let control;
158
+ try { control = JSON.parse(await readFile(join(root, 'supervisor-runtime/control.json'), 'utf8')); }
159
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
160
+ // Preparing a release must not disable the existing watchdog's recovery
161
+ // contract. Keep its pin and launchers until a coordinated handover.
162
+ if (handoverId) {
163
+ handover = await readSupervisorState(join(root, 'supervisor-runtime/handover.json'));
164
+ if (!control?.enabled || handover?.version !== 1 || handover.id !== handoverId || handover.status !== 'drained' ||
165
+ handover.epoch !== control.epoch || handover.target?.digest !== digest || handover.target.release !== release ||
166
+ ![handover.fromDigest, digest].includes(control.digest) ||
167
+ ![handover.fromDigest, digest].includes(before.installation?.digest)) throw new Error('Supervisor handover changed or was stopped.');
168
+ // Fence every replacement until the previous owner has actually released
169
+ // its singleton, including the acknowledgement/release crash gap.
170
+ handoverOwner = await acquireSupervisorOwner(root);
171
+ }
172
+ if (!handover && control?.enabled === true && control.digest !== digest) {
173
+ return { schema: 1, version: before.version, digest, release,
174
+ activation: 'deferred', reason: 'supervisor_handover_required',
175
+ activeInstallation: before.installation };
176
+ }
151
177
  await verifyBeforeActivate();
152
178
  const bin = join(root, 'bin');
153
179
  await mkdir(bin, { recursive: true, mode: 0o700 });
@@ -169,6 +195,11 @@ export async function installLocalRuntime({ root, source = packageRoot, allowMis
169
195
  previous: before.installation?.digest === digest ? before.installation.previous
170
196
  : before.installation ? { version: before.installation.version, digest: before.installation.digest, release: before.installation.release } : null };
171
197
  await atomicJson(join(storage, 'current.json'), current);
198
+ if (handover) {
199
+ await atomicJson(join(root, 'supervisor-runtime/control.json'), { ...control, digest, release, updatedAt: Date.now() });
200
+ await atomicJson(join(root, 'supervisor-runtime/handover.json'), { ...handover, status: 'activated', activatedAt: Date.now() });
201
+ }
172
202
  return current;
203
+ } finally { await handoverOwner?.release(); await activationLock.release(); }
173
204
  } finally { await rm(staged, { recursive: true, force: true }); await rm(lock, { recursive: true, force: true }); }
174
205
  }
@@ -3,23 +3,32 @@ 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
34
  if (once) break;
@@ -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
  };
@@ -2,8 +2,10 @@ import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { mkdir, lstat, readFile, writeFile } from 'node:fs/promises';
4
4
  import { homedir } from 'node:os';
5
- import { dirname, join } from 'node:path';
5
+ import { dirname, join, isAbsolute, delimiter } from 'node:path';
6
6
  import { pinnedWorkflowSource } from './local-runtime.js';
7
+ import { inspectBackendExecutable, backendCandidates, CODEX_WRITER_LOCK_MINIMUM_VERSION } from '../backend-executable.js';
8
+ import { runSceneReasoning } from '../codex-plugins/simy/hooks/scene-reasoning.mjs';
7
9
  import { readSupervisorState } from './supervisor-owner.js';
8
10
 
9
11
  const exec = promisify(execFile);
@@ -12,9 +14,12 @@ const xml = text => String(text).replaceAll('&', '&amp;').replaceAll('<', '&lt;'
12
14
  const systemdArgument = text => `"${String(text).replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%').replaceAll('$', () => '$$')}"`;
13
15
  const windowsArgument = text => `"${String(text).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, '$1$1')}"`;
14
16
 
15
- export function supervisorServiceDefinition({ root, entry, node = process.execPath, platform = process.platform, home = homedir() }) {
17
+ export function supervisorServiceDefinition({ root, entry, node = process.execPath, platform = process.platform, home = homedir(), providerEnvironment = {} }) {
16
18
  if ([root, entry, node].some(value => typeof value !== 'string' || /[\r\n\0]/.test(value))) throw new Error('Invalid supervisor service path.');
17
- const args = [entry, 'delivery', 'runtime', 'supervisor-watch', '--root', root, '--no-auto-update'];
19
+ const environment = Object.fromEntries(Object.entries(providerEnvironment).filter(([key]) => ['PATH', 'SIMY_CODEX_BIN', 'SIMY_CODEX_EXPECTED_VERSION'].includes(key)));
20
+ if (Object.values(environment).some(value => typeof value !== 'string' || /[\r\n\0]/.test(value))) throw new Error('Invalid provider environment.');
21
+ const bootstrap = Object.keys(environment).length ? ['--import', `data:text/javascript,${encodeURIComponent(`Object.assign(process.env,${JSON.stringify(environment)})`)}`] : [];
22
+ const args = [...bootstrap, entry, 'delivery', 'runtime', 'supervisor-watch', '--root', root, '--no-auto-update'];
18
23
  if (platform === 'linux') {
19
24
  const name = 'simy-delivery-supervisor.service';
20
25
  return { name, file: join(home, '.config/systemd/user', name),
@@ -37,13 +42,14 @@ export function supervisorServiceDefinition({ root, entry, node = process.execPa
37
42
  // Explicit service installation only. Installing/updating a runtime alone
38
43
  // never enables this service or enrolls a conversation.
39
44
  export async function installSupervisorService({ root, home = homedir(), platform = process.platform,
40
- run = exec, inspectPin = pinnedWorkflowSource } = {}) {
45
+ run = exec, inspectPin = pinnedWorkflowSource, inspectBackend = inspectBackendExecutable, environment = process.env } = {}) {
41
46
  const control = await readSupervisorState(join(root, 'supervisor-runtime/control.json'));
42
47
  if (control?.version !== 1 || control.enabled !== true) throw new Error('Enable supervisor recovery explicitly before installing its OS service.');
43
48
  await inspectPin({ root });
44
49
  const pin = await readSupervisorState(join(root, 'delivery-runtime/current.json'));
45
50
  if (!pin || pin.digest !== control.digest || pin.release !== control.release) throw new Error('Supervisor activation no longer matches the fixed runtime.');
46
- const definition = supervisorServiceDefinition({ root, home, platform, entry: join(pin.release, 'src/index.js') });
51
+ const provider = await supervisorProviderEnvironment({ home, platform, environment, inspectBackend });
52
+ const definition = supervisorServiceDefinition({ root, home, platform, entry: join(pin.release, 'src/index.js'), providerEnvironment: provider.environment });
47
53
  try {
48
54
  const info = await lstat(definition.file);
49
55
  if (!info.isFile() || info.isSymbolicLink() || !(await readFile(definition.file, 'utf8')).includes(marker)) throw new Error('Supervisor service path is not managed by SIMY.');
@@ -61,5 +67,29 @@ export async function installSupervisorService({ root, home = homedir(), platfor
61
67
  await run(scheduler, ['/Create', '/TN', definition.name, '/XML', definition.file, '/F']);
62
68
  await run(scheduler, ['/Run', '/TN', definition.name]);
63
69
  }
64
- return { ...definition, content: undefined, status: 'registered', scope: 'previously_enrolled_tasks_only' };
70
+ return { ...definition, content: undefined, status: 'registered', execution: 'awaiting_service_observation', provider: provider.inspection, scope: 'previously_enrolled_tasks_only' };
71
+ }
72
+
73
+
74
+ // Resolve and re-probe under exactly the environment passed to the service.
75
+ // This is a prerequisite observation, never proof of task execution/acceptance.
76
+ export async function supervisorProviderEnvironment({ home = homedir(), platform = process.platform, environment = process.env,
77
+ inspectBackend = inspectBackendExecutable, onStep } = {}) {
78
+ return runSceneReasoning({ home, platform, environment }, {
79
+ understand_scene: scene => ({ ...scene, candidates: backendCandidates('codex', { home, platform, environment }).filter(value => typeof value === 'string' && isAbsolute(value)) }),
80
+ derive_perspectives: async scene => ({ ...scene, inspection: await inspectBackend('codex', {
81
+ candidates: scene.candidates, minimumVersion: CODEX_WRITER_LOCK_MINIMUM_VERSION, discoveryOptions: { home, platform, environment },
82
+ }) }),
83
+ prioritize_options: scene => {
84
+ if (!scene.inspection.compatible || !isAbsolute(scene.inspection.executable || '')) throw new Error('No compatible absolute Codex executable for the supervisor service.');
85
+ const selected = scene.inspection.executable;
86
+ return { ...scene, selected, environment: { PATH: [dirname(process.execPath), dirname(selected), environment.PATH || environment.Path || ''].join(delimiter), SIMY_CODEX_BIN: selected, SIMY_CODEX_EXPECTED_VERSION: scene.inspection.installed_version } };
87
+ },
88
+ connect_to_next_check: async scene => {
89
+ const verified = await inspectBackend('codex', { candidates: [scene.selected], minimumVersion: CODEX_WRITER_LOCK_MINIMUM_VERSION,
90
+ discoveryOptions: { home, platform, environment: scene.environment }, executionEnvironment: scene.environment });
91
+ if (!verified.compatible || verified.executable !== scene.selected || verified.installed_version !== scene.inspection.installed_version) throw new Error('Supervisor provider changed during service-environment verification.');
92
+ return { environment: scene.environment, inspection: verified };
93
+ },
94
+ }, { onStep });
65
95
  }
@@ -5,6 +5,7 @@ import { setTimeout as delay } from 'node:timers/promises';
5
5
  import { acquireDesktopProcessLock, assertNoDesktopHostForOrigin } from '../desktop-process-lock.js';
6
6
  import { pinnedWorkflowSource } from './local-runtime.js';
7
7
  import { resolveWebOrigin } from '../web-origin.js';
8
+ import { completeSupervisorHandover } from './supervisor-handover.js';
8
9
  import { observeSupervisorOwner, supervisorProcessAlive, supervisorProcessIdentity,
9
10
  readSupervisorState, saveSupervisorState } from './supervisor-owner.js';
10
11
 
@@ -39,17 +40,18 @@ export async function supervisorWatchdogStatus({ root, now = Date.now(), observe
39
40
  export function createSupervisorWatchdog({ root, inspectPin = verifiedPin, readControl = () => readSupervisorState(controlPath(root)),
40
41
  observeOwner = observeSupervisorOwner, checkDesktop = assertNoDesktopHostForOrigin,
41
42
  launch = (bin, args, options) => spawn(bin, args, options), now = Date.now,
43
+ completeHandover = completeSupervisorHandover, watcherNonce,
42
44
  identity = supervisorProcessIdentity, save = state => saveSupervisorState(statePath(root), state) } = {}) {
43
45
  let child = null, childIdentity = null, launchTime = null, closed = false, running = null, lastExit = null;
44
- let pendingPin = null, lastWall = null, controlEpoch;
46
+ let pendingPin = null, lastWall = null, controlEpoch, handoverRetryAt = 0;
45
47
  const clock = () => Math.max(0, now());
46
48
  const tick = async () => {
47
49
  if (closed) return;
48
50
  const at = clock(), control = await readControl();
49
51
  controlEpoch = control?.epoch;
50
52
  const previous = pendingPin || await readSupervisorState(statePath(root));
51
- const state = { version: 1, at, action: 'observe', supervisor: null, lastExit,
52
- lastLaunchAt: launchTime ?? previous?.lastLaunchAt ?? null, epoch: previous?.epoch || null };
53
+ const state = { version: 1, handoverVersion: 1, watcherNonce, at, action: 'observe', supervisor: null, lastExit,
54
+ lastLaunchAt: launchTime ?? previous?.lastLaunchAt ?? null, epoch: control?.epoch || null };
53
55
  if (!control || control.version !== 1 || control.enabled !== true) { state.action = 'disabled'; await save(state); return state; }
54
56
  state.supervisor = await observeOwner(root, { now: at });
55
57
  if (state.supervisor.state === 'running') {
@@ -57,6 +59,15 @@ export function createSupervisorWatchdog({ root, inspectPin = verifiedPin, readC
57
59
  await save(state); return state;
58
60
  }
59
61
  if (!['missing', 'dead'].includes(state.supervisor.state)) { state.action = 'owner_unknown'; await save(state); return state; }
62
+ const retryAt = Math.max(handoverRetryAt, previous?.handoverRetryAt || 0);
63
+ if (retryAt > at) { state.action = 'handover_backoff'; state.handoverRetryAt = retryAt; await save(state); return state; }
64
+ try {
65
+ if (await completeHandover(root)) { state.action = 'handover_activated'; await save(state); return state; }
66
+ } catch (error) {
67
+ state.action = 'handover_wait'; state.reason = error.code === 'process_conflict' ? 'owner_not_released' : 'activation_failed';
68
+ handoverRetryAt = state.handoverRetryAt = at + 30_000;
69
+ await save(state); return state;
70
+ }
60
71
  if (child && child.exitCode === null && child.signalCode === null) { state.action = 'child_starting'; await save(state); return state; }
61
72
  // Persist the launch intent before spawn. On watchdog crash/restart this
62
73
  // gives the previous child time to publish its singleton owner receipt.
@@ -125,7 +136,7 @@ export async function watchSupervisor({ root, signal, once = false, wait = delay
125
136
  let watchdog, initialEpoch;
126
137
  try {
127
138
  initialEpoch = (await readSupervisorState(controlPath(root)))?.epoch;
128
- watchdog = createWatchdog({ root });
139
+ watchdog = createWatchdog({ root, watcherNonce: lock.nonce });
129
140
  do {
130
141
  if (signal?.aborted) break;
131
142
  await watchdog.tick();
@@ -1,6 +1,8 @@
1
1
  import { captureDispatchProvenance } from '../update-detachment/provenance.js';
2
2
  import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, queueDispatchReceipt, rootFromDelivery, observeDispatchEvent } from '../update-detachment/dispatch-gate.js';
3
3
  import { safeCheckpointDiagnostic } from './checkpoint-diagnostic.js';
4
+ import { continueResultCycle, resultEvent, executeResultCycle, acceptResultEvent } from '../codex-plugins/simy/hooks/result-cycle.mjs';
5
+ import { finalResponseRecoveryInstruction } from '../session-supervision/final-response-recovery.js';
4
6
  import { recordHandoffStage } from '../codex-plugins/simy/hooks/consistency-handoff.mjs';
5
7
  import { retainControllerEvidence } from '../codex-plugins/simy/hooks/purpose-evidence.mjs';
6
8
  import { workspaceIdentity, reviewWorkspaceIdentity } from '../session-supervision/purpose-verification.js';
@@ -15,9 +17,10 @@ import { requireProviderLink } from '../provider-links.js';
15
17
  import { spawn } from 'node:child_process';
16
18
  import { randomUUID } from 'node:crypto';
17
19
  import { dirname } from 'node:path';
18
- import { hasPendingUserStop, recordPath, canExecuteAutonomousExternal, automaticContinuationAllowed, settlePurposeVerification, assessedCheckpointContract, checkpointDelivery, decideStop, deliveryProgressContext, deliveryStop, listRecords, readRecord, managedBudgetReason, notifyState, processIdentity, sharesTaskWorkspace, taskDirectory, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
20
+ import { hasPendingUserStop, recordPath, canExecuteAutonomousExternal, automaticContinuationAllowed, queuePurposeDiagnosis, settlePurposeVerification, assessedCheckpointContract, checkpointDelivery, decideStop, deliveryProgressContext, deliveryStop, listRecords, readRecord, managedBudgetReason, notifyState, processIdentity, sharesTaskWorkspace, taskDirectory, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
19
21
  import { resolveBackendExecutable } from '../backend-executable.js';
20
- import { executeCodexAppServer } from '../session-supervision/codex-app-server.js';
22
+ import { safeProviderFailure, providerEnvironmentIdentity, checkProviderRetry } from '../session-supervision/provider-failure.js';
23
+ import { executeCodexAppServer, inspectCodexAppServer } from '../session-supervision/codex-app-server.js';
21
24
  import { acquireWorkspaceLease, leaseOwnerDead } from '../session-supervision/workspace-lease.js';
22
25
 
23
26
  const MAX_OUTPUT = 64 * 1024;
@@ -150,7 +153,7 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
150
153
  });
151
154
  }
152
155
 
153
- export async function tickManagedTasks({ root, webOrigin, now = Date.now(), execute, inspectProcess = processIdentity, signal, beforeClaim, authorizeDispatch, readRecords, observeTurn, observeAuthentication } = {}) {
156
+ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), execute, inspectProcess = processIdentity, signal, beforeClaim, authorizeDispatch, readRecords, observeTurn, observeAuthentication, inspectProvider = inspectCodexAppServer, shouldYield } = {}) {
154
157
  const tickWallStart = Date.now();
155
158
  const currentTime = () => now + Math.max(0, Date.now() - tickWallStart);
156
159
  if (signal?.aborted) return;
@@ -160,7 +163,7 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
160
163
  if (signal?.aborted) return;
161
164
  const records = await readCandidates();
162
165
  for (let record of records) {
163
- if (signal?.aborted) break;
166
+ if (signal?.aborted || await shouldYield?.()) break;
164
167
  const claimNow = currentTime();
165
168
  if (!record.run?.managed) continue;
166
169
  record = await recoverOutcomeAuditLease(record, { root, inspectProcess });
@@ -198,6 +201,24 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
198
201
  }
199
202
  if (!automaticContinuationAllowed(record)) continue;
200
203
  try { await requireProviderLink({ root, provider: record.provider }); } catch { continue; }
204
+ if (run.managed.providerCapabilityWait) {
205
+ if (run.state !== 'recovery_required' || run.managed.resumeLease || managedBudgetReason(run, claimNow)) continue;
206
+ const expected = run.managed.providerCapabilityWait;
207
+ let check;
208
+ try { check = await checkProviderRetry(expected, inspectProvider, { now: claimNow, signal }); } catch { continue; }
209
+ record = await updateRecord(root, record.provider, record.session, current => {
210
+ if (current?.run?.id !== run.id || !automaticContinuationAllowed(current) || current.run.state !== 'recovery_required' ||
211
+ current.run.managed.resumeLease || JSON.stringify(current.run.managed.providerCapabilityWait) !== JSON.stringify(expected)) return current;
212
+ if (check.retry) {
213
+ delete current.run.managed.providerCapabilityWait;
214
+ current.run.managed.autoRecovery = true; current.run.recoveryCause = 'session_supervision';
215
+ current.run.managed.nextAttemptAt = claimNow;
216
+ } else current.run.managed.providerCapabilityWait = check.wait;
217
+ return current;
218
+ });
219
+ run = record.run;
220
+ if (run.managed.providerCapabilityWait) continue;
221
+ }
201
222
  const appServer = run.managed.resumeChannel === 'codex_app_server';
202
223
  const causeAllowed = run.recoveryCause === 'process_exit' || appServer && ['session_supervision', 'approved_action'].includes(run.recoveryCause);
203
224
  const approvedWait = run.managed.decision?.outcome === 'wait' && ['read', 'local_edit', 'local_test', 'external'].includes(run.managed.decision.action?.kind) &&
@@ -228,6 +249,7 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
228
249
  let claimed;
229
250
  try { claimed = await updateRecord(root, record.provider, record.session, current => {
230
251
  const live = current?.run;
252
+ if (live?.id !== run.id || live.revision !== run.revision || live.managed?.decision?.id !== run.managed?.decision?.id || live.managed?.providerCapabilityWait) return current;
231
253
  if (!automaticContinuationAllowed(current)) return current;
232
254
  if (live?.managed?.outcomeAudit?.lease) return current;
233
255
  const appChannel = live?.managed?.resumeChannel === 'codex_app_server';
@@ -245,6 +267,12 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
245
267
  action: live.managed.decision?.action || live.managed.proposedAction,
246
268
  budgetReservation: { rootTaskId: live.managed.taskId, tokens: live.managed.maxTokens ? Math.max(0, live.managed.maxTokens - (live.managed.tokensUsed || 0)) : null, state: 'reserved', epoch: leaseId }, phase: 'claimed', claimedAt: currentTime(), lastActivityAt: currentTime() };
247
269
  live.managed.executionAttempt.recoveryIncidentId = live.managed.recovery?.currentId || null;
270
+ const pendingResult = live.resultCycle?.events.find(row => row.id === live.resultCycle.pendingId);
271
+ // Carry the actual preceding result into its dispatch receipt. A synthetic
272
+ // initial/condition event is needed only when no unconsumed result exists.
273
+ if (!pendingResult || pendingResult.dispatchAttemptId) acceptResultEvent(current, resultEvent(current, {
274
+ kind: 'condition_changed', origin: 'supervisor_claim',
275
+ sourceId: `dispatch:${live.managed.decision?.id}:${live.revision}`, now: currentTime() }));
248
276
  // Retain the repair action identity during independent verification.
249
277
  // Older receipts can still be executing after a verifier; bind those to
250
278
  // the next actual diagnosis/repair only after all lease/authority fences.
@@ -318,7 +346,8 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
318
346
  if (!result) {
319
347
  await authorizeClaim();
320
348
  const runner = execute || (appServer ? executeCodexAppServer : executeCodexResume);
321
- result = await runner(claimed, { signal: controller.signal, root, webOrigin, leaseId, currentTime, leaseMonitored: true,
349
+ result = await executeResultCycle({ record: claimed, attemptId: leaseId, execute: runner, persist: persistReceipt,
350
+ options: { signal: controller.signal, root, webOrigin, leaseId, currentTime, leaseMonitored: true,
322
351
  onStarted: async observation => {
323
352
  await persistReceipt(current => {
324
353
  const attempt = current?.run?.managed?.executionAttempt;
@@ -376,7 +405,7 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
376
405
  return current;
377
406
  });
378
407
  await authorizeClaim();
379
- } });
408
+ } } });
380
409
  }
381
410
  }
382
411
  } catch (error) { result = error?.code === 'provider_link_disconnected'
@@ -384,8 +413,13 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
384
413
  : error?.code === 'desktop_access_required'
385
414
  ? { code: -3, busy: true, accessDenied: true }
386
415
  : { code: controller.signal.aborted ? -2 : -1, cancelled: controller.signal.aborted,
387
- providerTurnId: error?.providerTurnId, providerSessionId:error?.providerSessionId, question: error?.question, errorCode: error?.code || 'provider_error', restorationUnverified: error?.restorationUnverified === true }; }
416
+ providerTurnId: error?.providerTurnId, providerSessionId:error?.providerSessionId, question: error?.question,
417
+ finalResponseWithoutCheckpoint: error?.finalResponseWithoutCheckpoint,
418
+ errorCode: error?.code || 'provider_error', diagnostic: safeProviderFailure(error?.diagnostic), restorationUnverified: error?.restorationUnverified === true }; }
388
419
  try {
420
+ if (!dispatched && ['unsupported', 'unavailable'].includes(result.errorCode) && ['writer_lock', 'provider_available', 'service_identity', 'bounded_history', 'connector_inventory', 'connector_pagination', 'review_configuration', 'review_isolation'].includes(result.diagnostic?.capability)) {
421
+ try { result.providerEnvironment = await providerEnvironmentIdentity(inspectProvider); } catch { /* No guessed identity: normal bounded failure path remains. */ }
422
+ }
389
423
  await usageQueue;
390
424
  if (usageError) result = { ...result, code: -1, errorCode: 'usage_unavailable' };
391
425
  usage ||= result?.usage || null;
@@ -435,6 +469,10 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
435
469
  }
436
470
  if (result.errorCode === 'checkpoint_rejected') live.managed.lastCheckpointRejection = { at: Date.now(), decisionId: claimed.run.managed.decision?.id, diagnostic: result.diagnostic };
437
471
  else if (result.code === 0) delete live.managed.lastCheckpointRejection;
472
+ if (result.errorCode === 'checkpoint' && result.finalResponseWithoutCheckpoint) {
473
+ live.managed.finalResponseWithoutCheckpoint = { ...result.finalResponseWithoutCheckpoint,
474
+ providerTurnId: result.providerTurnId, providerSessionId: result.providerSessionId || record.session };
475
+ } else if (result.code === 0) delete live.managed.finalResponseWithoutCheckpoint;
438
476
  const attempt = live.managed.executionAttempt;
439
477
  if (!result.busy && attempt?.phase === 'dispatching' && attempt.action?.kind !== 'read') live.managed.executionUncertain = {
440
478
  attemptId: leaseId, decisionId: attempt.decisionId, action: attempt.action, at: Date.now() };
@@ -445,8 +483,9 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
445
483
  checkpointRevision: live.revision, outcome: result.busy ? 'busy' : result.cancelled ? 'cancelled' : result.code === 0 ? 'checkpoint' : 'failed',
446
484
  errorCode: result.code === 0 || result.busy || result.cancelled ? null : safeProviderCode(result.errorCode),
447
485
  ...(result.providerSessionId ? {providerSessionId:result.providerSessionId} : {}),
486
+ ...(safeProviderFailure(result.diagnostic) ? { diagnostic: safeProviderFailure(result.diagnostic) } : {}),
448
487
  restorationUnverified: result.restorationUnverified === true,
449
- reason: result.accessDenied ? 'Desktop organization access is required before another provider turn.' : result.busy ? 'Exact provider session currently has another writer.' : result.cancelled ? 'Managed recovery cancelled before completion.' : result.code === 0 ? 'Structured checkpoint recorded.' : 'Provider failed or returned an invalid checkpoint.',
488
+ reason: result.accessDenied ? 'Desktop organization access is required before another provider turn.' : result.busy ? 'Exact provider session currently has another writer.' : result.cancelled ? 'Managed recovery cancelled before completion.' : result.code === 0 ? 'Structured checkpoint recorded.' : result.diagnostic ? `Provider capability unavailable: ${safeProviderFailure(result.diagnostic)?.capability || 'unknown'}. Retry after its environment changes.` : 'Provider failed or returned an invalid checkpoint.',
450
489
  nextStep: live.nextStep });
451
490
  live.managed.attempts = live.managed.attempts.slice(-20);
452
491
  if (result.code !== 0) recordRecoveryStage(live, result.busy ? 'waiting' : 'interrupted', {
@@ -486,6 +525,10 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
486
525
  } else if (result.cancelled) {
487
526
  live.managed.autoRecovery = false; live.state = 'recovery_required'; live.recoveryCause = 'shutdown_cancelled';
488
527
  live.reason = 'Managed recovery was cancelled; inspect the original session before the next bounded recovery.';
528
+ } else if (result.providerEnvironment && !dispatched) {
529
+ live.managed.providerCapabilityWait = { diagnostic: safeProviderFailure(result.diagnostic), environmentIdentity: result.providerEnvironment.identity, nextCheckAt: currentTime() + 60000, checks: 0 };
530
+ live.managed.autoRecovery = true; live.state = 'recovery_required'; live.recoveryCause = 'session_supervision';
531
+ live.reason = 'Provider capability needs an environment change; bounded read-only checks continue without spending another provider turn.';
489
532
  } else if (result.code !== 0) {
490
533
  live.failures += 1; live.managed.autoRecovery = false;
491
534
  live.state = failureLimitReached(live) ? 'failed' : 'recovery_required';
@@ -493,6 +536,16 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
493
536
  live.reason = appServer ? providerFailureReason(safeProviderCode(result.errorCode)) +
494
537
  (result.restorationUnverified ? ' Original provider permissions also require inspection.' : '') :
495
538
  'Codex recovery process failed; inspect its checkpoint and original session before the next bounded recovery.';
539
+ // Recover the missing handoff with a read diagnosis, never by replaying
540
+ // the last action. The existing dispatcher rechecks leases and rights.
541
+ if (appServer && live.state !== 'failed' && automaticContinuationAllowed(current) &&
542
+ !live.managed.pendingApproval && !live.managed.nativeWait && !live.managed.purpose?.nativeBoundary &&
543
+ (result.errorCode === 'checkpoint' && result.finalResponseWithoutCheckpoint || result.errorCode === 'checkpoint_rejected')) {
544
+ await continueResultCycle(current, resultEvent(current, { kind: 'final_response', origin: 'executor_result',
545
+ sourceId: result.providerTurnId || leaseId, attemptId: leaseId, resultRef: `attempt:${leaseId}` }), {
546
+ selectNext: () => queuePurposeDiagnosis(live, finalResponseRecoveryInstruction,
547
+ `missing-handoff:${result.providerTurnId || leaseId}`, Date.now(), 'checkpoint') });
548
+ }
496
549
  } else if (live.revision <= run.revision) {
497
550
  live.failures += 1;
498
551
  live.reason = result.code === 0 ? 'Codex recovery returned without a checkpoint.' : 'Codex recovery failed; inspect the original session.';
@@ -514,6 +567,9 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
514
567
  current.run.reason = 'Verified checkpoint recorded; next bounded exact-session attempt is pending.';
515
568
  }
516
569
  }
570
+ if (result.code !== 0) await continueResultCycle(current, resultEvent(current, {
571
+ kind: 'failure', origin: 'executor_result', sourceId: result.providerTurnId || leaseId,
572
+ attemptId: leaseId, resultRef: `attempt:${leaseId}` }));
517
573
  current.run.updatedAt = Date.now(); notifyState(current.run, current.run.updatedAt); return current;
518
574
  });
519
575
  // Settling uncertainty must precede unlocking: otherwise another session
@@ -532,12 +588,13 @@ export function createManagedSupervisor(options) {
532
588
  if (!auditing) auditing = (async () => {
533
589
  const runtime = await desktopRuntimeStatus({ root: dirname(options.root) }).catch(() => ({ state: 'runtime_observation_failed' }));
534
590
  await runConsistencyMonitor({ root: options.root, runtime, readRecords: options.readRecords || (() => listRecords(options.root)),
535
- onAudit: (record, findings, request) => auditConsistencyTask(record, findings, request, {
591
+ onAudit: async (record, findings, request) => closed || await options.shouldYield?.() ? undefined : auditConsistencyTask(record, findings, request, {
536
592
  root: options.root, signal: controller.signal, authorize: options.authorizeDispatch || (async () => {}) }) });
537
593
  })().catch(() => {}).finally(() => { auditing = null; });
538
594
  if (!running) running = tickManagedTasks({ ...options, signal: controller.signal }).finally(() => { running = null; });
539
595
  return running;
540
596
  },
597
+ async drain() { closed = true; await Promise.allSettled([running, auditing]); },
541
598
  async close() { closed = true; controller.abort(); await Promise.allSettled([running, auditing]); },
542
599
  };
543
600
  }