@moretea-labs/forge 1.5.1 → 1.7.0
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/AGENTS.md +2 -0
- package/CHANGELOG.md +46 -0
- package/CLAUDE.md +2 -0
- package/DESIGN.md +7 -2
- package/README.md +4 -4
- package/README.zh-CN.md +2 -2
- package/SKILL.md +2 -2
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/agents/openai.yaml +1 -1
- package/assets/hooks/hook-input.sh +11 -8
- package/assets/hooks/lib/session-state.sh +10 -7
- package/assets/hooks/lib/workflow-state.sh +13 -177
- package/assets/hooks/post-tool-observer.sh +8 -2
- package/assets/hooks/pre-edit-guard.sh +8 -39
- package/assets/hooks/prompt-guard.sh +7 -83
- package/assets/hooks/session-start-context.sh +56 -1
- package/assets/partials/05-workflow.partial.md +1 -1
- package/assets/partials-agents/03-orchestration.partial.md +1 -1
- package/assets/plugin-registry.v1.json +54 -3
- package/assets/reference-configs/document-generation.md +1 -1
- package/assets/reference-configs/harness-overview.md +1 -1
- package/assets/reference-configs/hook-operations.md +1 -1
- package/assets/reference-configs/sprint-contracts.md +3 -3
- package/assets/skill-version.json +2 -2
- package/assets/templates/contract.template.md +2 -7
- package/assets/templates/helpers/capture-plan.sh +2 -2
- package/assets/templates/helpers/check-task-workflow.sh +4 -4
- package/assets/templates/helpers/contract-worktree.sh +1 -21
- package/assets/templates/helpers/ensure-task-workflow.sh +13 -38
- package/assets/templates/helpers/migrate-project-template.sh +22 -45
- package/assets/templates/helpers/new-plan.sh +1 -1
- package/assets/templates/helpers/plan-to-todo.sh +12 -37
- package/assets/templates/helpers/ship-worktrees.sh +2 -29
- package/assets/templates/helpers/sprint-backlog.sh +6 -6
- package/assets/templates/helpers/verify-contract.sh +1 -111
- package/assets/templates/helpers/verify-sprint.sh +3 -180
- package/assets/templates/plan.template.md +1 -1
- package/assets/templates/review.template.md +9 -34
- package/assets/workflow-contract.v1.json +10 -19
- package/docs/README.md +10 -10
- package/docs/architecture/CURRENT.md +170 -0
- package/docs/architecture/EVOLUTION.md +42 -0
- package/docs/architecture/current/README.md +4 -156
- package/docs/architecture/decisions/20260803-release-identity-binding-and-exit-policy.md +2 -2
- package/docs/architecture/decisions/20260823-immediate-controller-relay.md +53 -0
- package/docs/architecture/decisions/20260826-browser-runtime-v2.md +73 -0
- package/docs/architecture/history/README.md +7 -0
- package/docs/architecture/{chatgpt-handoff-facade.md → history/chatgpt-handoff-facade.md} +4 -0
- package/docs/architecture/{global-hook-runtime.md → history/global-hook-runtime.md} +7 -3
- package/docs/architecture/{ios-semantic-automation-provider-v2.md → history/ios-semantic-automation-provider-v2.md} +6 -2
- package/docs/architecture/history.md +3 -34
- package/docs/architecture/index.md +15 -98
- package/docs/architecture/modules/controller-runtime/transport-lifecycle.md +1 -1
- package/docs/architecture/reviews/2026-08-22-semantic-harness-convergence-acceptance.md +39 -0
- package/docs/architecture/versions/1.6.md +38 -0
- package/docs/architecture/versions/1.7.md +41 -0
- package/docs/forge-chatgpt-browser-engine.md +2 -2
- package/docs/forge-chatgpt-mcp-setup.md +4 -28
- package/docs/forge-chatgpt-supervised-automation.md +3 -3
- package/docs/forge-github-issue-launcher.md +1 -1
- package/docs/forge-runtime-self-healing-loop.md +1 -1
- package/docs/operations/controller-browser-plugin.md +38 -22
- package/docs/operations/controller-performance-and-502.md +124 -0
- package/docs/operations/controller-reliability-runbook.md +1 -1
- package/docs/operations/features.md +3 -3
- package/docs/operations/github-repository.md +1 -1
- package/docs/operations/homebrew.md +2 -2
- package/docs/operations/mcp-tool-exposure.md +5 -3
- package/docs/tutorials/01-install-and-start.md +1 -1
- package/docs/tutorials/01-install-and-start.zh-CN.md +1 -1
- package/docs/wiki/Architecture.md +6 -6
- package/docs/wiki/Implementation.md +2 -2
- package/docs/wiki/Installation.md +1 -1
- package/docs/wiki/Integrations.md +2 -2
- package/docs/wiki/Quick-Start.md +1 -1
- package/docs/wiki/Work-Lifecycle.md +1 -1
- package/package.json +29 -10
- package/scripts/activate-source-baseline.ts +17 -13
- package/scripts/benchmark-background-check-overlap.ts +228 -0
- package/scripts/benchmark-mcp-host.sh +117 -0
- package/scripts/benchmark-mcp-transport.ts +170 -0
- package/scripts/benchmark-quality-harness.ts +146 -0
- package/scripts/benchmark-route-session-concurrency.ts +207 -6
- package/scripts/benchmark-thin-harness-mcp-e2e.ts +7 -1
- package/scripts/benchmark-typescript-navigation.ts +186 -0
- package/scripts/benchmark-work-efficiency.ts +334 -0
- package/scripts/capture-plan.sh +3 -3
- package/scripts/check-agent-tooling.sh +8 -35
- package/scripts/check-architecture-sync.sh +17 -86
- package/scripts/check-mcp-compatibility.ts +14 -0
- package/scripts/check-public-docs.sh +14 -1
- package/scripts/check-runtime-architecture.mjs +107 -64
- package/scripts/check-stable-baseline.ts +92 -0
- package/scripts/check-task-workflow.sh +4 -4
- package/scripts/contract-worktree.sh +1 -21
- package/scripts/ensure-task-workflow.sh +22 -55
- package/scripts/host-temporary-cloud-mcp-e2e.sh +265 -0
- package/scripts/install-standalone-recovery.ts +24 -0
- package/scripts/lib/project-init-lib.sh +15 -44
- package/scripts/migrate-chatgpt-automation-schedules.ts +64 -0
- package/scripts/migrate-project-template.sh +23 -1179
- package/scripts/new-plan.sh +1 -1
- package/scripts/plan-to-todo.sh +11 -36
- package/scripts/run-governed-gate.ts +2 -1
- package/scripts/ship-worktrees.sh +2 -29
- package/scripts/smoke-ios-physical-device.ts +4 -2
- package/scripts/smoke-runtime-recovery.ts +1 -35
- package/scripts/smoke-schedule-engine.ts +1 -20
- package/scripts/sprint-backlog.sh +1 -1
- package/scripts/verify-contract.sh +1 -111
- package/scripts/verify-forge-runtime.sh +0 -1
- package/scripts/verify-sprint.sh +3 -180
- package/scripts/workflow-contract.ts +1 -5
- package/src/cli/commands/chatgpt.ts +31 -2
- package/src/cli/commands/init-hook.ts +3 -3
- package/src/cli/commands/mcp.ts +9 -5
- package/src/cli/commands/plugin.ts +37 -10
- package/src/cli/commands/recovery.ts +25 -3
- package/src/cli/commands/runtime.ts +55 -3
- package/src/cli/commands/security.ts +12 -1
- package/src/cli/commands/setup-profile.ts +31 -21
- package/src/cli/controller/check-runner.ts +106 -3
- package/src/cli/controller/composite-result.ts +1 -1
- package/src/cli/controller/context/focus.ts +51 -0
- package/src/cli/controller/context/instruction-context.ts +114 -0
- package/src/cli/controller/context/known-paths.ts +76 -0
- package/src/cli/controller/context/query-planning.ts +110 -0
- package/src/cli/controller/context/rule-resolution.ts +532 -0
- package/src/cli/controller/context/source-materializer.ts +278 -0
- package/src/cli/controller/context/structural.ts +16 -0
- package/src/cli/controller/context/types.ts +286 -0
- package/src/cli/controller/context-pack.ts +898 -430
- package/src/cli/controller/launch-agents.ts +77 -2
- package/src/cli/controller/operational-plan.ts +2 -2
- package/src/cli/controller/work-mode.ts +6 -4
- package/src/cli/editing/edit-session.ts +60 -7
- package/src/cli/github/github.ts +133 -0
- package/src/cli/hook/prompt-guard-decision.ts +17 -18
- package/src/cli/hook/prompt-intents.ts +1 -1
- package/src/cli/installer/managed-entries.ts +15 -1
- package/src/cli/local-bridge/console-automations.ts +225 -67
- package/src/cli/local-bridge/console-view-models.ts +3 -35
- package/src/cli/local-bridge/facade-api.ts +2 -217
- package/src/cli/local-bridge/frontend/src/App.tsx +3 -3
- package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +153 -3
- package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +37 -4
- package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +183 -3
- package/src/cli/local-bridge/frontend/src/features/repositories/RepositoriesView.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/features/work/WorkView.tsx +4 -3
- package/src/cli/local-bridge/frontend/src/lib/api.ts +18 -11
- package/src/cli/local-bridge/frontend/src/lib/routes.ts +1 -1
- package/src/cli/local-bridge/frontend/src/shell/CommandBar.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +4 -3
- package/src/cli/local-bridge/frontend/src/styles.css +27 -11
- package/src/cli/local-bridge/frontend/src/types.ts +8 -7
- package/src/cli/local-bridge/job-store.ts +1 -490
- package/src/cli/local-bridge/server.ts +29 -532
- package/src/cli/local-bridge/ui-dist/app.css +1 -1
- package/src/cli/local-bridge/ui-dist/app.js +5101 -4967
- package/src/cli/mcp/auth.ts +5 -5
- package/src/cli/mcp/instructions.ts +3 -2
- package/src/cli/mcp/legacy-tool-service.ts +0 -189
- package/src/cli/mcp/multi-repository.ts +16 -5
- package/src/cli/mcp/oauth.ts +175 -26
- package/src/cli/mcp/paths.ts +8 -2
- package/src/cli/mcp/repository-tools.ts +356 -101
- package/src/cli/mcp/server.ts +625 -54
- package/src/cli/mcp/tool-capability-cache.ts +1 -1
- package/src/cli/mcp/toolset-selection.ts +1 -1
- package/src/cli/mcp/toolset.ts +42 -5
- package/src/cli/mcp/transports/http.ts +125 -15
- package/src/cli/mcp/transports/session-registry.ts +67 -5
- package/src/cli/mcp/types.ts +3 -3
- package/src/cli/repositories/command-classifier.ts +209 -14
- package/src/cli/repositories/command-executor.ts +55 -521
- package/src/cli/repositories/command-process.ts +108 -0
- package/src/cli/repositories/command-scope.ts +99 -41
- package/src/cli/repositories/controller-home.ts +7 -8
- package/src/cli/repositories/git-command-executor.ts +2 -0
- package/src/cli/repositories/locks.ts +53 -16
- package/src/cli/repositories/repository-snapshot.ts +121 -0
- package/src/cli/repositories/safe-patch.ts +6 -4
- package/src/cli/repositories/selected-path-actions.ts +17 -9
- package/src/cli/repositories/structured-git.ts +110 -10
- package/src/cli/repository/inspector.ts +565 -62
- package/src/cli/repository/session-cache.ts +58 -17
- package/src/cli/v81-entry.ts +1 -4
- package/src/effects/fs-transaction.ts +85 -17
- package/src/runtime/context/codegraph-read-provider.ts +233 -2
- package/src/runtime/context/codegraph-sidecar.cjs +74 -40
- package/src/runtime/context/generic-lsp-provider.ts +355 -0
- package/src/runtime/context/lsp-client.ts +283 -0
- package/src/runtime/context/semantic-navigation.ts +275 -0
- package/src/runtime/context/swift-navigation.ts +565 -0
- package/src/runtime/context/typescript-navigation.ts +226 -0
- package/src/runtime/control-plane/dispatch-priority.ts +28 -0
- package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +196 -2
- package/src/runtime/control-plane/execution/edit-validation-coordinator.ts +86 -24
- package/src/runtime/control-plane/execution/execution-identity.ts +65 -14
- package/src/runtime/control-plane/execution/session-store.ts +18 -0
- package/src/runtime/control-plane/execution/validation.ts +15 -2
- package/src/runtime/control-plane/execution/verification-evidence.ts +58 -3
- package/src/runtime/control-plane/execution/work-handle-store.ts +27 -2
- package/src/runtime/control-plane/execution/work-lifecycle-audit.ts +258 -0
- package/src/runtime/control-plane/execution/work-remote-delivery.ts +215 -0
- package/src/runtime/control-plane/execution/work-task-receipt.ts +54 -11
- package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +175 -8
- package/src/runtime/control-plane/execution/work-validation-reconciler.ts +28 -21
- package/src/runtime/control-plane/execution/work-verification-snapshot.ts +87 -9
- package/src/runtime/control-plane/facade/capability-registry.ts +129 -25
- package/src/runtime/control-plane/facade/controller-round-relay.ts +991 -0
- package/src/runtime/control-plane/facade/controller-session-store.ts +144 -14
- package/src/runtime/control-plane/facade/goal-workloop-access.ts +9 -2
- package/src/runtime/control-plane/facade/goal-workloop.ts +885 -105
- package/src/runtime/control-plane/facade/index.ts +1 -0
- package/src/runtime/control-plane/facade/plan-contract-store.ts +211 -26
- package/src/runtime/control-plane/facade/requirement-board.ts +24 -8
- package/src/runtime/control-plane/facade/semantic-admission.ts +205 -0
- package/src/runtime/control-plane/facade/suggested-actions.ts +5 -1
- package/src/runtime/control-plane/facade/types.ts +55 -11
- package/src/runtime/control-plane/facade/work-contract-store.ts +204 -73
- package/src/runtime/control-plane/generated-cache-retention.ts +271 -0
- package/src/runtime/control-plane/global-scheduler/config.ts +44 -0
- package/src/runtime/control-plane/global-scheduler/dispatch-capacity.ts +80 -0
- package/src/runtime/control-plane/global-scheduler/durable-admission.ts +46 -0
- package/src/runtime/control-plane/global-scheduler/maintenance.ts +145 -0
- package/src/runtime/control-plane/global-scheduler/projection-refresh.ts +84 -0
- package/src/runtime/control-plane/global-scheduler/reconciliation.ts +3 -3
- package/src/runtime/control-plane/global-scheduler/scheduler.ts +273 -542
- package/src/runtime/control-plane/global-scheduler/source-scan.ts +59 -0
- package/src/runtime/control-plane/global-scheduler/state.ts +88 -0
- package/src/runtime/control-plane/global-scheduler/worker-attachment.ts +57 -0
- package/src/runtime/control-plane/global-scheduler/worker-exit-decision.ts +38 -0
- package/src/runtime/control-plane/global-scheduler/worker-exit-reconciler.ts +175 -0
- package/src/runtime/control-plane/global-scheduler/worker-launch.ts +111 -0
- package/src/runtime/control-plane/global-scheduler/worker-lifecycle-store.ts +64 -0
- package/src/runtime/control-plane/global-scheduler/worker-lifecycle.ts +150 -0
- package/src/runtime/control-plane/global-scheduler/worker-process.ts +119 -0
- package/src/runtime/control-plane/global-scheduler/worker-stderr.ts +60 -0
- package/src/runtime/control-plane/launcher/chatgpt-work-binding-store.ts +50 -0
- package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +642 -51
- package/src/runtime/control-plane/launcher/launch-reservation-store.ts +40 -0
- package/src/runtime/control-plane/launcher/thin-launcher.ts +194 -10
- package/src/runtime/control-plane/persistence/controller-home-migration.ts +487 -0
- package/src/runtime/control-plane/persistence/requirement-store.ts +15 -11
- package/src/runtime/control-plane/persistence/sqlite-store.ts +33 -2
- package/src/runtime/control-plane/release-retention.ts +62 -2
- package/src/runtime/control-plane/routing/route-policy.ts +7 -16
- package/src/runtime/control-plane/runtime-status-client.ts +0 -2
- package/src/runtime/diagnostics/mcp-timing.ts +19 -0
- package/src/runtime/diagnostics/mcp-transport-benchmark.ts +129 -0
- package/src/runtime/diagnostics/performance.ts +24 -1
- package/src/runtime/evidence/event-ledger.ts +1 -1
- package/src/runtime/execution/jobs/store.ts +58 -4
- package/src/runtime/execution/jobs/types.ts +1 -1
- package/src/runtime/execution/managed-workspace.ts +203 -1
- package/src/runtime/execution/process-runtime/check-facade.ts +52 -14
- package/src/runtime/execution/process-runtime/check-result.ts +63 -2
- package/src/runtime/execution/process-runtime/check-scheduling.ts +114 -0
- package/src/runtime/execution/process-runtime/command-facade.ts +346 -40
- package/src/runtime/execution/process-runtime/gc.ts +98 -12
- package/src/runtime/execution/process-runtime/lightweight-managed.ts +1184 -0
- package/src/runtime/execution/process-runtime/persisted-check.ts +57 -6
- package/src/runtime/execution/process-runtime/resource-claims.ts +97 -8
- package/src/runtime/execution/process-runtime/runtime.ts +59 -4
- package/src/runtime/execution/process-runtime/store.ts +7 -0
- package/src/runtime/execution/process-runtime/types.ts +2 -0
- package/src/runtime/execution/thin-harness/async-process.ts +12 -2
- package/src/runtime/execution/thin-harness/search-worker.ts +54 -5
- package/src/runtime/execution/workers/executor.ts +0 -48
- package/src/runtime/gateway/mcp/execution-tools.ts +1219 -126
- package/src/runtime/gateway/mcp/process-tools.ts +83 -37
- package/src/runtime/gateway/mcp/router.ts +185 -83
- package/src/runtime/gateway/mcp/runtime-tool-definitions.ts +546 -0
- package/src/runtime/gateway/mcp/runtime-tools.ts +2555 -1616
- package/src/runtime/model-clients/deepseek-adapter.ts +0 -32
- package/src/runtime/plugins/app-store-connect-adapter.ts +123 -19
- package/src/runtime/plugins/browser-adapter.ts +2925 -318
- package/src/runtime/plugins/browser-automation-service.ts +10 -0
- package/src/runtime/plugins/browser-handoff-host.ts +6 -31
- package/src/runtime/plugins/browser-handoff.ts +92 -5
- package/src/runtime/plugins/browser-macos-bridge.ts +586 -45
- package/src/runtime/plugins/browser-node-bridge.ts +6 -34
- package/src/runtime/plugins/browser-provider-registry.ts +109 -0
- package/src/runtime/plugins/browser-runtime-contract.ts +152 -0
- package/src/runtime/plugins/browser-runtime.ts +183 -0
- package/src/runtime/plugins/browser-session-authority.ts +332 -0
- package/src/runtime/plugins/capability-authorization-grants.ts +354 -0
- package/src/runtime/plugins/desktop-operator-registration.ts +15 -12
- package/src/runtime/plugins/external-adapter.ts +324 -3
- package/src/runtime/plugins/external-registration.ts +84 -5
- package/src/runtime/plugins/external-unix-socket.ts +7 -1
- package/src/runtime/plugins/first-party-registry.ts +4 -2
- package/src/runtime/plugins/github-adapter.ts +125 -0
- package/src/runtime/plugins/gmail-adapter.ts +28 -2
- package/src/runtime/plugins/ios/agent-device-provider.ts +1 -1
- package/src/runtime/plugins/ios/agent-device-typed-provider.ts +52 -9
- package/src/runtime/plugins/ios/remote-xpc-hid.ts +1171 -0
- package/src/runtime/plugins/ios-adapter.ts +106 -16
- package/src/runtime/plugins/ios-agent-device.ts +234 -64
- package/src/runtime/plugins/ios-physical-device.ts +1143 -392
- package/src/runtime/plugins/lightweight-action.ts +153 -0
- package/src/runtime/plugins/local-system-adapter.ts +125 -8
- package/src/runtime/plugins/macos-capability-broker.ts +55 -9
- package/src/runtime/plugins/plugin-action-sidecar.ts +59 -0
- package/src/runtime/plugins/plugin-management-adapter.ts +181 -0
- package/src/runtime/plugins/resend-adapter.ts +1 -1
- package/src/runtime/plugins/store.ts +366 -24
- package/src/runtime/plugins/types.ts +39 -2
- package/src/runtime/plugins/xiaohongshu-publish.ts +515 -0
- package/src/runtime/projections/git-status-sampler.ts +16 -16
- package/src/runtime/projections/materialized-view.ts +28 -10
- package/src/runtime/recovery/actions.ts +0 -10
- package/src/runtime/recovery/classifier.ts +0 -9
- package/src/runtime/recovery/maintenance-executor.ts +415 -18
- package/src/runtime/recovery/probe.ts +0 -1
- package/src/runtime/recovery/types.ts +0 -1
- package/src/runtime/root/mcp-transport.ts +180 -10
- package/src/runtime/root/package-connector-service.ts +186 -8
- package/src/runtime/root/package-runtime-release.ts +143 -17
- package/src/runtime/root/package-runtime-service.ts +430 -22
- package/src/runtime/root/release-manifest.ts +7 -2
- package/src/runtime/root/release-materialize.ts +162 -19
- package/src/runtime/root/release-store.ts +48 -11
- package/src/runtime/root/runtime.ts +23 -5
- package/src/runtime/root/service.ts +100 -27
- package/src/runtime/root/types.ts +2 -0
- package/src/runtime/safe-tooling/index.ts +0 -1
- package/src/runtime/safe-tooling/ios-development.ts +321 -63
- package/src/runtime/safe-tooling/types.ts +0 -30
- package/src/runtime/shared/trusted-executable.ts +62 -0
- package/src/runtime/shared/trusted-node-executable.ts +48 -0
- package/src/runtime/standalone-recovery/core.ts +702 -94
- package/src/runtime/standalone-recovery/entry.ts +99 -23
- package/src/runtime/standalone-recovery/installer.ts +19 -3
- package/src/runtime/standalone-recovery/service-labels.ts +2 -0
- package/src/runtime/standalone-recovery/watchdog-heartbeat.ts +160 -0
- package/src/runtime/workflow/schedules/browser-probe.ts +258 -0
- package/src/runtime/workflow/schedules/chatgpt-automation-migration.ts +40 -0
- package/src/runtime/workflow/schedules/engine.ts +509 -80
- package/src/runtime/workflow/schedules/github-issue-watch.ts +158 -0
- package/src/runtime/workflow/schedules/settlement.ts +48 -4
- package/src/runtime/workflow/schedules/store.ts +131 -10
- package/src/runtime/workflow/schedules/types.ts +6 -3
- package/src/runtime/workflow/schedules/work-continuation.ts +120 -34
- package/src/testing/test-governance.ts +8 -40
- package/assets/reference-configs/heartbeat-triage.md +0 -57
- package/assets/templates/helpers/harness-trace-grade.sh +0 -150
- package/assets/templates/helpers/heartbeat-triage.sh +0 -361
- package/assets/templates/helpers/maintenance-triage.sh +0 -167
- package/bin/forge-desktop-helper.mjs +0 -204
- package/docs/architecture/current/approved-target-architecture.zh-CN.md +0 -1501
- package/docs/architecture/current/architecture-invariants.md +0 -331
- package/docs/architecture/current/automation-and-schedule-engine.md +0 -453
- package/docs/architecture/current/codegraph-context-provider.md +0 -226
- package/docs/architecture/current/control-plane-authority-inventory.md +0 -94
- package/docs/architecture/current/controller-repository-decoupling.md +0 -148
- package/docs/architecture/current/dispatch-and-agent-strategy.md +0 -354
- package/docs/architecture/current/entity-model.md +0 -592
- package/docs/architecture/current/external-plugin-governance.md +0 -396
- package/docs/architecture/current/failure-recovery.md +0 -447
- package/docs/architecture/current/governance.md +0 -213
- package/docs/architecture/current/human-interaction-plane.md +0 -87
- package/docs/architecture/current/implementation-status.md +0 -128
- package/docs/architecture/current/job-and-run-lifecycle.md +0 -413
- package/docs/architecture/current/legacy-governance.md +0 -58
- package/docs/architecture/current/local-workspace-target-grants.md +0 -70
- package/docs/architecture/current/migration-roadmap.md +0 -105
- package/docs/architecture/current/multi-repository-execution.md +0 -371
- package/docs/architecture/current/operations-runbook.md +0 -45
- package/docs/architecture/current/peer-supercontroller-runtime.md +0 -32
- package/docs/architecture/current/personal-assistant-plugin-baseline.md +0 -206
- package/docs/architecture/current/repository-access-modes.md +0 -102
- package/docs/architecture/current/route-policy.md +0 -15
- package/docs/architecture/current/route-session-concurrency.md +0 -85
- package/docs/architecture/current/runtime-architecture-simplification.md +0 -214
- package/docs/architecture/current/runtime-directory-map.md +0 -49
- package/docs/architecture/current/runtime-health-and-resource-lifecycle.md +0 -85
- package/docs/architecture/current/scheduler-and-resource-claims.md +0 -422
- package/docs/architecture/current/session-aware-execution-and-authorization.md +0 -77
- package/docs/architecture/current/system-overview.md +0 -144
- package/docs/architecture/current/target-requirements-traceability.md +0 -202
- package/docs/architecture/current/thin-harness-governance-policy.md +0 -32
- package/docs/architecture/current/thin-harness-v1.md +0 -311
- package/docs/architecture/current/verification-and-release-gates.md +0 -391
- package/docs/forge-autonomous-goal-loop.md +0 -9
- package/docs/forge-provider-configuration.md +0 -7
- package/docs/operations/chatgpt-personal-assistant-runtime.md +0 -83
- package/docs/operations/controller-desktop-plugin.md +0 -112
- package/docs/operations/gmail-assistant-routines.md +0 -186
- package/docs/operations/personal-assistant-operator-guide.md +0 -61
- package/docs/operations/provider-configuration.md +0 -31
- package/scripts/harness-trace-grade.sh +0 -150
- package/scripts/heartbeat-triage.sh +0 -361
- package/scripts/maintenance-triage.sh +0 -167
- package/src/cli/local-bridge/frontend/src/features/settings/SettingsView.tsx +0 -2
- package/src/cli/repositories/goal-registry.ts +0 -239
- package/src/runtime/assistant/action-proposals.ts +0 -279
- package/src/runtime/assistant/intent.ts +0 -395
- package/src/runtime/assistant/model-provider.ts +0 -460
- package/src/runtime/assistant/openapi.ts +0 -223
- package/src/runtime/assistant/policy.ts +0 -89
- package/src/runtime/assistant/readiness.ts +0 -258
- package/src/runtime/assistant/routine-runtime.ts +0 -477
- package/src/runtime/assistant/schedule-binding.ts +0 -171
- package/src/runtime/assistant/standing-grants.ts +0 -375
- package/src/runtime/assistant/store.ts +0 -243
- package/src/runtime/assistant/types.ts +0 -135
- package/src/runtime/control-plane/goal-loop/config-facade.ts +0 -700
- package/src/runtime/control-plane/goal-loop/config-store.ts +0 -570
- package/src/runtime/control-plane/goal-loop/config-types.ts +0 -213
- package/src/runtime/control-plane/goal-loop/executor-router.ts +0 -109
- package/src/runtime/control-plane/goal-loop/failure-taxonomy.ts +0 -149
- package/src/runtime/control-plane/goal-loop/goal-contract-store.ts +0 -304
- package/src/runtime/control-plane/goal-loop/goal-loop-engine.ts +0 -1001
- package/src/runtime/control-plane/goal-loop/handoff-packet.ts +0 -160
- package/src/runtime/control-plane/goal-loop/index.ts +0 -13
- package/src/runtime/control-plane/goal-loop/local-tools.ts +0 -290
- package/src/runtime/control-plane/goal-loop/policy-gates.ts +0 -203
- package/src/runtime/control-plane/goal-loop/provider-dispatch.ts +0 -93
- package/src/runtime/control-plane/goal-loop/provider-registry.ts +0 -545
- package/src/runtime/control-plane/goal-loop/types.ts +0 -378
- package/src/runtime/control-plane/startup-recovery.ts +0 -198
- package/src/runtime/gateway/mcp/resource-policy.ts +0 -186
- package/src/runtime/personal-assistant/gmail-triage-manager.ts +0 -231
- package/src/runtime/personal-assistant/reporting-runtime.ts +0 -344
- package/src/runtime/personal-assistant/triage-runtime.ts +0 -748
- package/src/runtime/plugins/desktop-adapter.ts +0 -374
- package/src/runtime/safe-tooling/browser-safe.ts +0 -133
- package/src/runtime/workflow/findings/store.ts +0 -204
- package/src/runtime/workflow/findings/types.ts +0 -58
- package/src/runtime/workflow/portfolio/engine.ts +0 -20
- package/src/runtime/workflow/portfolio/store.ts +0 -185
- package/src/runtime/workflow/portfolio/types.ts +0 -29
|
@@ -4,7 +4,14 @@ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, re
|
|
|
4
4
|
import { homedir } from 'os';
|
|
5
5
|
import { basename, dirname, isAbsolute, join, resolve } from 'path';
|
|
6
6
|
import { observeRuntimeStatus } from '../root/status';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
activeRuntimeEntrypoint,
|
|
9
|
+
activeRuntimeLaunchSpec,
|
|
10
|
+
ensureForgeRuntimeLaunchAgentContract,
|
|
11
|
+
forgeRuntimeServicePaths,
|
|
12
|
+
inspectForgeRuntimeLaunchAgentContract,
|
|
13
|
+
readForgeRuntimeServiceConfig,
|
|
14
|
+
} from '../root/service';
|
|
8
15
|
import { loadRuntimeReleaseManifest } from '../root/release-manifest';
|
|
9
16
|
import { assertRuntimeReleaseFiles, stageRuntimeReleaseFromCandidateSource, type StagedRuntimeRelease } from '../root/release-materialize';
|
|
10
17
|
import {
|
|
@@ -15,7 +22,10 @@ import {
|
|
|
15
22
|
type RuntimeReleaseAuthority,
|
|
16
23
|
} from '../root/release-store';
|
|
17
24
|
import type { RuntimeReleaseManifest } from '../root/types';
|
|
25
|
+
import { ensurePackageConnectorService, packageConnectorServicePaths, type PackageConnectorReleaseBinding } from '../root/package-connector-service';
|
|
18
26
|
import { createRecoveryHttpTransport, type RecoveryHttpTransport } from './http-transport';
|
|
27
|
+
import { observeRecoveryWatchdogHealth } from './watchdog-heartbeat';
|
|
28
|
+
import { RECOVERY_GATEWAY_LABEL, RECOVERY_WATCHDOG_LABEL } from './service-labels';
|
|
19
29
|
import {
|
|
20
30
|
migrateStoppedRepoLocalControllerHomeStorage,
|
|
21
31
|
repoLocalControllerHomeStorageNeedsMigration,
|
|
@@ -50,6 +60,12 @@ export interface PrimaryConnectorServiceConfig {
|
|
|
50
60
|
platform: 'launchd';
|
|
51
61
|
label: string;
|
|
52
62
|
plistPath?: string;
|
|
63
|
+
/** Local OAuth MCP endpoint used to distinguish Connector failure from tunnel failure. */
|
|
64
|
+
localMcpUrl?: string;
|
|
65
|
+
minimumFailures?: number;
|
|
66
|
+
minimumFailureDurationMs?: number;
|
|
67
|
+
restartCooldownMs?: number;
|
|
68
|
+
maximumRestartAttempts?: number;
|
|
53
69
|
postRestartVerifyTimeoutMs?: number;
|
|
54
70
|
}
|
|
55
71
|
|
|
@@ -59,6 +75,8 @@ export interface RecoveryConfig {
|
|
|
59
75
|
publicMcpUrl?: string;
|
|
60
76
|
recoveryPublicUrl?: string;
|
|
61
77
|
recoveryTunnelService?: PublicTunnelServiceConfig;
|
|
78
|
+
/** Optional public tunnel serving publicMcpUrl; independent from the OAuth Connector process. */
|
|
79
|
+
primaryPublicTunnelService?: PublicTunnelServiceConfig;
|
|
62
80
|
primaryRuntimeService?: PrimaryRuntimeServiceConfig;
|
|
63
81
|
primaryRuntimeSourceRoot?: string;
|
|
64
82
|
primaryConnectorService?: PrimaryConnectorServiceConfig;
|
|
@@ -95,6 +113,7 @@ export type RecoveryMutationAction =
|
|
|
95
113
|
| 'stage_and_activate_runtime_release'
|
|
96
114
|
| 'restart_primary_connector'
|
|
97
115
|
| 'restart_recovery_gateway'
|
|
116
|
+
| 'restart_recovery_watchdog'
|
|
98
117
|
| 'repair_public_tunnel';
|
|
99
118
|
|
|
100
119
|
interface RecoveryLock {
|
|
@@ -132,8 +151,8 @@ export interface VerifyStableRuntimeOptions {
|
|
|
132
151
|
export interface RecoveryWatchdogDiagnosticEvidence {
|
|
133
152
|
fingerprint: string;
|
|
134
153
|
releaseIdentity: string;
|
|
135
|
-
components: Array<'runtime' | 'gateway' | 'public_mcp' | 'recovery_gateway' | 'recovery_tunnel'>;
|
|
136
|
-
failedProbes: Array<{ name: string; component: 'runtime' | 'gateway' | 'public_mcp' | 'recovery_gateway' | 'recovery_tunnel'; detail: string; status?: number }>;
|
|
154
|
+
components: Array<'runtime' | 'gateway' | 'public_mcp' | 'recovery_gateway' | 'recovery_watchdog' | 'recovery_tunnel'>;
|
|
155
|
+
failedProbes: Array<{ name: string; component: 'runtime' | 'gateway' | 'public_mcp' | 'recovery_gateway' | 'recovery_watchdog' | 'recovery_tunnel'; detail: string; status?: number }>;
|
|
137
156
|
firstObservedAt: string;
|
|
138
157
|
lastObservedAt: string;
|
|
139
158
|
occurrences: number;
|
|
@@ -153,7 +172,7 @@ export interface RollbackResult {
|
|
|
153
172
|
}
|
|
154
173
|
|
|
155
174
|
export interface WatchdogDecision {
|
|
156
|
-
action: 'healthy' | 'degraded' | 'repair_public_tunnel' | 'restart_recovery_gateway' | 'restart_primary_runtime' | 'rollback' | 'recovery_exhausted';
|
|
175
|
+
action: 'healthy' | 'degraded' | 'repair_public_tunnel' | 'restart_recovery_gateway' | 'restart_primary_connector' | 'restart_primary_runtime' | 'rollback' | 'recovery_exhausted';
|
|
157
176
|
reason: string;
|
|
158
177
|
}
|
|
159
178
|
|
|
@@ -175,10 +194,16 @@ export interface WatchdogState {
|
|
|
175
194
|
publicTunnelFailures?: number;
|
|
176
195
|
publicTunnelFirstFailureAt?: number;
|
|
177
196
|
publicTunnelRepairFailures?: number;
|
|
197
|
+
primaryConnectorFailures?: number;
|
|
198
|
+
primaryConnectorFirstFailureAt?: number;
|
|
199
|
+
primaryConnectorRestartAttempts?: number;
|
|
200
|
+
primaryConnectorRestartFailures?: number;
|
|
201
|
+
primaryConnectorRestartLastAttemptAt?: number;
|
|
178
202
|
recoveryGatewayRestartUsed?: boolean;
|
|
179
203
|
recoveryReleaseRevision?: string;
|
|
180
204
|
lastFullVerifyAt?: number;
|
|
181
205
|
lastDecision?: WatchdogDecision['action'];
|
|
206
|
+
lastReason?: string;
|
|
182
207
|
updatedAt?: string;
|
|
183
208
|
}
|
|
184
209
|
|
|
@@ -300,6 +325,10 @@ function configuredRecoveryTunnel(config: RecoveryConfig): PublicTunnelServiceCo
|
|
|
300
325
|
return config.recoveryTunnelService;
|
|
301
326
|
}
|
|
302
327
|
|
|
328
|
+
function configuredPrimaryPublicTunnel(config: RecoveryConfig): PublicTunnelServiceConfig | undefined {
|
|
329
|
+
return config.primaryPublicTunnelService;
|
|
330
|
+
}
|
|
331
|
+
|
|
303
332
|
function configuredRecoveryPublicUrl(config: RecoveryConfig): string | undefined {
|
|
304
333
|
return config.recoveryPublicUrl;
|
|
305
334
|
}
|
|
@@ -324,6 +353,7 @@ export function loadRecoveryConfig(controllerHome: string, explicit?: string): R
|
|
|
324
353
|
...(typeof loaded.publicMcpUrl === 'string' ? { publicMcpUrl: loaded.publicMcpUrl } : {}),
|
|
325
354
|
...(typeof loaded.recoveryPublicUrl === 'string' ? { recoveryPublicUrl: loaded.recoveryPublicUrl } : {}),
|
|
326
355
|
...(loaded.recoveryTunnelService ? { recoveryTunnelService: loaded.recoveryTunnelService } : {}),
|
|
356
|
+
...(loaded.primaryPublicTunnelService ? { primaryPublicTunnelService: loaded.primaryPublicTunnelService } : {}),
|
|
327
357
|
...(loaded.primaryRuntimeService ? { primaryRuntimeService: loaded.primaryRuntimeService } : {}),
|
|
328
358
|
...(typeof loaded.primaryRuntimeSourceRoot === 'string' ? { primaryRuntimeSourceRoot: resolve(loaded.primaryRuntimeSourceRoot) } : {}),
|
|
329
359
|
...(loaded.primaryConnectorService ? { primaryConnectorService: loaded.primaryConnectorService } : {}),
|
|
@@ -423,6 +453,7 @@ function watchdogProbeComponent(name: string): RecoveryWatchdogDiagnosticEvidenc
|
|
|
423
453
|
if (name === 'runtime_status') return 'runtime';
|
|
424
454
|
if (name === 'active_gateway') return 'gateway';
|
|
425
455
|
if (name === 'recovery_gateway') return 'recovery_gateway';
|
|
456
|
+
if (name === 'recovery_watchdog') return 'recovery_watchdog';
|
|
426
457
|
if (name === 'recovery_external_http') return 'recovery_tunnel';
|
|
427
458
|
return 'public_mcp';
|
|
428
459
|
}
|
|
@@ -541,7 +572,23 @@ async function withLock<T>(
|
|
|
541
572
|
}
|
|
542
573
|
|
|
543
574
|
export async function runtimeStatus(config: RecoveryConfig) {
|
|
544
|
-
|
|
575
|
+
const observation = observeRuntimeStatus(config.controllerHome);
|
|
576
|
+
const watchdog = loadWatchdogState(config);
|
|
577
|
+
return {
|
|
578
|
+
...observation,
|
|
579
|
+
recoveryWatchdog: {
|
|
580
|
+
lastDecision: watchdog.lastDecision,
|
|
581
|
+
lastReason: watchdog.lastReason,
|
|
582
|
+
updatedAt: watchdog.updatedAt,
|
|
583
|
+
failures: watchdog.failures,
|
|
584
|
+
rollbackUsed: watchdog.rollbackUsed,
|
|
585
|
+
runtimeRestartAttempts: watchdog.runtimeRestartAttempts ?? 0,
|
|
586
|
+
runtimeRestartFailures: watchdog.runtimeRestartFailures ?? 0,
|
|
587
|
+
primaryConnectorFailures: watchdog.primaryConnectorFailures ?? 0,
|
|
588
|
+
primaryConnectorRestartAttempts: watchdog.primaryConnectorRestartAttempts ?? 0,
|
|
589
|
+
primaryConnectorRestartFailures: watchdog.primaryConnectorRestartFailures ?? 0,
|
|
590
|
+
},
|
|
591
|
+
};
|
|
545
592
|
}
|
|
546
593
|
|
|
547
594
|
async function probe(transport: RecoveryHttpTransport, url: string, timeoutMs = 4_000): Promise<{ ok: boolean; detail: string; status?: number }> {
|
|
@@ -583,6 +630,59 @@ async function probeExternalMcp(transport: RecoveryHttpTransport, url: string):
|
|
|
583
630
|
} finally { clearTimeout(timer); }
|
|
584
631
|
}
|
|
585
632
|
|
|
633
|
+
function publicGatewayReadinessEndpoint(endpoint: string): string {
|
|
634
|
+
const url = new URL(endpoint);
|
|
635
|
+
url.pathname = '/ready';
|
|
636
|
+
url.search = '';
|
|
637
|
+
url.hash = '';
|
|
638
|
+
return url.toString();
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async function probePublicGatewayReadiness(
|
|
642
|
+
transport: RecoveryHttpTransport,
|
|
643
|
+
endpoint: string,
|
|
644
|
+
): Promise<{ ok: boolean; detail: string; status?: number; value?: unknown }> {
|
|
645
|
+
const controller = new AbortController();
|
|
646
|
+
const timer = setTimeout(() => controller.abort('RECOVERY_HTTP_TIMEOUT'), 4_000);
|
|
647
|
+
try {
|
|
648
|
+
const response = await transport.request({
|
|
649
|
+
url: publicGatewayReadinessEndpoint(endpoint),
|
|
650
|
+
headers: { accept: 'application/json' },
|
|
651
|
+
timeoutMs: 4_000,
|
|
652
|
+
signal: controller.signal,
|
|
653
|
+
});
|
|
654
|
+
let sessionCapacity: unknown;
|
|
655
|
+
try {
|
|
656
|
+
const payload = JSON.parse(response.body) as { sessionCapacity?: unknown };
|
|
657
|
+
sessionCapacity = payload.sessionCapacity;
|
|
658
|
+
} catch { /* malformed readiness payload remains a failed/opaque probe */ }
|
|
659
|
+
const recoveryRecommended = Boolean(
|
|
660
|
+
sessionCapacity
|
|
661
|
+
&& typeof sessionCapacity === 'object'
|
|
662
|
+
&& (sessionCapacity as { recoveryRecommended?: unknown }).recoveryRecommended === true,
|
|
663
|
+
);
|
|
664
|
+
return {
|
|
665
|
+
ok: response.ok && !recoveryRecommended,
|
|
666
|
+
detail: `HTTP ${response.status}${recoveryRecommended ? '; session capacity recommends Connector recovery' : ''}`,
|
|
667
|
+
status: response.status,
|
|
668
|
+
...(sessionCapacity === undefined ? {} : { value: sessionCapacity }),
|
|
669
|
+
};
|
|
670
|
+
} catch (error) {
|
|
671
|
+
return { ok: false, detail: error instanceof Error ? error.message.slice(0, 180) : 'request failed' };
|
|
672
|
+
} finally {
|
|
673
|
+
clearTimeout(timer);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function connectorCapacityRecoveryRecommended(verified: VerifyResult): boolean {
|
|
678
|
+
const value = verified.probes.primary_connector_ready?.value;
|
|
679
|
+
return Boolean(
|
|
680
|
+
value
|
|
681
|
+
&& typeof value === 'object'
|
|
682
|
+
&& (value as { recoveryRecommended?: unknown }).recoveryRecommended === true,
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
|
|
586
686
|
function mainToken(config: RecoveryConfig): string | undefined {
|
|
587
687
|
const candidate = config.mainMcpTokenFile ?? join(config.controllerHome, 'mcp', 'mcp.tokens.json');
|
|
588
688
|
const parsed = json<{ bearerToken?: unknown }>(candidate);
|
|
@@ -753,8 +853,23 @@ export async function verifyStableRuntime(
|
|
|
753
853
|
probes.active_gateway = endpoint
|
|
754
854
|
? await probe(transport, runtimeHealthEndpoint(endpoint))
|
|
755
855
|
: { ok: false, detail: 'canonical Runtime endpoint is unavailable' };
|
|
756
|
-
if (config.publicMcpUrl)
|
|
856
|
+
if (config.publicMcpUrl) {
|
|
857
|
+
probes.external_mcp_http = await probeExternalMcp(transport, config.publicMcpUrl);
|
|
858
|
+
probes.primary_connector_ready = await probePublicGatewayReadiness(transport, config.publicMcpUrl);
|
|
859
|
+
}
|
|
757
860
|
if (config.gateway) probes.recovery_gateway = await probe(transport, `http://${config.gateway.host}:${config.gateway.port}/health`);
|
|
861
|
+
const watchdogHealth = observeRecoveryWatchdogHealth(config.controllerHome);
|
|
862
|
+
probes.recovery_watchdog = {
|
|
863
|
+
ok: watchdogHealth.ok,
|
|
864
|
+
detail: watchdogHealth.detail,
|
|
865
|
+
value: {
|
|
866
|
+
pulseAgeMs: watchdogHealth.pulseAgeMs,
|
|
867
|
+
tickAgeMs: watchdogHealth.tickAgeMs,
|
|
868
|
+
currentReleaseRevision: watchdogHealth.currentReleaseRevision,
|
|
869
|
+
watchdogReleaseRevision: watchdogHealth.runtimeIdentity?.releaseRevision,
|
|
870
|
+
watchdogPid: watchdogHealth.runtimeIdentity?.pid,
|
|
871
|
+
},
|
|
872
|
+
};
|
|
758
873
|
const recoveryPublicUrl = configuredRecoveryPublicUrl(config);
|
|
759
874
|
if (recoveryPublicUrl) probes.recovery_external_http = await probeExternalMcp(transport, recoveryPublicUrl);
|
|
760
875
|
if (options.probeMcpProtocol !== false) Object.assign(probes, await probeMcp(config, transport));
|
|
@@ -801,10 +916,8 @@ export async function verifyStableRuntime(
|
|
|
801
916
|
return result;
|
|
802
917
|
}
|
|
803
918
|
/** Explicitly records evidence only after the full independent verification passed. */
|
|
804
|
-
|
|
805
|
-
const
|
|
806
|
-
const verified = await verifyStableRuntime(config);
|
|
807
|
-
const authority = releaseAuthority(config);
|
|
919
|
+
function persistVerifiedKnownGood(config: RecoveryConfig, verified: VerifyResult): ReleaseEvidence {
|
|
920
|
+
const authority = releaseAuthority(config);
|
|
808
921
|
const active = verified.releases.active;
|
|
809
922
|
if (
|
|
810
923
|
!verified.ok
|
|
@@ -825,7 +938,10 @@ export async function attestKnownGood(config: RecoveryConfig): Promise<ReleaseEv
|
|
|
825
938
|
attestedAt: new Date().toISOString(),
|
|
826
939
|
};
|
|
827
940
|
const store = knownGood(config);
|
|
828
|
-
const releases = [
|
|
941
|
+
const releases = [
|
|
942
|
+
attested,
|
|
943
|
+
...store.releases.filter((entry) => entry.path !== active.path && existsSync(entry.path)),
|
|
944
|
+
].slice(0, 8);
|
|
829
945
|
writeJson(statePath(config), { schemaVersion: 1, releases, updatedAt: new Date().toISOString() } satisfies KnownGoodStore);
|
|
830
946
|
audit(config, 'known_good_attested', {
|
|
831
947
|
revision: active.revision,
|
|
@@ -833,8 +949,13 @@ export async function attestKnownGood(config: RecoveryConfig): Promise<ReleaseEv
|
|
|
833
949
|
manifestSha256: active.manifestSha256,
|
|
834
950
|
releaseAuthorityRevision: authority.revision,
|
|
835
951
|
});
|
|
836
|
-
|
|
837
|
-
|
|
952
|
+
return attested;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
export async function attestKnownGood(config: RecoveryConfig): Promise<ReleaseEvidence> {
|
|
956
|
+
const locked = await withLock(config, { action: 'attest_known_good' }, async () => (
|
|
957
|
+
persistVerifiedKnownGood(config, await verifyStableRuntime(config))
|
|
958
|
+
));
|
|
838
959
|
if (!locked.acquired) throw new Error(recoveryBusyDetail(locked.owner));
|
|
839
960
|
return locked.value;
|
|
840
961
|
}
|
|
@@ -849,8 +970,8 @@ function quarantine(config: RecoveryConfig, release: ReleaseEvidence | undefined
|
|
|
849
970
|
async function rollbackPreviousLocked(config: RecoveryConfig, reason: string): Promise<RollbackResult> {
|
|
850
971
|
const before = await verifyStableRuntime(config);
|
|
851
972
|
const active = before.releases.active;
|
|
852
|
-
if (matchingKnownGood(config, active)) {
|
|
853
|
-
return { ok: true, noOp: true, detail: 'active whole-Runtime release is
|
|
973
|
+
if (before.ok && matchingKnownGood(config, active)) {
|
|
974
|
+
return { ok: true, noOp: true, detail: 'active whole-Runtime release is currently healthy and independently attested known-good', verify: before };
|
|
854
975
|
}
|
|
855
976
|
if (before.runtime.running) {
|
|
856
977
|
return {
|
|
@@ -911,7 +1032,9 @@ export async function reconnectMain(config: RecoveryConfig): Promise<{ ok: boole
|
|
|
911
1032
|
// intentionally a bounded health/reconnect observation, never a rollout.
|
|
912
1033
|
const verified = await verifyStableRuntime(config);
|
|
913
1034
|
const publicProbe = verified.probes.external_mcp_http;
|
|
914
|
-
const ok = verified.probes.active_gateway?.ok === true
|
|
1035
|
+
const ok = verified.probes.active_gateway?.ok === true
|
|
1036
|
+
&& verified.probes.primary_connector_ready?.ok !== false
|
|
1037
|
+
&& (publicProbe?.ok === true || publicProbe?.status === 401);
|
|
915
1038
|
audit(config, 'reconnect_main', { ok, externalStatus: publicProbe?.status });
|
|
916
1039
|
return { ok, detail: ok ? 'canonical Runtime Gateway and primary endpoint are reachable; client session may refresh' : 'primary endpoint remains unavailable; recovery channel remains independent', verify: verified };
|
|
917
1040
|
}
|
|
@@ -1037,6 +1160,16 @@ export function decideWatchdog(input: {
|
|
|
1037
1160
|
rollbackUsed: boolean;
|
|
1038
1161
|
recoveryGatewayFailed?: boolean;
|
|
1039
1162
|
recoveryGatewayRestartUsed?: boolean;
|
|
1163
|
+
primaryConnectorConfigured?: boolean;
|
|
1164
|
+
primaryConnectorFailed?: boolean;
|
|
1165
|
+
primaryConnectorFailures?: number;
|
|
1166
|
+
primaryConnectorFirstFailureAt?: number;
|
|
1167
|
+
primaryConnectorRestartAttempts?: number;
|
|
1168
|
+
primaryConnectorMaximumRestartAttempts?: number;
|
|
1169
|
+
primaryConnectorRestartLastAttemptAt?: number;
|
|
1170
|
+
primaryConnectorRestartCooldownMs?: number;
|
|
1171
|
+
primaryConnectorMinimumFailures?: number;
|
|
1172
|
+
primaryConnectorMinimumFailureDurationMs?: number;
|
|
1040
1173
|
primaryRuntimeFailed?: boolean;
|
|
1041
1174
|
runtimeRestartAttempts?: number;
|
|
1042
1175
|
runtimeMaximumRestartAttempts?: number;
|
|
@@ -1064,6 +1197,22 @@ export function decideWatchdog(input: {
|
|
|
1064
1197
|
if (failures >= minimumFailures && sustained) return { action: 'repair_public_tunnel', reason: 'local runtime and Recovery Gateway are healthy while the dedicated Recovery public endpoint is unavailable' };
|
|
1065
1198
|
return { action: 'degraded', reason: 'Recovery tunnel failure has not yet met the bounded repair threshold' };
|
|
1066
1199
|
}
|
|
1200
|
+
if (input.primaryConnectorConfigured && input.primaryConnectorFailed) {
|
|
1201
|
+
const failures = input.primaryConnectorFailures ?? 0;
|
|
1202
|
+
const minimumFailures = Math.max(1, input.primaryConnectorMinimumFailures ?? 2);
|
|
1203
|
+
const minimumDurationMs = Math.max(0, input.primaryConnectorMinimumFailureDurationMs ?? 5_000);
|
|
1204
|
+
const sustained = input.primaryConnectorFirstFailureAt !== undefined && now - input.primaryConnectorFirstFailureAt >= minimumDurationMs;
|
|
1205
|
+
const attempts = input.primaryConnectorRestartAttempts ?? 0;
|
|
1206
|
+
const maximumAttempts = Math.max(1, input.primaryConnectorMaximumRestartAttempts ?? 3);
|
|
1207
|
+
const cooldownMs = Math.max(0, input.primaryConnectorRestartCooldownMs ?? 30_000);
|
|
1208
|
+
const cooldownElapsed = input.primaryConnectorRestartLastAttemptAt === undefined || now - input.primaryConnectorRestartLastAttemptAt >= cooldownMs;
|
|
1209
|
+
if (failures >= minimumFailures && sustained && attempts < maximumAttempts && cooldownElapsed) {
|
|
1210
|
+
return { action: 'restart_primary_connector', reason: `local Runtime is healthy but the primary Connector endpoint is unavailable; restart attempt ${attempts + 1}/${maximumAttempts}` };
|
|
1211
|
+
}
|
|
1212
|
+
return { action: 'degraded', reason: attempts >= maximumAttempts
|
|
1213
|
+
? `primary Connector restart budget exhausted (${attempts}/${maximumAttempts}); keeping the local Runtime available for independent Recovery`
|
|
1214
|
+
: 'primary Connector failure has not yet met its bounded restart threshold or cooldown' };
|
|
1215
|
+
}
|
|
1067
1216
|
if (input.failures === 0) return { action: 'healthy', reason: 'all recovery probes healthy' };
|
|
1068
1217
|
const recoveryRestartSustained = input.firstFailureAt !== undefined && now - input.firstFailureAt >= 5_000;
|
|
1069
1218
|
if (input.failures >= 2 && recoveryRestartSustained && input.recoveryGatewayFailed && !input.recoveryGatewayRestartUsed) {
|
|
@@ -1258,6 +1407,9 @@ export interface PrimaryConnectorRecoveryDependencies {
|
|
|
1258
1407
|
runCommand?: CommandRunner;
|
|
1259
1408
|
verifyLocal?: (config: RecoveryConfig) => Promise<VerifyResult>;
|
|
1260
1409
|
reconnect?: (config: RecoveryConfig) => Promise<{ ok: boolean; detail: string; verify: VerifyResult }>;
|
|
1410
|
+
probeConnectorLocal?: (config: RecoveryConfig) => Promise<{ ok: boolean; detail: string; status?: number } | undefined>;
|
|
1411
|
+
probeConnectorOwnership?: (config: RecoveryConfig) => Promise<{ ok: boolean; detail: string }>;
|
|
1412
|
+
repairConnectorBinding?: (config: RecoveryConfig) => Promise<{ ok: boolean; attempted: boolean; noOp?: boolean; detail: string }>;
|
|
1261
1413
|
now?: () => number;
|
|
1262
1414
|
sleep?: (ms: number) => Promise<void>;
|
|
1263
1415
|
}
|
|
@@ -1272,6 +1424,79 @@ function primaryConnectorLaunchdService(config: RecoveryConfig, uid: number): La
|
|
|
1272
1424
|
return { uid, domain, target: `${domain}/${label}`, label, plistPath };
|
|
1273
1425
|
}
|
|
1274
1426
|
|
|
1427
|
+
async function probePrimaryConnectorLocal(config: RecoveryConfig): Promise<{ ok: boolean; detail: string; status?: number } | undefined> {
|
|
1428
|
+
const localMcpUrl = config.primaryConnectorService?.localMcpUrl?.trim();
|
|
1429
|
+
if (!localMcpUrl) return undefined;
|
|
1430
|
+
return probeExternalMcp(createRecoveryHttpTransport(config.controllerHome), localMcpUrl);
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
function activePackageConnectorReleaseBinding(config: RecoveryConfig): PackageConnectorReleaseBinding | undefined {
|
|
1434
|
+
const configured = config.primaryConnectorService;
|
|
1435
|
+
if (!configured || configured.platform !== 'launchd' || !configured.localMcpUrl?.trim()) return undefined;
|
|
1436
|
+
const authority = readRuntimeReleaseAuthority(config.controllerHome);
|
|
1437
|
+
if (!authority) return undefined;
|
|
1438
|
+
const releaseRoot = dirname(resolve(authority.active.manifestPath));
|
|
1439
|
+
const packageRoot = join(releaseRoot, 'package');
|
|
1440
|
+
const paths = packageConnectorServicePaths(config.controllerHome);
|
|
1441
|
+
const configuredPlist = resolve(configured.plistPath ?? paths.installedPlistPath);
|
|
1442
|
+
if (configured.label !== paths.label || configuredPlist !== resolve(paths.installedPlistPath)) return undefined;
|
|
1443
|
+
if (!existsSync(join(packageRoot, 'src', 'cli', 'index.ts')) || !existsSync(join(packageRoot, 'src', 'runtime', 'shared', 'node-ts-loader.mjs'))) return undefined;
|
|
1444
|
+
return { releaseId: authority.active.releaseId, releaseRoot, packageRoot };
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
async function repairPrimaryConnectorBinding(config: RecoveryConfig): Promise<{ ok: boolean; attempted: boolean; noOp?: boolean; detail: string }> {
|
|
1448
|
+
const release = activePackageConnectorReleaseBinding(config);
|
|
1449
|
+
const endpoint = config.primaryConnectorService?.localMcpUrl?.trim();
|
|
1450
|
+
if (!release || !endpoint) return { ok: true, attempted: false, noOp: true, detail: 'primary Connector does not use the canonical package-release binding contract' };
|
|
1451
|
+
try {
|
|
1452
|
+
const result = await ensurePackageConnectorService({ release, controllerHome: config.controllerHome, endpoint, platform: 'darwin' });
|
|
1453
|
+
const attempted = result.reused !== true;
|
|
1454
|
+
return {
|
|
1455
|
+
ok: true,
|
|
1456
|
+
attempted,
|
|
1457
|
+
...(attempted ? {} : { noOp: true }),
|
|
1458
|
+
detail: attempted
|
|
1459
|
+
? `primary Connector binding repaired to active immutable Runtime release ${release.releaseId}`
|
|
1460
|
+
: `primary Connector binding already matches active immutable Runtime release ${release.releaseId}`,
|
|
1461
|
+
};
|
|
1462
|
+
} catch (error) {
|
|
1463
|
+
return { ok: false, attempted: true, detail: error instanceof Error ? error.message : 'primary Connector binding repair failed' };
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
async function probePrimaryConnectorOwnership(
|
|
1468
|
+
config: RecoveryConfig,
|
|
1469
|
+
service: LaunchdService,
|
|
1470
|
+
runCommand: CommandRunner = command,
|
|
1471
|
+
): Promise<{ ok: boolean; detail: string }> {
|
|
1472
|
+
const localMcpUrl = config.primaryConnectorService?.localMcpUrl?.trim();
|
|
1473
|
+
if (!localMcpUrl) return { ok: false, detail: 'primary Connector local MCP endpoint is not configured' };
|
|
1474
|
+
let port: number;
|
|
1475
|
+
try {
|
|
1476
|
+
const parsed = new URL(localMcpUrl);
|
|
1477
|
+
const rawPort = parsed.port || (parsed.protocol === 'https:' ? '443' : parsed.protocol === 'http:' ? '80' : '');
|
|
1478
|
+
port = Number(rawPort);
|
|
1479
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error('invalid port');
|
|
1480
|
+
} catch {
|
|
1481
|
+
return { ok: false, detail: `primary Connector local MCP endpoint has no verifiable TCP port: ${localMcpUrl}` };
|
|
1482
|
+
}
|
|
1483
|
+
const printed = await runCommand('launchctl', ['print', service.target], 5_000);
|
|
1484
|
+
const pidMatch = printed.ok ? printed.stdout.match(/\bpid\s*=\s*(\d+)/) : null;
|
|
1485
|
+
const pid = pidMatch ? Number(pidMatch[1]) : undefined;
|
|
1486
|
+
if (!pid || !Number.isInteger(pid)) {
|
|
1487
|
+
return { ok: false, detail: `configured primary Connector launchd service has no live pid: ${service.target}` };
|
|
1488
|
+
}
|
|
1489
|
+
const listening = await runCommand('lsof', ['-nP', '-a', '-p', String(pid), `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], 5_000);
|
|
1490
|
+
const listenerPids = listening.stdout.split(/\s+/).map((value) => Number(value)).filter(Number.isInteger);
|
|
1491
|
+
const ok = listening.ok && listenerPids.includes(pid);
|
|
1492
|
+
return {
|
|
1493
|
+
ok,
|
|
1494
|
+
detail: ok
|
|
1495
|
+
? `configured primary Connector pid ${pid} owns TCP ${port}`
|
|
1496
|
+
: `configured primary Connector pid ${pid} does not own TCP ${port}`,
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1275
1500
|
export async function restartPrimaryConnector(
|
|
1276
1501
|
config: RecoveryConfig,
|
|
1277
1502
|
dependencies: PrimaryConnectorRecoveryDependencies = {},
|
|
@@ -1296,21 +1521,107 @@ export async function restartPrimaryConnector(
|
|
|
1296
1521
|
return { ok: false, attempted: false, noOp: true, detail: 'primary Connector launchd service is not configured or installed', verify: initialLocal };
|
|
1297
1522
|
}
|
|
1298
1523
|
const reconnect = dependencies.reconnect ?? reconnectMain;
|
|
1524
|
+
const probeConnectorLocal = dependencies.probeConnectorLocal ?? probePrimaryConnectorLocal;
|
|
1525
|
+
const runCommand = dependencies.runCommand ?? command;
|
|
1526
|
+
const probeConnectorOwnership = dependencies.probeConnectorOwnership
|
|
1527
|
+
?? ((candidateConfig: RecoveryConfig) => probePrimaryConnectorOwnership(candidateConfig, service, runCommand));
|
|
1299
1528
|
const now = dependencies.now ?? Date.now;
|
|
1300
1529
|
const wait = dependencies.sleep ?? sleep;
|
|
1301
1530
|
const timeoutMs = config.primaryConnectorService?.postRestartVerifyTimeoutMs ?? 30_000;
|
|
1302
1531
|
const locked = await withLock(config, { action: 'restart_primary_connector' }, async () => {
|
|
1303
|
-
const
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1532
|
+
const tunnelConfigured = Boolean(configuredPrimaryPublicTunnel(config));
|
|
1533
|
+
const repairConnectorBinding = dependencies.repairConnectorBinding ?? repairPrimaryConnectorBinding;
|
|
1534
|
+
const bindingRepair = await repairConnectorBinding(config);
|
|
1535
|
+
if (bindingRepair.attempted) {
|
|
1536
|
+
audit(config, bindingRepair.ok ? 'primary_connector_binding_repaired' : 'primary_connector_binding_repair_failed', { detail: bindingRepair.detail });
|
|
1307
1537
|
}
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1538
|
+
if (!bindingRepair.ok) {
|
|
1539
|
+
return { ok: false, attempted: true, detail: `primary Connector immutable release binding repair failed: ${bindingRepair.detail}`, serviceTarget: service.target, verify: initialLocal } satisfies PrimaryConnectorRestartResult;
|
|
1540
|
+
}
|
|
1541
|
+
let localConnector = await probeConnectorLocal(config);
|
|
1542
|
+
let connectorOwnership = localConnector?.ok ? await probeConnectorOwnership(config) : undefined;
|
|
1543
|
+
let observed = localConnector?.ok && connectorOwnership?.ok ? await reconnect(config) : undefined;
|
|
1544
|
+
if (observed?.ok) {
|
|
1545
|
+
return {
|
|
1546
|
+
ok: true,
|
|
1547
|
+
attempted: bindingRepair.attempted,
|
|
1548
|
+
...(bindingRepair.attempted ? {} : { noOp: true }),
|
|
1549
|
+
detail: bindingRepair.attempted
|
|
1550
|
+
? 'primary Connector immutable release binding was repaired and the public MCP endpoint recovered'
|
|
1551
|
+
: 'primary Connector and public MCP endpoint recovered before restart',
|
|
1552
|
+
serviceTarget: service.target,
|
|
1553
|
+
verify: observed.verify,
|
|
1554
|
+
} satisfies PrimaryConnectorRestartResult;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// A healthy local OAuth endpoint proves the Connector is not the broken hop.
|
|
1558
|
+
// If a distinct primary tunnel is configured, skip the pointless Gateway
|
|
1559
|
+
// restart and repair the public hop directly.
|
|
1560
|
+
const restartConnector = !localConnector?.ok || !connectorOwnership?.ok || !tunnelConfigured;
|
|
1561
|
+
if (restartConnector) {
|
|
1562
|
+
const restarted = await ensureLaunchdServiceStarted(service, runCommand);
|
|
1563
|
+
if (!restarted.ok) {
|
|
1564
|
+
audit(config, 'primary_connector_restart_failed', { serviceTarget: service.target, detail: restarted.detail });
|
|
1565
|
+
return { ok: false, attempted: true, detail: restarted.detail, serviceTarget: service.target, verify: initialLocal } satisfies PrimaryConnectorRestartResult;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
const localDeadline = now() + timeoutMs;
|
|
1569
|
+
localConnector = await probeConnectorLocal(config);
|
|
1570
|
+
while (localConnector && !localConnector.ok && now() < localDeadline) {
|
|
1571
|
+
await wait(1_000);
|
|
1572
|
+
localConnector = await probeConnectorLocal(config);
|
|
1573
|
+
}
|
|
1574
|
+
connectorOwnership = localConnector?.ok ? await probeConnectorOwnership(config) : undefined;
|
|
1575
|
+
if (localConnector && (!localConnector.ok || !connectorOwnership?.ok)) {
|
|
1576
|
+
const detail = localConnector.ok
|
|
1577
|
+
? (connectorOwnership?.detail ?? 'configured primary Connector listener ownership could not be verified')
|
|
1578
|
+
: localConnector.detail;
|
|
1579
|
+
audit(config, 'primary_connector_restart_local_unverified', { serviceTarget: service.target, detail });
|
|
1580
|
+
return {
|
|
1581
|
+
ok: false,
|
|
1582
|
+
attempted: true,
|
|
1583
|
+
detail: localConnector.ok
|
|
1584
|
+
? 'primary Connector restarted but the configured launchd service does not own its local MCP listener'
|
|
1585
|
+
: 'primary Connector restarted but its local MCP endpoint did not recover before timeout',
|
|
1586
|
+
serviceTarget: service.target,
|
|
1587
|
+
verify: initialLocal,
|
|
1588
|
+
} satisfies PrimaryConnectorRestartResult;
|
|
1589
|
+
}
|
|
1313
1590
|
}
|
|
1591
|
+
|
|
1592
|
+
observed ??= await reconnect(config);
|
|
1593
|
+
if (!observed.ok && localConnector?.ok && connectorOwnership?.ok && configuredPrimaryPublicTunnel(config)) {
|
|
1594
|
+
const tunnel = primaryPublicTunnelService(config, service.uid);
|
|
1595
|
+
if (tunnel) {
|
|
1596
|
+
const tunnelRestarted = await ensureLaunchdServiceStarted(tunnel, dependencies.runCommand ?? command);
|
|
1597
|
+
if (!tunnelRestarted.ok) {
|
|
1598
|
+
audit(config, 'primary_public_tunnel_restart_failed', { serviceTarget: tunnel.target, detail: tunnelRestarted.detail });
|
|
1599
|
+
return {
|
|
1600
|
+
ok: false,
|
|
1601
|
+
attempted: true,
|
|
1602
|
+
detail: `primary Connector is locally healthy but public tunnel restart failed: ${tunnelRestarted.detail}`,
|
|
1603
|
+
serviceTarget: service.target,
|
|
1604
|
+
verify: observed.verify,
|
|
1605
|
+
} satisfies PrimaryConnectorRestartResult;
|
|
1606
|
+
}
|
|
1607
|
+
const tunnelDeadline = now() + (configuredPrimaryPublicTunnel(config)?.postRestartVerifyTimeoutMs ?? 20_000);
|
|
1608
|
+
while (!observed.ok && now() < tunnelDeadline) {
|
|
1609
|
+
await wait(1_000);
|
|
1610
|
+
observed = await reconnect(config);
|
|
1611
|
+
}
|
|
1612
|
+
audit(config, observed.ok ? 'primary_public_tunnel_restart_succeeded' : 'primary_public_tunnel_restart_unverified', {
|
|
1613
|
+
serviceTarget: tunnel.target,
|
|
1614
|
+
detail: observed.detail,
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
} else {
|
|
1618
|
+
const deadline = now() + timeoutMs;
|
|
1619
|
+
while (!observed.ok && now() < deadline) {
|
|
1620
|
+
await wait(1_000);
|
|
1621
|
+
observed = await reconnect(config);
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1314
1625
|
audit(config, observed.ok ? 'primary_connector_restart_succeeded' : 'primary_connector_restart_unverified', {
|
|
1315
1626
|
serviceTarget: service.target,
|
|
1316
1627
|
detail: observed.detail,
|
|
@@ -1318,7 +1629,9 @@ export async function restartPrimaryConnector(
|
|
|
1318
1629
|
return {
|
|
1319
1630
|
ok: observed.ok,
|
|
1320
1631
|
attempted: true,
|
|
1321
|
-
detail: observed.ok
|
|
1632
|
+
detail: observed.ok
|
|
1633
|
+
? 'primary Connector recovery completed and the public MCP endpoint is reachable'
|
|
1634
|
+
: 'primary Connector recovery completed but the public MCP endpoint did not recover before timeout',
|
|
1322
1635
|
serviceTarget: service.target,
|
|
1323
1636
|
verify: observed.verify,
|
|
1324
1637
|
} satisfies PrimaryConnectorRestartResult;
|
|
@@ -1362,6 +1675,44 @@ async function waitForLaunchdServiceUnloaded(input: {
|
|
|
1362
1675
|
return !printed.ok;
|
|
1363
1676
|
}
|
|
1364
1677
|
|
|
1678
|
+
interface PrimaryRuntimePortObservation {
|
|
1679
|
+
port: number;
|
|
1680
|
+
pids: number[];
|
|
1681
|
+
uncertain?: string;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
interface PrimaryRuntimePortCleanupResult {
|
|
1685
|
+
released: boolean;
|
|
1686
|
+
cleaned: boolean;
|
|
1687
|
+
detail: string;
|
|
1688
|
+
pid?: number;
|
|
1689
|
+
signal?: 'SIGTERM' | 'SIGKILL';
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
async function observePrimaryRuntimePort(config: RecoveryConfig, runCommand: CommandRunner): Promise<PrimaryRuntimePortObservation> {
|
|
1693
|
+
const paths = forgeRuntimeServicePaths(config.controllerHome);
|
|
1694
|
+
let port: number;
|
|
1695
|
+
try {
|
|
1696
|
+
port = readForgeRuntimeServiceConfig(paths.configPath).port;
|
|
1697
|
+
} catch {
|
|
1698
|
+
return { port: -1, pids: [], uncertain: 'primary Runtime service port could not be read' };
|
|
1699
|
+
}
|
|
1700
|
+
const result = await runCommand('lsof', ['-nP', '-Fp', `-iTCP:${port}`, '-sTCP:LISTEN'], 3_000);
|
|
1701
|
+
if (result.status === 1 && result.stderr.trim().length === 0) return { port, pids: [] };
|
|
1702
|
+
if (!result.ok) {
|
|
1703
|
+
return { port, pids: [], uncertain: `primary Runtime listener observation failed: ${result.stderr || result.stdout || result.status}` };
|
|
1704
|
+
}
|
|
1705
|
+
const pids = [...new Set(result.stdout.split(/\r?\n/)
|
|
1706
|
+
.map((line) => /^p(\d+)$/.exec(line.trim())?.[1])
|
|
1707
|
+
.filter((value): value is string => Boolean(value))
|
|
1708
|
+
.map(Number)
|
|
1709
|
+
.filter((value) => Number.isInteger(value) && value > 0))];
|
|
1710
|
+
if (result.stdout.trim().length > 0 && pids.length === 0) {
|
|
1711
|
+
return { port, pids: [], uncertain: 'primary Runtime listener identity was not machine-readable' };
|
|
1712
|
+
}
|
|
1713
|
+
return { port, pids };
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1365
1716
|
async function waitForPrimaryRuntimePortReleased(input: {
|
|
1366
1717
|
config: RecoveryConfig;
|
|
1367
1718
|
timeoutMs: number;
|
|
@@ -1369,25 +1720,102 @@ async function waitForPrimaryRuntimePortReleased(input: {
|
|
|
1369
1720
|
wait: (ms: number) => Promise<void>;
|
|
1370
1721
|
runCommand: CommandRunner;
|
|
1371
1722
|
}): Promise<boolean> {
|
|
1372
|
-
const paths = forgeRuntimeServicePaths(input.config.controllerHome);
|
|
1373
|
-
let port: number;
|
|
1374
|
-
try {
|
|
1375
|
-
port = readForgeRuntimeServiceConfig(paths.configPath).port;
|
|
1376
|
-
} catch {
|
|
1377
|
-
return false;
|
|
1378
|
-
}
|
|
1379
|
-
const isListening = async (): Promise<boolean> => {
|
|
1380
|
-
const result = await input.runCommand('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN'], 3_000);
|
|
1381
|
-
if (result.ok) return result.stdout.trim().length > 0;
|
|
1382
|
-
if (result.status === 1 && result.stderr.trim().length === 0) return false;
|
|
1383
|
-
return true;
|
|
1384
|
-
};
|
|
1385
1723
|
const deadline = input.now() + input.timeoutMs;
|
|
1386
1724
|
while (input.now() < deadline) {
|
|
1387
|
-
|
|
1725
|
+
const observation = await observePrimaryRuntimePort(input.config, input.runCommand);
|
|
1726
|
+
if (!observation.uncertain && observation.pids.length === 0) return true;
|
|
1388
1727
|
await input.wait(250);
|
|
1389
1728
|
}
|
|
1390
|
-
|
|
1729
|
+
const observation = await observePrimaryRuntimePort(input.config, input.runCommand);
|
|
1730
|
+
return !observation.uncertain && observation.pids.length === 0;
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
function commandLineHasArgument(commandLine: string, flag: string, value: string): boolean {
|
|
1734
|
+
const candidates = [
|
|
1735
|
+
`${flag} ${value}`,
|
|
1736
|
+
`${flag} "${value}"`,
|
|
1737
|
+
`${flag} '${value}'`,
|
|
1738
|
+
];
|
|
1739
|
+
return candidates.some((candidate) => {
|
|
1740
|
+
const index = commandLine.indexOf(candidate);
|
|
1741
|
+
if (index < 0) return false;
|
|
1742
|
+
const before = index === 0 ? '' : commandLine[index - 1];
|
|
1743
|
+
const afterIndex = index + candidate.length;
|
|
1744
|
+
const after = afterIndex >= commandLine.length ? '' : commandLine[afterIndex];
|
|
1745
|
+
return (!before || /\s/.test(before)) && (!after || /\s/.test(after));
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
async function verifyPrimaryRuntimeListenerIdentity(input: {
|
|
1750
|
+
config: RecoveryConfig;
|
|
1751
|
+
pid: number;
|
|
1752
|
+
uid: number;
|
|
1753
|
+
port: number;
|
|
1754
|
+
runCommand: CommandRunner;
|
|
1755
|
+
}): Promise<{ ok: boolean; detail: string }> {
|
|
1756
|
+
const paths = forgeRuntimeServicePaths(input.config.controllerHome);
|
|
1757
|
+
const physicalEntrypoint = activeRuntimeEntrypoint(input.config.controllerHome);
|
|
1758
|
+
const launchSpec = activeRuntimeLaunchSpec(input.config.controllerHome);
|
|
1759
|
+
if (!physicalEntrypoint || !launchSpec) return { ok: false, detail: 'active Runtime release identity is unavailable' };
|
|
1760
|
+
const manifestIndex = launchSpec.args.indexOf('--release-manifest');
|
|
1761
|
+
const manifestPath = manifestIndex >= 0 ? launchSpec.args[manifestIndex + 1] : undefined;
|
|
1762
|
+
if (!manifestPath) return { ok: false, detail: 'active Runtime release manifest identity is unavailable' };
|
|
1763
|
+
|
|
1764
|
+
const result = await input.runCommand('ps', ['-ww', '-p', String(input.pid), '-o', 'uid=', '-o', 'command='], 3_000);
|
|
1765
|
+
if (!result.ok) return { ok: false, detail: `listener process identity could not be read: ${result.stderr || result.stdout || result.status}` };
|
|
1766
|
+
const line = result.stdout.trim().split(/\r?\n/).find(Boolean) ?? '';
|
|
1767
|
+
const match = /^\s*(\d+)\s+(.+)$/.exec(line);
|
|
1768
|
+
if (!match) return { ok: false, detail: 'listener process identity was not machine-readable' };
|
|
1769
|
+
const observedUid = Number(match[1]);
|
|
1770
|
+
const commandLine = match[2]!.trim();
|
|
1771
|
+
if (observedUid !== input.uid) return { ok: false, detail: `listener uid ${observedUid} does not match Recovery uid ${input.uid}` };
|
|
1772
|
+
const executableMatches = [resolve(paths.activeEntrypointPath), resolve(physicalEntrypoint)]
|
|
1773
|
+
.some((candidate) => commandLine === candidate || commandLine.startsWith(`${candidate} `));
|
|
1774
|
+
if (!executableMatches) return { ok: false, detail: 'listener executable is not the active Forge Runtime entrypoint' };
|
|
1775
|
+
if (!commandLineHasArgument(commandLine, '--controller-home', resolve(input.config.controllerHome))) {
|
|
1776
|
+
return { ok: false, detail: 'listener controller-home identity does not match' };
|
|
1777
|
+
}
|
|
1778
|
+
if (!commandLineHasArgument(commandLine, '--port', String(input.port))) {
|
|
1779
|
+
return { ok: false, detail: 'listener port identity does not match' };
|
|
1780
|
+
}
|
|
1781
|
+
if (!commandLineHasArgument(commandLine, '--release-manifest', resolve(manifestPath))) {
|
|
1782
|
+
return { ok: false, detail: 'listener release-manifest identity does not match' };
|
|
1783
|
+
}
|
|
1784
|
+
return { ok: true, detail: 'listener matches the current Forge Runtime release and controller identity' };
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
async function cleanupVerifiedStalePrimaryRuntimeListener(input: {
|
|
1788
|
+
config: RecoveryConfig;
|
|
1789
|
+
uid: number;
|
|
1790
|
+
now: () => number;
|
|
1791
|
+
wait: (ms: number) => Promise<void>;
|
|
1792
|
+
runCommand: CommandRunner;
|
|
1793
|
+
}): Promise<PrimaryRuntimePortCleanupResult> {
|
|
1794
|
+
const initial = await observePrimaryRuntimePort(input.config, input.runCommand);
|
|
1795
|
+
if (initial.uncertain) return { released: false, cleaned: false, detail: initial.uncertain };
|
|
1796
|
+
if (initial.pids.length === 0) return { released: true, cleaned: false, detail: 'primary Runtime port is already released' };
|
|
1797
|
+
if (initial.pids.length !== 1) return { released: false, cleaned: false, detail: `primary Runtime port has ${initial.pids.length} listeners; cleanup requires exactly one` };
|
|
1798
|
+
const pid = initial.pids[0]!;
|
|
1799
|
+
const identity = await verifyPrimaryRuntimeListenerIdentity({ config: input.config, pid, uid: input.uid, port: initial.port, runCommand: input.runCommand });
|
|
1800
|
+
if (!identity.ok) return { released: false, cleaned: false, detail: identity.detail, pid };
|
|
1801
|
+
|
|
1802
|
+
const term = await input.runCommand('kill', ['-TERM', String(pid)], 3_000);
|
|
1803
|
+
const releasedAfterTerm = await waitForPrimaryRuntimePortReleased({ ...input, timeoutMs: 5_000 });
|
|
1804
|
+
if (releasedAfterTerm) return { released: true, cleaned: true, detail: 'verified stale Forge Runtime listener released after SIGTERM', pid, signal: 'SIGTERM' };
|
|
1805
|
+
if (!term.ok) return { released: false, cleaned: false, detail: `verified listener SIGTERM failed: ${term.stderr || term.stdout || term.status}`, pid };
|
|
1806
|
+
|
|
1807
|
+
const beforeKill = await observePrimaryRuntimePort(input.config, input.runCommand);
|
|
1808
|
+
if (beforeKill.uncertain || beforeKill.pids.length !== 1 || beforeKill.pids[0] !== pid) {
|
|
1809
|
+
return { released: false, cleaned: false, detail: 'listener identity changed after SIGTERM; refusing SIGKILL', pid };
|
|
1810
|
+
}
|
|
1811
|
+
const identityBeforeKill = await verifyPrimaryRuntimeListenerIdentity({ config: input.config, pid, uid: input.uid, port: beforeKill.port, runCommand: input.runCommand });
|
|
1812
|
+
if (!identityBeforeKill.ok) return { released: false, cleaned: false, detail: `listener identity changed after SIGTERM: ${identityBeforeKill.detail}`, pid };
|
|
1813
|
+
const killed = await input.runCommand('kill', ['-KILL', String(pid)], 3_000);
|
|
1814
|
+
if (!killed.ok) return { released: false, cleaned: false, detail: `verified listener SIGKILL failed: ${killed.stderr || killed.stdout || killed.status}`, pid };
|
|
1815
|
+
const releasedAfterKill = await waitForPrimaryRuntimePortReleased({ ...input, timeoutMs: 5_000 });
|
|
1816
|
+
return releasedAfterKill
|
|
1817
|
+
? { released: true, cleaned: true, detail: 'verified stale Forge Runtime listener released after SIGKILL', pid, signal: 'SIGKILL' }
|
|
1818
|
+
: { released: false, cleaned: false, detail: 'verified stale Forge Runtime listener remained on the port after SIGKILL', pid };
|
|
1391
1819
|
}
|
|
1392
1820
|
|
|
1393
1821
|
async function verifyPrimaryRuntimeAfterStart(input: {
|
|
@@ -1489,10 +1917,23 @@ export async function recoverPrimaryRuntime(
|
|
|
1489
1917
|
return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies PrimaryRuntimeRecoveryResult;
|
|
1490
1918
|
}
|
|
1491
1919
|
|
|
1492
|
-
|
|
1920
|
+
let portReleased = await waitForPrimaryRuntimePortReleased({ config, timeoutMs: 20_000, now, wait, runCommand });
|
|
1921
|
+
let staleListenerCleanup: PrimaryRuntimePortCleanupResult | undefined;
|
|
1493
1922
|
if (!portReleased) {
|
|
1494
|
-
|
|
1495
|
-
|
|
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
|
+
}
|
|
1933
|
+
}
|
|
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 });
|
|
1496
1937
|
return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies PrimaryRuntimeRecoveryResult;
|
|
1497
1938
|
}
|
|
1498
1939
|
|
|
@@ -1668,7 +2109,25 @@ export async function activateRuntimeRelease(
|
|
|
1668
2109
|
} satisfies RuntimeReleaseActivationResult;
|
|
1669
2110
|
}
|
|
1670
2111
|
if (current) {
|
|
1671
|
-
|
|
2112
|
+
const behaviorEquivalent = runtimeBehaviorEquivalent(current.active.manifestPath, candidate.manifestPath);
|
|
2113
|
+
let serviceContractMatches = false;
|
|
2114
|
+
if (behaviorEquivalent && !storageMigrationRequired) {
|
|
2115
|
+
try {
|
|
2116
|
+
serviceContractMatches = inspectForgeRuntimeLaunchAgentContract({
|
|
2117
|
+
controllerHome: config.controllerHome,
|
|
2118
|
+
inspectUserLaunchAgent: true,
|
|
2119
|
+
}).matches;
|
|
2120
|
+
} catch (error) {
|
|
2121
|
+
audit(config, 'runtime_release_activation_service_contract_inspection_failed', {
|
|
2122
|
+
operationId,
|
|
2123
|
+
requestId: lockRequestId,
|
|
2124
|
+
activeRevision: current.active.releaseId,
|
|
2125
|
+
candidateRevision: candidate.manifest.releaseId,
|
|
2126
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
2127
|
+
});
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
if (behaviorEquivalent && !storageMigrationRequired && serviceContractMatches) {
|
|
1672
2131
|
const verify = await verifyLocal(config);
|
|
1673
2132
|
audit(config, 'runtime_release_activation_behavior_identical', {
|
|
1674
2133
|
operationId,
|
|
@@ -1682,8 +2141,8 @@ export async function activateRuntimeRelease(
|
|
|
1682
2141
|
attempted: false,
|
|
1683
2142
|
noOp: true,
|
|
1684
2143
|
detail: verify.ok
|
|
1685
|
-
? 'candidate Runtime behavior
|
|
1686
|
-
: 'candidate Runtime behavior
|
|
2144
|
+
? 'candidate Runtime behavior and launchd service contract are identical to the active release; restart skipped'
|
|
2145
|
+
: 'candidate Runtime behavior and launchd service contract are identical to the active release, but the active Runtime is unhealthy',
|
|
1687
2146
|
operationId,
|
|
1688
2147
|
verify,
|
|
1689
2148
|
} satisfies RuntimeReleaseActivationResult;
|
|
@@ -1720,10 +2179,24 @@ export async function activateRuntimeRelease(
|
|
|
1720
2179
|
audit(config, 'runtime_release_activation_stop_unverified', { serviceTarget: service.target, operationId, detail });
|
|
1721
2180
|
return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies RuntimeReleaseActivationResult;
|
|
1722
2181
|
}
|
|
1723
|
-
|
|
2182
|
+
let portReleased = await waitForPrimaryRuntimePortReleased({ config, timeoutMs: 20_000, now, wait, runCommand });
|
|
2183
|
+
let staleListenerCleanup: PrimaryRuntimePortCleanupResult | undefined;
|
|
1724
2184
|
if (!portReleased) {
|
|
1725
|
-
|
|
1726
|
-
|
|
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
|
+
}
|
|
2196
|
+
}
|
|
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 });
|
|
1727
2200
|
return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies RuntimeReleaseActivationResult;
|
|
1728
2201
|
}
|
|
1729
2202
|
let committed: RuntimeReleaseAuthority;
|
|
@@ -1740,7 +2213,7 @@ export async function activateRuntimeRelease(
|
|
|
1740
2213
|
return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies RuntimeReleaseActivationResult;
|
|
1741
2214
|
}
|
|
1742
2215
|
try {
|
|
1743
|
-
ensureForgeRuntimeLaunchAgentContract({ controllerHome: config.controllerHome });
|
|
2216
|
+
ensureForgeRuntimeLaunchAgentContract({ controllerHome: config.controllerHome, installUserLaunchAgent: true });
|
|
1744
2217
|
} catch (error) {
|
|
1745
2218
|
const detail = `runtime launchd stable-release contract update failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1746
2219
|
audit(config, 'runtime_release_activation_launchd_contract_failed', { serviceTarget: service.target, operationId, detail });
|
|
@@ -1821,7 +2294,7 @@ export async function activateRuntimeRelease(
|
|
|
1821
2294
|
physicalHome: storageMigration.physicalHome,
|
|
1822
2295
|
});
|
|
1823
2296
|
}
|
|
1824
|
-
ensureForgeRuntimeLaunchAgentContract({ controllerHome: config.controllerHome });
|
|
2297
|
+
ensureForgeRuntimeLaunchAgentContract({ controllerHome: config.controllerHome, installUserLaunchAgent: true });
|
|
1825
2298
|
const previousRevision = previousActive?.releaseId ?? '';
|
|
1826
2299
|
const previousIdentity = previousActive?.artifactIdentity;
|
|
1827
2300
|
if (
|
|
@@ -1933,8 +2406,7 @@ export async function stageAndActivateConfiguredRuntimeRelease(
|
|
|
1933
2406
|
};
|
|
1934
2407
|
}
|
|
1935
2408
|
|
|
1936
|
-
function
|
|
1937
|
-
const configured = configuredRecoveryTunnel(config);
|
|
2409
|
+
function tunnelLaunchdService(configured: PublicTunnelServiceConfig | undefined, uid: number): LaunchdService | undefined {
|
|
1938
2410
|
if (!configured || configured.platform !== 'launchd') return undefined;
|
|
1939
2411
|
if (!/^com\.[A-Za-z0-9._-]{1,180}$/.test(configured.label)) return undefined;
|
|
1940
2412
|
const plistPath = configured.plistPath;
|
|
@@ -1948,6 +2420,14 @@ function recoveryTunnelService(config: RecoveryConfig, uid: number): LaunchdServ
|
|
|
1948
2420
|
};
|
|
1949
2421
|
}
|
|
1950
2422
|
|
|
2423
|
+
function recoveryTunnelService(config: RecoveryConfig, uid: number): LaunchdService | undefined {
|
|
2424
|
+
return tunnelLaunchdService(configuredRecoveryTunnel(config), uid);
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
function primaryPublicTunnelService(config: RecoveryConfig, uid: number): LaunchdService | undefined {
|
|
2428
|
+
return tunnelLaunchdService(configuredPrimaryPublicTunnel(config), uid);
|
|
2429
|
+
}
|
|
2430
|
+
|
|
1951
2431
|
function tunnelRepairAllowed(config: RecoveryConfig, now: number): boolean {
|
|
1952
2432
|
const cooldownMs = configuredRecoveryTunnel(config)?.cooldownMs ?? 60_000;
|
|
1953
2433
|
const prior = json<{ lastAttemptAt?: unknown }>(publicTunnelRepairStatePath(config));
|
|
@@ -2015,17 +2495,32 @@ export interface RecoveryGatewayRestartResult {
|
|
|
2015
2495
|
serviceTarget?: string;
|
|
2016
2496
|
}
|
|
2017
2497
|
|
|
2018
|
-
export interface
|
|
2498
|
+
export interface RecoveryWatchdogRestartResult {
|
|
2499
|
+
ok: boolean;
|
|
2500
|
+
attempted: boolean;
|
|
2501
|
+
noOp?: boolean;
|
|
2502
|
+
detail: string;
|
|
2503
|
+
serviceTarget?: string;
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
interface RecoveryRoleRestartDependencies {
|
|
2019
2507
|
platform?: NodeJS.Platform;
|
|
2020
2508
|
currentUid?: () => Promise<number | undefined>;
|
|
2021
2509
|
runCommand?: CommandRunner;
|
|
2022
|
-
probeGateway?: (config: RecoveryConfig) => Promise<{ ok: boolean; detail: string }>;
|
|
2023
2510
|
now?: () => number;
|
|
2024
2511
|
sleep?: (ms: number) => Promise<void>;
|
|
2025
2512
|
}
|
|
2026
2513
|
|
|
2027
|
-
|
|
2028
|
-
|
|
2514
|
+
export interface RecoveryGatewayRestartDependencies extends RecoveryRoleRestartDependencies {
|
|
2515
|
+
probeGateway?: (config: RecoveryConfig) => Promise<{ ok: boolean; detail: string }>;
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
export interface RecoveryWatchdogRestartDependencies extends RecoveryRoleRestartDependencies {
|
|
2519
|
+
probeWatchdog?: (config: RecoveryConfig) => Promise<{ ok: boolean; detail: string }>;
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
function recoveryRoleLaunchdService(config: RecoveryConfig, uid: number, role: 'gateway' | 'watchdog'): LaunchdService {
|
|
2523
|
+
const label = role === 'gateway' ? RECOVERY_GATEWAY_LABEL : RECOVERY_WATCHDOG_LABEL;
|
|
2029
2524
|
const generated = join(recoveryRoot(config), 'launchd', `${label}.plist`);
|
|
2030
2525
|
const installed = join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
|
|
2031
2526
|
return {
|
|
@@ -2042,46 +2537,81 @@ async function probeRecoveryGateway(config: RecoveryConfig): Promise<{ ok: boole
|
|
|
2042
2537
|
return probe(createRecoveryHttpTransport(config.controllerHome), `http://${config.gateway.host}:${config.gateway.port}/health`);
|
|
2043
2538
|
}
|
|
2044
2539
|
|
|
2045
|
-
|
|
2046
|
-
config
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2540
|
+
async function probeRecoveryWatchdog(config: RecoveryConfig): Promise<{ ok: boolean; detail: string }> {
|
|
2541
|
+
const health = observeRecoveryWatchdogHealth(config.controllerHome);
|
|
2542
|
+
return { ok: health.ok, detail: health.detail };
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
async function restartRecoveryRole(input: {
|
|
2546
|
+
config: RecoveryConfig;
|
|
2547
|
+
role: 'gateway' | 'watchdog';
|
|
2548
|
+
action: 'restart_recovery_gateway' | 'restart_recovery_watchdog';
|
|
2549
|
+
check: (config: RecoveryConfig) => Promise<{ ok: boolean; detail: string }>;
|
|
2550
|
+
dependencies: RecoveryRoleRestartDependencies;
|
|
2551
|
+
}): Promise<RecoveryGatewayRestartResult> {
|
|
2552
|
+
const display = input.role === 'gateway' ? 'Recovery Gateway' : 'Recovery Watchdog';
|
|
2553
|
+
if ((input.dependencies.platform ?? process.platform) !== 'darwin') return { ok: false, attempted: false, noOp: true, detail: `${display} launchd restart is only supported on macOS` };
|
|
2554
|
+
const initial = await input.check(input.config);
|
|
2555
|
+
if (initial.ok) return { ok: true, attempted: false, noOp: true, detail: `${display} is already healthy` };
|
|
2556
|
+
const uid = await (input.dependencies.currentUid ?? currentUid)();
|
|
2557
|
+
if (uid === undefined) return { ok: false, attempted: false, noOp: true, detail: `${display} launchd UID is unavailable` };
|
|
2558
|
+
const service = recoveryRoleLaunchdService(input.config, uid, input.role);
|
|
2559
|
+
if (!existsSync(service.plistPath)) return { ok: false, attempted: false, noOp: true, detail: `${display} launchd plist is missing: ${service.plistPath}`, serviceTarget: service.target };
|
|
2560
|
+
const eventPrefix = input.role === 'gateway' ? 'recovery_gateway' : 'recovery_watchdog';
|
|
2561
|
+
const locked = await withLock(input.config, { action: input.action }, async () => {
|
|
2562
|
+
const before = await input.check(input.config);
|
|
2563
|
+
if (before.ok) return { ok: true, attempted: false, noOp: true, detail: `${display} recovered before restart`, serviceTarget: service.target } satisfies RecoveryGatewayRestartResult;
|
|
2564
|
+
const started = await ensureLaunchdServiceStarted(service, input.dependencies.runCommand ?? command);
|
|
2062
2565
|
if (!started.ok) {
|
|
2063
|
-
audit(config,
|
|
2566
|
+
audit(input.config, `${eventPrefix}_restart_failed`, { serviceTarget: service.target, detail: started.detail });
|
|
2064
2567
|
return { ok: false, attempted: true, detail: started.detail, serviceTarget: service.target } satisfies RecoveryGatewayRestartResult;
|
|
2065
2568
|
}
|
|
2066
|
-
const now = dependencies.now ?? Date.now;
|
|
2067
|
-
const wait = dependencies.sleep ?? sleep;
|
|
2569
|
+
const now = input.dependencies.now ?? Date.now;
|
|
2570
|
+
const wait = input.dependencies.sleep ?? sleep;
|
|
2068
2571
|
const deadline = now() + 20_000;
|
|
2069
2572
|
let observed = before;
|
|
2070
2573
|
while (now() < deadline) {
|
|
2071
2574
|
await wait(1_000);
|
|
2072
|
-
observed = await check(config);
|
|
2575
|
+
observed = await input.check(input.config);
|
|
2073
2576
|
if (observed.ok) {
|
|
2074
|
-
audit(config,
|
|
2075
|
-
return { ok: true, attempted: true, detail:
|
|
2577
|
+
audit(input.config, `${eventPrefix}_restart_succeeded`, { serviceTarget: service.target });
|
|
2578
|
+
return { ok: true, attempted: true, detail: `${display} restarted and passed local health verification`, serviceTarget: service.target } satisfies RecoveryGatewayRestartResult;
|
|
2076
2579
|
}
|
|
2077
2580
|
}
|
|
2078
|
-
audit(config,
|
|
2079
|
-
return { ok: false, attempted: true, detail:
|
|
2581
|
+
audit(input.config, `${eventPrefix}_restart_unverified`, { serviceTarget: service.target, detail: observed.detail });
|
|
2582
|
+
return { ok: false, attempted: true, detail: `${display} restarted but did not pass local health verification before timeout`, serviceTarget: service.target } satisfies RecoveryGatewayRestartResult;
|
|
2080
2583
|
});
|
|
2081
2584
|
if (!locked.acquired) return { ok: false, attempted: false, noOp: true, detail: recoveryBusyDetail(locked.owner), serviceTarget: service.target };
|
|
2082
2585
|
return locked.value;
|
|
2083
2586
|
}
|
|
2084
2587
|
|
|
2588
|
+
export async function restartRecoveryGateway(
|
|
2589
|
+
config: RecoveryConfig,
|
|
2590
|
+
dependencies: RecoveryGatewayRestartDependencies = {},
|
|
2591
|
+
): Promise<RecoveryGatewayRestartResult> {
|
|
2592
|
+
if (!config.gateway) return { ok: false, attempted: false, noOp: true, detail: 'Recovery Gateway is not configured' };
|
|
2593
|
+
return restartRecoveryRole({
|
|
2594
|
+
config,
|
|
2595
|
+
role: 'gateway',
|
|
2596
|
+
action: 'restart_recovery_gateway',
|
|
2597
|
+
check: dependencies.probeGateway ?? probeRecoveryGateway,
|
|
2598
|
+
dependencies,
|
|
2599
|
+
});
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
export async function restartRecoveryWatchdog(
|
|
2603
|
+
config: RecoveryConfig,
|
|
2604
|
+
dependencies: RecoveryWatchdogRestartDependencies = {},
|
|
2605
|
+
): Promise<RecoveryWatchdogRestartResult> {
|
|
2606
|
+
return restartRecoveryRole({
|
|
2607
|
+
config,
|
|
2608
|
+
role: 'watchdog',
|
|
2609
|
+
action: 'restart_recovery_watchdog',
|
|
2610
|
+
check: dependencies.probeWatchdog ?? probeRecoveryWatchdog,
|
|
2611
|
+
dependencies,
|
|
2612
|
+
});
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2085
2615
|
function pidAlive(pid: number | undefined): boolean {
|
|
2086
2616
|
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return false;
|
|
2087
2617
|
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
@@ -2101,7 +2631,7 @@ export function gatewayToken(config: RecoveryConfig): string | undefined {
|
|
|
2101
2631
|
export function initializeStandaloneRecovery(
|
|
2102
2632
|
controllerHome: string,
|
|
2103
2633
|
port = 8787,
|
|
2104
|
-
extensions: Partial<Pick<RecoveryConfig, 'publicMcpUrl' | 'recoveryPublicUrl' | 'recoveryTunnelService' | 'primaryRuntimeService' | 'primaryRuntimeSourceRoot' | 'primaryConnectorService' | 'readOnlyTool'>> = {},
|
|
2634
|
+
extensions: Partial<Pick<RecoveryConfig, 'publicMcpUrl' | 'recoveryPublicUrl' | 'recoveryTunnelService' | 'primaryPublicTunnelService' | 'primaryRuntimeService' | 'primaryRuntimeSourceRoot' | 'primaryConnectorService' | 'readOnlyTool'>> = {},
|
|
2105
2635
|
): RecoveryConfig {
|
|
2106
2636
|
const root = resolve(controllerHome);
|
|
2107
2637
|
const tokenPath = join(root, 'recovery', 'config', 'gateway-token.json');
|
|
@@ -2128,6 +2658,7 @@ export async function watchdogTick(config: RecoveryConfig, prior: WatchdogState)
|
|
|
2128
2658
|
rollback?: RollbackResult;
|
|
2129
2659
|
publicTunnelRepair?: PublicTunnelRepairResult;
|
|
2130
2660
|
recoveryGatewayRestart?: RecoveryGatewayRestartResult;
|
|
2661
|
+
primaryConnectorRestart?: PrimaryConnectorRestartResult;
|
|
2131
2662
|
primaryRuntimeRestart?: PrimaryRuntimeRestartResult;
|
|
2132
2663
|
primaryRuntimeRecovery?: PrimaryRuntimeRecoveryResult;
|
|
2133
2664
|
}> {
|
|
@@ -2168,6 +2699,7 @@ export async function watchdogTick(config: RecoveryConfig, prior: WatchdogState)
|
|
|
2168
2699
|
runtimeHealthySince: undefined,
|
|
2169
2700
|
lastFullVerifyAt,
|
|
2170
2701
|
lastDecision: 'degraded',
|
|
2702
|
+
lastReason: 'canonical Runtime is within startup grace; restart escalation suppressed',
|
|
2171
2703
|
};
|
|
2172
2704
|
return {
|
|
2173
2705
|
state,
|
|
@@ -2177,8 +2709,30 @@ export async function watchdogTick(config: RecoveryConfig, prior: WatchdogState)
|
|
|
2177
2709
|
}
|
|
2178
2710
|
const recoveryHealthy = verified.probes.recovery_gateway?.ok !== false
|
|
2179
2711
|
&& verified.probes.recovery_external_http?.ok !== false;
|
|
2180
|
-
const primaryRuntimeHealthy =
|
|
2181
|
-
|
|
2712
|
+
const primaryRuntimeHealthy = localVerify.ok;
|
|
2713
|
+
const primaryConnectorConfigured = Boolean(config.primaryConnectorService && config.publicMcpUrl);
|
|
2714
|
+
const primaryConnectorFailed = Boolean(
|
|
2715
|
+
primaryConnectorConfigured
|
|
2716
|
+
&& primaryRuntimeHealthy
|
|
2717
|
+
&& (
|
|
2718
|
+
verified.probes.external_mcp_http?.ok === false
|
|
2719
|
+
|| verified.probes.primary_connector_ready?.ok === false
|
|
2720
|
+
|| connectorCapacityRecoveryRecommended(verified)
|
|
2721
|
+
),
|
|
2722
|
+
);
|
|
2723
|
+
if (primaryRuntimeHealthy && !primaryConnectorFailed && recoveryHealthy) {
|
|
2724
|
+
if (fullVerificationPerformed && verified.ok && !matchingKnownGood(config, verified.releases.active)) {
|
|
2725
|
+
try {
|
|
2726
|
+
const attestation = await withLock(config, { action: 'attest_known_good' }, async () => persistVerifiedKnownGood(config, verified));
|
|
2727
|
+
if (!attestation.acquired) {
|
|
2728
|
+
audit(config, 'known_good_attestation_deferred', { reason: recoveryBusyDetail(attestation.owner) });
|
|
2729
|
+
}
|
|
2730
|
+
} catch (error) {
|
|
2731
|
+
audit(config, 'known_good_attestation_deferred', {
|
|
2732
|
+
reason: error instanceof Error ? error.message : 'watchdog known-good attestation failed',
|
|
2733
|
+
});
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2182
2736
|
const stable = recordWatchdogRuntimeHealthy(
|
|
2183
2737
|
scopeWatchdogStateToRuntimeRelease(scopedPrior, verified.releases.active),
|
|
2184
2738
|
now,
|
|
@@ -2193,9 +2747,15 @@ export async function watchdogTick(config: RecoveryConfig, prior: WatchdogState)
|
|
|
2193
2747
|
publicTunnelFailures: 0,
|
|
2194
2748
|
publicTunnelFirstFailureAt: undefined,
|
|
2195
2749
|
publicTunnelRepairFailures: 0,
|
|
2750
|
+
primaryConnectorFailures: 0,
|
|
2751
|
+
primaryConnectorFirstFailureAt: undefined,
|
|
2752
|
+
primaryConnectorRestartAttempts: 0,
|
|
2753
|
+
primaryConnectorRestartFailures: 0,
|
|
2754
|
+
primaryConnectorRestartLastAttemptAt: undefined,
|
|
2196
2755
|
recoveryGatewayRestartUsed: false,
|
|
2197
2756
|
lastFullVerifyAt,
|
|
2198
2757
|
lastDecision: 'healthy',
|
|
2758
|
+
lastReason: 'primary runtime and standalone Recovery verification passed',
|
|
2199
2759
|
};
|
|
2200
2760
|
return { state, decision: { action: 'healthy', reason: 'primary runtime and standalone Recovery verification passed' }, verify: verified };
|
|
2201
2761
|
}
|
|
@@ -2221,15 +2781,30 @@ export async function watchdogTick(config: RecoveryConfig, prior: WatchdogState)
|
|
|
2221
2781
|
firstFailureAt: budgetPrior.firstFailureAt ?? now,
|
|
2222
2782
|
publicTunnelFailures: (budgetPrior.publicTunnelFailures ?? 0) + 1,
|
|
2223
2783
|
publicTunnelFirstFailureAt: budgetPrior.publicTunnelFirstFailureAt ?? now,
|
|
2784
|
+
primaryConnectorFailures: 0,
|
|
2785
|
+
primaryConnectorFirstFailureAt: undefined,
|
|
2224
2786
|
}
|
|
2225
|
-
:
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2787
|
+
: primaryConnectorFailed
|
|
2788
|
+
? {
|
|
2789
|
+
...budgetPrior,
|
|
2790
|
+
lastFullVerifyAt,
|
|
2791
|
+
failures: budgetPrior.failures + 1,
|
|
2792
|
+
firstFailureAt: budgetPrior.firstFailureAt ?? now,
|
|
2793
|
+
publicTunnelFailures: 0,
|
|
2794
|
+
publicTunnelFirstFailureAt: undefined,
|
|
2795
|
+
primaryConnectorFailures: (budgetPrior.primaryConnectorFailures ?? 0) + 1,
|
|
2796
|
+
primaryConnectorFirstFailureAt: budgetPrior.primaryConnectorFirstFailureAt ?? now,
|
|
2797
|
+
}
|
|
2798
|
+
: {
|
|
2799
|
+
...budgetPrior,
|
|
2800
|
+
lastFullVerifyAt,
|
|
2801
|
+
failures: budgetPrior.failures + 1,
|
|
2802
|
+
firstFailureAt: budgetPrior.firstFailureAt ?? now,
|
|
2803
|
+
publicTunnelFailures: 0,
|
|
2804
|
+
publicTunnelFirstFailureAt: undefined,
|
|
2805
|
+
primaryConnectorFailures: 0,
|
|
2806
|
+
primaryConnectorFirstFailureAt: undefined,
|
|
2807
|
+
};
|
|
2233
2808
|
if (!localVerify.ok) state.runtimeHealthySince = undefined;
|
|
2234
2809
|
const primaryConfig = configuredPrimaryRuntimeService(config);
|
|
2235
2810
|
const decision = decideWatchdog({
|
|
@@ -2246,12 +2821,23 @@ export async function watchdogTick(config: RecoveryConfig, prior: WatchdogState)
|
|
|
2246
2821
|
runtimeRecoveryLastAttemptAt: state.runtimeRecoveryLastAttemptAt,
|
|
2247
2822
|
runtimeRecoveryCooldownMs: primaryConfig.recoveryCooldownMs,
|
|
2248
2823
|
recoveryGatewayFailed: verified.probes.recovery_gateway?.ok === false,
|
|
2824
|
+
primaryConnectorConfigured,
|
|
2825
|
+
primaryConnectorFailed,
|
|
2826
|
+
primaryConnectorFailures: state.primaryConnectorFailures,
|
|
2827
|
+
primaryConnectorFirstFailureAt: state.primaryConnectorFirstFailureAt,
|
|
2828
|
+
primaryConnectorRestartAttempts: state.primaryConnectorRestartAttempts,
|
|
2829
|
+
primaryConnectorMaximumRestartAttempts: config.primaryConnectorService?.maximumRestartAttempts,
|
|
2830
|
+
primaryConnectorRestartLastAttemptAt: state.primaryConnectorRestartLastAttemptAt,
|
|
2831
|
+
primaryConnectorRestartCooldownMs: config.primaryConnectorService?.restartCooldownMs,
|
|
2832
|
+
primaryConnectorMinimumFailures: config.primaryConnectorService?.minimumFailures,
|
|
2833
|
+
primaryConnectorMinimumFailureDurationMs: config.primaryConnectorService?.minimumFailureDurationMs,
|
|
2249
2834
|
publicTunnelConfigured: Boolean(configuredRecoveryTunnel(config)),
|
|
2250
2835
|
publicTunnelFailed,
|
|
2251
2836
|
publicTunnelMinimumFailures: configuredRecoveryTunnel(config)?.minimumFailures,
|
|
2252
2837
|
publicTunnelMinimumFailureDurationMs: configuredRecoveryTunnel(config)?.minimumFailureDurationMs,
|
|
2253
2838
|
});
|
|
2254
2839
|
state.lastDecision = decision.action;
|
|
2840
|
+
state.lastReason = decision.reason;
|
|
2255
2841
|
if (decision.action === 'repair_public_tunnel') {
|
|
2256
2842
|
const publicTunnelRepair = await repairPublicTunnel(config);
|
|
2257
2843
|
const nextState = publicTunnelRepair.ok
|
|
@@ -2263,6 +2849,28 @@ export async function watchdogTick(config: RecoveryConfig, prior: WatchdogState)
|
|
|
2263
2849
|
const recoveryGatewayRestart = await restartRecoveryGateway(config);
|
|
2264
2850
|
return { state: { ...state, recoveryGatewayRestartUsed: true }, decision, verify: verified, recoveryGatewayRestart };
|
|
2265
2851
|
}
|
|
2852
|
+
if (decision.action === 'restart_primary_connector') {
|
|
2853
|
+
const primaryConnectorRestart = await restartPrimaryConnector(config);
|
|
2854
|
+
const attempts = (state.primaryConnectorRestartAttempts ?? 0) + (primaryConnectorRestart.attempted ? 1 : 0);
|
|
2855
|
+
const nextState: WatchdogState = primaryConnectorRestart.ok
|
|
2856
|
+
? {
|
|
2857
|
+
...state,
|
|
2858
|
+
failures: 0,
|
|
2859
|
+
firstFailureAt: undefined,
|
|
2860
|
+
primaryConnectorFailures: 0,
|
|
2861
|
+
primaryConnectorFirstFailureAt: undefined,
|
|
2862
|
+
primaryConnectorRestartAttempts: 0,
|
|
2863
|
+
primaryConnectorRestartFailures: 0,
|
|
2864
|
+
primaryConnectorRestartLastAttemptAt: now,
|
|
2865
|
+
}
|
|
2866
|
+
: {
|
|
2867
|
+
...state,
|
|
2868
|
+
primaryConnectorRestartAttempts: attempts,
|
|
2869
|
+
primaryConnectorRestartFailures: (state.primaryConnectorRestartFailures ?? 0) + (primaryConnectorRestart.attempted ? 1 : 0),
|
|
2870
|
+
primaryConnectorRestartLastAttemptAt: now,
|
|
2871
|
+
};
|
|
2872
|
+
return { state: nextState, decision, verify: verified, primaryConnectorRestart };
|
|
2873
|
+
}
|
|
2266
2874
|
if (decision.action === 'restart_primary_runtime') {
|
|
2267
2875
|
const primaryRuntimeRestart = await restartPrimaryRuntime(config);
|
|
2268
2876
|
const attempts = (state.runtimeRestartAttempts ?? 0) + (primaryRuntimeRestart.attempted ? 1 : 0);
|