@ran-sh/dsh-crew 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/hub/index.mjs CHANGED
@@ -3,9 +3,9 @@
3
3
  // Web UI session list), exposes a loopback jobs API for the CC/Codex MCP shim,
4
4
  // and serves the one-click installer endpoints for the settings page.
5
5
 
6
- import { randomUUID } from 'node:crypto';
7
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
8
- import { join, isAbsolute } from 'node:path';
6
+ import { createHash, randomUUID } from 'node:crypto';
7
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
8
+ import { dirname, join, isAbsolute, resolve as resolvePath } from 'node:path';
9
9
  import { homedir } from 'node:os';
10
10
  import { createShardWriter, readMergedStatus } from '../status-shard.mjs';
11
11
  import {
@@ -35,10 +35,11 @@ import { localRequestCore, originLoopback } from '../local-request-guard.mjs';
35
35
  import { raceWaiters } from '../removable-waiter.mjs';
36
36
  import { buildProviderInventory } from '../provider-inventory.mjs';
37
37
  import { inspectProviderProfile, readProviderDeclarations } from '../provider-profile-store.mjs';
38
+ import { inspectProviderSettings, readHarnessDefault, readProviderSettingsDeclarations } from '../provider-settings-store.mjs';
38
39
  import { normalizeProviderLifecycleState } from '../provider-lifecycle-state.mjs';
39
40
  import { createProviderHealthStore } from '../provider-health.mjs';
40
41
  import { planProviderDelete } from '../provider-lifecycle.mjs';
41
- import { createProviderDeleteFileHooks } from '../provider-delete-adapters.mjs';
42
+ import { createProviderDeleteFileHooks, isRecoverableProviderDeleteBackup, readProviderDeleteManifestFile } from '../provider-delete-adapters.mjs';
42
43
  import { buildCredentialReferenceInventory } from '../credential-reference-inventory.mjs';
43
44
  import { executeCredentialPurge, planCredentialPurge } from '../credential-lifecycle.mjs';
44
45
  import { createCredentialPurgeFileHooks } from '../credential-purge-adapters.mjs';
@@ -106,19 +107,100 @@ const LEGACY_TIER_MODELS = { flash: 'deepseek-v4-flash', pro: 'deepseek-v4-pro'
106
107
  const ROLES = { worker: true, reviewer: true };
107
108
  const CONFIG_DIR = join(homedir(), '.config', 'dsh-crew');
108
109
  const CREDENTIAL_PURGE_STATE_FILE = join(CONFIG_DIR, 'credential-purge-lifecycle.json');
110
+ const RECOVERY_ACTION_PATTERN = /^[a-f0-9]{32}$/u;
109
111
 
110
- function readProviderLifecycleState() {
112
+ function recoveryActionId(name) {
113
+ return createHash('sha256').update(String(name)).digest('hex').slice(0, 32);
114
+ }
115
+
116
+ function isRecoveryControlEntry(name) {
117
+ return name === '.quarantine'
118
+ || name === '.delete.lock'
119
+ || name === '.delete.reclaim.lock'
120
+ || name === '.delete.recovery.lock'
121
+ || name === '.delete.recovery.lock.claim'
122
+ || /^\.delete\.lock\.[0-9a-f-]+\.(?:active|staging|retired)$/iu.test(name)
123
+ || /^\.delete\.recovery\.lock\.[0-9a-f-]+\.stale$/iu.test(name);
124
+ }
125
+
126
+ function readProviderLifecycleStateStatus() {
127
+ const file = join(CONFIG_DIR, 'provider-lifecycle.json');
128
+ if (!existsSync(file)) return { ok: true, state: normalizeProviderLifecycleState() };
111
129
  try {
112
- return normalizeProviderLifecycleState(JSON.parse(readFileSync(join(CONFIG_DIR, 'provider-lifecycle.json'), 'utf8')));
130
+ return { ok: true, state: normalizeProviderLifecycleState(JSON.parse(readFileSync(file, 'utf8'))) };
113
131
  } catch {
114
- return normalizeProviderLifecycleState();
132
+ return { ok: false, code: 'PROVIDER_LIFECYCLE_UNAVAILABLE', state: normalizeProviderLifecycleState() };
115
133
  }
116
134
  }
117
135
 
136
+ function readProviderLifecycleState() {
137
+ return readProviderLifecycleStateStatus().state;
138
+ }
139
+
118
140
  function readCredentialPurgeState() {
119
141
  try { return normalizeCredentialPurgeState(JSON.parse(readFileSync(CREDENTIAL_PURGE_STATE_FILE, 'utf8'))); } catch { return normalizeCredentialPurgeState(); }
120
142
  }
121
143
 
144
+ export function readProviderRecoveryTransactions(root = join(CONFIG_DIR, 'provider-backups')) {
145
+ if (!existsSync(root)) return [];
146
+ try {
147
+ const managedRoot = dirname(resolvePath(root));
148
+ const paths = {
149
+ profile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
150
+ settings: join(CONFIG_DIR, 'harness', 'settings.yaml'),
151
+ config: join(CONFIG_DIR, 'config.json'),
152
+ lifecycle: join(CONFIG_DIR, 'provider-lifecycle.json'),
153
+ };
154
+ const transactions = readdirSync(root, { withFileTypes: true }).filter((entry) => !isRecoveryControlEntry(entry.name)).flatMap((entry) => {
155
+ const transactionRoot = join(root, entry.name);
156
+ const manifestFile = join(root, entry.name, 'manifest.json');
157
+ const actionId = recoveryActionId(entry.name);
158
+ if (!entry.isDirectory()) {
159
+ let updatedAt;
160
+ try { updatedAt = statSync(transactionRoot).mtime.toISOString(); } catch { updatedAt = new Date(0).toISOString(); }
161
+ return [{ storage_id: entry.name, action_id: actionId, transaction_id: null, provider_id: null, phase: 'RECOVERY_UNRESOLVED', updated_at: updatedAt, recoverable: false, unresolved: true }];
162
+ }
163
+ try {
164
+ const manifest = readProviderDeleteManifestFile(manifestFile, managedRoot);
165
+ const transactionId = typeof manifest?.plan?.plan_id === 'string' ? manifest.plan.plan_id : null;
166
+ const providerId = typeof manifest?.provider_id === 'string' ? manifest.provider_id : null;
167
+ const phase = typeof manifest?.phase_journal?.phase === 'string' && manifest.phase_journal.phase.trim()
168
+ ? manifest.phase_journal.phase.trim() : 'RECOVERY_INCOMPLETE';
169
+ const updatedAt = typeof manifest?.phase_journal?.updated_at === 'string' && manifest.phase_journal.updated_at.trim()
170
+ ? manifest.phase_journal.updated_at : statSync(manifestFile).mtime.toISOString();
171
+ const hasPendingMutation = manifest?.mutation_journal && typeof manifest.mutation_journal === 'object'
172
+ && Object.keys(manifest.mutation_journal).length > 0;
173
+ const recoverable = transactionId != null && providerId != null && isRecoverableProviderDeleteBackup(manifest, {
174
+ root: transactionRoot,
175
+ managedRoot,
176
+ paths,
177
+ expectedProviderId: providerId,
178
+ }) === true;
179
+ if (['ROLLED_BACK', 'VERIFIED'].includes(phase) && !hasPendingMutation && recoverable) return [];
180
+ return [{ storage_id: entry.name, action_id: actionId, transaction_id: transactionId ?? entry.name, provider_id: providerId, phase, updated_at: updatedAt, recoverable, unresolved: recoverable !== true }];
181
+ } catch {
182
+ let updatedAt;
183
+ try { updatedAt = statSync(transactionRoot).mtime.toISOString(); } catch { updatedAt = new Date(0).toISOString(); }
184
+ return [{
185
+ storage_id: entry.name,
186
+ action_id: actionId,
187
+ transaction_id: null,
188
+ provider_id: null,
189
+ phase: 'RECOVERY_UNRESOLVED',
190
+ updated_at: updatedAt,
191
+ recoverable: false,
192
+ unresolved: true,
193
+ }];
194
+ }
195
+ });
196
+ return transactions.sort((a, b) => a.updated_at.localeCompare(b.updated_at)).slice(-64);
197
+ } catch {
198
+ let updatedAt;
199
+ try { updatedAt = statSync(root).mtime.toISOString(); } catch { updatedAt = new Date(0).toISOString(); }
200
+ return [{ storage_id: null, action_id: null, transaction_id: null, provider_id: null, phase: 'RECOVERY_UNRESOLVED', updated_at: updatedAt, recoverable: false, unresolved: true }];
201
+ }
202
+ }
203
+
122
204
  function writeCredentialPurgeState(state) {
123
205
  mkdirSync(CONFIG_DIR, { recursive: true });
124
206
  const temp = `${CREDENTIAL_PURGE_STATE_FILE}.${process.pid}.${Date.now()}.tmp`;
@@ -144,34 +226,114 @@ function providerInventoryPolicy(config) {
144
226
  };
145
227
  }
146
228
 
229
+ export function hasCompleteProviderCatalogEvidence(evidence) {
230
+ return evidence?.ok === true && evidence.partial !== true;
231
+ }
232
+
233
+ export function hasCompleteProviderDeclarationEvidence(evidence) {
234
+ return evidence?.ok === true
235
+ && Object.values(evidence.sources ?? {}).every((source) => source?.present !== true || !source.code);
236
+ }
237
+
238
+ export function hasAvailableProviderLifecycleEvidence(evidence) {
239
+ return evidence?.ok === true;
240
+ }
241
+
242
+ export function hasPendingProviderRecoveryTransactions(inventory) {
243
+ return Array.isArray(inventory?.recovery_transactions) && inventory.recovery_transactions.length > 0;
244
+ }
245
+
246
+ export function hasProviderRuntimeRestartEvidence(beforeRuntimeId, currentRuntimeId) {
247
+ return typeof beforeRuntimeId === 'string' && beforeRuntimeId.trim() !== ''
248
+ && typeof currentRuntimeId === 'string' && currentRuntimeId.trim() !== ''
249
+ && beforeRuntimeId !== currentRuntimeId;
250
+ }
251
+
147
252
  async function readProviderInventorySnapshot(hub, ctx, config) {
148
253
  let catalog = { providers: [], harness_default: null };
254
+ let catalogEvidence = { ok: false, code: 'MODEL_CATALOG_UNAVAILABLE' };
149
255
  try {
150
256
  catalog = await readHarnessModelCatalog({
151
257
  llm: ctx.llm ?? ctx.get('llm'),
152
258
  getCurrentSelection: () => ctx.get('agentDefaultModel')?.currentSelection?.(),
153
259
  });
260
+ catalogEvidence = {
261
+ ok: catalog.partial !== true,
262
+ ...(catalog.partial === true ? { code: 'PROVIDER_CATALOG_INCOMPLETE' } : {}),
263
+ provider_count: Number.isInteger(catalog.provider_count) ? catalog.provider_count : catalog.providers.length,
264
+ refreshed_at: typeof catalog.refreshed_at === 'string' ? catalog.refreshed_at : null,
265
+ partial: catalog.partial === true,
266
+ };
154
267
  } catch {}
155
268
  const profileFile = join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml');
156
269
  let declarations = [];
270
+ const declarationEvidence = { ok: true, sources: {} };
157
271
  try {
272
+ declarationEvidence.sources.profile = { present: existsSync(profileFile) };
158
273
  if (existsSync(profileFile)) {
159
274
  const source = readFileSync(profileFile, 'utf8');
160
275
  const parsed = readProviderDeclarations(source, { file: 'harness/profiles/dsh-crew/cordis.patch.yml' });
161
276
  if (parsed.ok) declarations = parsed.declarations;
277
+ else { declarationEvidence.ok = false; declarationEvidence.sources.profile.code = parsed.code; }
162
278
  }
163
- } catch {}
164
- const lifecycle = readProviderLifecycleState();
279
+ } catch { declarationEvidence.ok = false; declarationEvidence.sources.profile = { present: true, code: 'PROVIDER_SOURCE_UNAVAILABLE' }; }
280
+ const settingsFile = join(CONFIG_DIR, 'harness', 'settings.yaml');
281
+ let settingsDefault = null;
282
+ let defaultEvidence = { ok: true, source: 'none' };
283
+ try {
284
+ declarationEvidence.sources.settings = { present: existsSync(settingsFile) };
285
+ if (existsSync(settingsFile)) {
286
+ const source = readFileSync(settingsFile, 'utf8');
287
+ const parsed = readProviderSettingsDeclarations(source, { file: 'harness/settings.yaml' });
288
+ if (parsed.ok) declarations = [...declarations, ...parsed.declarations];
289
+ else { declarationEvidence.ok = false; declarationEvidence.sources.settings.code = parsed.code; }
290
+ const parsedDefault = readHarnessDefault(source);
291
+ if (parsedDefault.ok) settingsDefault = parsedDefault;
292
+ else defaultEvidence = { ok: false, code: 'PROVIDER_DEFAULT_AUTHORITY_UNAVAILABLE' };
293
+ }
294
+ } catch {
295
+ declarationEvidence.ok = false;
296
+ declarationEvidence.sources.settings = { present: true, code: 'PROVIDER_SOURCE_UNAVAILABLE' };
297
+ defaultEvidence = { ok: false, code: 'PROVIDER_DEFAULT_AUTHORITY_UNAVAILABLE' };
298
+ }
299
+ const liveDefault = catalog.harness_default && typeof catalog.harness_default.provider === 'string' && typeof catalog.harness_default.model === 'string'
300
+ ? { provider: catalog.harness_default.provider, model: catalog.harness_default.model } : null;
301
+ const persistedDefault = settingsDefault ? { provider: settingsDefault.provider, model: settingsDefault.model } : null;
302
+ if (liveDefault || persistedDefault) {
303
+ if (!liveDefault || !persistedDefault) defaultEvidence = { ok: false, code: 'PROVIDER_DEFAULT_AUTHORITY_MISMATCH' };
304
+ else if (liveDefault.provider !== persistedDefault.provider || liveDefault.model !== persistedDefault.model) defaultEvidence = { ok: false, code: 'PROVIDER_DEFAULT_AUTHORITY_MISMATCH' };
305
+ }
306
+ // The persisted agent-default-model is the mutation authority. Keep it in
307
+ // the inventory even when the live selection is stale; the evidence gate
308
+ // above prevents destructive operations until both sources agree.
309
+ if (persistedDefault) {
310
+ catalog = {
311
+ ...catalog,
312
+ harness_default: persistedDefault,
313
+ harness_default_authority: { kind: 'harness-settings', locator: settingsDefault.locator },
314
+ };
315
+ }
316
+ const lifecycleEvidence = readProviderLifecycleStateStatus();
317
+ const lifecycle = lifecycleEvidence.state;
318
+ const recoveryTransactions = readProviderRecoveryTransactions();
165
319
  const credentialPurge = readCredentialPurgeState();
166
320
  const inventory = buildProviderInventory({
167
321
  catalog,
168
322
  declarations,
169
323
  policy: providerInventoryPolicy(config),
170
324
  tombstones: lifecycle.tombstones,
325
+ additionalProviderIds: [
326
+ ...Object.keys(lifecycle.tombstones ?? {}),
327
+ ...recoveryTransactions.map((entry) => entry.provider_id).filter((id) => typeof id === 'string' && id.trim()),
328
+ ],
171
329
  activeJobs: hub.list(),
172
330
  });
173
331
  return {
174
332
  ...inventory,
333
+ catalog_evidence: catalogEvidence,
334
+ declaration_evidence: declarationEvidence,
335
+ default_evidence: defaultEvidence,
336
+ lifecycle_evidence: lifecycleEvidence.ok ? { ok: true } : { ok: false, code: lifecycleEvidence.code },
175
337
  credential_history_refs: Object.values(lifecycle.transactions ?? {})
176
338
  .flatMap((transaction) => Array.isArray(transaction?.credential_refs) ? transaction.credential_refs : []),
177
339
  credential_purged_refs: Object.keys(credentialPurge.purged),
@@ -184,23 +346,27 @@ async function readProviderInventorySnapshot(hub, ctx, config) {
184
346
  transaction_id: transactionId,
185
347
  provider_id: transaction.provider_id,
186
348
  state: transaction.state,
349
+ ...(transaction.updated_at ? { updated_at: transaction.updated_at } : {}),
187
350
  ...(transaction.expected_revision ? { expected_revision: transaction.expected_revision } : {}),
188
351
  })),
352
+ recovery_transactions: recoveryTransactions,
189
353
  };
190
354
  }
