@moretea-labs/forge 1.7.0 → 1.7.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.
Files changed (175) hide show
  1. package/AGENTS.md +2 -0
  2. package/CHANGELOG.md +26 -0
  3. package/CLAUDE.md +2 -0
  4. package/THIRD_PARTY_NOTICES.md +1 -1
  5. package/agents/openai.yaml +1 -1
  6. package/assets/partials-agents/02-operating-mode.partial.md +2 -0
  7. package/assets/plugin-registry.v1.json +3 -3
  8. package/assets/skill-version.json +2 -2
  9. package/assets/templates/helpers/check-architecture-sync.sh +17 -0
  10. package/assets/templates/helpers/inspect-project-state.ts +8 -6
  11. package/assets/templates/helpers/migrate-workflow-docs.ts +9 -7
  12. package/assets/templates/helpers/workflow-contract.ts +5 -1
  13. package/docs/architecture/CURRENT.md +12 -4
  14. package/docs/forge-plugin-management.md +1 -1
  15. package/docs/operations/controller-performance-and-502.md +19 -0
  16. package/docs/operations/homebrew.md +2 -2
  17. package/package.json +5 -2
  18. package/scripts/architecture-event.ts +2 -820
  19. package/scripts/architecture-queue.sh +2 -645
  20. package/scripts/archive-architecture-request.sh +2 -242
  21. package/scripts/archive-workflow.sh +1 -291
  22. package/scripts/benchmark-work-efficiency.ts +0 -0
  23. package/scripts/capability-config.ts +2 -392
  24. package/scripts/capability-resolver.ts +2 -503
  25. package/scripts/capture-plan.sh +1 -332
  26. package/scripts/check-agent-tooling.sh +2 -1531
  27. package/scripts/check-architecture-sync.sh +2 -302
  28. package/scripts/check-brain-manifest.sh +2 -253
  29. package/scripts/check-context-files.sh +2 -47
  30. package/scripts/check-deploy-sql-order.sh +2 -115
  31. package/scripts/check-runtime-architecture.mjs +243 -16
  32. package/scripts/check-skill-version.ts +3 -191
  33. package/scripts/check-source-duplication.mjs +61 -0
  34. package/scripts/check-stable-baseline.ts +0 -0
  35. package/scripts/check-task-sync.sh +2 -63
  36. package/scripts/check-task-workflow.sh +2 -1000
  37. package/scripts/codex-handoff-resume.sh +2 -267
  38. package/scripts/context-contract-sync.sh +2 -514
  39. package/scripts/contract-run.ts +2 -463
  40. package/scripts/contract-worktree.sh +1 -702
  41. package/scripts/ensure-task-workflow.sh +1 -1192
  42. package/scripts/inspect-project-state.ts +4 -279
  43. package/scripts/lib/project-init-lib.sh +7 -2
  44. package/scripts/lib/run-forge-helper.sh +19 -0
  45. package/scripts/lib/run-forge-helper.ts +26 -0
  46. package/scripts/migrate-project-template.sh +1 -33
  47. package/scripts/migrate-workflow-docs.ts +4 -412
  48. package/scripts/new-plan.sh +1 -180
  49. package/scripts/new-spec.sh +1 -47
  50. package/scripts/new-sprint.sh +1 -46
  51. package/scripts/plan-to-todo.sh +1 -771
  52. package/scripts/prepare-codex-handoff.sh +2 -201
  53. package/scripts/prepare-handoff.sh +1 -78
  54. package/scripts/refresh-current-status.sh +2 -467
  55. package/scripts/run-governed-gate.ts +3 -0
  56. package/scripts/select-agent-context-blocks.sh +2 -64
  57. package/scripts/ship-worktrees.sh +1 -531
  58. package/scripts/sprint-backlog.sh +1 -834
  59. package/scripts/stage-runtime-release.ts +2 -1
  60. package/scripts/summarize-failures.sh +2 -123
  61. package/scripts/switch-plan.sh +1 -150
  62. package/scripts/sync-brain-docs.sh +2 -379
  63. package/scripts/sync-generated-authority.mjs +175 -0
  64. package/scripts/verify-contract.sh +2 -593
  65. package/scripts/verify-sprint.sh +1 -500
  66. package/scripts/workflow-contract.ts +4 -201
  67. package/scripts/workstream-sync.sh +2 -261
  68. package/src/cli/agent-jobs/executor-health.ts +1 -1
  69. package/src/cli/commands/plugin.ts +12 -3
  70. package/src/cli/controller/check-runner.ts +7 -2
  71. package/src/cli/controller/context-pack.ts +46 -21
  72. package/src/cli/github/contracts.ts +16 -0
  73. package/src/cli/github/github.ts +2 -16
  74. package/src/cli/github/session.ts +2 -1
  75. package/src/cli/local-bridge/facade-api.ts +37 -47
  76. package/src/cli/local-bridge/server.ts +2 -2
  77. package/src/cli/mcp/legacy-tool-service.ts +10 -33
  78. package/src/cli/mcp/post-finalize-work-attribution.ts +150 -0
  79. package/src/cli/mcp/repository-tools.ts +125 -101
  80. package/src/cli/mcp/server.ts +57 -13
  81. package/src/cli/mcp/tool-contract.ts +14 -0
  82. package/src/cli/mcp/toolset.ts +2 -2
  83. package/src/cli/mcp/transports/http.ts +23 -0
  84. package/src/cli/repositories/command-classifier.ts +2 -2
  85. package/src/cli/repositories/command-executor.ts +24 -6
  86. package/src/cli/repositories/repository-snapshot.ts +5 -2
  87. package/src/cli/repositories/structured-git.ts +57 -3
  88. package/src/cli/repository/inspector.ts +11 -3
  89. package/src/runtime/context/generic-lsp-provider.ts +1 -1
  90. package/src/runtime/context/semantic-navigation-contract.ts +61 -0
  91. package/src/runtime/context/semantic-navigation.ts +9 -61
  92. package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +69 -29
  93. package/src/runtime/control-plane/execution/repository-work-attribution.ts +106 -0
  94. package/src/runtime/control-plane/execution/retained-work-resume.ts +203 -0
  95. package/src/runtime/control-plane/execution/work-completion-authority.ts +58 -0
  96. package/src/runtime/control-plane/execution/work-evidence-policy.ts +200 -0
  97. package/src/runtime/control-plane/execution/work-execution-support.ts +386 -0
  98. package/src/runtime/control-plane/execution/work-finalization-service.ts +1913 -0
  99. package/src/runtime/control-plane/execution/work-handle-authority.ts +150 -0
  100. package/src/runtime/control-plane/execution/work-handle-store.ts +1 -1
  101. package/src/runtime/control-plane/execution/work-operation-service.ts +468 -0
  102. package/src/runtime/control-plane/execution/work-path-scope.ts +37 -0
  103. package/src/runtime/control-plane/execution/work-preparation-service.ts +479 -0
  104. package/src/runtime/control-plane/execution/work-process-evidence.ts +129 -0
  105. package/src/runtime/control-plane/execution/work-task-receipt.ts +4 -43
  106. package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +13 -3
  107. package/src/runtime/control-plane/execution/work-verification-context.ts +56 -0
  108. package/src/runtime/control-plane/execution/work-verification-service.ts +388 -0
  109. package/src/runtime/control-plane/execution/work-verification-snapshot.ts +5 -1
  110. package/src/runtime/control-plane/facade/controller-round-relay.ts +164 -37
  111. package/src/runtime/control-plane/facade/controller-session-store.ts +183 -24
  112. package/src/runtime/control-plane/facade/goal-workloop-access.ts +15 -1
  113. package/src/runtime/control-plane/facade/goal-workloop.ts +329 -148
  114. package/src/runtime/control-plane/facade/index.ts +2 -0
  115. package/src/runtime/control-plane/facade/plan-contract-store.ts +90 -22
  116. package/src/runtime/control-plane/facade/repository-work-admission.ts +147 -0
  117. package/src/runtime/control-plane/facade/requirement-authority.ts +83 -0
  118. package/src/runtime/control-plane/facade/types.ts +68 -5
  119. package/src/runtime/control-plane/facade/work-contract-store.ts +3 -209
  120. package/src/runtime/control-plane/facade/work-state-machine.ts +250 -0
  121. package/src/runtime/control-plane/global-scheduler/maintenance.ts +14 -7
  122. package/src/runtime/control-plane/global-scheduler/scheduler.ts +5 -1
  123. package/src/runtime/control-plane/launcher/chatgpt-work-binding-store.ts +13 -0
  124. package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +15 -6
  125. package/src/runtime/control-plane/launcher/launch-reservation-store.ts +54 -5
  126. package/src/runtime/control-plane/launcher/thin-launcher.ts +56 -7
  127. package/src/runtime/control-plane/persistence/requirement-store.ts +0 -12
  128. package/src/runtime/control-plane/repo-actor/registry.ts +4 -2
  129. package/src/runtime/control-plane/routing/route-policy.ts +18 -6
  130. package/src/runtime/control-plane/routing/workspace-admission.ts +70 -0
  131. package/src/runtime/evidence/process-check-execution.ts +18 -0
  132. package/src/runtime/execution/jobs/child-reference.ts +6 -2
  133. package/src/runtime/execution/process-runtime/check-result.ts +53 -18
  134. package/src/runtime/execution/process-runtime/check-runner-sidecar.ts +7 -1
  135. package/src/runtime/execution/process-runtime/command-facade.ts +39 -17
  136. package/src/runtime/execution/process-runtime/lightweight-managed.ts +20 -3
  137. package/src/runtime/execution/process-runtime/persisted-check.ts +4 -1
  138. package/src/runtime/execution/process-runtime/runtime.ts +10 -0
  139. package/src/runtime/execution/process-runtime/types.ts +2 -12
  140. package/src/runtime/execution/work-activity.ts +21 -0
  141. package/src/runtime/execution/workers/executor.ts +18 -13
  142. package/src/runtime/gateway/mcp/execution-tools.ts +35 -2930
  143. package/src/runtime/gateway/mcp/legacy-ios-tool-adapter.ts +61 -0
  144. package/src/runtime/gateway/mcp/router.ts +2 -2
  145. package/src/runtime/gateway/mcp/runtime-tool-definitions.ts +21 -2
  146. package/src/runtime/gateway/mcp/runtime-tools.ts +815 -1015
  147. package/src/runtime/plugins/app-store-connect-adapter.ts +93 -28
  148. package/src/runtime/plugins/browser-adapter.ts +256 -1083
  149. package/src/runtime/plugins/browser-handoff-host.ts +2 -25
  150. package/src/runtime/plugins/browser-handoff.ts +18 -3
  151. package/src/runtime/plugins/browser-macos-bridge.ts +121 -16
  152. package/src/runtime/plugins/browser-manifest-surface.ts +603 -0
  153. package/src/runtime/plugins/browser-native-session-service.ts +222 -0
  154. package/src/runtime/plugins/browser-runtime-contract.ts +2 -0
  155. package/src/runtime/plugins/browser-runtime.ts +10 -0
  156. package/src/runtime/plugins/browser-session-store.ts +121 -0
  157. package/src/runtime/plugins/browser-session-types.ts +98 -0
  158. package/src/runtime/plugins/desktop-operator-registration.ts +4 -4
  159. package/src/runtime/plugins/external-adapter.ts +92 -58
  160. package/src/runtime/plugins/macos-capability-broker.ts +0 -8
  161. package/src/runtime/plugins/store.ts +66 -9
  162. package/src/runtime/plugins/types.ts +3 -1
  163. package/src/runtime/recovery/maintenance-executor.ts +61 -25
  164. package/src/runtime/root/package-connector-service.ts +46 -4
  165. package/src/runtime/root/package-runtime-release.ts +3 -2
  166. package/src/runtime/root/package-runtime-service.ts +48 -3
  167. package/src/runtime/root/release-manifest.ts +137 -16
  168. package/src/runtime/root/release-materialize.ts +109 -35
  169. package/src/runtime/root/types.ts +10 -0
  170. package/src/runtime/standalone-recovery/core.ts +425 -194
  171. package/src/runtime/standalone-recovery/entry.ts +1 -1
  172. package/src/runtime/standalone-recovery/installer.ts +1 -1
  173. package/src/runtime/workflow/schedules/engine.ts +113 -87
  174. package/src/runtime/workflow/schedules/settlement.ts +26 -23
  175. package/src/runtime/workflow/schedules/store.ts +79 -10