191
355
 
192
- function readProviderProfileRevision() {
356
+ function readProviderSourceRevisions() {
193
357
  const profileFile = join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml');
194
- try {
195
- if (!existsSync(profileFile)) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
196
- const parsed = inspectProviderProfile(readFileSync(profileFile, 'utf8'));
197
- return parsed.ok ? { ok: true, revision: parsed.revision } : { ok: false, code: parsed.code };
198
- } catch {
199
- return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
200
- }
358
+ const settingsFile = join(CONFIG_DIR, 'harness', 'settings.yaml');
359
+ const read = (file, inspect) => {
360
+ try {
361
+ if (!existsSync(file)) return { ok: false, code: 'PROVIDER_SOURCE_MISSING', revision: null };
362
+ const parsed = inspect(readFileSync(file, 'utf8'));
363
+ return parsed.ok ? { ok: true, revision: parsed.revision } : { ok: false, code: parsed.code, revision: parsed.revision ?? null };
364
+ } catch { return { ok: false, code: 'PROVIDER_SOURCE_UNAVAILABLE', revision: null }; }
365
+ };
366
+ return { profile: read(profileFile, inspectProviderProfile), settings: read(settingsFile, inspectProviderSettings) };
201
367
  }
202
-
203
- export function hubCanonicalEvents(job = {}) {
368
+
369
+ export function hubCanonicalEvents(job = {}) {
204
370
  if (!job.id) return [];
205
371
  const role = job.role === 'reviewer' ? 'reviewer' : 'worker';
206
372
  const atStart = job.startedAt ?? null;
@@ -312,6 +478,9 @@ export function selectProviderProbeModel({ providerId, record, config } = {}) {
312
478
  export class WorkerRegistry { constructor(ctx) {
313
479
  this.ctx = ctx;
314
480
  this.jobs = new Map();
481
+ this.providerMutations = new Set();
482
+ this.providerMutationEpochs = new Map();
483
+ this.providerLeases = new Map();
315
484
  this.nextId = 1;
316
485
  this.shard = createShardWriter('hub');
317
486
  this.healthStore = createProviderHealthStore();
@@ -357,9 +526,45 @@ export class WorkerRegistry { constructor(ctx) {
357
526
  return v;
358
527
  }
359
528
 
360
- list() {
361
- return [...this.jobs.values()].map((job) => this.view(job));
362
- }
529
+ list() {
530
+ return [...this.jobs.values()].map((job) => this.view(job));
531
+ }
532
+
533
+ beginProviderMutation(providerId) {
534
+ if (typeof providerId !== 'string' || !providerId.trim() || this.providerMutations.has(providerId)) return false;
535
+ this.providerMutationEpochs.set(providerId, (this.providerMutationEpochs.get(providerId) ?? 0) + 1);
536
+ this.providerMutations.add(providerId);
537
+ return true;
538
+ }
539
+
540
+ endProviderMutation(providerId) {
541
+ this.providerMutations.delete(providerId);
542
+ }
543
+
544
+ getProviderMutationEpoch(providerId) {
545
+ return this.providerMutationEpochs.get(providerId) ?? 0;
546
+ }
547
+
548
+ assertProviderNotMutating(providerId, expectedEpoch = undefined) {
549
+ if (this.providerMutations.has(providerId)
550
+ || (expectedEpoch !== undefined && this.getProviderMutationEpoch(providerId) !== expectedEpoch)) {
551
+ throw Object.assign(new Error('provider mutation is active'), { code: 'PROVIDER_DELETE_BUSY' });
552
+ }
553
+ }
554
+
555
+ acquireProviderLease(providerId) {
556
+ this.providerLeases.set(providerId, (this.providerLeases.get(providerId) ?? 0) + 1);
557
+ }
558
+
559
+ releaseProviderLease(providerId) {
560
+ const count = this.providerLeases.get(providerId) ?? 0;
561
+ if (count <= 1) this.providerLeases.delete(providerId);
562
+ else this.providerLeases.set(providerId, count - 1);
563
+ }
564
+
565
+ hasProviderLease(providerId) {
566
+ return (this.providerLeases.get(providerId) ?? 0) > 0;
567
+ }
363
568
 
364
569
  publish() {
365
570
  this.shard.publish([...this.jobs.values()].map((j) => this.view(j)));
@@ -467,8 +672,10 @@ export class WorkerRegistry { constructor(ctx) {
467
672
  },
468
673
  });
469
674
  }
470
- if (!selection.ok) throw Object.assign(new Error(selection.message), { policyCode: selection.code });
471
- }
675
+ if (!selection.ok) throw Object.assign(new Error(selection.message), { policyCode: selection.code });
676
+ }
677
+ const providerMutationEpoch = this.getProviderMutationEpoch(selection.provider);
678
+ this.assertProviderNotMutating(selection.provider, providerMutationEpoch);
472
679
 
473
680
  // The worker always gets the auditable Delivery Contract appended (unless
474
681
  // it already carries one), so its final message follows ## Diff / ## Tests
@@ -527,9 +734,13 @@ export class WorkerRegistry { constructor(ctx) {
527
734
  // Abort latch: cancel()/timeout set this the moment the job becomes
528
735
  // terminal, even while agents.create() is still pending, so a handle
529
736
  // acquired after that point is disposed instead of being driven.
530
- abortRequested: false,
737
+ abortRequested: false,
531
738
  abortPromise: null,
532
739
  resolveAbort: null,
740
+ providerLease: false,
741
+ providerLeaseReleaseBlocked: false,
742
+ lateHandleCreated: false,
743
+ lateCreateCleanupPromise: null,
533
744
  };
534
745
  job.abortPromise = new Promise((resolve) => { job.resolveAbort = resolve; });
535
746
  const disposeJobHandle = async () => {
@@ -541,14 +752,30 @@ export class WorkerRegistry { constructor(ctx) {
541
752
  job.handle_dispose_promise = Promise.reject(error);
542
753
  }
543
754
  }
544
- await job.handle_dispose_promise;
755
+ try {
756
+ await job.handle_dispose_promise;
757
+ } catch (error) {
758
+ // A failed disposer is not proof that the provider is idle. Keep the
759
+ // lease fenced even when the job is already terminal; the operator
760
+ // must restart/reclaim the owned runtime before deletion can proceed.
761
+ job.providerLeaseReleaseBlocked = true;
762
+ throw error;
763
+ }
545
764
  };
546
765
  job.disposeHandle = disposeJobHandle;
547
766
  // Read-only pre-run snapshot (async, never blocks dispatch): the audit
548
767
  // only needs the before-state by the time the worker finishes. Non-repos
549
768
  // degrade to { kind:'no-git' } instead of failing the job.
550
- job.baseline = await captureWorkspaceBaseline({ cwd: executionCwd }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace audit failed' }));
551
- this.jobs.set(id, job);
769
+ job.baseline = await captureWorkspaceBaseline({ cwd: executionCwd }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace audit failed' }));
770
+ try {
771
+ this.assertProviderNotMutating(selection.provider, providerMutationEpoch);
772
+ this.acquireProviderLease(selection.provider);
773
+ job.providerLease = true;
774
+ } catch (error) {
775
+ if (isolatedWorkspace) await cleanupIsolatedWorkspace({ repoRoot: isolatedWorkspace.repoRoot, worktreePath: isolatedWorkspace.worktreePath }).catch(() => {});
776
+ throw error;
777
+ }
778
+ this.jobs.set(id, job);
552
779
 
553
780
  const onEvent = (session, event) => {
554
781
  switch (event.type) {
@@ -598,14 +825,15 @@ export class WorkerRegistry { constructor(ctx) {
598
825
  job.abortPromise.then(() => ({ aborted: true })),
599
826
  ]);
600
827
  if (created.aborted) {
601
- createPromise.then(async (handle) => {
602
- if (handle && (job.status !== 'running' || job.abortRequested)) {
603
- job.handle = handle;
604
- try { await job.disposeHandle(); } catch (error) {
605
- job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
606
- }
607
- }
608
- }).catch(() => {});
828
+ job.lateCreateCleanupPromise = createPromise.then(async (handle) => {
829
+ if (!handle) return;
830
+ job.lateHandleCreated = true;
831
+ job.handle = handle;
832
+ if (job.status !== 'running' || job.abortRequested) await job.disposeHandle();
833
+ }).catch((error) => {
834
+ job.cleanup_warning = `late agent creation failed: ${error?.message ?? String(error)}`;
835
+ throw error;
836
+ });
609
837
  return;
610
838
  }
611
839
  const handle = created.handle;
@@ -674,9 +902,23 @@ export class WorkerRegistry { constructor(ctx) {
674
902
  job.endedAt = new Date().toISOString();
675
903
  job.currentTool = null;
676
904
  let handleCleanupWarning = job.cleanup_warning ?? null;
677
- try { await disposeJobHandle(); } catch (error) {
678
- handleCleanupWarning = `agent cleanup failed: ${error?.message ?? String(error)}`;
679
- }
905
+ try { await disposeJobHandle(); } catch (error) {
906
+ handleCleanupWarning = `agent cleanup failed: ${error?.message ?? String(error)}`;
907
+ }
908
+ if (job.providerLease) {
909
+ const releaseLease = () => {
910
+ if (!job.providerLease) return;
911
+ this.releaseProviderLease(job.provider);
912
+ job.providerLease = false;
913
+ };
914
+ if (job.lateCreateCleanupPromise) {
915
+ job.lateCreateCleanupPromise.then(releaseLease).catch(() => {
916
+ if (!job.lateHandleCreated && !job.providerLeaseReleaseBlocked) releaseLease();
917
+ // A late-created handle that failed disposal remains fenced by
918
+ // the provider lease until an operator restarts/reclaims it.
919
+ });
920
+ } else if (!job.providerLeaseReleaseBlocked) releaseLease();
921
+ }
680
922
  // Delivery completeness is separate from execution status: a job can
681
923
  // be done yet fail to report Diff/Tests/Risks (or Review sections for
682
924
  // an automatic review). Parse whatever final message the worker
@@ -1129,13 +1371,11 @@ export async function apply(ctx) {
1129
1371
  ? { status: 'UNAVAILABLE', reason_code: 'WORKSPACE_CONTEXT_NOT_FOUND' }
1130
1372
  : await assessWorkspaceReadiness({ cwd: requestedContext?.repo_root ?? null });
1131
1373
  const liveJobs = typeof hub.list === 'function' ? hub.list() : [];
1132
- const [modelExecution, reviewerExecution] = buildHubExecutionRows(liveJobs);
1374
+ const executionRows = buildHubExecutionRows(liveJobs);
1133
1375
  const readinessMatrix = { rows: [
1134
1376
  { id: 'hub_compatibility', status: 'PASS', reason_code: 'LIVE_CHECK_PASSED' },
1135
1377
  catalogStatus,
1136
- modelExecution,
1137
- reviewerExecution,
1138
- { id: 'provider_lifecycle_consistent', status: 'NOT_RUN', reason_code: 'NO_EXECUTION_EVIDENCE' },
1378
+ ...executionRows,
1139
1379
  ] };
1140
1380
  const readinessSnapshot = buildRuntimeReadinessSnapshot({
1141
1381
  runtime: getHubRuntimeIdentity(),
@@ -1365,21 +1605,74 @@ export async function apply(ctx) {
1365
1605
  try {
1366
1606
  const providerId = decodeURIComponent(parts[0]);
1367
1607
  const body = await readBody(req);
1608
+ if (req.method === 'POST' && parts[0] === '_recovery' && parts[1] === 'lock' && parts.length === 2) {
1609
+ if (body?.confirm !== true) return sendJson(res, 400, { ok: false, code: 'PROVIDER_DELETE_RECOVERY_CONFIRM_REQUIRED' });
1610
+ const hooks = createProviderDeleteFileHooks({
1611
+ profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
1612
+ settingsFile: join(CONFIG_DIR, 'harness', 'settings.yaml'),
1613
+ configFile: join(CONFIG_DIR, 'config.json'),
1614
+ lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
1615
+ backupDir: join(CONFIG_DIR, 'provider-backups'),
1616
+ });
1617
+ const recovered = await hooks.recoverLock({ force: body?.force === true });
1618
+ return sendJson(res, recovered.ok ? 200 : 409, recovered);
1619
+ }
1620
+ if (req.method === 'POST' && parts[0] === '_recovery' && parts[1] === 'quarantine' && parts.length === 2) {
1621
+ if (body?.confirm !== true) return sendJson(res, 400, { ok: false, code: 'PROVIDER_DELETE_RECOVERY_QUARANTINE_CONFIRM_REQUIRED' });
1622
+ const actionId = typeof body?.action_id === 'string' && RECOVERY_ACTION_PATTERN.test(body.action_id)
1623
+ ? body.action_id : null;
1624
+ if (!actionId) return sendJson(res, 400, { ok: false, code: 'PROVIDER_DELETE_PLAN_INVALID' });
1625
+ const recovery = readProviderRecoveryTransactions().find((entry) => entry.action_id === actionId);
1626
+ if (!recovery) return sendJson(res, 404, { ok: false, code: 'PROVIDER_DELETE_RECOVERY_NOT_FOUND' });
1627
+ if (recovery.recoverable === true) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_RECOVERY_RECOVERABLE' });
1628
+ if (typeof recovery.storage_id !== 'string') return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_RECOVERY_QUARANTINE_FAILED' });
1629
+ try {
1630
+ const hooks = createProviderDeleteFileHooks({
1631
+ profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
1632
+ settingsFile: join(CONFIG_DIR, 'harness', 'settings.yaml'),
1633
+ configFile: join(CONFIG_DIR, 'config.json'),
1634
+ lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
1635
+ backupDir: join(CONFIG_DIR, 'provider-backups'),
1636
+ afterLockAcquired: () => {
1637
+ const current = readProviderRecoveryTransactions().find((entry) => entry.action_id === actionId);
1638
+ if (!current || current.recoverable === true || current.storage_id !== recovery.storage_id) {
1639
+ throw Object.assign(new Error('provider recovery entry changed before quarantine'), { code: 'PROVIDER_DELETE_STATE_CHANGED' });
1640
+ }
1641
+ },
1642
+ });
1643
+ const quarantined = await hooks.quarantine(recovery.storage_id);
1644
+ return sendJson(res, 200, { ...quarantined, action_id: actionId, transaction_id: recovery.transaction_id ?? null });
1645
+ } catch (error) {
1646
+ const code = boundedMachineCodeFromError(error) ?? 'PROVIDER_DELETE_RECOVERY_QUARANTINE_FAILED';
1647
+ return sendJson(res, 409, { ok: false, code });
1648
+ }
1649
+ }
1368
1650
  const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
1369
1651
  const inventory = await readProviderInventorySnapshot(hub, ctx, config);
1370
1652
  if (req.method === 'POST' && parts.length === 2 && parts[1] === 'delete-plan') {
1371
- const profile = readProviderProfileRevision();
1372
- if (!profile.ok) return sendJson(res, 409, { ok: false, code: profile.code });
1653
+ if (providerId !== 'deepseek-official' && hasPendingProviderRecoveryTransactions(inventory)) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_RECOVERY_PENDING' });
1654
+ if (providerId !== 'deepseek-official' && !hasCompleteProviderCatalogEvidence(inventory.catalog_evidence)) return sendJson(res, 503, { ok: false, code: 'MODEL_CATALOG_UNAVAILABLE' });
1655
+ if (providerId !== 'deepseek-official' && !hasCompleteProviderDeclarationEvidence(inventory.declaration_evidence)) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_SOURCE_UNRESOLVED' });
1656
+ if (providerId !== 'deepseek-official' && !hasAvailableProviderLifecycleEvidence(inventory.lifecycle_evidence)) return sendJson(res, 503, { ok: false, code: 'PROVIDER_LIFECYCLE_UNAVAILABLE' });
1657
+ if (providerId !== 'deepseek-official' && inventory.default_evidence?.ok !== true) return sendJson(res, 409, { ok: false, code: inventory.default_evidence?.code ?? 'PROVIDER_DEFAULT_AUTHORITY_UNAVAILABLE' });
1658
+ const revisions = readProviderSourceRevisions();
1659
+ if (!revisions.profile.ok && !revisions.settings.ok) return sendJson(res, 409, { ok: false, code: revisions.profile.code ?? revisions.settings.code });
1660
+ const expectedRevision = (revisions.profile.ok ? revisions.profile.revision : null) ?? revisions.settings.revision;
1373
1661
  const planned = planProviderDelete({
1374
1662
  providerId,
1375
1663
  inventory,
1376
1664
  activeJobs: hub.list(),
1377
1665
  replacementDefault: body?.replacement_default,
1378
- expectedRevision: profile.revision,
1666
+ expectedRevision,
1667
+ expectedRevisions: {
1668
+ profile: revisions.profile.ok ? revisions.profile.revision : null,
1669
+ settings: revisions.settings.ok ? revisions.settings.revision : null,
1670
+ },
1379
1671
  });
1380
1672
  if (!planned.ok) return sendJson(res, 409, { ok: false, code: planned.code });
1381
- rememberProviderDeletePlan(planned.plan);
1382
- return sendJson(res, 200, { ok: true, profile_revision: profile.revision, plan: planned.plan });
1673
+ const plan = { ...planned.plan, runtime_id_before: getHubRuntimeIdentity().runtime_id };
1674
+ rememberProviderDeletePlan(plan);
1675
+ return sendJson(res, 200, { ok: true, profile_revision: expectedRevision, plan });
1383
1676
  }
1384
1677
  if (req.method === 'POST' && parts.length === 2 && parts[1] === 'probe') {
1385
1678
  const record = inventory.records.find((entry) => entry.id === providerId);
@@ -1415,6 +1708,7 @@ export async function apply(ctx) {
1415
1708
  const { transaction_id: transactionId } = body ?? {};
1416
1709
  const hooks = createProviderDeleteFileHooks({
1417
1710
  profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
1711
+ settingsFile: join(CONFIG_DIR, 'harness', 'settings.yaml'),
1418
1712
  configFile: join(CONFIG_DIR, 'config.json'),
1419
1713
  lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
1420
1714
  backupDir: join(CONFIG_DIR, 'provider-backups'),
@@ -1426,10 +1720,21 @@ export async function apply(ctx) {
1426
1720
  await hooks.acquireLock();
1427
1721
  try {
1428
1722
  const verification = await hooks.verify(plan);
1429
- if (verification?.providerAbsent !== true || verification?.routingClear !== true || verification?.tombstonePresent !== true) {
1723
+ const live = await readProviderInventorySnapshot(hub, ctx, config);
1724
+ verification.catalog_evidence = live.catalog_evidence;
1725
+ verification.default_evidence = live.default_evidence;
1726
+ verification.catalogAbsent = hasCompleteProviderCatalogEvidence(live.catalog_evidence)
1727
+ && !live.records.some((entry) => entry.id === providerId && entry.lifecycle?.catalogued === true);
1728
+ verification.harnessDefault = plan.was_harness_default !== true || (
1729
+ live.harness_default?.provider === plan.replacement_default
1730
+ && live.harness_default?.model === plan.replacement_default_model
1731
+ );
1732
+ verification.runtimeRestarted = hasProviderRuntimeRestartEvidence(plan.delete_runtime_id_before_restart, getHubRuntimeIdentity().runtime_id);
1733
+ if (verification?.providerAbsent !== true || !hasCompleteProviderCatalogEvidence(verification?.catalog_evidence) || verification?.default_evidence?.ok !== true || verification?.catalogAbsent !== true || verification?.harnessDefault !== true || verification?.runtimeRestarted !== true || verification?.routingClear !== true || verification?.tombstonePresent !== true) {
1430
1734
  return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_VERIFY_FAILED', verification });
1431
1735
  }
1432
1736
  await hooks.recordTransaction({ transaction_id: transactionId, provider_id: providerId, state: 'VERIFIED' }, plan);
1737
+ hub.endProviderMutation(providerId);
1433
1738
  return sendJson(res, 200, { ok: true, transaction_id: transactionId, provider_id: providerId, state: 'VERIFIED', verification });
1434
1739
  } finally {
1435
1740
  await hooks.release();
@@ -1440,6 +1745,7 @@ export async function apply(ctx) {
1440
1745
  const { transaction_id: transactionId } = body ?? {};
1441
1746
  const hooks = createProviderDeleteFileHooks({
1442
1747
  profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
1748
+ settingsFile: join(CONFIG_DIR, 'harness', 'settings.yaml'),
1443
1749
  configFile: join(CONFIG_DIR, 'config.json'),
1444
1750
  lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
1445
1751
  backupDir: join(CONFIG_DIR, 'provider-backups'),
@@ -1451,8 +1757,10 @@ export async function apply(ctx) {
1451
1757
  const plan = hooks.backupPlan();
1452
1758
  if (!plan || plan.provider_id !== providerId) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_PROVIDER_MISMATCH' });
1453
1759
  await hooks.rollback(plan);
1760
+ await hooks.setRuntimeBaseline(getHubRuntimeIdentity().runtime_id, 'rollback');
1761
+ const rollbackPlan = hooks.backupPlan() ?? plan;
1454
1762
  try {
1455
- await hooks.recordTransaction({ transaction_id: transactionId, provider_id: providerId, state: 'ROLLBACK_PENDING' }, plan);
1763
+ await hooks.recordTransaction({ transaction_id: transactionId, provider_id: providerId, state: 'ROLLBACK_PENDING' }, rollbackPlan);
1456
1764
  } catch (error) {
1457
1765
  return sendJson(res, 409, { ok: false, code: boundedMachineCodeFromError(error) ?? 'PROVIDER_LIFECYCLE_RECORD_FAILED' });
1458
1766
  }
@@ -1466,6 +1774,7 @@ export async function apply(ctx) {
1466
1774
  const { transaction_id: transactionId } = body ?? {};
1467
1775
  const hooks = createProviderDeleteFileHooks({
1468
1776
  profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
1777
+ settingsFile: join(CONFIG_DIR, 'harness', 'settings.yaml'),
1469
1778
  configFile: join(CONFIG_DIR, 'config.json'),
1470
1779
  lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
1471
1780
  backupDir: join(CONFIG_DIR, 'provider-backups'),
@@ -1477,8 +1786,19 @@ export async function apply(ctx) {
1477
1786
  await hooks.acquireLock();
1478
1787
  try {
1479
1788
  const verified = await hooks.verifyRollback(plan);
1480
- if (verified?.ok !== true) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_ROLLBACK_VERIFY_FAILED' });
1789
+ const live = await readProviderInventorySnapshot(hub, ctx, config);
1790
+ verified.catalog_evidence = live.catalog_evidence;
1791
+ verified.default_evidence = live.default_evidence;
1792
+ verified.catalogPresent = hasCompleteProviderCatalogEvidence(live.catalog_evidence)
1793
+ && live.records.some((entry) => entry.id === providerId && entry.lifecycle?.catalogued === true);
1794
+ verified.harnessDefaultRestored = plan.was_harness_default !== true || (
1795
+ live.harness_default?.provider === plan.harness_default_before?.provider
1796
+ && live.harness_default?.model === plan.harness_default_before?.model
1797
+ );
1798
+ verified.runtimeRestarted = hasProviderRuntimeRestartEvidence(plan.rollback_runtime_id_before_restart, getHubRuntimeIdentity().runtime_id);
1799
+ if (verified?.ok !== true || !hasCompleteProviderCatalogEvidence(verified?.catalog_evidence) || verified?.default_evidence?.ok !== true || verified?.catalogPresent !== true || verified?.harnessDefaultRestored !== true || verified?.runtimeRestarted !== true) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_ROLLBACK_VERIFY_FAILED', verification: verified });
1481
1800
  await hooks.recordTransaction({ transaction_id: transactionId, provider_id: providerId, state: 'ROLLED_BACK' }, plan);
1801
+ hub.endProviderMutation(providerId);
1482
1802
  return sendJson(res, 200, { ok: true, transaction_id: transactionId, provider_id: providerId, state: 'ROLLED_BACK' });
1483
1803
  } finally {
1484
1804
  await hooks.release();
@@ -1494,33 +1814,67 @@ export async function apply(ctx) {
1494
1814
  providerDeletePlans.delete(body?.plan_id);
1495
1815
  return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_EXPIRED' });
1496
1816
  }
1497
- const profile = readProviderProfileRevision();
1498
- if (!profile.ok) return sendJson(res, 409, { ok: false, code: profile.code });
1499
- if (body?.expected_revision !== stored.plan.expected_revision || profile.revision !== stored.plan.expected_revision) {
1817
+ const revisions = readProviderSourceRevisions();
1818
+ if (!revisions.profile.ok && !revisions.settings.ok) return sendJson(res, 409, { ok: false, code: revisions.profile.code ?? revisions.settings.code });
1819
+ if (providerId !== 'deepseek-official' && !hasCompleteProviderCatalogEvidence(inventory.catalog_evidence)) return sendJson(res, 503, { ok: false, code: 'MODEL_CATALOG_UNAVAILABLE' });
1820
+ if (providerId !== 'deepseek-official' && !hasCompleteProviderDeclarationEvidence(inventory.declaration_evidence)) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_SOURCE_UNRESOLVED' });
1821
+ if (providerId !== 'deepseek-official' && !hasAvailableProviderLifecycleEvidence(inventory.lifecycle_evidence)) return sendJson(res, 503, { ok: false, code: 'PROVIDER_LIFECYCLE_UNAVAILABLE' });
1822
+ if (providerId !== 'deepseek-official' && inventory.default_evidence?.ok !== true) return sendJson(res, 409, { ok: false, code: inventory.default_evidence?.code ?? 'PROVIDER_DEFAULT_AUTHORITY_UNAVAILABLE' });
1823
+ const storedExpected = stored.plan.expected_revisions;
1824
+ const revisionsMatch = storedExpected
1825
+ ? (storedExpected.profile === (revisions.profile.ok ? revisions.profile.revision : null)
1826
+ && storedExpected.settings === (revisions.settings.ok ? revisions.settings.revision : null))
1827
+ : (body?.expected_revision === stored.plan.expected_revision && (revisions.profile.revision ?? revisions.settings.revision) === stored.plan.expected_revision);
1828
+ if (!revisionsMatch || body?.expected_revision !== stored.plan.expected_revision) {
1500
1829
  return sendJson(res, 409, { ok: false, code: 'PROVIDER_PROFILE_CHANGED' });
1501
1830
  }
1831
+ if (providerId !== 'deepseek-official' && hasPendingProviderRecoveryTransactions(inventory)) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_RECOVERY_PENDING' });
1502
1832
  const refreshed = planProviderDelete({
1503
1833
  providerId,
1504
1834
  inventory,
1505
1835
  activeJobs: hub.list(),
1506
1836
  replacementDefault: stored.plan.replacement_default,
1507
- expectedRevision: profile.revision,
1837
+ expectedRevision: stored.plan.expected_revision,
1838
+ expectedRevisions: stored.plan.expected_revisions,
1508
1839
  });
1509
1840
  if (!refreshed.ok) return sendJson(res, 409, { ok: false, code: refreshed.code });
1510
1841
  if (refreshed.plan.replacement_default_model !== stored.plan.replacement_default_model) {
1511
1842
  return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_CHANGED' });
1512
1843
  }
1513
- const executionPlan = { ...refreshed.plan, plan_id: stored.plan.plan_id, expected_revision: stored.plan.expected_revision };
1514
- const hooks = createProviderDeleteFileHooks({
1515
- profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
1516
- configFile: join(CONFIG_DIR, 'config.json'),
1517
- lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
1518
- backupDir: join(CONFIG_DIR, 'provider-backups'),
1519
- });
1520
- providerDeletePlans.delete(body.plan_id);
1521
- const { executeProviderDelete } = await import('../provider-lifecycle.mjs');
1522
- const result = await executeProviderDelete(executionPlan, hooks, { deferRestart: true });
1523
- return sendJson(res, result.state === 'RESTART_PENDING' ? 202 : 409, { ok: result.state === 'RESTART_PENDING', restart_required: true, result });
1844
+ const executionPlan = {
1845
+ ...refreshed.plan,
1846
+ plan_id: stored.plan.plan_id,
1847
+ expected_revision: stored.plan.expected_revision,
1848
+ expected_revisions: stored.plan.expected_revisions,
1849
+ runtime_id_before: stored.plan.runtime_id_before,
1850
+ };
1851
+ if (!hub.beginProviderMutation(providerId)) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_BUSY' });
1852
+ let retainProviderMutation = false;
1853
+ try {
1854
+ const hooks = createProviderDeleteFileHooks({
1855
+ profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
1856
+ settingsFile: join(CONFIG_DIR, 'harness', 'settings.yaml'),
1857
+ configFile: join(CONFIG_DIR, 'config.json'),
1858
+ lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
1859
+ backupDir: join(CONFIG_DIR, 'provider-backups'),
1860
+ runtimeIdProvider: () => getHubRuntimeIdentity().runtime_id,
1861
+ afterLockAcquired: () => {
1862
+ if (readProviderRecoveryTransactions().length > 0) {
1863
+ throw Object.assign(new Error('provider recovery transaction is pending'), { code: 'PROVIDER_DELETE_RECOVERY_PENDING' });
1864
+ }
1865
+ if (hub.hasProviderLease(providerId)) {
1866
+ throw Object.assign(new Error('provider is in use'), { code: 'PROVIDER_IN_USE' });
1867
+ }
1868
+ },
1869
+ });
1870
+ providerDeletePlans.delete(body.plan_id);
1871
+ const { executeProviderDelete } = await import('../provider-lifecycle.mjs');
1872
+ const result = await executeProviderDelete(executionPlan, hooks, { deferRestart: true });
1873
+ retainProviderMutation = result.state === 'RESTART_PENDING';
1874
+ return sendJson(res, result.state === 'RESTART_PENDING' ? 202 : 409, { ok: result.state === 'RESTART_PENDING', restart_required: true, result });
1875
+ } finally {
1876
+ if (!retainProviderMutation) hub.endProviderMutation(providerId);
1877
+ }
1524
1878
  }
1525
1879
  return sendJson(res, 404, { ok: false, error: 'unknown providers endpoint' });
1526
1880
  } catch (err) {