@@ -513,8 +513,34 @@ function recoveryLockOwnerAlive(lock: RecoveryLock): boolean {
513
513
  return observed === undefined || observed === lock.processStartTime;
514
514
  }
515
515
 
516
+ function recoveryLockOwnerAttributable(owner: RecoveryLock): boolean {
517
+ return Boolean(owner.action && owner.requestId?.trim());
518
+ }
519
+
520
+ function recoveryLockIdentityFailure(owner: RecoveryLock): string {
521
+ return `RECOVERY_OPERATION_LOCK_IDENTITY_UNCERTAIN: live mutation lock pid=${owner.pid} instance=${owner.instanceId} is missing action/request identity`;
522
+ }
523
+
524
+ function assertRecoveryLockOwnerAttributable(config: RecoveryConfig, owner: RecoveryLock): void {
525
+ if (recoveryLockOwnerAttributable(owner)) return;
526
+ audit(config, 'recovery_operation_lock_identity_uncertain', {
527
+ pid: owner.pid,
528
+ instanceId: owner.instanceId,
529
+ action: owner.action ?? null,
530
+ requestId: owner.requestId ?? null,
531
+ acquiredAt: owner.acquiredAt,
532
+ });
533
+ throw new Error(recoveryLockIdentityFailure(owner));
534
+ }
535
+
516
536
  function recoveryBusyDetail(owner: RecoveryLock): string {
517
- return `Recovery mutation already in progress: action=${owner.action ?? 'unknown'} request=${owner.requestId ?? 'unknown'} pid=${owner.pid}`;
537
+ return `Recovery mutation already in progress: action=${owner.action ?? 'invalid'} request=${owner.requestId ?? 'invalid'} pid=${owner.pid} instance=${owner.instanceId}`;
538
+ }
539
+
540
+ function liveRecoveryMutationLock(config: RecoveryConfig): RecoveryLock | undefined {
541
+ const owner = json<RecoveryLock>(lockPath(config));
542
+ if (!owner || !Number.isInteger(owner.pid) || owner.pid <= 0 || typeof owner.instanceId !== 'string' || !owner.instanceId.trim()) return undefined;
543
+ return recoveryLockOwnerAlive(owner) ? owner : undefined;
518
544
  }
519
545
 
520
546
  async function withLock<T>(
@@ -524,14 +550,16 @@ async function withLock<T>(
524
550
  ): Promise<RecoveryLockAttempt<T>> {
525
551
  const path = lockPath(config);
526
552
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
553
+ const instanceId = randomUUID();
554
+ const requestId = intent.requestId?.trim() || `internal:${intent.action}:${instanceId}`;
527
555
  const lock: RecoveryLock = {
528
556
  schemaVersion: 1,
529
557
  pid: process.pid,
530
- instanceId: randomUUID(),
558
+ instanceId,
531
559
  processStartTime: processStartTime(process.pid),
532
560
  acquiredAt: new Date().toISOString(),
533
561
  action: intent.action,
534
- ...(intent.requestId ? { requestId: intent.requestId } : {}),
562
+ requestId,
535
563
  };
536
564
 
537
565
  for (let attempt = 0; attempt < 2; attempt += 1) {
@@ -548,13 +576,31 @@ async function withLock<T>(
548
576
  if (!existing || !Number.isInteger(existing.pid) || typeof existing.instanceId !== 'string') {
549
577
  throw new Error('RECOVERY_OPERATION_LOCK_UNCERTAIN');
550
578
  }
551
- if (recoveryLockOwnerAlive(existing)) return { acquired: false, owner: existing };
579
+ if (recoveryLockOwnerAlive(existing)) {
580
+ assertRecoveryLockOwnerAttributable(config, existing);
581
+ return { acquired: false, owner: existing };
582
+ }
552
583
  const latest = json<RecoveryLock>(path);
553
584
  if (!latest || latest.instanceId !== existing.instanceId) {
554
- if (attempt === 1) return { acquired: false, owner: latest ?? existing };
585
+ if (attempt === 1) {
586
+ if (latest && recoveryLockOwnerAlive(latest)) {
587
+ assertRecoveryLockOwnerAttributable(config, latest);
588
+ return { acquired: false, owner: latest };
589
+ }
590
+ audit(config, 'recovery_operation_lock_race', {
591
+ existingInstanceId: existing.instanceId,
592
+ existingPid: existing.pid,
593
+ latestInstanceId: latest?.instanceId ?? null,
594
+ latestPid: latest?.pid ?? null,
595
+ });
596
+ throw new Error('RECOVERY_OPERATION_LOCK_RACE');
597
+ }
555
598
  continue;
556
599
  }
557
- if (recoveryLockOwnerAlive(latest)) return { acquired: false, owner: latest };
600
+ if (recoveryLockOwnerAlive(latest)) {
601
+ assertRecoveryLockOwnerAttributable(config, latest);
602
+ return { acquired: false, owner: latest };
603
+ }
558
604
  try { writeFileSync(`${path}.stale-${Date.now()}-${existing.instanceId}`, readFileSync(path)); } catch { /* evidence best effort */ }
559
605
  rmSync(path, { force: true });
560
606
  continue;
@@ -632,7 +678,7 @@ async function probeExternalMcp(transport: RecoveryHttpTransport, url: string):
632
678
 
633
679
  function publicGatewayReadinessEndpoint(endpoint: string): string {
634
680
  const url = new URL(endpoint);
635
- url.pathname = '/ready';
681
+ url.pathname = '/transport-ready';
636
682
  url.search = '';
637
683
  url.hash = '';
638
684
  return url.toString();
@@ -644,6 +690,7 @@ async function probePublicGatewayReadiness(
644
690
  ): Promise<{ ok: boolean; detail: string; status?: number; value?: unknown }> {
645
691
  const controller = new AbortController();
646
692
  const timer = setTimeout(() => controller.abort('RECOVERY_HTTP_TIMEOUT'), 4_000);
693
+ const surface = 'transport-ready';
647
694
  try {
648
695
  const response = await transport.request({
649
696
  url: publicGatewayReadinessEndpoint(endpoint),
@@ -651,6 +698,18 @@ async function probePublicGatewayReadiness(
651
698
  timeoutMs: 4_000,
652
699
  signal: controller.signal,
653
700
  });
701
+ if (response.status === 404 || response.status === 405) {
702
+ // Older Connectors predate the transport-scoped capacity surface. Their
703
+ // whole-control-plane /ready route is intentionally not a transport
704
+ // liveness contract and can depend on the Runtime that Recovery is in the
705
+ // middle of restarting. Falling back to it creates a circular cutover
706
+ // dependency. Verify the actual MCP transport instead: a successful
707
+ // initialize response or the expected OAuth Bearer challenge proves the
708
+ // legacy Connector transport is reachable without weakening 5xx/timeout
709
+ // failure handling.
710
+ const legacy = await probeExternalMcp(transport, endpoint);
711
+ return { ...legacy, detail: `legacy-mcp ${legacy.detail}` };
712
+ }
654
713
  let sessionCapacity: unknown;
655
714
  try {
656
715
  const payload = JSON.parse(response.body) as { sessionCapacity?: unknown };
@@ -663,12 +722,12 @@ async function probePublicGatewayReadiness(
663
722
  );
664
723
  return {
665
724
  ok: response.ok && !recoveryRecommended,
666
- detail: `HTTP ${response.status}${recoveryRecommended ? '; session capacity recommends Connector recovery' : ''}`,
725
+ detail: `${surface} HTTP ${response.status}${recoveryRecommended ? '; session capacity recommends Connector recovery' : ''}`,
667
726
  status: response.status,
668
727
  ...(sessionCapacity === undefined ? {} : { value: sessionCapacity }),
669
728
  };
670
729
  } catch (error) {
671
- return { ok: false, detail: error instanceof Error ? error.message.slice(0, 180) : 'request failed' };
730
+ return { ok: false, detail: `${surface} ${error instanceof Error ? error.message.slice(0, 180) : 'request failed'}` };
672
731
  } finally {
673
732
  clearTimeout(timer);
674
733
  }
@@ -855,8 +914,11 @@ export async function verifyStableRuntime(
855
914
  : { ok: false, detail: 'canonical Runtime endpoint is unavailable' };
856
915
  if (config.publicMcpUrl) {
857
916
  probes.external_mcp_http = await probeExternalMcp(transport, config.publicMcpUrl);
858
- probes.primary_connector_ready = await probePublicGatewayReadiness(transport, config.publicMcpUrl);
917
+ const connectorReadinessEndpoint = config.primaryConnectorService?.localMcpUrl?.trim() || config.publicMcpUrl;
918
+ probes.primary_connector_ready = await probePublicGatewayReadiness(transport, connectorReadinessEndpoint);
859
919
  }
920
+ const primaryConnectorLocal = await probePrimaryConnectorLocal(config, transport);
921
+ if (primaryConnectorLocal) probes.primary_connector_local = primaryConnectorLocal;
860
922
  if (config.gateway) probes.recovery_gateway = await probe(transport, `http://${config.gateway.host}:${config.gateway.port}/health`);
861
923
  const watchdogHealth = observeRecoveryWatchdogHealth(config.controllerHome);
862
924
  probes.recovery_watchdog = {
@@ -1369,6 +1431,7 @@ export interface PrimaryRuntimeRecoveryDependencies {
1369
1431
  runCommand?: CommandRunner;
1370
1432
  verifyLocal?: (config: RecoveryConfig) => Promise<VerifyResult>;
1371
1433
  runtimeRunning?: (config: RecoveryConfig) => boolean;
1434
+ ensureRuntimeLaunchContract?: (controllerHome: string) => void;
1372
1435
  now?: () => number;
1373
1436
  sleep?: (ms: number) => Promise<void>;
1374
1437
  }
@@ -1402,6 +1465,8 @@ function primaryRuntimeLaunchdService(config: RecoveryConfig, uid: number): Laun
1402
1465
  }
1403
1466
 
1404
1467
  export interface PrimaryConnectorRecoveryDependencies {
1468
+ /** Durable caller identity propagated into the Recovery mutation lock and audit trail. */
1469
+ requestId?: string;
1405
1470
  platform?: NodeJS.Platform;
1406
1471
  currentUid?: () => Promise<number | undefined>;
1407
1472
  runCommand?: CommandRunner;
@@ -1424,10 +1489,13 @@ function primaryConnectorLaunchdService(config: RecoveryConfig, uid: number): La
1424
1489
  return { uid, domain, target: `${domain}/${label}`, label, plistPath };
1425
1490
  }
1426
1491
 
1427
- async function probePrimaryConnectorLocal(config: RecoveryConfig): Promise<{ ok: boolean; detail: string; status?: number } | undefined> {
1492
+ async function probePrimaryConnectorLocal(
1493
+ config: RecoveryConfig,
1494
+ transport = createRecoveryHttpTransport(config.controllerHome),
1495
+ ): Promise<{ ok: boolean; detail: string; status?: number } | undefined> {
1428
1496
  const localMcpUrl = config.primaryConnectorService?.localMcpUrl?.trim();
1429
1497
  if (!localMcpUrl) return undefined;
1430
- return probeExternalMcp(createRecoveryHttpTransport(config.controllerHome), localMcpUrl);
1498
+ return probeExternalMcp(transport, localMcpUrl);
1431
1499
  }
1432
1500
 
1433
1501
  function activePackageConnectorReleaseBinding(config: RecoveryConfig): PackageConnectorReleaseBinding | undefined {
@@ -1503,7 +1571,16 @@ export async function restartPrimaryConnector(
1503
1571
  ): Promise<PrimaryConnectorRestartResult> {
1504
1572
  const verifyLocal = dependencies.verifyLocal ?? verifyLocalRuntime;
1505
1573
  const initialLocal = await verifyLocal(config);
1506
- if (!initialLocal.ok) {
1574
+ // `verifyLocalRuntime` also reports the Connector's own loopback probe.
1575
+ // That probe is expected to be false when this recovery action is needed,
1576
+ // so it must not prevent a healthy canonical Runtime from repairing the
1577
+ // Connector service.
1578
+ const canonicalRuntimeHealthy = initialLocal.runtime.ok
1579
+ && initialLocal.runtime.running
1580
+ && initialLocal.runtime.ready
1581
+ && !initialLocal.runtime.stale
1582
+ && initialLocal.probes.active_gateway?.ok === true;
1583
+ if (!canonicalRuntimeHealthy) {
1507
1584
  return {
1508
1585
  ok: false,
1509
1586
  attempted: false,
@@ -1528,7 +1605,7 @@ export async function restartPrimaryConnector(
1528
1605
  const now = dependencies.now ?? Date.now;
1529
1606
  const wait = dependencies.sleep ?? sleep;
1530
1607
  const timeoutMs = config.primaryConnectorService?.postRestartVerifyTimeoutMs ?? 30_000;
1531
- const locked = await withLock(config, { action: 'restart_primary_connector' }, async () => {
1608
+ const locked = await withLock(config, { action: 'restart_primary_connector', requestId: dependencies.requestId }, async () => {
1532
1609
  const tunnelConfigured = Boolean(configuredPrimaryPublicTunnel(config));
1533
1610
  const repairConnectorBinding = dependencies.repairConnectorBinding ?? repairPrimaryConnectorBinding;
1534
1611
  const bindingRepair = await repairConnectorBinding(config);
@@ -1818,6 +1895,77 @@ async function cleanupVerifiedStalePrimaryRuntimeListener(input: {
1818
1895
  : { released: false, cleaned: false, detail: 'verified stale Forge Runtime listener remained on the port after SIGKILL', pid };
1819
1896
  }
1820
1897
 
1898
+ interface PrimaryRuntimeStoppedTransition {
1899
+ ok: boolean;
1900
+ detail: string;
1901
+ staleListenerCleanup?: PrimaryRuntimePortCleanupResult;
1902
+ }
1903
+
1904
+ /**
1905
+ * Stop the one canonical Runtime service and prove that every execution owner
1906
+ * boundary is gone before release authority may change. This is the shared
1907
+ * transition primitive for activation and recovery; callers must not publish
1908
+ * or roll back a release after a partial stop.
1909
+ */
1910
+ async function stopPrimaryRuntimeForReleaseTransition(input: {
1911
+ config: RecoveryConfig;
1912
+ service: LaunchdService;
1913
+ now: () => number;
1914
+ wait: (ms: number) => Promise<void>;
1915
+ runCommand: CommandRunner;
1916
+ runtimeRunning: (config: RecoveryConfig) => boolean;
1917
+ }): Promise<PrimaryRuntimeStoppedTransition> {
1918
+ const stopped = await input.runCommand('launchctl', ['bootout', input.service.target], 15_000);
1919
+ const stoppedCleanly = stopped.ok || /not found|no such process|could not find service|service is not loaded/i.test(`${stopped.stderr}\n${stopped.stdout}`);
1920
+ if (!stoppedCleanly) {
1921
+ return { ok: false, detail: `primary Runtime bootout failed: ${stopped.stderr || stopped.stdout || stopped.status}` };
1922
+ }
1923
+ const runtimeStopped = await waitForPrimaryRuntimeState({
1924
+ config: input.config,
1925
+ expectedRunning: false,
1926
+ timeoutMs: 20_000,
1927
+ now: input.now,
1928
+ wait: input.wait,
1929
+ runtimeRunning: input.runtimeRunning,
1930
+ });
1931
+ if (!runtimeStopped) return { ok: false, detail: 'primary Runtime owner remained live after bounded launchd bootout' };
1932
+ const serviceUnloaded = await waitForLaunchdServiceUnloaded({
1933
+ service: input.service,
1934
+ timeoutMs: 20_000,
1935
+ now: input.now,
1936
+ wait: input.wait,
1937
+ runCommand: input.runCommand,
1938
+ });
1939
+ if (!serviceUnloaded) return { ok: false, detail: 'primary Runtime launchd service remained loaded after bounded bootout' };
1940
+
1941
+ let portReleased = await waitForPrimaryRuntimePortReleased({
1942
+ config: input.config,
1943
+ timeoutMs: 20_000,
1944
+ now: input.now,
1945
+ wait: input.wait,
1946
+ runCommand: input.runCommand,
1947
+ });
1948
+ let staleListenerCleanup: PrimaryRuntimePortCleanupResult | undefined;
1949
+ if (!portReleased) {
1950
+ staleListenerCleanup = await cleanupVerifiedStalePrimaryRuntimeListener({
1951
+ config: input.config,
1952
+ uid: input.service.uid,
1953
+ now: input.now,
1954
+ wait: input.wait,
1955
+ runCommand: input.runCommand,
1956
+ });
1957
+ portReleased = staleListenerCleanup.released;
1958
+ }
1959
+ if (!portReleased) {
1960
+ return {
1961
+ ok: false,
1962
+ detail: `primary Runtime TCP port remained occupied after bounded launchd bootout: ${staleListenerCleanup?.detail ?? 'listener did not release'}`,
1963
+ staleListenerCleanup,
1964
+ };
1965
+ }
1966
+ return { ok: true, detail: 'primary Runtime service, owner, and TCP listener are stopped', staleListenerCleanup };
1967
+ }
1968
+
1821
1969
  async function verifyPrimaryRuntimeAfterStart(input: {
1822
1970
  config: RecoveryConfig;
1823
1971
  timeoutMs: number;
@@ -1834,6 +1982,73 @@ async function verifyPrimaryRuntimeAfterStart(input: {
1834
1982
  return observed;
1835
1983
  }
1836
1984
 
1985
+ interface PrimaryRuntimeRebindStartResult {
1986
+ ok: boolean;
1987
+ detail: string;
1988
+ verify: VerifyResult;
1989
+ }
1990
+
1991
+ /**
1992
+ * Complete the only safe restart sequence after release authority changes:
1993
+ * rebind the release-pinned launchd contract, start the sole Runtime service,
1994
+ * and require whole-Runtime verification. A started-but-unverified Runtime is
1995
+ * stopped again so callers never leave a restart loop behind.
1996
+ */
1997
+ async function rebindStartAndVerifyPrimaryRuntime(input: {
1998
+ config: RecoveryConfig;
1999
+ service: LaunchdService;
2000
+ runCommand: CommandRunner;
2001
+ now: () => number;
2002
+ wait: (ms: number) => Promise<void>;
2003
+ verifyLocal: (config: RecoveryConfig) => Promise<VerifyResult>;
2004
+ ensureRuntimeLaunchContract?: (controllerHome: string) => void;
2005
+ beforeStart?: () => void;
2006
+ contractFailureContext?: string;
2007
+ timeoutMs: number;
2008
+ successDetail: string;
2009
+ }): Promise<PrimaryRuntimeRebindStartResult> {
2010
+ try {
2011
+ const ensureRuntimeLaunchContract = input.ensureRuntimeLaunchContract
2012
+ ?? ((controllerHome: string) => { ensureForgeRuntimeLaunchAgentContract({ controllerHome, installUserLaunchAgent: true }); });
2013
+ ensureRuntimeLaunchContract(input.config.controllerHome);
2014
+ } catch (error) {
2015
+ return {
2016
+ ok: false,
2017
+ detail: `primary Runtime launchd contract rebuild failed ${input.contractFailureContext ?? 'after release transition'}: ${error instanceof Error ? error.message : String(error)}`,
2018
+ verify: await input.verifyLocal(input.config),
2019
+ };
2020
+ }
2021
+
2022
+ try {
2023
+ input.beforeStart?.();
2024
+ } catch (error) {
2025
+ return {
2026
+ ok: false,
2027
+ detail: `release transition pre-start preparation failed: ${error instanceof Error ? error.message : String(error)}`,
2028
+ verify: await input.verifyLocal(input.config),
2029
+ };
2030
+ }
2031
+
2032
+ const started = await ensureLaunchdServiceStarted(input.service, input.runCommand);
2033
+ if (!started.ok) {
2034
+ return { ok: false, detail: started.detail, verify: await input.verifyLocal(input.config) };
2035
+ }
2036
+ const verify = await verifyPrimaryRuntimeAfterStart({
2037
+ config: input.config,
2038
+ timeoutMs: input.timeoutMs,
2039
+ now: input.now,
2040
+ wait: input.wait,
2041
+ verifyLocal: input.verifyLocal,
2042
+ });
2043
+ if (verify.ok) return { ok: true, detail: input.successDetail, verify };
2044
+ await input.runCommand('launchctl', ['bootout', input.service.target], 15_000);
2045
+ return {
2046
+ ok: false,
2047
+ detail: 'release transition started a Runtime that failed whole-Runtime verification; service was stopped to prevent a restart loop',
2048
+ verify,
2049
+ };
2050
+ }
2051
+
1837
2052
  export async function restartPrimaryRuntime(
1838
2053
  config: RecoveryConfig,
1839
2054
  dependencies: PrimaryRuntimeRecoveryDependencies = {},
@@ -1897,44 +2112,21 @@ export async function recoverPrimaryRuntime(
1897
2112
  const before = await verifyLocal(config);
1898
2113
  if (before.ok) return { ok: true, attempted: false, noOp: true, detail: 'Canonical Forge Runtime recovered before rollback', serviceTarget: service.target, verify: before } satisfies PrimaryRuntimeRecoveryResult;
1899
2114
 
1900
- const stopped = await runCommand('launchctl', ['bootout', service.target], 15_000);
1901
- const stoppedCleanly = stopped.ok || /not found|no such process|could not find service|service is not loaded/i.test(`${stopped.stderr}\n${stopped.stdout}`);
1902
- if (!stoppedCleanly) {
1903
- const detail = `primary Runtime bootout failed: ${stopped.stderr || stopped.stdout || stopped.status}`;
1904
- audit(config, 'primary_runtime_recovery_stop_failed', { serviceTarget: service.target, detail });
1905
- return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: before } satisfies PrimaryRuntimeRecoveryResult;
1906
- }
1907
- const runtimeStopped = await waitForPrimaryRuntimeState({ config, expectedRunning: false, timeoutMs: 20_000, now, wait, runtimeRunning });
1908
- if (!runtimeStopped) {
1909
- const detail = 'primary Runtime owner remained live after bounded launchd bootout';
1910
- audit(config, 'primary_runtime_recovery_stop_unverified', { serviceTarget: service.target });
1911
- return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies PrimaryRuntimeRecoveryResult;
1912
- }
1913
- const serviceUnloaded = await waitForLaunchdServiceUnloaded({ service, timeoutMs: 20_000, now, wait, runCommand });
1914
- if (!serviceUnloaded) {
1915
- const detail = 'primary Runtime launchd service remained loaded after bounded bootout';
1916
- audit(config, 'primary_runtime_recovery_stop_unverified', { serviceTarget: service.target, detail });
1917
- return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies PrimaryRuntimeRecoveryResult;
1918
- }
1919
-
1920
- let portReleased = await waitForPrimaryRuntimePortReleased({ config, timeoutMs: 20_000, now, wait, runCommand });
1921
- let staleListenerCleanup: PrimaryRuntimePortCleanupResult | undefined;
1922
- if (!portReleased) {
1923
- staleListenerCleanup = await cleanupVerifiedStalePrimaryRuntimeListener({ config, uid: service.uid, now, wait, runCommand });
1924
- portReleased = staleListenerCleanup.released;
1925
- if (staleListenerCleanup.cleaned) {
1926
- audit(config, 'primary_runtime_recovery_stale_listener_cleaned', {
1927
- serviceTarget: service.target,
1928
- pid: staleListenerCleanup.pid,
1929
- signal: staleListenerCleanup.signal,
1930
- detail: staleListenerCleanup.detail,
1931
- });
1932
- }
2115
+ const stopped = await stopPrimaryRuntimeForReleaseTransition({ config, service, now, wait, runCommand, runtimeRunning });
2116
+ if (!stopped.ok) {
2117
+ const action = stopped.detail.startsWith('primary Runtime bootout failed')
2118
+ ? 'primary_runtime_recovery_stop_failed'
2119
+ : 'primary_runtime_recovery_stop_unverified';
2120
+ audit(config, action, { serviceTarget: service.target, detail: stopped.detail, pid: stopped.staleListenerCleanup?.pid });
2121
+ return { ok: false, attempted: true, detail: stopped.detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies PrimaryRuntimeRecoveryResult;
1933
2122
  }
1934
- if (!portReleased) {
1935
- const detail = `primary Runtime TCP port remained occupied after bounded launchd bootout: ${staleListenerCleanup?.detail ?? 'listener did not release'}`;
1936
- audit(config, 'primary_runtime_recovery_stop_unverified', { serviceTarget: service.target, detail, pid: staleListenerCleanup?.pid });
1937
- return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies PrimaryRuntimeRecoveryResult;
2123
+ if (stopped.staleListenerCleanup?.cleaned) {
2124
+ audit(config, 'primary_runtime_recovery_stale_listener_cleaned', {
2125
+ serviceTarget: service.target,
2126
+ pid: stopped.staleListenerCleanup.pid,
2127
+ signal: stopped.staleListenerCleanup.signal,
2128
+ detail: stopped.staleListenerCleanup.detail,
2129
+ });
1938
2130
  }
1939
2131
 
1940
2132
  const rollback = await rollbackPreviousLocked(config, reason);
@@ -1943,26 +2135,30 @@ export async function recoverPrimaryRuntime(
1943
2135
  return { ok: false, attempted: true, detail: rollback.detail, serviceTarget: service.target, rollback, verify: rollback.verify ?? await verifyLocal(config) } satisfies PrimaryRuntimeRecoveryResult;
1944
2136
  }
1945
2137
 
1946
- const started = await ensureLaunchdServiceStarted(service, runCommand);
1947
- if (!started.ok) {
1948
- audit(config, 'primary_runtime_recovery_start_failed', { serviceTarget: service.target, detail: started.detail, rollbackOperationId: rollback.operationId });
1949
- return { ok: false, attempted: true, detail: started.detail, serviceTarget: service.target, rollback, verify: await verifyLocal(config) } satisfies PrimaryRuntimeRecoveryResult;
1950
- }
1951
- const after = await verifyPrimaryRuntimeAfterStart({
2138
+ const restarted = await rebindStartAndVerifyPrimaryRuntime({
1952
2139
  config,
1953
- timeoutMs: configuredPrimaryRuntimeService(config).postRestartVerifyTimeoutMs ?? 45_000,
2140
+ service,
2141
+ runCommand,
1954
2142
  now,
1955
2143
  wait,
1956
2144
  verifyLocal,
2145
+ ensureRuntimeLaunchContract: dependencies.ensureRuntimeLaunchContract,
2146
+ contractFailureContext: 'after rollback',
2147
+ timeoutMs: configuredPrimaryRuntimeService(config).postRestartVerifyTimeoutMs ?? 45_000,
2148
+ successDetail: 'previous whole-Runtime release and SQLite backup restored, restarted, and verified',
1957
2149
  });
1958
- if (after.ok) {
1959
- audit(config, 'primary_runtime_recovery_succeeded', { serviceTarget: service.target, rollbackOperationId: rollback.operationId, restoredRelease: after.releases.active?.revision });
1960
- return { ok: true, attempted: true, detail: 'previous whole-Runtime release and SQLite backup restored, restarted, and verified', serviceTarget: service.target, rollback, verify: after } satisfies PrimaryRuntimeRecoveryResult;
2150
+ if (restarted.ok) {
2151
+ audit(config, 'primary_runtime_recovery_succeeded', { serviceTarget: service.target, rollbackOperationId: rollback.operationId, restoredRelease: restarted.verify.releases.active?.revision });
2152
+ return { ok: true, attempted: true, detail: restarted.detail, serviceTarget: service.target, rollback, verify: restarted.verify } satisfies PrimaryRuntimeRecoveryResult;
1961
2153
  }
1962
-
1963
- await runCommand('launchctl', ['bootout', service.target], 15_000);
1964
- audit(config, 'primary_runtime_recovery_unverified', { serviceTarget: service.target, rollbackOperationId: rollback.operationId, reasonCodes: after.runtime.reasonCodes });
1965
- return { ok: false, attempted: true, detail: 'previous whole-Runtime release started but failed verification; service was stopped to prevent a restart loop', serviceTarget: service.target, rollback, verify: after } satisfies PrimaryRuntimeRecoveryResult;
2154
+ const contractFailure = restarted.detail.startsWith('primary Runtime launchd contract rebuild failed');
2155
+ audit(config, contractFailure ? 'primary_runtime_recovery_launchd_contract_failed' : 'primary_runtime_recovery_unverified', {
2156
+ serviceTarget: service.target,
2157
+ rollbackOperationId: rollback.operationId,
2158
+ detail: restarted.detail,
2159
+ reasonCodes: restarted.verify.runtime.reasonCodes,
2160
+ });
2161
+ return { ok: false, attempted: true, detail: restarted.detail, serviceTarget: service.target, rollback, verify: restarted.verify } satisfies PrimaryRuntimeRecoveryResult;
1966
2162
  });
1967
2163
  if (!locked.acquired) return { ok: false, attempted: false, noOp: true, detail: recoveryBusyDetail(locked.owner), serviceTarget: service.target, verify: initial };
1968
2164
  return locked.value;
@@ -2160,44 +2356,22 @@ export async function activateRuntimeRelease(
2160
2356
  return { ok: false, attempted: false, noOp: true, detail, operationId } satisfies RuntimeReleaseActivationResult;
2161
2357
  }
2162
2358
  const before = await verifyLocal(config);
2163
- const stopped = await runCommand('launchctl', ['bootout', service.target], 15_000);
2164
- const stoppedCleanly = stopped.ok || /not found|no such process|could not find service|service is not loaded/i.test(`${stopped.stderr}\n${stopped.stdout}`);
2165
- if (!stoppedCleanly) {
2166
- const detail = `primary Runtime bootout failed: ${stopped.stderr || stopped.stdout || stopped.status}`;
2167
- audit(config, 'runtime_release_activation_stop_failed', { serviceTarget: service.target, operationId, detail });
2168
- return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies RuntimeReleaseActivationResult;
2169
- }
2170
- const runtimeStopped = await waitForPrimaryRuntimeState({ config, expectedRunning: false, timeoutMs: 20_000, now, wait, runtimeRunning });
2171
- if (!runtimeStopped) {
2172
- const detail = 'primary Runtime owner remained live after bounded launchd bootout';
2173
- audit(config, 'runtime_release_activation_stop_unverified', { serviceTarget: service.target, operationId });
2174
- return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies RuntimeReleaseActivationResult;
2175
- }
2176
- const serviceUnloaded = await waitForLaunchdServiceUnloaded({ service, timeoutMs: 20_000, now, wait, runCommand });
2177
- if (!serviceUnloaded) {
2178
- const detail = 'primary Runtime launchd service remained loaded after bounded bootout';
2179
- audit(config, 'runtime_release_activation_stop_unverified', { serviceTarget: service.target, operationId, detail });
2180
- return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies RuntimeReleaseActivationResult;
2181
- }
2182
- let portReleased = await waitForPrimaryRuntimePortReleased({ config, timeoutMs: 20_000, now, wait, runCommand });
2183
- let staleListenerCleanup: PrimaryRuntimePortCleanupResult | undefined;
2184
- if (!portReleased) {
2185
- staleListenerCleanup = await cleanupVerifiedStalePrimaryRuntimeListener({ config, uid: service.uid, now, wait, runCommand });
2186
- portReleased = staleListenerCleanup.released;
2187
- if (staleListenerCleanup.cleaned) {
2188
- audit(config, 'runtime_release_activation_stale_listener_cleaned', {
2189
- serviceTarget: service.target,
2190
- operationId,
2191
- pid: staleListenerCleanup.pid,
2192
- signal: staleListenerCleanup.signal,
2193
- detail: staleListenerCleanup.detail,
2194
- });
2195
- }
2359
+ const stopped = await stopPrimaryRuntimeForReleaseTransition({ config, service, now, wait, runCommand, runtimeRunning });
2360
+ if (!stopped.ok) {
2361
+ const action = stopped.detail.startsWith('primary Runtime bootout failed')
2362
+ ? 'runtime_release_activation_stop_failed'
2363
+ : 'runtime_release_activation_stop_unverified';
2364
+ audit(config, action, { serviceTarget: service.target, operationId, detail: stopped.detail, pid: stopped.staleListenerCleanup?.pid });
2365
+ return { ok: false, attempted: true, detail: stopped.detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies RuntimeReleaseActivationResult;
2196
2366
  }
2197
- if (!portReleased) {
2198
- const detail = `primary Runtime TCP port remained occupied after bounded launchd bootout: ${staleListenerCleanup?.detail ?? 'listener did not release'}`;
2199
- audit(config, 'runtime_release_activation_stop_unverified', { serviceTarget: service.target, operationId, detail, pid: staleListenerCleanup?.pid });
2200
- return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies RuntimeReleaseActivationResult;
2367
+ if (stopped.staleListenerCleanup?.cleaned) {
2368
+ audit(config, 'runtime_release_activation_stale_listener_cleaned', {
2369
+ serviceTarget: service.target,
2370
+ operationId,
2371
+ pid: stopped.staleListenerCleanup.pid,
2372
+ signal: stopped.staleListenerCleanup.signal,
2373
+ detail: stopped.staleListenerCleanup.detail,
2374
+ });
2201
2375
  }
2202
2376
  let committed: RuntimeReleaseAuthority;
2203
2377
  try {
@@ -2212,73 +2386,59 @@ export async function activateRuntimeRelease(
2212
2386
  audit(config, 'runtime_release_activation_commit_mismatch', { serviceTarget: service.target, operationId });
2213
2387
  return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies RuntimeReleaseActivationResult;
2214
2388
  }
2215
- try {
2216
- ensureForgeRuntimeLaunchAgentContract({ controllerHome: config.controllerHome, installUserLaunchAgent: true });
2217
- } catch (error) {
2218
- const detail = `runtime launchd stable-release contract update failed: ${error instanceof Error ? error.message : String(error)}`;
2219
- audit(config, 'runtime_release_activation_launchd_contract_failed', { serviceTarget: service.target, operationId, detail });
2220
- return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies RuntimeReleaseActivationResult;
2221
- }
2222
2389
  let storageMigration: ControllerHomeStorageMigration | undefined;
2223
- let after: VerifyResult;
2390
+ const activated = await rebindStartAndVerifyPrimaryRuntime({
2391
+ config,
2392
+ service,
2393
+ runCommand,
2394
+ now,
2395
+ wait,
2396
+ verifyLocal,
2397
+ timeoutMs: configuredPrimaryRuntimeService(config).postRestartVerifyTimeoutMs ?? 60_000,
2398
+ successDetail: 'requested Runtime release started and passed whole-Runtime verification',
2399
+ beforeStart: () => {
2400
+ storageMigration = migrateStoppedRepoLocalControllerHomeStorage(config.controllerHome, platform);
2401
+ if (storageMigration.migrated) {
2402
+ audit(config, 'runtime_controller_home_noindex_migrated', {
2403
+ serviceTarget: service.target,
2404
+ operationId,
2405
+ logicalHome: storageMigration.logicalHome,
2406
+ physicalHome: storageMigration.physicalHome,
2407
+ });
2408
+ }
2409
+ },
2410
+ });
2411
+ let after = activated.verify;
2224
2412
  let activationFailureDetail: string | undefined;
2225
- try {
2226
- storageMigration = migrateStoppedRepoLocalControllerHomeStorage(config.controllerHome, platform);
2227
- if (storageMigration.migrated) {
2228
- audit(config, 'runtime_controller_home_noindex_migrated', {
2229
- serviceTarget: service.target,
2230
- operationId,
2231
- logicalHome: storageMigration.logicalHome,
2232
- physicalHome: storageMigration.physicalHome,
2233
- });
2234
- }
2235
- } catch (error) {
2236
- activationFailureDetail = `Controller Home .noindex migration failed: ${error instanceof Error ? error.message : String(error)}`;
2237
- audit(config, 'runtime_controller_home_noindex_migration_failed', { serviceTarget: service.target, operationId, detail: activationFailureDetail });
2413
+ if (activated.ok && after.releases.active?.revision === candidate.manifest.releaseId) {
2414
+ audit(config, 'runtime_release_activation_succeeded', { serviceTarget: service.target, operationId, requestId: lockRequestId, activeRevision: after.releases.active?.revision, expectedAuthorityRevision: guard.expectedAuthorityRevision, expectedActiveReleaseId: guard.expectedActiveReleaseId, controllerHomeStorageMigrated: storageMigration?.migrated === true });
2415
+ return { ok: true, attempted: true, detail: storageMigration?.migrated ? 'requested Runtime release activated, Controller Home migrated to .noindex storage, and whole-Runtime verification passed' : 'requested Runtime release activated and passed whole-Runtime verification', serviceTarget: service.target, operationId, verify: after } satisfies RuntimeReleaseActivationResult;
2238
2416
  }
2239
-
2240
- if (activationFailureDetail) {
2241
- after = await verifyLocal(config);
2417
+ if (!activated.ok) {
2418
+ activationFailureDetail = activated.detail;
2419
+ const auditAction = activated.detail.startsWith('primary Runtime launchd contract rebuild failed')
2420
+ ? 'runtime_release_activation_launchd_contract_failed'
2421
+ : activated.detail.startsWith('release transition pre-start preparation failed')
2422
+ ? 'runtime_controller_home_noindex_migration_failed'
2423
+ : activated.detail.includes('start')
2424
+ ? 'runtime_release_activation_start_failed'
2425
+ : 'runtime_release_activation_unverified';
2426
+ audit(config, auditAction, { serviceTarget: service.target, operationId, detail: activated.detail });
2242
2427
  } else {
2243
- const started = await ensureLaunchdServiceStarted(service, runCommand);
2244
- if (!started.ok) {
2245
- activationFailureDetail = started.detail;
2246
- audit(config, 'runtime_release_activation_start_failed', { serviceTarget: service.target, operationId, detail: started.detail });
2247
- after = await verifyLocal(config);
2248
- } else {
2249
- after = await verifyPrimaryRuntimeAfterStart({
2250
- config,
2251
- timeoutMs: configuredPrimaryRuntimeService(config).postRestartVerifyTimeoutMs ?? 60_000,
2252
- now,
2253
- wait,
2254
- verifyLocal,
2255
- });
2256
- if (after.ok && after.releases.active?.revision === candidate.manifest.releaseId) {
2257
- audit(config, 'runtime_release_activation_succeeded', { serviceTarget: service.target, operationId, requestId: lockRequestId, activeRevision: after.releases.active?.revision, expectedAuthorityRevision: guard.expectedAuthorityRevision, expectedActiveReleaseId: guard.expectedActiveReleaseId, controllerHomeStorageMigrated: storageMigration?.migrated === true });
2258
- return { ok: true, attempted: true, detail: storageMigration?.migrated ? 'requested Runtime release activated, Controller Home migrated to .noindex storage, and whole-Runtime verification passed' : 'requested Runtime release activated and passed whole-Runtime verification', serviceTarget: service.target, operationId, verify: after } satisfies RuntimeReleaseActivationResult;
2259
- }
2260
- }
2428
+ activationFailureDetail = `activated Runtime release identity mismatch: expected ${candidate.manifest.releaseId}, observed ${after.releases.active?.revision ?? 'none'}`;
2429
+ audit(config, 'runtime_release_activation_commit_mismatch', { serviceTarget: service.target, operationId, detail: activationFailureDetail });
2261
2430
  }
2262
2431
 
2263
2432
  // Activation failed: stop, restore the previous whole release and its SQLite
2264
2433
  // backup, restart the one service, and require verification again.
2265
- await runCommand('launchctl', ['bootout', service.target], 15_000);
2266
2434
  let rollback: RollbackResult;
2267
- const rollbackRuntimeStopped = await waitForPrimaryRuntimeState({ config, expectedRunning: false, timeoutMs: 20_000, now, wait, runtimeRunning });
2268
- const rollbackServiceUnloaded = rollbackRuntimeStopped
2269
- ? await waitForLaunchdServiceUnloaded({ service, timeoutMs: 20_000, now, wait, runCommand })
2270
- : false;
2271
- const rollbackPortReleased = rollbackRuntimeStopped && rollbackServiceUnloaded
2272
- ? await waitForPrimaryRuntimePortReleased({ config, timeoutMs: 20_000, now, wait, runCommand })
2273
- : false;
2274
- if (!rollbackRuntimeStopped || !rollbackServiceUnloaded || !rollbackPortReleased) {
2435
+ const rollbackStop = await stopPrimaryRuntimeForReleaseTransition({ config, service, now, wait, runCommand, runtimeRunning });
2436
+ if (!rollbackStop.ok) {
2275
2437
  rollback = {
2276
2438
  ok: false,
2277
- detail: !rollbackRuntimeStopped
2278
- ? 'candidate Runtime owner remained live after bounded rollback bootout'
2279
- : !rollbackServiceUnloaded
2280
- ? 'candidate Runtime launchd service remained loaded after bounded rollback bootout'
2281
- : 'candidate Runtime TCP port remained occupied after bounded rollback bootout',
2439
+ detail: rollbackStop.detail
2440
+ .replace('primary Runtime', 'candidate Runtime')
2441
+ .replace('bounded launchd bootout', 'bounded rollback bootout'),
2282
2442
  };
2283
2443
  } else {
2284
2444
  try {
@@ -2294,7 +2454,6 @@ export async function activateRuntimeRelease(
2294
2454
  physicalHome: storageMigration.physicalHome,
2295
2455
  });
2296
2456
  }
2297
- ensureForgeRuntimeLaunchAgentContract({ controllerHome: config.controllerHome, installUserLaunchAgent: true });
2298
2457
  const previousRevision = previousActive?.releaseId ?? '';
2299
2458
  const previousIdentity = previousActive?.artifactIdentity;
2300
2459
  if (
@@ -2303,24 +2462,18 @@ export async function activateRuntimeRelease(
2303
2462
  ) {
2304
2463
  throw new Error('RECOVERY_RUNTIME_RELEASE_ROLLBACK_AUTHORITY_MISMATCH');
2305
2464
  }
2306
- const restarted = await ensureLaunchdServiceStarted(service, runCommand);
2307
- if (!restarted.ok) {
2308
- rollback = { ok: false, operationId: rollbackOperationId, detail: `previous release authority restored but service start failed: ${restarted.detail}` };
2309
- } else {
2310
- const rolledBack = await verifyPrimaryRuntimeAfterStart({
2311
- config,
2312
- timeoutMs: configuredPrimaryRuntimeService(config).postRestartVerifyTimeoutMs ?? 60_000,
2313
- now,
2314
- wait,
2315
- verifyLocal,
2316
- });
2317
- if (rolledBack.ok) {
2318
- rollback = { ok: true, operationId: rollbackOperationId, detail: 'previous whole-Runtime release and SQLite backup restored, restarted, and verified', verify: rolledBack };
2319
- } else {
2320
- await runCommand('launchctl', ['bootout', service.target], 15_000);
2321
- rollback = { ok: false, operationId: rollbackOperationId, detail: 'previous whole-Runtime release started but failed verification; service was stopped to prevent a restart loop', verify: rolledBack };
2322
- }
2323
- }
2465
+ const restarted = await rebindStartAndVerifyPrimaryRuntime({
2466
+ config,
2467
+ service,
2468
+ runCommand,
2469
+ now,
2470
+ wait,
2471
+ verifyLocal,
2472
+ contractFailureContext: 'after rollback',
2473
+ timeoutMs: configuredPrimaryRuntimeService(config).postRestartVerifyTimeoutMs ?? 60_000,
2474
+ successDetail: 'previous whole-Runtime release and SQLite backup restored, restarted, and verified',
2475
+ });
2476
+ rollback = { ok: restarted.ok, operationId: rollbackOperationId, detail: restarted.detail, verify: restarted.verify };
2324
2477
  } catch (error) {
2325
2478
  rollback = { ok: false, detail: error instanceof Error ? error.message : 'previous whole-Runtime release rollback failed' };
2326
2479
  }
@@ -2369,15 +2522,26 @@ export async function stageAndActivateConfiguredRuntimeRelease(
2369
2522
  // Capture the base before staging. Staging can take long enough for another
2370
2523
  // activation to win; the later publish must prove this base is still current.
2371
2524
  const expectedAuthority = releaseAuthority(config);
2372
- let staged: StagedRuntimeRelease;
2373
- try {
2374
- staged = (dependencies.stage ?? stageRuntimeReleaseFromCandidateSource)({ controllerHome: config.controllerHome, sourceRoot });
2375
- assertRuntimeReleaseFiles(staged);
2376
- } catch (error) {
2377
- const detail = error instanceof Error ? error.message : 'Runtime release staging failed';
2378
- audit(config, 'runtime_release_stage_failed', { sourceRoot, detail });
2379
- return { ok: false, attempted: false, noOp: true, detail };
2525
+ const stagedAttempt = await withLock(config, {
2526
+ action: 'stage_and_activate_runtime_release',
2527
+ ...(requestId?.trim() ? { requestId: requestId.trim() } : {}),
2528
+ }, async () => {
2529
+ try {
2530
+ const staged = (dependencies.stage ?? stageRuntimeReleaseFromCandidateSource)({ controllerHome: config.controllerHome, sourceRoot });
2531
+ assertRuntimeReleaseFiles(staged);
2532
+ return { ok: true as const, staged };
2533
+ } catch (error) {
2534
+ return { ok: false as const, detail: error instanceof Error ? error.message : 'Runtime release staging failed' };
2535
+ }
2536
+ });
2537
+ if (!stagedAttempt.acquired) {
2538
+ return { ok: false, attempted: false, noOp: true, detail: recoveryBusyDetail(stagedAttempt.owner) };
2380
2539
  }
2540
+ if (!stagedAttempt.value.ok) {
2541
+ audit(config, 'runtime_release_stage_failed', { sourceRoot, detail: stagedAttempt.value.detail });
2542
+ return { ok: false, attempted: false, noOp: true, detail: stagedAttempt.value.detail };
2543
+ }
2544
+ const staged: StagedRuntimeRelease = stagedAttempt.value.staged;
2381
2545
  const activationGuard: RuntimeReleaseActivationGuard = {
2382
2546
  ...(requestId?.trim() ? { requestId: requestId.trim() } : {}),
2383
2547
  expectedAuthorityRevision: expectedAuthority?.revision ?? null,
@@ -2691,6 +2855,35 @@ export async function watchdogTick(config: RecoveryConfig, prior: WatchdogState)
2691
2855
  fullVerificationPerformed = true;
2692
2856
  }
2693
2857
  const lastFullVerifyAt = fullVerificationPerformed ? now : scopedPrior.lastFullVerifyAt;
2858
+ const activeMutation = liveRecoveryMutationLock(config);
2859
+ if (activeMutation) {
2860
+ const attributable = recoveryLockOwnerAttributable(activeMutation);
2861
+ if (!attributable) {
2862
+ audit(config, 'recovery_operation_lock_identity_uncertain', {
2863
+ pid: activeMutation.pid,
2864
+ instanceId: activeMutation.instanceId,
2865
+ action: activeMutation.action ?? null,
2866
+ requestId: activeMutation.requestId ?? null,
2867
+ acquiredAt: activeMutation.acquiredAt,
2868
+ });
2869
+ }
2870
+ const reason = attributable
2871
+ ? `Recovery mutation in progress: action=${activeMutation.action} request=${activeMutation.requestId}; watchdog repair escalation suppressed`
2872
+ : 'Recovery mutation lock identity is uncertain; watchdog repair escalation suppressed';
2873
+ const state: WatchdogState = {
2874
+ ...scopedPrior,
2875
+ failures: 0,
2876
+ firstFailureAt: undefined,
2877
+ publicTunnelFailures: 0,
2878
+ publicTunnelFirstFailureAt: undefined,
2879
+ primaryConnectorFailures: 0,
2880
+ primaryConnectorFirstFailureAt: undefined,
2881
+ lastFullVerifyAt,
2882
+ lastDecision: 'degraded',
2883
+ lastReason: reason,
2884
+ };
2885
+ return { state, decision: { action: 'degraded', reason }, verify: verified };
2886
+ }
2694
2887
  if (runtimeStartupGrace && !localVerify.ok) {
2695
2888
  const state: WatchdogState = {
2696
2889
  ...scopedPrior,
@@ -2709,18 +2902,34 @@ export async function watchdogTick(config: RecoveryConfig, prior: WatchdogState)
2709
2902
  }
2710
2903
  const recoveryHealthy = verified.probes.recovery_gateway?.ok !== false
2711
2904
  && verified.probes.recovery_external_http?.ok !== false;
2712
- const primaryRuntimeHealthy = localVerify.ok;
2713
- const primaryConnectorConfigured = Boolean(config.primaryConnectorService && config.publicMcpUrl);
2714
- const primaryConnectorFailed = Boolean(
2715
- primaryConnectorConfigured
2905
+ const primaryRuntimeHealthy = localVerify.runtime.ok
2906
+ && localVerify.runtime.running
2907
+ && localVerify.runtime.ready
2908
+ && !localVerify.runtime.stale
2909
+ && localVerify.probes.active_gateway?.ok === true;
2910
+ const primaryConnectorConfigured = Boolean(config.primaryConnectorService);
2911
+ const primaryConnectorLocalFailed = verified.probes.primary_connector_local?.ok === false;
2912
+ const primaryConnectorCapacityFailed = connectorCapacityRecoveryRecommended(verified);
2913
+ const primaryPublicTransportManaged = Boolean(configuredPrimaryPublicTunnel(config));
2914
+ const primaryPublicTransportFailed = Boolean(
2915
+ config.publicMcpUrl
2716
2916
  && primaryRuntimeHealthy
2917
+ && !primaryConnectorCapacityFailed
2717
2918
  && (
2718
2919
  verified.probes.external_mcp_http?.ok === false
2719
2920
  || verified.probes.primary_connector_ready?.ok === false
2720
- || connectorCapacityRecoveryRecommended(verified)
2721
2921
  ),
2722
2922
  );
2723
- if (primaryRuntimeHealthy && !primaryConnectorFailed && recoveryHealthy) {
2923
+ const primaryConnectorFailed = Boolean(
2924
+ primaryConnectorConfigured
2925
+ && primaryRuntimeHealthy
2926
+ && (
2927
+ primaryConnectorLocalFailed
2928
+ || primaryConnectorCapacityFailed
2929
+ || (primaryPublicTransportManaged && primaryPublicTransportFailed)
2930
+ ),
2931
+ );
2932
+ if (primaryRuntimeHealthy && !primaryConnectorFailed && !primaryPublicTransportFailed && recoveryHealthy) {
2724
2933
  if (fullVerificationPerformed && verified.ok && !matchingKnownGood(config, verified.releases.active)) {
2725
2934
  try {
2726
2935
  const attestation = await withLock(config, { action: 'attest_known_good' }, async () => persistVerifiedKnownGood(config, verified));
@@ -2759,6 +2968,26 @@ export async function watchdogTick(config: RecoveryConfig, prior: WatchdogState)
2759
2968
  };
2760
2969
  return { state, decision: { action: 'healthy', reason: 'primary runtime and standalone Recovery verification passed' }, verify: verified };
2761
2970
  }
2971
+ if (primaryRuntimeHealthy && !primaryConnectorFailed && primaryPublicTransportFailed && recoveryHealthy) {
2972
+ const reason = primaryPublicTransportManaged
2973
+ ? 'primary public transport is unavailable but no safely repairable primary Connector path was established'
2974
+ : 'primary public transport is unavailable while the local Connector is healthy, but no managed primary public tunnel is configured; external transport repair is required';
2975
+ const state: WatchdogState = {
2976
+ ...recordWatchdogRuntimeHealthy(
2977
+ scopeWatchdogStateToRuntimeRelease(scopedPrior, verified.releases.active),
2978
+ now,
2979
+ watchdogRuntimeRestartBudgetStableMs(config),
2980
+ ),
2981
+ failures: scopedPrior.failures + 1,
2982
+ firstFailureAt: scopedPrior.firstFailureAt ?? now,
2983
+ primaryConnectorFailures: 0,
2984
+ primaryConnectorFirstFailureAt: undefined,
2985
+ lastFullVerifyAt,
2986
+ lastDecision: 'degraded',
2987
+ lastReason: reason,
2988
+ };
2989
+ return { state, decision: { action: 'degraded', reason }, verify: verified };
2990
+ }
2762
2991
  const publicTunnelFailed = isExternalTunnelFailure(config, verified, localVerify);
2763
2992
  const evidenceClasses = Object.entries(localVerify.probes)
2764
2993
  .filter(([name, value]) => !name.startsWith('recovery_') && !value.ok)
@@ -2805,7 +3034,7 @@ export async function watchdogTick(config: RecoveryConfig, prior: WatchdogState)
2805
3034
  primaryConnectorFailures: 0,
2806
3035
  primaryConnectorFirstFailureAt: undefined,
2807
3036
  };
2808
- if (!localVerify.ok) state.runtimeHealthySince = undefined;
3037
+ if (!primaryRuntimeHealthy) state.runtimeHealthySince = undefined;
2809
3038
  const primaryConfig = configuredPrimaryRuntimeService(config);
2810
3039
  const decision = decideWatchdog({
2811
3040
  ...state,
@@ -2813,7 +3042,7 @@ export async function watchdogTick(config: RecoveryConfig, prior: WatchdogState)
2813
3042
  evidenceClasses,
2814
3043
  activeKnownGood,
2815
3044
  previousKnownGood,
2816
- primaryRuntimeFailed: !localVerify.ok,
3045
+ primaryRuntimeFailed: !primaryRuntimeHealthy,
2817
3046
  runtimeMaximumRestartAttempts: primaryConfig.maximumRestartAttempts,
2818
3047
  runtimeRestartCooldownMs: primaryConfig.restartCooldownMs,
2819
3048
  runtimeMinimumFailures: primaryConfig.minimumFailures,
@@ -2850,7 +3079,9 @@ export async function watchdogTick(config: RecoveryConfig, prior: WatchdogState)
2850
3079
  return { state: { ...state, recoveryGatewayRestartUsed: true }, decision, verify: verified, recoveryGatewayRestart };
2851
3080
  }
2852
3081
  if (decision.action === 'restart_primary_connector') {
2853
- const primaryConnectorRestart = await restartPrimaryConnector(config);
3082
+ const primaryConnectorRestart = await restartPrimaryConnector(config, {
3083
+ requestId: `watchdog:restart_primary_connector:${now}`,
3084
+ });
2854
3085
  const attempts = (state.primaryConnectorRestartAttempts ?? 0) + (primaryConnectorRestart.attempted ? 1 : 0);
2855
3086
  const nextState: WatchdogState = primaryConnectorRestart.ok
2856
3087
  ? {