@moretea-labs/forge 1.7.2 → 1.8.1
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 +121 -62
- package/CHANGELOG.md +40 -0
- package/CLAUDE.md +57 -62
- package/CONTRIBUTING.md +3 -2
- package/README.md +2 -2
- package/SKILL.md +0 -1
- package/THIRD_PARTY_NOTICES.md +5 -2
- package/adapters/browser/index.ts +2 -0
- package/adapters/chatgpt/browser-delivery-host.ts +77 -0
- package/adapters/chatgpt/browser-delivery-runtime.ts +1256 -0
- package/adapters/chatgpt/controller-binding-store.ts +134 -0
- package/adapters/chatgpt/controller-round-host.ts +68 -0
- package/adapters/chatgpt/controller-round-settlement-store.ts +99 -0
- package/adapters/chatgpt/provider-delivery.ts +340 -0
- package/adapters/chatgpt/work-conversation-binding-store.ts +218 -0
- package/adapters/chatgpt/wsl-bridge-delivery-host.ts +63 -0
- package/adapters/computer/chatgpt-extension-target.ts +463 -0
- package/adapters/computer/chatgpt-target-router.ts +103 -0
- package/adapters/computer/chrome-extension/background.js +385 -0
- package/adapters/computer/chrome-extension/content.js +378 -0
- package/adapters/computer/chrome-extension/core.js +24 -0
- package/adapters/computer/chrome-extension/manifest.json +30 -0
- package/adapters/computer/desktop-operator-contract.ts +7 -0
- package/adapters/computer/desktop-operator-discovery.ts +181 -0
- package/adapters/computer/desktop-operator-negotiation.ts +174 -0
- package/adapters/computer/desktop-operator-provider.ts +312 -0
- package/adapters/computer/index.ts +1 -0
- package/adapters/computer/interaction-target-authority.ts +1068 -0
- package/adapters/controller-process/binding-store.ts +34 -0
- package/adapters/controller-process/controller-host.ts +26 -0
- package/adapters/mcp/access-mode.ts +132 -0
- package/adapters/mcp/audit.ts +32 -0
- package/adapters/mcp/auth.ts +406 -0
- package/adapters/mcp/instructions.ts +37 -0
- package/adapters/mcp/legacy-context.ts +18 -0
- package/adapters/mcp/multi-repository.ts +421 -0
- package/adapters/mcp/oauth.ts +464 -0
- package/adapters/mcp/paths.ts +143 -0
- package/adapters/mcp/policy.ts +232 -0
- package/adapters/mcp/proxy-env.ts +62 -0
- package/adapters/mcp/readiness-projection.ts +24 -0
- package/adapters/mcp/redaction.ts +21 -0
- package/adapters/mcp/repo.ts +24 -0
- package/adapters/mcp/runtime-gateway/artifact-adapter.ts +52 -0
- package/adapters/mcp/runtime-gateway/automation-receipt-adapter.ts +87 -0
- package/adapters/mcp/runtime-gateway/bounded-result-runtime.ts +62 -0
- package/adapters/mcp/runtime-gateway/cognition-settlement.ts +201 -0
- package/adapters/mcp/runtime-gateway/context-adapter.ts +1167 -0
- package/adapters/mcp/runtime-gateway/context-pack-sidecar.ts +91 -0
- package/adapters/mcp/runtime-gateway/controller-authority-adapter.ts +206 -0
- package/adapters/mcp/runtime-gateway/core-capability-adapter.ts +258 -0
- package/adapters/mcp/runtime-gateway/engineering-preconditions.ts +130 -0
- package/adapters/mcp/runtime-gateway/engineering-tool-contract.ts +15 -0
- package/adapters/mcp/runtime-gateway/execution-control-adapter.ts +28 -0
- package/adapters/mcp/runtime-gateway/execution-tools.ts +173 -0
- package/adapters/mcp/runtime-gateway/filesystem-adapter.ts +38 -0
- package/adapters/mcp/runtime-gateway/gateway-execution-adapter.ts +412 -0
- package/adapters/mcp/runtime-gateway/legacy-ios-tool-adapter.ts +105 -0
- package/adapters/mcp/runtime-gateway/maintenance-adapter.ts +44 -0
- package/adapters/mcp/runtime-gateway/model-adapter.ts +63 -0
- package/adapters/mcp/runtime-gateway/persisted-check-process.ts +3 -0
- package/adapters/mcp/runtime-gateway/plugin-adapter.ts +380 -0
- package/adapters/mcp/runtime-gateway/process-tools.ts +457 -0
- package/adapters/mcp/runtime-gateway/protected-computer-adapter.ts +88 -0
- package/adapters/mcp/runtime-gateway/recovery-adapter.ts +285 -0
- package/adapters/mcp/runtime-gateway/recovery-client-adapter.ts +70 -0
- package/adapters/mcp/runtime-gateway/recovery-tool-contract.ts +99 -0
- package/adapters/mcp/runtime-gateway/repository-compat-adapter.ts +79 -0
- package/adapters/mcp/runtime-gateway/result-adapter.ts +114 -0
- package/adapters/mcp/runtime-gateway/router.ts +242 -0
- package/adapters/mcp/runtime-gateway/routing-policy.ts +499 -0
- package/adapters/mcp/runtime-gateway/runtime-observation-adapter.ts +1200 -0
- package/adapters/mcp/runtime-gateway/runtime-readiness-observation.ts +218 -0
- package/adapters/mcp/runtime-gateway/runtime-tool-definitions.ts +574 -0
- package/adapters/mcp/runtime-gateway/runtime-tool-shared.ts +27 -0
- package/adapters/mcp/runtime-gateway/runtime-tools.ts +78 -0
- package/adapters/mcp/runtime-gateway/scheduler-adapter.ts +187 -0
- package/adapters/mcp/runtime-gateway/shared-adapter.ts +45 -0
- package/adapters/mcp/runtime-gateway/status-inbox-adapter.ts +962 -0
- package/adapters/mcp/runtime-gateway/typescript-navigation-sidecar.ts +136 -0
- package/adapters/mcp/runtime-gateway/work-adapter.ts +325 -0
- package/adapters/mcp/runtime-gateway/work-controller-operations.ts +257 -0
- package/adapters/mcp/runtime-gateway/work-detail-projection.ts +60 -0
- package/adapters/mcp/runtime-gateway/work-input-wire-migration.ts +246 -0
- package/adapters/mcp/runtime-gateway/work-learning-operations.ts +145 -0
- package/adapters/mcp/runtime-gateway/work-list-projection.ts +38 -0
- package/adapters/mcp/runtime-gateway/work-plan-operations.ts +382 -0
- package/adapters/mcp/runtime-gateway/work-repair-adapter.ts +308 -0
- package/adapters/mcp/runtime-gateway/work-requirement-operations.ts +148 -0
- package/adapters/mcp/runtime-gateway/work-semantic-operations.ts +263 -0
- package/adapters/mcp/runtime-gateway/work-validation-reconciler.ts +1 -0
- package/adapters/mcp/runtime-gateway/work-workflow-operations.ts +113 -0
- package/adapters/mcp/runtime-gateway/workflow-supervisor-adapter.ts +156 -0
- package/adapters/mcp/server.ts +1092 -0
- package/adapters/mcp/setup.ts +789 -0
- package/adapters/mcp/tool-capability-cache.ts +87 -0
- package/adapters/mcp/tool-mapping/access-tools.ts +387 -0
- package/adapters/mcp/tool-mapping/legacy-tool-service.ts +5139 -0
- package/adapters/mcp/tool-mapping/post-finalize-work-attribution.ts +145 -0
- package/adapters/mcp/tool-mapping/repository-tools.ts +1682 -0
- package/adapters/mcp/tool-mapping/tools.ts +9 -0
- package/adapters/mcp/tool-mapping/umbrella-tools.ts +117 -0
- package/adapters/mcp/toolset-names.ts +79 -0
- package/adapters/mcp/toolset-selection.ts +41 -0
- package/adapters/mcp/toolset.ts +337 -0
- package/adapters/mcp/transports/http-observation.ts +365 -0
- package/adapters/mcp/transports/http.ts +933 -0
- package/adapters/mcp/transports/oauth-http.ts +322 -0
- package/adapters/mcp/transports/session-registry.ts +519 -0
- package/adapters/mcp/transports/stdio.ts +8 -0
- package/adapters/mcp/tunnels/openai-secure-tunnel.ts +223 -0
- package/adapters/mcp/types.ts +41 -0
- package/assets/AGENTS.md +2 -16
- package/assets/CLAUDE.md +2 -16
- package/assets/hooks/AGENTS.md +3 -14
- package/assets/hooks/CLAUDE.md +3 -14
- package/assets/hooks/hook-input.sh +9 -2
- package/assets/hooks/lib/workflow-state.sh +28 -10
- package/assets/hooks/prompt-guard.sh +63 -37
- package/assets/hooks/security-sentinel.sh +2 -1
- package/assets/hooks/session-start-context.sh +7 -5
- package/assets/hooks/stop-orchestrator.sh +13 -3
- package/assets/initializer-question-pack.v4.json +11 -7
- package/assets/partials/05-workflow.partial.md +18 -73
- package/assets/partials/07-footer.partial.md +1 -1
- package/assets/partials-agents/02-operating-mode.partial.md +1 -2
- package/assets/partials-agents/04-task-protocol.partial.md +14 -59
- package/assets/partials-agents/06-quality-safety.partial.md +11 -16
- package/assets/partials-agents/08-deep-docs.partial.md +1 -2
- package/assets/plan-map.json +11 -11
- package/assets/plugin-registry.v1.json +11 -3
- package/assets/plugin-trust-policy.v1.json +6 -0
- package/assets/recovery/ForgeRecovery.ps1 +100 -0
- package/assets/recovery/forge-wsl-rescue +271 -0
- package/assets/reference-configs/agentic-development-flow.md +25 -0
- package/assets/reference-configs/development-protocol.md +11 -15
- package/assets/reference-configs/document-generation.md +1 -1
- package/assets/reference-configs/global-working-rules.md +28 -1
- package/assets/reference-configs/harness-overview.md +22 -112
- package/assets/reference-configs/hook-operations.md +1 -1
- package/assets/reference-configs/sprint-contracts.md +2 -2
- package/assets/skill-version.json +2 -2
- package/assets/templates/helpers/architecture-event.ts +3 -34
- package/assets/templates/helpers/architecture-queue.sh +61 -10
- package/assets/templates/helpers/archive-workflow.sh +90 -180
- package/assets/templates/helpers/capability-config.ts +8 -11
- package/assets/templates/helpers/capability-resolver.ts +37 -19
- package/assets/templates/helpers/capture-plan.sh +2 -2
- package/assets/templates/helpers/check-architecture-sync.sh +1 -9
- package/assets/templates/helpers/check-task-sync.sh +82 -11
- package/assets/templates/helpers/check-task-workflow.sh +1 -3
- package/assets/templates/helpers/context-contract-sync.sh +1 -12
- package/assets/templates/helpers/contract-worktree.sh +26 -24
- package/assets/templates/helpers/ensure-task-workflow.sh +6 -8
- package/assets/templates/helpers/inspect-project-state.ts +1 -1
- package/assets/templates/helpers/migrate-project-template.sh +1 -1
- package/assets/templates/helpers/migrate-workflow-docs.ts +53 -239
- package/assets/templates/helpers/new-plan.sh +2 -2
- package/assets/templates/helpers/plan-to-todo.sh +3 -31
- package/assets/templates/helpers/sprint-backlog.sh +1 -1
- package/assets/templates/helpers/switch-plan.sh +1 -1
- package/assets/templates/helpers/workflow-contract.ts +1 -0
- package/assets/workflow-contract.v1.json +63 -171
- package/assets/workflows/xiaohongshu/xiaohongshu.collect-metrics.v1.draft.json +118 -0
- package/assets/workflows/xiaohongshu/xiaohongshu.publish-image-note.v1.draft.json +316 -0
- package/assets/workflows/xiaohongshu/xiaohongshu.publish-long-text.v1.draft.json +324 -0
- package/docs/architecture/CURRENT.md +233 -28
- package/docs/architecture/EVOLUTION.md +13 -0
- package/docs/architecture/decisions/20260801-controller-home-sqlite-state.md +2 -1
- package/docs/architecture/decisions/20260803-release-identity-binding-and-exit-policy.md +14 -5
- package/docs/architecture/decisions/20260823-immediate-controller-relay.md +6 -3
- package/docs/architecture/decisions/20260831-independent-windows-wsl-host-rescue.md +59 -0
- package/docs/architecture/decisions/20260904-ios-device-migration-baseline.md +120 -0
- package/docs/architecture/decisions/20260904-repository-runtime-data-boundary.md +42 -0
- package/docs/architecture/decisions/20260907-assistant-learning-loop.md +37 -0
- package/docs/architecture/decisions/20260907-controller-round-transition-policy.md +311 -0
- package/docs/architecture/decisions/20260907-maintenance-diagnosis-bounded-read.md +39 -0
- package/docs/architecture/decisions/20260916-workflow-supervisor-authority.md +249 -0
- package/docs/architecture/decisions/20260917-cognitive-memory-foundation.md +82 -0
- package/docs/architecture/decisions/20260918-stable-candidate-release-lifecycle.md +221 -0
- package/docs/architecture/decisions/20260919-autonomous-work-liveness.md +62 -0
- package/docs/architecture/decisions/20260924-thin-semantic-working-context.md +72 -0
- package/docs/architecture/decisions/20261001-supervisor-turn-authority.md +237 -0
- package/docs/architecture/decisions/20261005-forge-v3-desktop-client-boundary.md +77 -0
- package/docs/architecture/domains/verification.md +8 -0
- package/docs/architecture/domains/workflow-engine.md +7 -1
- package/docs/architecture/index.md +3 -3
- package/docs/architecture/modules/controller-runtime/transport-lifecycle.md +19 -8
- package/docs/architecture/requests/2026-09-04-computer-plugin-workflow-taxonomy.md +124 -0
- package/docs/forge-chatgpt-mcp-setup.md +5 -3
- package/docs/forge-plugin-management.md +20 -4
- package/docs/forge-runtime-self-healing-loop.md +2 -0
- package/docs/operations/benchmark-cross-version-freeze.md +38 -0
- package/docs/operations/controller-browser-plugin.md +3 -1
- package/docs/operations/controller-performance-and-502.md +17 -0
- package/docs/operations/controller-reliability-runbook.md +2 -2
- package/docs/operations/features.md +1 -1
- package/docs/operations/features.zh-CN.md +1 -1
- package/docs/operations/homebrew.md +2 -2
- package/docs/operations/mcp-tool-exposure.md +2 -0
- package/docs/operations/platform-support.md +16 -8
- package/docs/operations/platform-support.zh-CN.md +2 -2
- package/docs/operations/releasing.md +2 -0
- package/docs/operations/standalone-disaster-recovery.md +38 -5
- package/docs/operations/uu-remote-rescue.md +41 -0
- package/docs/operations/windows-wsl-host-rescue.md +133 -0
- package/docs/public-usage-guide.md +1 -1
- package/docs/public-usage-guide.zh-CN.md +1 -1
- package/docs/tutorials/01-install-and-start.md +1 -1
- package/docs/tutorials/01-install-and-start.zh-CN.md +1 -1
- package/docs/tutorials/02-connect-chatgpt.md +4 -4
- package/docs/tutorials/02-connect-chatgpt.zh-CN.md +4 -4
- package/docs/wiki/Implementation.md +1 -1
- package/package.json +46 -26
- package/packages/kernel/cognition/api/index.ts +6 -0
- package/packages/kernel/cognition/application/activation-service.ts +451 -0
- package/packages/kernel/cognition/application/consolidation-service.ts +185 -0
- package/packages/kernel/cognition/application/legacy-experience-adapter.ts +38 -0
- package/packages/kernel/cognition/application/memory-service.ts +64 -0
- package/packages/kernel/cognition/domain/learning.ts +106 -0
- package/packages/kernel/cognition/domain/memory.ts +254 -0
- package/packages/kernel/controller/api/index.ts +3 -0
- package/packages/kernel/controller/application/continuation-service.ts +281 -0
- package/packages/kernel/controller/application/controller-round-service.ts +41 -0
- package/packages/kernel/controller/application/controller-service.ts +30 -0
- package/packages/kernel/controller/domain/controller-round-transition-policy.ts +529 -0
- package/packages/kernel/controller/domain/controller-round.ts +110 -0
- package/packages/kernel/controller/domain/execution-quality.ts +135 -0
- package/packages/kernel/controller/domain/types.ts +56 -0
- package/packages/kernel/controller/index.ts +1 -0
- package/packages/kernel/controller/infrastructure/controller-binding-store.ts +84 -0
- package/packages/kernel/controller/infrastructure/controller-round-store.ts +1970 -0
- package/packages/kernel/controller/infrastructure/controller-session-store.ts +804 -0
- package/packages/kernel/controller/ports/controller-host.ts +18 -0
- package/packages/kernel/goal/api/index.ts +1 -0
- package/packages/kernel/goal/domain/types.ts +40 -0
- package/packages/kernel/goal/index.ts +1 -0
- package/packages/kernel/identity/api/index.ts +15 -0
- package/packages/kernel/identity/application/identity-service.ts +67 -0
- package/packages/kernel/identity/domain/activity-projection.ts +43 -0
- package/packages/kernel/identity/domain/capability-broker.ts +60 -0
- package/packages/kernel/identity/domain/grant.ts +72 -0
- package/packages/kernel/identity/domain/owned-resource.ts +116 -0
- package/packages/kernel/identity/domain/scope.ts +63 -0
- package/packages/kernel/identity/domain/semantic-sync.ts +81 -0
- package/packages/kernel/identity/domain/types.ts +44 -0
- package/packages/kernel/identity/domain/user-request.ts +93 -0
- package/packages/kernel/identity/domain/workspace.ts +56 -0
- package/packages/kernel/identity/index.ts +1 -0
- package/packages/kernel/identity/infrastructure/activity-projection-store.ts +77 -0
- package/packages/kernel/identity/infrastructure/capability-broker.ts +81 -0
- package/packages/kernel/identity/infrastructure/grant-store.ts +284 -0
- package/packages/kernel/identity/infrastructure/identity-store.ts +75 -0
- package/packages/kernel/identity/infrastructure/owned-resource-store.ts +259 -0
- package/packages/kernel/identity/infrastructure/user-request-store.ts +182 -0
- package/packages/kernel/memory/api/index.ts +5 -0
- package/packages/kernel/memory/application/experience-service.ts +118 -0
- package/packages/kernel/memory/application/outcome-service.ts +77 -0
- package/packages/kernel/memory/domain/experience.ts +103 -0
- package/packages/kernel/memory/domain/operational-prior.ts +92 -0
- package/packages/kernel/memory/index.ts +1 -0
- package/packages/kernel/scheduler/api/index.ts +4 -0
- package/packages/kernel/scheduler/application/eligibility.ts +252 -0
- package/packages/kernel/scheduler/application/schedule-service.ts +27 -0
- package/packages/kernel/scheduler/application/settlement.ts +182 -0
- package/packages/kernel/scheduler/domain/schedule.ts +236 -0
- package/packages/kernel/scheduler/index.ts +1 -0
- package/packages/kernel/scheduler/infrastructure/schedule-store.ts +805 -0
- package/packages/kernel/work/api/index.ts +16 -0
- package/packages/kernel/work/application/work-service.ts +123 -0
- package/packages/kernel/work/domain/check-receipt.ts +43 -0
- package/packages/kernel/work/domain/context-closure-receipt.ts +32 -0
- package/packages/kernel/work/domain/current-task-lineage.ts +104 -0
- package/packages/kernel/work/domain/engineering-contracts.ts +157 -0
- package/packages/kernel/work/domain/engineering-design.ts +286 -0
- package/packages/kernel/work/domain/engineering-profile.ts +238 -0
- package/packages/kernel/work/domain/evaluation-promotion-receipt.ts +186 -0
- package/packages/kernel/work/domain/execution-concurrency.ts +247 -0
- package/packages/kernel/work/domain/execution-snapshot.ts +2 -0
- package/packages/kernel/work/domain/implementation-review.ts +193 -0
- package/packages/kernel/work/domain/objective-graph.ts +119 -0
- package/packages/kernel/work/domain/project-engineering-contract.ts +224 -0
- package/packages/kernel/work/domain/repository-completion-receipt.ts +73 -0
- package/{src/runtime/control-plane/facade/work-state-machine.ts → packages/kernel/work/domain/state-machine.ts} +37 -66
- package/packages/kernel/work/domain/types.ts +534 -0
- package/packages/kernel/work/index.ts +1 -0
- package/packages/kernel/work/infrastructure/work-contract-store.ts +1967 -0
- package/packages/kernel/work/ports/work-contract-store.ts +21 -0
- package/packages/plugin-runtime/browser/index.ts +3 -0
- package/packages/plugin-runtime/browser/provider-registry.ts +110 -0
- package/packages/plugin-runtime/browser/runtime-contract.ts +150 -0
- package/packages/plugin-runtime/browser/session-authority.ts +62 -0
- package/packages/plugin-runtime/computer/chatgpt-identity.ts +24 -0
- package/packages/plugin-runtime/computer/conversation-target.ts +143 -0
- package/packages/plugin-runtime/computer/index.ts +8 -0
- package/packages/plugin-runtime/computer/provider-error.ts +26 -0
- package/packages/plugin-runtime/computer/provider-registration.ts +52 -0
- package/packages/plugin-runtime/computer/provider-registry.ts +63 -0
- package/packages/plugin-runtime/computer/provider.ts +19 -0
- package/packages/plugin-runtime/computer/target-authority.ts +215 -0
- package/packages/plugin-runtime/computer/target-persistence.ts +54 -0
- package/packages/plugin-runtime/external/index.ts +1 -0
- package/packages/plugin-runtime/external/unix-jsonl-transport.ts +581 -0
- package/packages/plugin-runtime/index.ts +3 -0
- package/packages/protocols/browser/index.ts +1 -0
- package/packages/protocols/browser/session.ts +98 -0
- package/packages/protocols/computer/contract.ts +225 -0
- package/packages/protocols/computer/index.ts +1 -0
- package/packages/protocols/failure/contract.ts +158 -0
- package/packages/protocols/failure/index.ts +1 -0
- package/packages/protocols/handoff/index.ts +1 -0
- package/packages/protocols/handoff/status.ts +22 -0
- package/packages/protocols/mcp/execution-context.ts +11 -0
- package/packages/protocols/mcp/index.ts +2 -0
- package/packages/protocols/mcp/tool-contract.ts +42 -0
- package/packages/workflow-runtime/api/index.ts +2 -0
- package/packages/workflow-runtime/application/interpreter.ts +342 -0
- package/packages/workflow-runtime/domain/workflow-asset.ts +223 -0
- package/packages/workflow-runtime/index.ts +1 -0
- package/scripts/activate-source-baseline.ts +7 -9
- package/scripts/assemble-template.ts +1 -1
- package/scripts/bootstrap-runtime-maintenance-recovery.sh +4 -1
- package/scripts/canary-chatgpt-provider-delivery.ts +97 -0
- package/scripts/canary-controller-semantic-wait.ts +183 -0
- package/scripts/canary-global-hook.sh +2 -2
- package/scripts/check-mcp-compatibility.ts +85 -7
- package/scripts/check-platform-support.mjs +28 -2
- package/scripts/check-public-docs.sh +1 -1
- package/scripts/check-repository-hygiene.mjs +92 -0
- package/scripts/check-runtime-architecture.mjs +1643 -127
- package/scripts/check-source-duplication.mjs +9 -1
- package/scripts/controller-ngrok-rotation.sh +5 -4
- package/scripts/create-project-dirs.sh +10 -74
- package/scripts/forge-local-recovery-helper.mjs +282 -0
- package/scripts/forge-uu-remote-rescue-helper.d.mts +31 -0
- package/scripts/forge-uu-remote-rescue-helper.mjs +409 -0
- package/scripts/forge-windows-host-recovery-helper.mjs +136 -0
- package/scripts/hook-shim.sh +2 -2
- package/scripts/init-project.sh +12 -70
- package/scripts/install-desktop-operator-registration.ts +4 -2
- package/scripts/install-independent-windows-wsl-recovery.ts +125 -0
- package/scripts/install-standalone-recovery.ts +122 -10
- package/scripts/install-uu-remote-rescue-registration.ts +74 -0
- package/scripts/install-windows-host-recovery-registration.ts +22 -0
- package/scripts/lib/controller-home-env.sh +6 -3
- package/scripts/lib/project-init-lib.sh +42 -582
- package/scripts/migrate-chatgpt-automation-schedules.ts +2 -2
- package/scripts/migrate-windows-wsl-controller-home.ts +306 -0
- package/scripts/open-source-audit-allowlist.txt +11 -1
- package/scripts/portability-bindings.mjs +70 -0
- package/scripts/run-bounded-command.mjs +60 -0
- package/scripts/run-bun-test-file.ts +22 -6
- package/scripts/run-governed-gate.ts +4 -2
- package/scripts/run-mobile-monitor-bridge.ts +0 -1
- package/scripts/smoke-control-plane-failure-domain.ts +94 -0
- package/scripts/smoke-mcp-http-runtime.ts +6 -1
- package/scripts/smoke-runtime-recovery.ts +13 -11
- package/scripts/smoke-schedule-engine.ts +24 -16
- package/scripts/smoke-unattended-transport-recovery.ts +223 -0
- package/scripts/smoke-user-request-continuation-restart.ts +232 -0
- package/scripts/smoke-workflow-supervisor-chrome-adapter.ts +92 -0
- package/scripts/smoke-workflow-supervisor-process-restart.ts +276 -0
- package/scripts/smoke-workflow-supervisor-restart-recovery.ts +150 -0
- package/scripts/smoke-workflow-supervisor.ts +103 -0
- package/scripts/test-governance.ts +51 -2
- package/scripts/verify-forge-runtime.sh +2 -1
- package/src/cli/agent-jobs/executor-health.ts +11 -34
- package/src/cli/agent-jobs/integration.ts +18 -49
- package/src/cli/agent-jobs/job-manager.ts +17 -4
- package/src/cli/chatgpt-browser/bind-server.ts +2 -1
- package/src/cli/chatgpt-browser/binding.ts +20 -10
- package/src/cli/chatgpt-browser/bridge-extension.ts +75 -0
- package/src/cli/chatgpt-browser/bridge-provider.ts +376 -11
- package/src/cli/chatgpt-browser/engine.ts +10 -11
- package/src/cli/chatgpt-browser/native-provider.ts +24 -83
- package/src/cli/chatgpt-browser/oracle-provider.ts +5 -3
- package/src/cli/chatgpt-browser/types.ts +1 -0
- package/src/cli/commands/bootstrap-control.ts +334 -0
- package/src/cli/commands/brain-assistant.ts +52 -0
- package/src/cli/commands/brain.ts +191 -40
- package/src/cli/commands/chatgpt.ts +32 -67
- package/src/cli/commands/computer.ts +437 -0
- package/src/cli/commands/doctor.ts +18 -1
- package/src/cli/commands/init-hook.ts +71 -61
- package/src/cli/commands/init.ts +3 -1
- package/src/cli/commands/mcp.ts +16 -10
- package/src/cli/commands/plugin.ts +38 -12
- package/src/cli/commands/recovery.ts +507 -80
- package/src/cli/commands/runtime.ts +12 -7
- package/src/cli/commands/setup-profile.ts +97 -39
- package/src/cli/commands/status.ts +2 -1
- package/src/cli/controller/check-runner.ts +500 -100
- package/src/cli/controller/completion-backlog.ts +3 -3
- package/src/cli/controller/completion-orchestrator.ts +4 -43
- package/src/cli/controller/completion-target.ts +3 -72
- package/src/cli/controller/context/source-materializer.ts +81 -100
- package/src/cli/controller/context/types.ts +7 -1
- package/src/cli/controller/context-pack.ts +28 -6
- package/src/cli/controller/execution-completion.ts +4 -7
- package/src/cli/controller/execution-policy.ts +36 -153
- package/src/cli/controller/governance.ts +2 -3
- package/src/cli/controller/issue-store.ts +4 -99
- package/src/cli/controller/launch-agents.ts +59 -18
- package/src/cli/controller/operational-plan.ts +24 -51
- package/src/cli/controller/runtime-config.ts +5 -1
- package/src/cli/controller/stuck-state-migration.ts +2 -3
- package/src/cli/controller/systemd-user.ts +148 -0
- package/src/cli/controller/types.ts +10 -70
- package/src/cli/controller/worklog.ts +67 -13
- package/src/cli/editing/edit-operation-contract.ts +92 -0
- package/src/cli/editing/edit-session.ts +290 -20
- package/src/cli/github/plugin.ts +17 -12
- package/src/cli/hook/route-registry.ts +3 -5
- package/src/cli/hook/runtime.ts +49 -3
- package/src/cli/index.ts +3 -0
- package/src/cli/local-bridge/connector-freshness.ts +2 -2
- package/src/cli/local-bridge/console-automations.ts +20 -15
- package/src/cli/local-bridge/console-view-models.ts +3 -12
- package/src/cli/local-bridge/facade-api.ts +105 -205
- package/src/cli/local-bridge/job-store.ts +1 -1
- package/src/cli/local-bridge/server.ts +48 -174
- package/src/cli/local-bridge/types.ts +0 -1
- package/src/cli/mcp/access-mode.ts +2 -132
- package/src/cli/mcp/access-tools.ts +2 -387
- package/src/cli/mcp/audit.ts +2 -32
- package/src/cli/mcp/auth.ts +2 -404
- package/src/cli/mcp/instructions.ts +2 -34
- package/src/cli/mcp/legacy-context.ts +2 -16
- package/src/cli/mcp/legacy-tool-service.ts +2 -5308
- package/src/cli/mcp/multi-repository.ts +2 -436
- package/src/cli/mcp/oauth-client-retirement.ts +74 -0
- package/src/cli/mcp/oauth.ts +2 -375
- package/src/cli/mcp/openai-secure-tunnel.ts +2 -0
- package/src/cli/mcp/paths.ts +2 -140
- package/src/cli/mcp/policy.ts +2 -232
- package/src/cli/mcp/post-finalize-work-attribution.ts +2 -150
- package/src/cli/mcp/proxy-env.ts +2 -62
- package/src/cli/mcp/readiness-projection.ts +2 -24
- package/src/cli/mcp/redaction.ts +2 -21
- package/src/cli/mcp/repo.ts +2 -24
- package/src/cli/mcp/repository-tools.ts +2 -1613
- package/src/cli/mcp/server.ts +2 -951
- package/src/cli/mcp/setup.ts +2 -737
- package/src/cli/mcp/tool-capability-cache.ts +2 -87
- package/src/cli/mcp/tool-contract.ts +2 -14
- package/src/cli/mcp/tools.ts +2 -9
- package/src/cli/mcp/toolset-names.ts +2 -70
- package/src/cli/mcp/toolset-selection.ts +2 -41
- package/src/cli/mcp/toolset.ts +2 -336
- package/src/cli/mcp/transports/AGENTS.md +1 -10
- package/src/cli/mcp/transports/CLAUDE.md +1 -10
- package/src/cli/mcp/transports/http.ts +2 -1414
- package/src/cli/mcp/transports/session-registry.ts +2 -490
- package/src/cli/mcp/transports/stdio.ts +2 -8
- package/src/cli/mcp/types.ts +2 -41
- package/src/cli/mcp/umbrella-tools.ts +2 -117
- package/src/cli/repositories/command-executor.ts +91 -39
- package/src/cli/repositories/command-scope.ts +139 -4
- package/src/cli/repositories/controller-home.ts +167 -1
- package/src/cli/repositories/ephemeral-workspace.ts +20 -30
- package/src/cli/repositories/git-command-executor.ts +2 -2
- package/src/cli/repositories/identity.ts +13 -0
- package/src/cli/repositories/registry.ts +145 -60
- package/src/cli/repositories/repository-snapshot.ts +53 -7
- package/src/cli/repositories/runtime-storage.ts +27 -2
- package/src/cli/repositories/safe-patch.ts +34 -38
- package/src/cli/repositories/selected-path-actions.ts +22 -2
- package/src/cli/repositories/structured-git.ts +167 -27
- package/src/cli/repository/session-cache.ts +92 -6
- package/src/cli/runtime/helper-runner.ts +7 -0
- package/src/cli/runtime-invocation.ts +8 -1
- package/src/cli/tools/codegraph.ts +44 -25
- package/src/core/adoption/manifest-templates.ts +1 -1
- package/src/core/adoption/plan.ts +3 -26
- package/src/core/adoption/workflow-contract-plan.ts +17 -9
- package/src/effects/process-runner.ts +8 -14
- package/src/runtime/context/assistant-context.ts +167 -0
- package/src/runtime/context/assistant-work-context.ts +209 -0
- package/src/runtime/context/automatic-learning.ts +565 -0
- package/src/runtime/context/codegraph-cache-boundary.ts +255 -0
- package/src/runtime/context/codegraph-read-provider.ts +55 -22
- package/src/runtime/context/codegraph-sidecar.cjs +8 -1
- package/src/runtime/context/cognitive-attention.ts +173 -0
- package/src/runtime/context/cognitive-skill-canary.ts +158 -0
- package/src/runtime/context/context-closure.ts +305 -0
- package/src/runtime/context/context-pack-process.ts +103 -0
- package/src/runtime/context/context-plane.ts +203 -0
- package/src/runtime/context/direct-controller-learning.ts +298 -0
- package/src/runtime/context/project-engineering-contract.ts +60 -0
- package/src/runtime/context/semantic-navigation-contract.ts +9 -0
- package/src/runtime/context/semantic-navigation.ts +6 -34
- package/src/runtime/context/typescript-navigation-process.ts +145 -0
- package/src/runtime/context/typescript-navigation.ts +89 -3
- package/src/runtime/control-plane/bootstrap/capability-provider.ts +74 -0
- package/src/runtime/control-plane/bootstrap/control-api.ts +57 -0
- package/src/runtime/control-plane/bootstrap/index.ts +5 -0
- package/src/runtime/control-plane/bootstrap/state-machine.ts +141 -0
- package/src/runtime/control-plane/bootstrap/store.ts +21 -0
- package/src/runtime/control-plane/bootstrap/types.ts +87 -0
- package/src/runtime/control-plane/cleanup-artifact-retention.ts +531 -0
- package/src/runtime/control-plane/codegraph-cache-retention.ts +164 -0
- package/src/runtime/control-plane/concurrency/work-execution-concurrency.ts +282 -0
- package/src/runtime/control-plane/execution/controller-authority-recovery.ts +380 -0
- package/src/runtime/control-plane/execution/edit-validation-coordinator.ts +113 -16
- package/src/runtime/control-plane/execution/execution-identity.ts +23 -3
- package/src/runtime/control-plane/execution/implementation-review-content.ts +95 -0
- package/src/runtime/control-plane/execution/repository-work-attribution.ts +7 -30
- package/src/runtime/control-plane/execution/retained-work-resume.ts +439 -102
- package/src/runtime/control-plane/execution/session-store.ts +31 -1
- package/src/runtime/control-plane/execution/validation.ts +34 -11
- package/src/runtime/control-plane/execution/work-authority-reconciler.ts +87 -0
- package/src/runtime/control-plane/execution/work-check-evidence.ts +137 -0
- package/src/runtime/control-plane/execution/work-completion-authority.ts +31 -44
- package/src/runtime/control-plane/execution/work-delivery-service.ts +497 -0
- package/src/runtime/control-plane/execution/work-evidence-policy.ts +46 -53
- package/src/runtime/control-plane/execution/work-execution-support.ts +85 -164
- package/src/runtime/control-plane/execution/work-handle-authority.ts +349 -73
- package/src/runtime/control-plane/execution/work-handle-store.ts +55 -2
- package/src/runtime/control-plane/execution/work-head-settlement.ts +108 -0
- package/src/runtime/control-plane/execution/work-lifecycle-audit.ts +173 -28
- package/src/runtime/control-plane/execution/work-operation-service.ts +92 -40
- package/src/runtime/control-plane/execution/work-preparation-service.ts +145 -269
- package/src/runtime/control-plane/execution/work-remote-delivery.ts +2 -3
- package/src/runtime/control-plane/execution/work-revision-diff.ts +39 -0
- package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +543 -225
- package/src/runtime/control-plane/execution/work-validation-reconciler.ts +59 -45
- package/src/runtime/control-plane/execution/work-verification-context.ts +1 -1
- package/src/runtime/control-plane/execution/work-verification-service.ts +662 -52
- package/src/runtime/control-plane/execution/work-verification-snapshot.ts +205 -16
- package/src/runtime/control-plane/facade/capability-registry.ts +380 -44
- package/src/runtime/control-plane/facade/check-normalization.ts +3 -9
- package/src/runtime/control-plane/facade/codex-delegation.ts +10 -11
- package/src/runtime/control-plane/facade/controller-connection.ts +135 -0
- package/src/runtime/control-plane/facade/controller-session-store.ts +2 -488
- package/src/runtime/control-plane/facade/goal-workloop-access.ts +6 -22
- package/src/runtime/control-plane/facade/goal-workloop.ts +565 -1202
- package/src/runtime/control-plane/facade/handoff-inbox-application.ts +145 -0
- package/src/runtime/control-plane/facade/handoff-inbox-store.ts +266 -52
- package/src/runtime/control-plane/facade/index.ts +4 -3
- package/src/runtime/control-plane/facade/operation-digest.ts +34 -73
- package/src/runtime/control-plane/facade/plan-contract-store.ts +671 -245
- package/src/runtime/control-plane/facade/policy-gate.ts +0 -23
- package/src/runtime/control-plane/facade/repository-work-admission.ts +31 -23
- package/src/runtime/control-plane/facade/requirement-authority.ts +121 -0
- package/src/runtime/control-plane/facade/requirement-board.ts +9 -4
- package/src/runtime/control-plane/facade/rh-work-operation-contract.ts +85 -0
- package/src/runtime/control-plane/facade/self-healing-loop.ts +18 -53
- package/src/runtime/control-plane/facade/semantic-admission.ts +8 -16
- package/src/runtime/control-plane/facade/suggested-actions.ts +15 -15
- package/src/runtime/control-plane/facade/types.ts +218 -620
- package/src/runtime/control-plane/facade/work-continuation.ts +8 -8
- package/src/runtime/control-plane/facade/work-contract-store.ts +2 -1043
- package/src/runtime/control-plane/generated-cache-retention.ts +336 -19
- package/src/runtime/control-plane/global-scheduler/autonomous-continuation.ts +248 -0
- package/src/runtime/control-plane/global-scheduler/durable-admission.ts +13 -2
- package/src/runtime/control-plane/global-scheduler/maintenance.ts +274 -84
- package/src/runtime/control-plane/global-scheduler/periodic-cleanup-entry.ts +55 -0
- package/src/runtime/control-plane/global-scheduler/periodic-cleanup-process.ts +98 -0
- package/src/runtime/control-plane/global-scheduler/reconciliation.ts +3 -2
- package/src/runtime/control-plane/global-scheduler/scheduler-worker-entry.ts +10 -0
- package/src/runtime/control-plane/global-scheduler/scheduler.ts +227 -109
- package/src/runtime/control-plane/global-scheduler/wake-signal.ts +42 -2
- package/src/runtime/control-plane/global-scheduler/worker-launch.ts +18 -6
- package/src/runtime/control-plane/governance/access-policy.ts +19 -2
- package/src/runtime/control-plane/governance/authorization.ts +20 -3
- package/src/runtime/control-plane/launcher/chatgpt-round-continuation.ts +160 -0
- package/src/runtime/control-plane/launcher/chatgpt-work-binding-store.ts +2 -177
- package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +139 -670
- package/src/runtime/control-plane/launcher/local-conversation-controller.ts +238 -0
- package/src/runtime/control-plane/launcher/provider-mcp-bootstrap.ts +25 -6
- package/src/runtime/control-plane/launcher/thin-launcher.ts +96 -194
- package/src/runtime/control-plane/lifecycle-retention-metrics.ts +60 -0
- package/src/runtime/control-plane/persistence/cognition-store.ts +820 -0
- package/src/runtime/control-plane/persistence/experience-store.ts +302 -0
- package/src/runtime/control-plane/persistence/operational-prior-store.ts +348 -0
- package/src/runtime/control-plane/persistence/requirement-store.ts +230 -80
- package/src/runtime/control-plane/persistence/sqlite-store.ts +654 -78
- package/src/runtime/control-plane/persistence/workflow-content-store.ts +98 -0
- package/src/runtime/control-plane/persistence/workflow-registry-store.ts +186 -0
- package/src/runtime/control-plane/persistence/workflow-run-store.ts +123 -0
- package/src/runtime/control-plane/release-retention.ts +154 -26
- package/src/runtime/control-plane/repository-namespace-retention.ts +208 -0
- package/src/runtime/control-plane/runtime-cleanup.ts +844 -38
- package/src/runtime/control-plane/runtime-generation.ts +6 -0
- package/src/runtime/control-plane/workspace/project-onboarding.ts +80 -0
- package/src/runtime/control-plane/workspace/semantic-sync-service.ts +479 -0
- package/src/runtime/control-plane/workspace/workspace-store.ts +249 -0
- package/src/runtime/diagnostics/incident-repair.ts +573 -0
- package/src/runtime/diagnostics/mcp-timing.ts +239 -6
- package/src/runtime/diagnostics/performance.ts +6 -14
- package/src/runtime/diagnostics/read-only-tool.ts +1 -1
- package/src/runtime/evidence/artifact-store.ts +216 -35
- package/src/runtime/evidence/evidence-store.ts +112 -8
- package/src/runtime/evidence/operational-shadow.ts +38 -0
- package/src/runtime/evidence/output-handle-index.ts +225 -0
- package/src/runtime/evidence/process-check-receipt.ts +5 -43
- package/src/runtime/evidence/result-store.ts +158 -44
- package/src/runtime/evidence/sensitive-output.ts +4 -4
- package/src/runtime/execution/jobs/receipt-recovery.ts +1 -1
- package/src/runtime/execution/jobs/store.ts +124 -6
- package/src/runtime/execution/managed-workspace.ts +112 -5
- package/src/runtime/execution/process-runtime/canary.ts +19 -5
- package/src/runtime/execution/process-runtime/check-classification.ts +48 -0
- package/src/runtime/execution/process-runtime/check-facade.ts +24 -10
- package/src/runtime/execution/process-runtime/check-receipt.ts +1 -1
- package/src/runtime/execution/process-runtime/check-result-retention.ts +132 -0
- package/src/runtime/execution/process-runtime/check-result.ts +177 -2
- package/src/runtime/execution/process-runtime/check-runner-sidecar.ts +58 -9
- package/src/runtime/execution/process-runtime/check-storage.ts +187 -0
- package/src/runtime/execution/process-runtime/command-facade.ts +117 -114
- package/src/runtime/execution/process-runtime/gc.ts +15 -2
- package/src/runtime/execution/process-runtime/handle-index.ts +176 -0
- package/src/runtime/execution/process-runtime/host-command-lane.ts +139 -0
- package/src/runtime/execution/process-runtime/index.ts +4 -0
- package/src/runtime/execution/process-runtime/interactive-admission.ts +2 -1
- package/src/runtime/execution/process-runtime/lightweight-managed.ts +283 -73
- package/src/runtime/execution/process-runtime/persisted-check.ts +130 -23
- package/src/runtime/execution/process-runtime/process-runner-entry.ts +47 -5
- package/src/runtime/execution/process-runtime/process-scope.ts +160 -0
- package/src/runtime/execution/process-runtime/resource-claims.ts +125 -64
- package/src/runtime/execution/process-runtime/runtime.ts +167 -17
- package/src/runtime/execution/process-runtime/store.ts +30 -2
- package/src/runtime/execution/process-runtime/types.ts +27 -0
- package/src/runtime/execution/workers/executor.ts +18 -19
- package/src/runtime/execution/workers/worker-entry.ts +1 -1
- package/src/runtime/gateway/mcp/execution-tools.ts +2 -207
- package/src/runtime/gateway/mcp/legacy-ios-tool-adapter.ts +2 -61
- package/src/runtime/gateway/mcp/persisted-check-process.ts +2 -3
- package/src/runtime/gateway/mcp/process-tools.ts +2 -289
- package/src/runtime/gateway/mcp/router.ts +2 -1125
- package/src/runtime/gateway/mcp/runtime-tool-definitions.ts +2 -565
- package/src/runtime/gateway/mcp/runtime-tools.ts +2 -6655
- package/src/runtime/gateway/mcp/work-validation-reconciler.ts +2 -1
- package/src/runtime/maintenance/cleanup.ts +38 -24
- package/src/runtime/platform/browser-product-discovery.ts +174 -0
- package/src/runtime/platform/computer-platform.ts +14 -0
- package/src/runtime/platform/executable-discovery.ts +54 -0
- package/src/runtime/platform/fetch-http-server.ts +90 -0
- package/src/runtime/platform/service-inventory.ts +130 -0
- package/src/runtime/platform/service-manager.ts +146 -0
- package/src/runtime/platform/windows-host-discovery.ts +118 -0
- package/src/runtime/platform/windows-recovery-deployment.ts +67 -0
- package/src/runtime/plugins/action-application.ts +192 -0
- package/src/runtime/plugins/app-store-connect-adapter.ts +47 -35
- package/src/runtime/plugins/browser-adapter.ts +898 -135
- package/src/runtime/plugins/browser-automation-service.ts +33 -34
- package/src/runtime/plugins/browser-macos-bridge.ts +386 -46
- package/src/runtime/plugins/browser-manifest-surface.ts +75 -17
- package/src/runtime/plugins/browser-native-session-service.ts +64 -14
- package/src/runtime/plugins/browser-provider-registry.ts +2 -109
- package/src/runtime/plugins/browser-registration.ts +32 -0
- package/src/runtime/plugins/browser-runtime-contract.ts +2 -154
- package/src/runtime/plugins/browser-runtime.ts +55 -10
- package/src/runtime/plugins/browser-session-authority.ts +101 -291
- package/src/runtime/plugins/browser-session-legacy-migration.ts +203 -0
- package/src/runtime/plugins/browser-session-store.ts +243 -80
- package/src/runtime/plugins/browser-session-types.ts +2 -98
- package/src/runtime/plugins/capability-authorization-grants.ts +272 -59
- package/src/runtime/plugins/catalog-trust.ts +82 -0
- package/src/runtime/plugins/computer-chatgpt-browser-adapter-installer.ts +219 -0
- package/src/runtime/plugins/computer-chatgpt-macos-target.ts +891 -0
- package/src/runtime/plugins/computer-registration.ts +1050 -0
- package/src/runtime/plugins/config-store.ts +119 -0
- package/src/runtime/plugins/desktop-operator-external-policy.ts +964 -0
- package/src/runtime/plugins/desktop-operator-registration.ts +123 -12
- package/src/runtime/plugins/errors.ts +8 -1
- package/src/runtime/plugins/external-adapter.ts +82 -323
- package/src/runtime/plugins/external-provider-policy.ts +48 -0
- package/src/runtime/plugins/external-registration.ts +30 -2
- package/src/runtime/plugins/external-unix-socket-probe.cjs +9 -3
- package/src/runtime/plugins/external-unix-socket.ts +64 -193
- package/src/runtime/plugins/first-party-external-registration.ts +163 -0
- package/src/runtime/plugins/first-party-registry.ts +3 -1
- package/src/runtime/plugins/github-adapter.ts +1 -2
- package/src/runtime/plugins/gmail-adapter.ts +29 -8
- package/src/runtime/plugins/google-calendar-adapter.ts +8 -8
- package/src/runtime/plugins/google-shared.ts +45 -53
- package/src/runtime/plugins/google-tasks-adapter.ts +8 -8
- package/src/runtime/plugins/ios-adapter.ts +2 -3
- package/src/runtime/plugins/lightweight-action.ts +208 -2
- package/src/runtime/plugins/local-system-adapter.ts +192 -2
- package/src/runtime/plugins/macos-capability-broker.ts +15 -105
- package/src/runtime/plugins/managed-process-adapter.ts +12 -5
- package/src/runtime/plugins/plugin-action-sidecar.ts +17 -13
- package/src/runtime/plugins/plugin-management-adapter.ts +1 -1
- package/src/runtime/plugins/resend-adapter.ts +15 -24
- package/src/runtime/plugins/store.ts +1003 -387
- package/src/runtime/plugins/types.ts +28 -1
- package/src/runtime/plugins/uu-remote-rescue-registration.ts +155 -0
- package/src/runtime/plugins/windows-host-recovery-install.ts +68 -0
- package/src/runtime/plugins/windows-host-recovery-registration.ts +85 -0
- package/src/runtime/plugins/xiaohongshu-publish.ts +102 -430
- package/src/runtime/projections/controller-context.ts +12 -6
- package/src/runtime/projections/git-status-sampler.ts +73 -22
- package/src/runtime/projections/invalidation.ts +7 -3
- package/src/runtime/projections/materialized-view.ts +93 -43
- package/src/runtime/recovery/actions.ts +3 -3
- package/src/runtime/recovery/capability-action-application.ts +229 -0
- package/src/runtime/recovery/index.ts +3 -0
- package/src/runtime/recovery/lifecycle-inventory.ts +338 -0
- package/src/runtime/recovery/local-jobs-repair.ts +14 -14
- package/src/runtime/recovery/maintenance-executor.ts +701 -168
- package/src/runtime/recovery/quarantine-retention.ts +256 -0
- package/src/runtime/recovery/store.ts +29 -11
- package/src/runtime/release/identity.ts +6 -0
- package/src/runtime/release/release-coordinator.ts +296 -0
- package/src/runtime/release/release-session.ts +716 -0
- package/src/runtime/release/release-state-migration.ts +25 -0
- package/src/runtime/resources/leases/store.ts +56 -25
- package/src/runtime/root/active-execution-power-assertion.ts +139 -0
- package/src/runtime/root/assistant-learning-loop.ts +45 -0
- package/src/runtime/root/browser-session-composition.ts +59 -0
- package/src/runtime/root/computer-composition.ts +376 -0
- package/src/runtime/root/computer-target-composition.ts +16 -0
- package/src/runtime/root/computer-target-persistence.ts +39 -0
- package/src/runtime/root/controller-progression-composition.ts +192 -0
- package/src/runtime/root/controller-round-composition.ts +126 -0
- package/src/runtime/root/controller-services.ts +2 -0
- package/src/runtime/root/deployment-topology.ts +106 -0
- package/src/runtime/root/entry.ts +8 -6
- package/src/runtime/root/gateway-adapter.ts +3 -3
- package/src/runtime/root/known-good-recovery.ts +184 -0
- package/src/runtime/root/local-bridge.ts +5 -3
- package/src/runtime/root/mcp-transport.ts +116 -23
- package/src/runtime/root/ownership.ts +261 -9
- package/src/runtime/root/package-connector-service.ts +212 -74
- package/src/runtime/root/package-runtime-release.ts +199 -33
- package/src/runtime/root/package-runtime-service.ts +77 -108
- package/src/runtime/root/release-execution-canary.ts +103 -0
- package/src/runtime/root/release-loader.ts +150 -0
- package/src/runtime/root/release-manifest.ts +185 -7
- package/src/runtime/root/release-materialize.ts +474 -31
- package/src/runtime/root/release-store.ts +425 -37
- package/src/runtime/root/runtime-lane.ts +191 -0
- package/src/runtime/root/runtime.ts +379 -32
- package/src/runtime/root/scheduled-controller-composition.ts +76 -0
- package/src/runtime/root/scheduler.ts +12 -4
- package/src/runtime/root/service-runner.ts +3 -3
- package/src/runtime/root/service.ts +73 -45
- package/src/runtime/root/status.ts +49 -0
- package/src/runtime/root/types.ts +55 -3
- package/src/runtime/root/workflow-supervisor-composition.ts +493 -0
- package/src/runtime/root/workflow-supervisor-runtime.ts +209 -0
- package/src/runtime/root/write-fence.ts +50 -2
- package/src/runtime/safe-tooling/google-credential-store.ts +76 -21
- package/src/runtime/safe-tooling/google-oauth-broker.ts +25 -7
- package/src/runtime/safe-tooling/workspace-auth.ts +1 -1
- package/src/runtime/shared/bounded-child-supervisor.ts +9 -1
- package/src/runtime/shared/local-bridge-surface.ts +1 -1
- package/src/runtime/shared/process-environment.ts +36 -10
- package/src/runtime/shared/response-budget.ts +1 -3
- package/src/runtime/shared/storage-capacity.ts +68 -0
- package/src/runtime/standalone-recovery/controller-home-migration.ts +1108 -0
- package/src/runtime/standalone-recovery/core.ts +4609 -495
- package/src/runtime/standalone-recovery/entry.ts +642 -81
- package/src/runtime/standalone-recovery/independent-host-rescue.ts +172 -0
- package/src/runtime/standalone-recovery/installer.ts +399 -177
- package/src/runtime/standalone-recovery/mcp-server.ts +79 -0
- package/src/runtime/standalone-recovery/mutation-identity-contract.ts +21 -0
- package/src/runtime/standalone-recovery/operation-lock.ts +160 -0
- package/src/runtime/standalone-recovery/performance.ts +173 -0
- package/src/runtime/standalone-recovery/release.ts +58 -5
- package/src/runtime/standalone-recovery/service-labels.ts +5 -2
- package/src/runtime/standalone-recovery/watchdog-heartbeat.ts +5 -2
- package/src/runtime/watchdog/workflow-watchdog.ts +1 -1
- package/src/runtime/workflow/schedules/browser-probe.ts +17 -6
- package/src/runtime/workflow/schedules/chatgpt-automation-migration.ts +11 -4
- package/src/runtime/workflow/schedules/engine.ts +298 -484
- package/src/runtime/workflow/schedules/pending-activation.ts +1 -1
- package/src/runtime/workflow/schedules/settlement.ts +2 -191
- package/src/runtime/workflow/schedules/store.ts +2 -607
- package/src/runtime/workflow/schedules/types.ts +2 -127
- package/src/runtime/workflow/schedules/work-continuation.ts +220 -16
- package/src/runtime/workflows/first-party/xiaohongshu.ts +122 -0
- package/src/runtime/workflows/runtime.ts +482 -0
- package/src/testing/test-governance.ts +95 -30
- package/supervisor/chatgpt-conversation.ts +23 -0
- package/supervisor/client.ts +201 -0
- package/supervisor/control-plane.ts +868 -0
- package/supervisor/entry.ts +15 -0
- package/supervisor/forge-validators.ts +80 -0
- package/supervisor/index.ts +5 -0
- package/supervisor/native-browser-adapter.ts +786 -0
- package/supervisor/native-messaging/host.ts +39 -0
- package/supervisor/native-messaging/manifest.ts +26 -0
- package/supervisor/native-messaging/protocol.ts +27 -0
- package/supervisor/paths.ts +18 -0
- package/supervisor/protocol.ts +202 -0
- package/supervisor/server.ts +404 -0
- package/supervisor/store.ts +1451 -0
- package/supervisor/tsconfig.json +8 -0
- package/supervisor/types.ts +255 -0
- package/assets/templates/helpers/archive-architecture-request.sh +0 -244
- package/docs/architecture/history/README.md +0 -7
- package/docs/architecture/history/chatgpt-handoff-facade.md +0 -397
- package/docs/architecture/history/global-hook-runtime.md +0 -166
- package/docs/architecture/history/ios-semantic-automation-provider-v2.md +0 -170
- package/docs/architecture/requests/archive/2026/20260528-154740-ai-hooks-ai-hooks-hook-input-sh.md +0 -59
- package/docs/architecture/requests/archive/2026/20260528-154816-assets-hooks-assets-hooks-hook-input-sh.md +0 -59
- package/docs/architecture/requests/archive/2026/20260528-161851-ai-harness-workflow-contract-json-ai-harness-workflow-contract-json.md +0 -59
- package/docs/architecture/requests/archive/2026/20260528-161853-scripts-check-task-workflow-sh-scripts-check-task-workflow-sh.md +0 -59
- package/docs/architecture/requests/archive/2026/20260528-161911-assets-workflow-contract-v1-json-assets-workflow-contract-v1-json.md +0 -59
- package/docs/architecture/requests/archive/2026/20260528-222416-ai-hooks-ai-hooks-hook-input-sh.md +0 -59
- package/docs/architecture/requests/archive/2026/20260528-222444-ai-hooks-ai-hooks-pre-edit-guard-sh.md +0 -59
- package/docs/architecture/requests/archive/2026/20260528-222457-ai-hooks-ai-hooks-pre-edit-guard-sh.md +0 -59
- package/docs/architecture/requests/archive/2026/20260528-222648-ai-hooks-ai-hooks-pre-edit-guard-sh.md +0 -59
- package/docs/architecture/requests/archive/2026/20260528-222705-ai-hooks-ai-hooks-worktree-guard-sh.md +0 -59
- package/docs/architecture/requests/archive/2026/20260528-222726-ai-hooks-ai-hooks-prompt-guard-sh.md +0 -59
- package/docs/architecture/requests/archive/2026/20260528-222727-ai-hooks-ai-hooks-prompt-guard-sh.md +0 -59
- package/docs/architecture/requests/archive/2026/20260528-224736-ai-hooks-ai-hooks-hook-input-sh.md +0 -59
- package/docs/architecture/requests/archive/2026/20260528-233259-ai-hooks-ai-hooks-prompt-guard-sh.md +0 -59
- package/docs/architecture/requests/archive/2026/20260528-233335-assets-hooks-assets-hooks-prompt-guard-sh.md +0 -59
- package/docs/architecture/requests/archive/2026/20260528-233456-ai-hooks-ai-hooks-prompt-guard-sh.md +0 -59
- package/docs/architecture/requests/archive/2026/20260528-233457-assets-hooks-assets-hooks-prompt-guard-sh.md +0 -59
- package/docs/architecture/requests/archive/2026/20260528-233637-ai-hooks-ai-hooks-prompt-guard-sh.md +0 -59
- package/docs/architecture/requests/archive/2026/20260528-233638-assets-hooks-assets-hooks-prompt-guard-sh.md +0 -59
- package/docs/architecture/requests/archive/2026/20260529-000322-root-package-json.md +0 -59
- package/docs/architecture/requests/archive/2026/20260529-004238-ai-hooks-ai-hooks-lib-workflow-state-sh.md +0 -59
- package/docs/architecture/requests/archive/2026/20260529-020654-ai-harness-policy-json-ai-harness-policy-json.md +0 -63
- package/docs/architecture/requests/archive/2026/20260529-094236-ai-hooks-ai-hooks-prompt-guard-sh.md +0 -60
- package/docs/architecture/requests/archive/2026/20260529-094323-assets-hooks-assets-hooks-prompt-guard-sh.md +0 -60
- package/docs/architecture/requests/archive/2026/20260529-094331-ai-hooks-ai-hooks-prompt-guard-sh.md +0 -60
- package/docs/architecture/requests/archive/2026/20260529-094339-assets-hooks-assets-hooks-prompt-guard-sh.md +0 -60
- package/docs/architecture/requests/archive/2026/20260529-094430-assets-hooks-assets-hooks-session-start-context-sh.md +0 -60
- package/docs/architecture/requests/archive/2026/20260529-094446-ai-hooks-ai-hooks-session-start-context-sh.md +0 -60
- package/docs/architecture/requests/archive/2026/20260613-002040-runtime-harness-hook-adapters.md +0 -68
- package/docs/architecture/requests/archive/2026/root.md +0 -68
- package/docs/architecture/requests/archive/2026/runtime-harness-hook-adapters.md +0 -76
- package/docs/architecture/requests/archive/2026/verification-evals-checks.md +0 -68
- package/docs/architecture/requests/archive/2026/workflow-engine-contract-assets.md +0 -69
- package/scripts/archive-architecture-request.sh +0 -4
- package/scripts/benchmark-background-check-overlap.ts +0 -228
- package/scripts/benchmark-controller-hotpath.ts +0 -212
- package/scripts/benchmark-controller-round2.ts +0 -139
- package/scripts/benchmark-ios-agent-device.ts +0 -514
- package/scripts/benchmark-mcp-host.sh +0 -117
- package/scripts/benchmark-mcp-transport.ts +0 -170
- package/scripts/benchmark-quality-harness.ts +0 -146
- package/scripts/benchmark-route-session-concurrency.ts +0 -915
- package/scripts/benchmark-thin-harness-gateway-ab.ts +0 -632
- package/scripts/benchmark-thin-harness-mcp-e2e.ts +0 -498
- package/scripts/benchmark-thin-harness.ts +0 -251
- package/scripts/benchmark-typescript-navigation.ts +0 -186
- package/scripts/benchmark-work-efficiency.ts +0 -334
- package/scripts/build-controller-ui.ts +0 -28
- package/scripts/check-npm-release.sh +0 -32
- package/scripts/check-public-release-surface.sh +0 -19
- package/scripts/check-release-published.sh +0 -57
- package/scripts/check-release-readiness.sh +0 -37
- package/scripts/check-release-version.mjs +0 -74
- package/scripts/check-tarball-install-smoke.sh +0 -67
- package/scripts/package-source-archive.sh +0 -147
- package/scripts/public-release-files.txt +0 -39
- package/scripts/public-release.ts +0 -174
- package/scripts/publish-release-tarball.sh +0 -27
- package/scripts/route-nl-vs-ts-eval.ts +0 -679
- package/scripts/run-skill-evals.ts +0 -1022
- package/scripts/stage-runtime-release.ts +0 -39
- package/src/cli/controller/progress.ts +0 -656
- package/src/cli/controller/work-mode.ts +0 -181
- package/src/cli/local-bridge/console-assets.ts +0 -17
- package/src/cli/local-bridge/dashboard.ts +0 -17
- package/src/cli/local-bridge/frontend/src/App.tsx +0 -10
- package/src/cli/local-bridge/frontend/src/components/Button.tsx +0 -1
- package/src/cli/local-bridge/frontend/src/components/DetailPane.tsx +0 -3
- package/src/cli/local-bridge/frontend/src/components/SearchField.tsx +0 -1
- package/src/cli/local-bridge/frontend/src/components/SectionHeader.tsx +0 -1
- package/src/cli/local-bridge/frontend/src/components/Segmented.tsx +0 -1
- package/src/cli/local-bridge/frontend/src/components/StatusText.tsx +0 -3
- package/src/cli/local-bridge/frontend/src/design/icons.tsx +0 -11
- package/src/cli/local-bridge/frontend/src/design/tokens.css +0 -8
- package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +0 -153
- package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +0 -37
- package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +0 -183
- package/src/cli/local-bridge/frontend/src/features/repositories/RepositoriesView.tsx +0 -2
- package/src/cli/local-bridge/frontend/src/features/system/SystemView.tsx +0 -2
- package/src/cli/local-bridge/frontend/src/features/work/WorkView.tsx +0 -4
- package/src/cli/local-bridge/frontend/src/lib/api.ts +0 -30
- package/src/cli/local-bridge/frontend/src/lib/format.ts +0 -4
- package/src/cli/local-bridge/frontend/src/lib/routes.ts +0 -7
- package/src/cli/local-bridge/frontend/src/main.tsx +0 -1
- package/src/cli/local-bridge/frontend/src/shell/AppShell.tsx +0 -1
- package/src/cli/local-bridge/frontend/src/shell/CommandBar.tsx +0 -2
- package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +0 -4
- package/src/cli/local-bridge/frontend/src/styles.css +0 -28
- package/src/cli/local-bridge/frontend/src/types.ts +0 -15
- package/src/cli/local-bridge/frontend/src/vite-env.d.ts +0 -1
- package/src/cli/local-bridge/frontend/vite.config.ts +0 -27
- package/src/cli/local-bridge/ui-dist/app.css +0 -1
- package/src/cli/local-bridge/ui-dist/app.js +0 -11228
- package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +0 -385
- package/src/runtime/control-plane/execution/work-finalization-service.ts +0 -1913
- package/src/runtime/control-plane/execution/work-path-scope.ts +0 -37
- package/src/runtime/control-plane/execution/work-task-receipt.ts +0 -449
- package/src/runtime/control-plane/facade/controller-round-relay.ts +0 -1118
- package/src/runtime/control-plane/facade/work-admission-policy.ts +0 -93
- package/src/runtime/control-plane/facade/work-task-projection.ts +0 -66
- package/src/runtime/control-plane/routing/index.ts +0 -1
- package/src/runtime/control-plane/routing/route-policy.ts +0 -382
- package/src/runtime/control-plane/routing/workspace-admission.ts +0 -70
|
@@ -0,0 +1,1970 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'crypto';
|
|
2
|
+
import { withControllerLock } from '../../../../src/cli/repositories/locks';
|
|
3
|
+
import { readRequirement } from '../../../../src/runtime/control-plane/persistence/requirement-store';
|
|
4
|
+
import {
|
|
5
|
+
listControlPlaneRecords,
|
|
6
|
+
readControlPlaneRecord,
|
|
7
|
+
readControlPlaneRecordWithinTransaction,
|
|
8
|
+
withControlPlaneTransaction,
|
|
9
|
+
writeControlPlaneRecord,
|
|
10
|
+
writeControlPlaneRecordWithinTransaction,
|
|
11
|
+
withControlPlaneReadDatabase,
|
|
12
|
+
type ControlPlaneRecord,
|
|
13
|
+
} from '../../../../src/runtime/control-plane/persistence/sqlite-store';
|
|
14
|
+
import { workHasActiveExecution } from '../../../../src/runtime/execution/work-activity';
|
|
15
|
+
import {
|
|
16
|
+
assertControllerSessionWorkClaimable,
|
|
17
|
+
controllerSessionBlocksRecovery,
|
|
18
|
+
controllerSessionPrincipalId,
|
|
19
|
+
getControllerSession,
|
|
20
|
+
releaseObservedControllerSession,
|
|
21
|
+
resumeControllerSessionWithinTransaction,
|
|
22
|
+
withControllerSessionMutationLock,
|
|
23
|
+
type ClaimedControllerSession,
|
|
24
|
+
type ControllerSessionClaimInput,
|
|
25
|
+
} from './controller-session-store';
|
|
26
|
+
import { getHandoffItem, listHandoffItems } from '../../../../src/runtime/control-plane/facade/handoff-inbox-store';
|
|
27
|
+
import { currentTaskLineageWorkIds, getWorkContract, readActiveWorkCandidates, readWorkContractLineageSnapshot, readWorkContractStore, semanticWorkState, type WorkContract } from '../../work/api/index';
|
|
28
|
+
import { isTerminalHandoffStatus } from '../../../protocols/handoff/index';
|
|
29
|
+
import type { ControllerSession, ControllerType } from '../domain/types';
|
|
30
|
+
import { deriveClosedRoundQualitySignals, type AssistantContextSnapshot, type AssistantContextUsage, type ClosedRoundObservation, type ExecutionQualityAdjustmentResult, type ExecutionQualityDecision, type ExecutionQualitySignal } from '../domain/execution-quality';
|
|
31
|
+
import {
|
|
32
|
+
CONTROLLER_ROUND_DISPOSITIONS,
|
|
33
|
+
CONTROLLER_RELAY_ABANDONED_RELEASE_ERROR,
|
|
34
|
+
type ControllerRoundDisposition,
|
|
35
|
+
type ControllerRoundRelayIdentity,
|
|
36
|
+
type ControllerRoundRelayRecord,
|
|
37
|
+
type ControllerRoundRelayStatus,
|
|
38
|
+
} from '../domain/controller-round';
|
|
39
|
+
import {
|
|
40
|
+
controllerRoundBlockerClass,
|
|
41
|
+
controllerRoundProviderEffectId,
|
|
42
|
+
controllerRoundRelayClaimable,
|
|
43
|
+
decideControllerRoundTransition,
|
|
44
|
+
type ControllerRoundBlockerClass,
|
|
45
|
+
type ControllerRoundTransitionDecision,
|
|
46
|
+
type ControllerRoundTransitionEvent,
|
|
47
|
+
} from '../domain/controller-round-transition-policy';
|
|
48
|
+
|
|
49
|
+
export interface ControllerRoundRelayStoreOptions {
|
|
50
|
+
controllerHome: string;
|
|
51
|
+
repoId: string;
|
|
52
|
+
now?: () => string;
|
|
53
|
+
/** Trusted composition supplies advisory data; Kernel does not read project files. */
|
|
54
|
+
prepareAssistantContext?: (workId: string) => string | undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SubmitControllerRoundDispositionInput {
|
|
58
|
+
workId: string;
|
|
59
|
+
identity: ControllerRoundRelayIdentity;
|
|
60
|
+
disposition: ControllerRoundDisposition;
|
|
61
|
+
executionQualityDecisions?: ExecutionQualityDecision[];
|
|
62
|
+
executionQualityAdjustmentResults?: Array<Omit<ExecutionQualityAdjustmentResult, 'verifiedAt'>>;
|
|
63
|
+
assistantContextDigest?: string;
|
|
64
|
+
assistantContextUsage?: AssistantContextUsage[];
|
|
65
|
+
relayScopeId?: string;
|
|
66
|
+
requirementId?: string;
|
|
67
|
+
handoffId?: string;
|
|
68
|
+
stateFingerprint?: string;
|
|
69
|
+
reason?: string;
|
|
70
|
+
/** Exact facade-authenticated authority for completed-Work provider-wait goal closure only. */
|
|
71
|
+
terminalAuthorityId?: string;
|
|
72
|
+
/** Opaque provider binding owned by a ControllerHost adapter. */
|
|
73
|
+
bindingId?: string;
|
|
74
|
+
maxRounds?: number;
|
|
75
|
+
maxRepeatedState?: number;
|
|
76
|
+
maxFailures?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface BeginInitialControllerRoundDispatchInput {
|
|
80
|
+
workId: string;
|
|
81
|
+
identity: ControllerRoundRelayIdentity;
|
|
82
|
+
relayScopeId?: string;
|
|
83
|
+
requirementId?: string;
|
|
84
|
+
/** Opaque provider binding owned by a ControllerHost adapter. */
|
|
85
|
+
bindingId?: string;
|
|
86
|
+
maxRounds?: number;
|
|
87
|
+
maxRepeatedState?: number;
|
|
88
|
+
maxFailures?: number;
|
|
89
|
+
/** Required for any later schedule/manual/replan occurrence after a prior semantic state exists. */
|
|
90
|
+
occurrenceId?: string;
|
|
91
|
+
/**
|
|
92
|
+
* Scheduler-owned recovery may re-arm an unchanged semantic wait when the
|
|
93
|
+
* outer Workflow Supervisor task is no longer runnable. This is bounded by
|
|
94
|
+
* the existing repeated-state/round budgets and never applies to a user
|
|
95
|
+
* blocker or an unknown provider outcome.
|
|
96
|
+
*/
|
|
97
|
+
allowSemanticWaitRecovery?: boolean;
|
|
98
|
+
/** Authenticated launcher evidence for a distinct occurrence after round-budget exhaustion. */
|
|
99
|
+
authorizeRoundBudgetOccurrence?: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface RecoverControllerRoundRelayAuthorityInput {
|
|
103
|
+
workId: string;
|
|
104
|
+
requestedBy?: string;
|
|
105
|
+
recoveryReason?: string;
|
|
106
|
+
identity: ControllerRoundRelayIdentity;
|
|
107
|
+
/** Set only after the caller proves this identity is served by the live canonical Runtime. */
|
|
108
|
+
allowCanonicalRuntimeMigration?: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Legacy ControllerRound rows predate explicit controllerType and were ChatGPT-only. */
|
|
112
|
+
function relayControllerType(record: Pick<ControllerRoundRelayRecord, 'controllerType'>): ControllerType {
|
|
113
|
+
return record.controllerType ?? 'chatgpt';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const NAMESPACE = 'controller_round_relay';
|
|
117
|
+
const SCHEMA_VERSION = 1;
|
|
118
|
+
const DEFAULT_MAX_ROUNDS = 8;
|
|
119
|
+
const DEFAULT_MAX_REPEATED_STATE = 2;
|
|
120
|
+
const DEFAULT_MAX_FAILURES = 3;
|
|
121
|
+
const DEFAULT_UNCLOSED_ROUND_GRACE_MS = 10 * 60_000;
|
|
122
|
+
const MAX_UNCLOSED_ROUND_GRACE_MS = 60 * 60_000;
|
|
123
|
+
const DEFAULT_STALLED_RECOVERY_BACKOFF_MS = 60_000;
|
|
124
|
+
const MAX_STALLED_RECOVERY_BACKOFF_MS = 15 * 60_000;
|
|
125
|
+
const MAX_LATEST_RELAY_CACHE_ENTRIES = 128;
|
|
126
|
+
|
|
127
|
+
interface LatestRelayRecordsCacheEntry {
|
|
128
|
+
signature: string;
|
|
129
|
+
records: ControllerRoundRelayRecord[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const latestRelayRecordsCache = new Map<string, LatestRelayRecordsCacheEntry>();
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
function transitionDecisionOrThrow(decision: ControllerRoundTransitionDecision): { record: ControllerRoundRelayRecord; action?: string; changed: boolean } {
|
|
136
|
+
if (decision.kind === 'accept') return { record: decision.next, action: decision.action, changed: true };
|
|
137
|
+
if (decision.kind === 'accept_atomic') throw new Error('CONTROLLER_RELAY_ATOMIC_TRANSITION_REQUIRES_TRANSACTION');
|
|
138
|
+
if (decision.kind === 'no_op') return { record: decision.current, changed: false };
|
|
139
|
+
if (decision.kind === 'needs_evidence') throw new Error(decision.code);
|
|
140
|
+
throw new Error(decision.code);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function atomicTransitionDecisionOrThrow(decision: ControllerRoundTransitionDecision): Extract<ControllerRoundTransitionDecision, { kind: 'accept_atomic' }> {
|
|
144
|
+
if (decision.kind === 'accept_atomic') return decision;
|
|
145
|
+
if (decision.kind === 'needs_evidence' || decision.kind === 'reject') throw new Error(decision.code);
|
|
146
|
+
throw new Error('CONTROLLER_RELAY_ATOMIC_TRANSITION_REQUIRED');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function applyControllerRoundTransition(
|
|
150
|
+
options: ControllerRoundRelayStoreOptions,
|
|
151
|
+
current: ControlPlaneRecord<ControllerRoundRelayRecord> | undefined,
|
|
152
|
+
event: ControllerRoundTransitionEvent,
|
|
153
|
+
): ControllerRoundRelayRecord {
|
|
154
|
+
const decided = transitionDecisionOrThrow(decideControllerRoundTransition(current?.value, event));
|
|
155
|
+
if (!decided.changed) return decided.record;
|
|
156
|
+
const key = current?.value.originWorkId ?? (event.type === 'occurrence_requested' ? event.originWorkId : undefined);
|
|
157
|
+
if (!key) throw new Error('CONTROLLER_RELAY_TRANSITION_KEY_REQUIRED');
|
|
158
|
+
writeControlPlaneRecord(options.controllerHome, {
|
|
159
|
+
namespace: NAMESPACE, scope: options.repoId, key, schemaVersion: SCHEMA_VERSION, value: decided.record,
|
|
160
|
+
action: decided.action ?? `controller_round_transition_${event.type}`, expectedRevision: current?.revision ?? null,
|
|
161
|
+
});
|
|
162
|
+
return decided.record;
|
|
163
|
+
}
|
|
164
|
+
function nowIso(options: ControllerRoundRelayStoreOptions): string {
|
|
165
|
+
return options.now?.() ?? new Date().toISOString();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function newControllerRoundAuthorityId(): string {
|
|
169
|
+
return `cra_${randomUUID().replace(/-/g, '')}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function bounded(value: string | undefined, max: number): string | undefined {
|
|
173
|
+
const normalized = value?.trim();
|
|
174
|
+
return normalized ? normalized.slice(0, max) : undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function normalizeScopeId(value: string): string {
|
|
178
|
+
const normalized = value.trim();
|
|
179
|
+
if (!normalized || /[\r\n]/.test(normalized)) throw new Error('CONTROLLER_RELAY_SCOPE_INVALID');
|
|
180
|
+
return normalized.slice(0, 240);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function boundedInteger(value: number | undefined, fallback: number, min: number, max: number): number {
|
|
184
|
+
if (value === undefined || !Number.isFinite(value)) return fallback;
|
|
185
|
+
return Math.max(min, Math.min(Math.trunc(value), max));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function relayLock<T>(
|
|
189
|
+
options: ControllerRoundRelayStoreOptions,
|
|
190
|
+
relayScopeId: string,
|
|
191
|
+
actor: string,
|
|
192
|
+
operation: () => T,
|
|
193
|
+
): T {
|
|
194
|
+
const lockId = createHash('sha256').update(relayScopeId).digest('hex').slice(0, 20);
|
|
195
|
+
return withControllerLock(
|
|
196
|
+
options.controllerHome,
|
|
197
|
+
{ scope: 'task', repoId: options.repoId, taskId: `controller-round-relay-${lockId}` },
|
|
198
|
+
actor,
|
|
199
|
+
operation,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function readRelayRecord(
|
|
204
|
+
options: ControllerRoundRelayStoreOptions,
|
|
205
|
+
workId: string,
|
|
206
|
+
): ControlPlaneRecord<ControllerRoundRelayRecord> | undefined {
|
|
207
|
+
return readControlPlaneRecord<ControllerRoundRelayRecord>(options.controllerHome, NAMESPACE, options.repoId, workId);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function getControllerRoundRelay(
|
|
211
|
+
options: ControllerRoundRelayStoreOptions,
|
|
212
|
+
workId: string,
|
|
213
|
+
): ControllerRoundRelayRecord | undefined {
|
|
214
|
+
return readRelayRecord(options, workId)?.value;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function getRequirementControllerRoundRelay(
|
|
218
|
+
options: ControllerRoundRelayStoreOptions,
|
|
219
|
+
requirementId: string,
|
|
220
|
+
): ControllerRoundRelayRecord | undefined {
|
|
221
|
+
const normalizedRequirementId = requirementId.trim();
|
|
222
|
+
if (!normalizedRequirementId) return undefined;
|
|
223
|
+
const relayScopeId = `requirement:${normalizedRequirementId}`;
|
|
224
|
+
return latestRelayRecordsByScope(options).find((entry) =>
|
|
225
|
+
entry.relayScopeId === relayScopeId
|
|
226
|
+
&& entry.requirementId === normalizedRequirementId);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Resolve an existing Requirement-scoped ControllerRound authority for another
|
|
231
|
+
* Work that is mechanically part of the same durable Requirement/Work graph.
|
|
232
|
+
* This never mints, copies, or rebinds relay state; callers may only project the
|
|
233
|
+
* already-proven opaque authority into the target Work's ControllerSession.
|
|
234
|
+
*/
|
|
235
|
+
export function resolveRequirementControllerRoundRelayForWork(
|
|
236
|
+
options: ControllerRoundRelayStoreOptions,
|
|
237
|
+
input: { workId: string; authorityId: string; relayScopeId: string },
|
|
238
|
+
): ControllerRoundRelayRecord | undefined {
|
|
239
|
+
const workId = input.workId.trim();
|
|
240
|
+
const authorityId = input.authorityId.trim();
|
|
241
|
+
const relayScopeId = input.relayScopeId.trim();
|
|
242
|
+
if (!workId || !authorityId || !relayScopeId.startsWith('requirement:')) return undefined;
|
|
243
|
+
const candidate = latestRelayRecordsByScope(options).find((entry) =>
|
|
244
|
+
entry.relayScopeId === relayScopeId
|
|
245
|
+
&& entry.authorityId?.trim() === authorityId
|
|
246
|
+
&& Boolean(entry.requirementId)
|
|
247
|
+
&& `requirement:${entry.requirementId}` === relayScopeId);
|
|
248
|
+
if (!candidate) return undefined;
|
|
249
|
+
const target = getWorkContract(options, workId);
|
|
250
|
+
if (!target) return undefined;
|
|
251
|
+
if (candidate.requirementId && target.requirementId !== candidate.requirementId) return undefined;
|
|
252
|
+
const all = readWorkContractStore({ controllerHome: options.controllerHome, repoId: options.repoId }).contracts;
|
|
253
|
+
return currentTaskLineageWorkIds([candidate.originWorkId], all).has(workId) ? candidate : undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function relayHistory(options: ControllerRoundRelayStoreOptions, relayScopeId: string): ControllerRoundRelayRecord[] {
|
|
257
|
+
return listControlPlaneRecords<ControllerRoundRelayRecord>(options.controllerHome, {
|
|
258
|
+
namespace: NAMESPACE,
|
|
259
|
+
scope: options.repoId,
|
|
260
|
+
limit: 5_000,
|
|
261
|
+
})
|
|
262
|
+
.map((entry) => entry.value)
|
|
263
|
+
.filter((entry) => entry.relayScopeId === relayScopeId && entry.status !== 'handed_off')
|
|
264
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function latestRelayRecordsByScope(options: ControllerRoundRelayStoreOptions): ControllerRoundRelayRecord[] {
|
|
268
|
+
const cacheKey = `${options.controllerHome}\u0000${options.repoId}`;
|
|
269
|
+
const signature = withControlPlaneReadDatabase(options.controllerHome, (database) => {
|
|
270
|
+
const statement = database.prepare(`
|
|
271
|
+
SELECT COUNT(*) AS recordCount,
|
|
272
|
+
COALESCE(SUM(revision), 0) AS revisionSum,
|
|
273
|
+
MAX(updated_at) AS latestUpdatedAt
|
|
274
|
+
FROM control_plane_records
|
|
275
|
+
WHERE namespace = ? AND scope = ?
|
|
276
|
+
`);
|
|
277
|
+
try {
|
|
278
|
+
const row = statement.get(NAMESPACE, options.repoId) as { recordCount?: unknown; revisionSum?: unknown; latestUpdatedAt?: unknown } | undefined;
|
|
279
|
+
return `${String(row?.recordCount ?? 0)}:${String(row?.revisionSum ?? 0)}:${typeof row?.latestUpdatedAt === 'string' ? row.latestUpdatedAt : ''}`;
|
|
280
|
+
} finally {
|
|
281
|
+
statement.finalize?.();
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
const cached = latestRelayRecordsCache.get(cacheKey);
|
|
285
|
+
if (cached && cached.signature === signature) return cached.records;
|
|
286
|
+
|
|
287
|
+
const latest = new Map<string, ControllerRoundRelayRecord>();
|
|
288
|
+
const retiredTerminalWork = (entry: ControllerRoundRelayRecord): boolean =>
|
|
289
|
+
entry.status === 'failed' && entry.failureClass === 'terminal_work';
|
|
290
|
+
for (const entry of listControlPlaneRecords<ControllerRoundRelayRecord>(options.controllerHome, {
|
|
291
|
+
namespace: NAMESPACE,
|
|
292
|
+
scope: options.repoId,
|
|
293
|
+
limit: 5_000,
|
|
294
|
+
}).map((record) => record.value)) {
|
|
295
|
+
if (entry.status === 'handed_off') continue;
|
|
296
|
+
const current = latest.get(entry.relayScopeId);
|
|
297
|
+
if (!current) {
|
|
298
|
+
latest.set(entry.relayScopeId, entry);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const currentRetired = retiredTerminalWork(current);
|
|
302
|
+
const candidateRetired = retiredTerminalWork(entry);
|
|
303
|
+
if (currentRetired !== candidateRetired) {
|
|
304
|
+
if (currentRetired && !candidateRetired) latest.set(entry.relayScopeId, entry);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (entry.updatedAt > current.updatedAt) latest.set(entry.relayScopeId, entry);
|
|
308
|
+
}
|
|
309
|
+
const records = [...latest.values()].sort((left, right) => left.updatedAt.localeCompare(right.updatedAt));
|
|
310
|
+
latestRelayRecordsCache.set(cacheKey, { signature, records });
|
|
311
|
+
while (latestRelayRecordsCache.size > MAX_LATEST_RELAY_CACHE_ENTRIES) {
|
|
312
|
+
const oldest = latestRelayRecordsCache.keys().next().value;
|
|
313
|
+
if (typeof oldest !== 'string') break;
|
|
314
|
+
latestRelayRecordsCache.delete(oldest);
|
|
315
|
+
}
|
|
316
|
+
return records;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Read-only current relay projection for bounded lifecycle/resource reconciliation. */
|
|
320
|
+
export function listControllerRoundRelaysByBlocker(
|
|
321
|
+
options: ControllerRoundRelayStoreOptions,
|
|
322
|
+
blocker: ControllerRoundBlockerClass,
|
|
323
|
+
limit = 16,
|
|
324
|
+
): ControllerRoundRelayRecord[] {
|
|
325
|
+
const boundedLimit = Math.max(1, Math.min(Math.trunc(limit), 100));
|
|
326
|
+
return latestRelayRecordsByScope(options)
|
|
327
|
+
.filter((record) => controllerRoundBlockerClass(record) === blocker)
|
|
328
|
+
.slice(0, boundedLimit);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Read-only latest relay projection used by bounded lifecycle/resource reconciliation. */
|
|
332
|
+
export function listCurrentControllerRoundRelays(
|
|
333
|
+
options: ControllerRoundRelayStoreOptions,
|
|
334
|
+
limit = 100,
|
|
335
|
+
): ControllerRoundRelayRecord[] {
|
|
336
|
+
const boundedLimit = Math.max(1, Math.min(Math.trunc(limit), 100));
|
|
337
|
+
return latestRelayRecordsByScope(options).slice(0, boundedLimit);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Read-only dispatch candidate query. Filter mechanical provider-dispatch facts
|
|
342
|
+
* before applying the bounded result limit so unrelated historical relays cannot
|
|
343
|
+
* permanently hide a newer runnable continuation behind the projection window.
|
|
344
|
+
*/
|
|
345
|
+
export function listControllerRoundRelaysAwaitingProviderDispatch(
|
|
346
|
+
options: ControllerRoundRelayStoreOptions,
|
|
347
|
+
input: {
|
|
348
|
+
limit?: number;
|
|
349
|
+
controllerTypes?: readonly ControllerType[];
|
|
350
|
+
occurrenceIdPrefix?: string;
|
|
351
|
+
} = {},
|
|
352
|
+
): ControllerRoundRelayRecord[] {
|
|
353
|
+
const boundedLimit = Math.max(1, Math.min(Math.trunc(input.limit ?? 100), 100));
|
|
354
|
+
const occurrenceIdPrefix = input.occurrenceIdPrefix?.trim();
|
|
355
|
+
return latestRelayRecordsByScope(options)
|
|
356
|
+
.filter((record) => {
|
|
357
|
+
if (record.status !== 'dispatching') return false;
|
|
358
|
+
// Attempt count is diagnostic history, not evidence that an external
|
|
359
|
+
// provider effect may have committed. A resolved user-action blocker clears
|
|
360
|
+
// the concrete effect facts below while preserving the retry count; that
|
|
361
|
+
// exact round must become runnable again without inventing a new occurrence.
|
|
362
|
+
if (record.providerDispatchEffectId
|
|
363
|
+
|| record.providerDispatchStartedAt
|
|
364
|
+
|| record.providerDispatchReceiptId
|
|
365
|
+
|| record.dispatchedAt) return false;
|
|
366
|
+
if (input.controllerTypes && !input.controllerTypes.includes(relayControllerType(record))) return false;
|
|
367
|
+
if (occurrenceIdPrefix && !record.occurrenceId?.startsWith(occurrenceIdPrefix)) return false;
|
|
368
|
+
return true;
|
|
369
|
+
})
|
|
370
|
+
.slice(0, boundedLimit);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
function requirementForRelay(options: ControllerRoundRelayStoreOptions, requirementId: string | undefined) {
|
|
375
|
+
return requirementId ? readRequirement({ controllerHome: options.controllerHome }, requirementId)?.value : undefined;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function relevantWork(
|
|
379
|
+
options: ControllerRoundRelayStoreOptions,
|
|
380
|
+
record: Pick<ControllerRoundRelayRecord, 'relayScopeId' | 'originWorkId' | 'requirementId'>,
|
|
381
|
+
allWorkContracts?: readonly WorkContract[],
|
|
382
|
+
): WorkContract[] {
|
|
383
|
+
const snapshot = allWorkContracts ? undefined : readWorkContractLineageSnapshot({
|
|
384
|
+
controllerHome: options.controllerHome,
|
|
385
|
+
repoId: options.repoId,
|
|
386
|
+
});
|
|
387
|
+
const all = allWorkContracts ?? snapshot!.contracts;
|
|
388
|
+
const invalid = snapshot?.invalid ?? [];
|
|
389
|
+
const candidates = invalid.length === 0
|
|
390
|
+
? all
|
|
391
|
+
: [...all, ...invalid.map((work) => ({
|
|
392
|
+
workId: work.workId,
|
|
393
|
+
parentWorkId: work.parentWorkId,
|
|
394
|
+
predecessorWorkId: work.predecessorWorkId,
|
|
395
|
+
supersedes: work.supersedes,
|
|
396
|
+
supersededBy: work.supersededBy,
|
|
397
|
+
} as WorkContract))];
|
|
398
|
+
const linkedWorkIds = currentTaskLineageWorkIds([record.originWorkId], candidates);
|
|
399
|
+
const relatedInvalid = invalid.find((work) => linkedWorkIds.has(work.workId));
|
|
400
|
+
if (relatedInvalid) {
|
|
401
|
+
throw new Error(`WORK_LINEAGE_CONTRACT_INVALID: ${relatedInvalid.workId}: ${relatedInvalid.error}`);
|
|
402
|
+
}
|
|
403
|
+
return all
|
|
404
|
+
.filter((work) => linkedWorkIds.has(work.workId))
|
|
405
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function recoveryFenceWork(
|
|
409
|
+
options: ControllerRoundRelayStoreOptions,
|
|
410
|
+
record: Pick<ControllerRoundRelayRecord, 'relayScopeId' | 'originWorkId' | 'requirementId'>,
|
|
411
|
+
allWorkContracts: readonly WorkContract[] = readWorkContractStore({ controllerHome: options.controllerHome, repoId: options.repoId }).contracts,
|
|
412
|
+
): WorkContract[] {
|
|
413
|
+
const explicit = relevantWork(options, record, allWorkContracts);
|
|
414
|
+
const requirementId = record.requirementId?.trim();
|
|
415
|
+
if (!requirementId || record.relayScopeId !== `requirement:${requirementId}`) return explicit;
|
|
416
|
+
const byId = new Map(explicit.map((work) => [work.workId, work] as const));
|
|
417
|
+
for (const work of allWorkContracts) {
|
|
418
|
+
if (work.requirementId?.trim() === requirementId) byId.set(work.workId, work);
|
|
419
|
+
}
|
|
420
|
+
return [...byId.values()].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function relayMayHaveActiveWork(
|
|
424
|
+
options: ControllerRoundRelayStoreOptions,
|
|
425
|
+
record: Pick<ControllerRoundRelayRecord, 'relayScopeId' | 'originWorkId' | 'requirementId'>,
|
|
426
|
+
activeWorkSnapshot: ReturnType<typeof readActiveWorkCandidates>,
|
|
427
|
+
): boolean {
|
|
428
|
+
const all = readWorkContractStore({ controllerHome: options.controllerHome, repoId: options.repoId }).contracts;
|
|
429
|
+
const recoveryFenceIds = new Set(recoveryFenceWork(options, record, all).map((work) => work.workId));
|
|
430
|
+
// Recovery is a destructive liveness decision. For Requirement-scoped relay
|
|
431
|
+
// authority it must conservatively fence every active Work in that Requirement,
|
|
432
|
+
// while normal semantic context continues to use exact current-task lineage.
|
|
433
|
+
if (activeWorkSnapshot.invalid.some((work) => recoveryFenceIds.has(work.workId))) return true;
|
|
434
|
+
return activeWorkSnapshot.contracts.some((work) => recoveryFenceIds.has(work.workId));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function relevantHandoffs(
|
|
438
|
+
options: ControllerRoundRelayStoreOptions,
|
|
439
|
+
works: readonly Pick<WorkContract, 'workId'>[],
|
|
440
|
+
explicitHandoffId?: string,
|
|
441
|
+
) {
|
|
442
|
+
const linkedWorkIds = new Set(works.map((work) => work.workId));
|
|
443
|
+
return listHandoffItems({ controllerHome: options.controllerHome, repoId: options.repoId, status: 'active', limit: 100 })
|
|
444
|
+
.filter((handoff) => handoff.workId ? linkedWorkIds.has(handoff.workId) : handoff.id === explicitHandoffId);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function semanticVerificationFacts(work: WorkContract): Array<{
|
|
448
|
+
checkId: string; verificationInputFingerprint: string; checkDefinitionDigest: string;
|
|
449
|
+
checkEnvironmentFingerprint: string; checkCacheKey: string; outcome: string; status: string;
|
|
450
|
+
}> {
|
|
451
|
+
const unique = new Map<string, {
|
|
452
|
+
checkId: string; verificationInputFingerprint: string; checkDefinitionDigest: string;
|
|
453
|
+
checkEnvironmentFingerprint: string; checkCacheKey: string; outcome: string; status: string;
|
|
454
|
+
}>();
|
|
455
|
+
for (const record of work.checkRefs.slice(-32)) {
|
|
456
|
+
const receipt = record.receipt;
|
|
457
|
+
if (!receipt || !record.verificationInputFingerprint || !receipt.checkDefinitionDigest
|
|
458
|
+
|| !receipt.checkEnvironmentFingerprint || !receipt.checkCacheKey
|
|
459
|
+
|| !['valid_pass', 'valid_fail'].includes(record.outcome)
|
|
460
|
+
|| !['passed', 'failed'].includes(receipt.status)) continue;
|
|
461
|
+
const fact = {
|
|
462
|
+
checkId: record.checkId,
|
|
463
|
+
verificationInputFingerprint: record.verificationInputFingerprint,
|
|
464
|
+
checkDefinitionDigest: receipt.checkDefinitionDigest,
|
|
465
|
+
checkEnvironmentFingerprint: receipt.checkEnvironmentFingerprint,
|
|
466
|
+
checkCacheKey: receipt.checkCacheKey,
|
|
467
|
+
outcome: record.outcome,
|
|
468
|
+
status: receipt.status,
|
|
469
|
+
};
|
|
470
|
+
unique.set(JSON.stringify(fact), fact);
|
|
471
|
+
}
|
|
472
|
+
return [...unique.values()].sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function mechanicalStateFingerprint(
|
|
476
|
+
options: ControllerRoundRelayStoreOptions,
|
|
477
|
+
work: WorkContract,
|
|
478
|
+
requirementId: string | undefined,
|
|
479
|
+
relayScopeId: string,
|
|
480
|
+
explicitHandoffId?: string,
|
|
481
|
+
allWorkContracts?: readonly WorkContract[],
|
|
482
|
+
): string {
|
|
483
|
+
const requirement = requirementForRelay(options, requirementId);
|
|
484
|
+
const works = relevantWork(options, { relayScopeId, originWorkId: work.workId, requirementId }, allWorkContracts)
|
|
485
|
+
.map((entry) => ({
|
|
486
|
+
workId: entry.workId,
|
|
487
|
+
parentWorkId: entry.parentWorkId,
|
|
488
|
+
state: entry.semanticState,
|
|
489
|
+
phase: entry.phase,
|
|
490
|
+
dispatchState: entry.dispatchState,
|
|
491
|
+
evidenceState: entry.evidenceState,
|
|
492
|
+
completionOutcome: entry.completionOutcome,
|
|
493
|
+
verificationFacts: semanticVerificationFacts(entry),
|
|
494
|
+
executionConcurrency: entry.executionConcurrency ? {
|
|
495
|
+
status: entry.executionConcurrency.status,
|
|
496
|
+
source: entry.executionConcurrency.source,
|
|
497
|
+
blockerCode: entry.executionConcurrency.blockerCode,
|
|
498
|
+
blockingWorkId: entry.executionConcurrency.blockingWorkId,
|
|
499
|
+
semanticScopeKeys: entry.executionConcurrency.semanticScopeKeys,
|
|
500
|
+
resourceKeys: entry.executionConcurrency.resourceKeys,
|
|
501
|
+
leaseRepoId: entry.executionConcurrency.leaseRepoId,
|
|
502
|
+
resourceIntents: entry.executionConcurrency.resourceIntents,
|
|
503
|
+
wakeTrigger: entry.executionConcurrency.wakeTrigger,
|
|
504
|
+
} : undefined,
|
|
505
|
+
}))
|
|
506
|
+
.sort((left, right) => left.workId.localeCompare(right.workId));
|
|
507
|
+
const handoffs = relevantHandoffs(options, works, explicitHandoffId)
|
|
508
|
+
.map((handoff) => ({
|
|
509
|
+
id: handoff.id,
|
|
510
|
+
workId: handoff.workId,
|
|
511
|
+
status: handoff.status,
|
|
512
|
+
severity: handoff.severity,
|
|
513
|
+
title: handoff.title,
|
|
514
|
+
reason: handoff.reason,
|
|
515
|
+
creationReason: handoff.creationReason,
|
|
516
|
+
blockingDecision: handoff.blockingDecision,
|
|
517
|
+
}))
|
|
518
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
519
|
+
return createHash('sha256').update(JSON.stringify({
|
|
520
|
+
requirement: requirement ? {
|
|
521
|
+
requirementId: requirement.requirementId,
|
|
522
|
+
state: requirement.state,
|
|
523
|
+
revision: requirement.revision,
|
|
524
|
+
} : undefined,
|
|
525
|
+
works,
|
|
526
|
+
handoffs,
|
|
527
|
+
})).digest('hex');
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Current semantic state identity for one existing ControllerRound lineage. Volatile persistence timestamps are intentionally excluded. */
|
|
531
|
+
export function readControllerRoundSemanticStateFingerprint(
|
|
532
|
+
options: ControllerRoundRelayStoreOptions,
|
|
533
|
+
workId: string,
|
|
534
|
+
): string | undefined {
|
|
535
|
+
const work = getWorkContract(options, workId);
|
|
536
|
+
const current = readRelayRecord(options, workId)?.value;
|
|
537
|
+
if (!work || !current) return undefined;
|
|
538
|
+
return mechanicalStateFingerprint(options, work, current.requirementId, current.relayScopeId, current.handoffId);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function resolveRequirementId(
|
|
542
|
+
options: ControllerRoundRelayStoreOptions,
|
|
543
|
+
work: WorkContract,
|
|
544
|
+
requestedRequirementId: string | undefined,
|
|
545
|
+
): string | undefined {
|
|
546
|
+
const requested = bounded(requestedRequirementId, 160);
|
|
547
|
+
if (work.requirementId && requested && work.requirementId !== requested) {
|
|
548
|
+
throw new Error(`CONTROLLER_RELAY_REQUIREMENT_MISMATCH: Work ${work.workId} belongs to ${work.requirementId}`);
|
|
549
|
+
}
|
|
550
|
+
const requirementId = work.requirementId ?? requested;
|
|
551
|
+
if (requirementId && !requirementForRelay(options, requirementId)) {
|
|
552
|
+
throw new Error(`REQUIREMENT_NOT_FOUND: ${requirementId}`);
|
|
553
|
+
}
|
|
554
|
+
return requirementId;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function resolveRelayScope(
|
|
558
|
+
work: WorkContract,
|
|
559
|
+
requirementId: string | undefined,
|
|
560
|
+
requestedScopeId: string | undefined,
|
|
561
|
+
): string {
|
|
562
|
+
const canonicalRequirementScope = requirementId ? `requirement:${requirementId}` : undefined;
|
|
563
|
+
if (canonicalRequirementScope) {
|
|
564
|
+
if (requestedScopeId && normalizeScopeId(requestedScopeId) !== canonicalRequirementScope) {
|
|
565
|
+
throw new Error(`CONTROLLER_RELAY_SCOPE_MISMATCH: expected ${canonicalRequirementScope}`);
|
|
566
|
+
}
|
|
567
|
+
return canonicalRequirementScope;
|
|
568
|
+
}
|
|
569
|
+
return requestedScopeId ? normalizeScopeId(requestedScopeId) : `goal:${work.workId}`;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function assertControllerOwner(
|
|
573
|
+
options: ControllerRoundRelayStoreOptions,
|
|
574
|
+
workId: string,
|
|
575
|
+
identity: ControllerRoundRelayIdentity,
|
|
576
|
+
): ControllerSession & { claimGeneration: number } {
|
|
577
|
+
const owner = getControllerSession(options, workId);
|
|
578
|
+
if (!owner) throw new Error(`WORK_CONTROLLER_OWNER_REQUIRED: ${workId}`);
|
|
579
|
+
if (owner.controllerType !== identity.controllerType) throw new Error(`CONTROLLER_RELAY_CONTROLLER_TYPE_MISMATCH: ${workId}`);
|
|
580
|
+
if (owner.controllerId !== identity.controllerId) throw new Error(`WORK_CONTROLLER_OWNER_MISMATCH: ${workId}`);
|
|
581
|
+
const ownerPrincipal = owner.principalId?.trim() || owner.controllerId;
|
|
582
|
+
if (ownerPrincipal !== identity.principalId.trim()) throw new Error(`WORK_CONTROLLER_PRINCIPAL_MISMATCH: ${workId}`);
|
|
583
|
+
if (owner.sessionId !== identity.sessionId.trim()) throw new Error(`WORK_CONTROLLER_SESSION_MISMATCH: ${workId}`);
|
|
584
|
+
const ownerInstanceId = owner.controllerInstanceId?.trim();
|
|
585
|
+
if (ownerInstanceId && ownerInstanceId !== identity.controllerInstanceId.trim()) {
|
|
586
|
+
throw new Error(`WORK_CONTROLLER_INSTANCE_MISMATCH: ${workId}`);
|
|
587
|
+
}
|
|
588
|
+
if (typeof owner.claimGeneration !== 'number' || owner.claimGeneration < 1) throw new Error(`CONTROLLER_RELAY_CLAIM_GENERATION_REQUIRED: ${workId}`);
|
|
589
|
+
return owner as ControllerSession & { claimGeneration: number };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function assertControllerRoundSuccessorLineage(
|
|
593
|
+
options: ControllerRoundRelayStoreOptions,
|
|
594
|
+
predecessor: WorkContract,
|
|
595
|
+
successorWorkId: string,
|
|
596
|
+
): WorkContract {
|
|
597
|
+
const successor = getWorkContract(options, successorWorkId);
|
|
598
|
+
if (!successor) throw new Error(`CONTROLLER_RELAY_SUCCESSOR_WORK_NOT_FOUND: ${successorWorkId}`);
|
|
599
|
+
if (semanticWorkState(successor) !== 'open') {
|
|
600
|
+
throw new Error(`CONTROLLER_RELAY_SUCCESSOR_WORK_TERMINAL: ${successor.workId}:${semanticWorkState(successor)}`);
|
|
601
|
+
}
|
|
602
|
+
if (successor.predecessorWorkId !== predecessor.workId) {
|
|
603
|
+
throw new Error(`CONTROLLER_RELAY_SUCCESSOR_LINEAGE_MISMATCH: ${successor.workId}:predecessor=${successor.predecessorWorkId ?? 'none'}:expected=${predecessor.workId}`);
|
|
604
|
+
}
|
|
605
|
+
if (predecessor.requirementId && successor.requirementId !== predecessor.requirementId) {
|
|
606
|
+
throw new Error(`CONTROLLER_RELAY_SUCCESSOR_REQUIREMENT_MISMATCH: ${successor.workId}`);
|
|
607
|
+
}
|
|
608
|
+
if (predecessor.planId && successor.planId !== predecessor.planId) {
|
|
609
|
+
throw new Error(`CONTROLLER_RELAY_SUCCESSOR_PLAN_MISMATCH: ${successor.workId}`);
|
|
610
|
+
}
|
|
611
|
+
if (successor.workId === predecessor.workId) throw new Error('CONTROLLER_RELAY_SUCCESSOR_IDENTITY_REUSE_FORBIDDEN');
|
|
612
|
+
return successor;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Bind an already-admitted GoalWorkloop successor to the currently claimed
|
|
617
|
+
* ControllerRound. This stores only a mechanical handoff target: Goal/Plan
|
|
618
|
+
* semantics were decided before this call and are revalidated through Work
|
|
619
|
+
* lineage here. No successor Work is created by ControllerRound.
|
|
620
|
+
*/
|
|
621
|
+
export function bindControllerRoundSuccessorWork(
|
|
622
|
+
options: ControllerRoundRelayStoreOptions,
|
|
623
|
+
input: { workId: string; successorWorkId: string; identity: ControllerRoundRelayIdentity; controllerAuthorityId?: string },
|
|
624
|
+
): ControllerRoundRelayRecord {
|
|
625
|
+
const predecessor = getWorkContract(options, input.workId);
|
|
626
|
+
if (!predecessor) throw new Error(`WORK_NOT_FOUND: ${input.workId}`);
|
|
627
|
+
if (semanticWorkState(predecessor) !== 'completed') {
|
|
628
|
+
throw new Error(`CONTROLLER_RELAY_SUCCESSOR_PREDECESSOR_NOT_COMPLETED: ${predecessor.workId}:${semanticWorkState(predecessor)}`);
|
|
629
|
+
}
|
|
630
|
+
const initial = readRelayRecord(options, predecessor.workId);
|
|
631
|
+
if (!initial) throw new Error(`CONTROLLER_RELAY_ROUND_NOT_OPEN: ${predecessor.workId}`);
|
|
632
|
+
return relayLock(options, initial.value.relayScopeId, `controller-relay-bind-successor:${input.identity.controllerId}`, () => {
|
|
633
|
+
const current = readRelayRecord(options, predecessor.workId);
|
|
634
|
+
if (!current || !['claimed', 'failed'].includes(current.value.status)) {
|
|
635
|
+
throw new Error(`CONTROLLER_RELAY_SUCCESSOR_BIND_REQUIRES_ACTIVE_OR_FAILED_PRECLAIM_ROUND: ${predecessor.workId}:${current?.value.status ?? 'missing'}`);
|
|
636
|
+
}
|
|
637
|
+
const failedPreclaim = current.value.status === 'failed';
|
|
638
|
+
const expectedAuthorityId = current.value.authorityId?.trim() || '';
|
|
639
|
+
const requestedAuthorityId = input.controllerAuthorityId?.trim() || '';
|
|
640
|
+
if (expectedAuthorityId) {
|
|
641
|
+
if (!requestedAuthorityId) throw new Error(`CONTROLLER_RELAY_SUCCESSOR_BIND_AUTHORITY_REQUIRED: ${predecessor.workId}`);
|
|
642
|
+
if (requestedAuthorityId !== expectedAuthorityId) throw new Error(`CONTROLLER_RELAY_SUCCESSOR_BIND_AUTHORITY_MISMATCH: ${predecessor.workId}`);
|
|
643
|
+
const principalId = input.identity.principalId.trim();
|
|
644
|
+
if (!principalId || current.value.controllerId !== input.identity.controllerId || current.value.principalId !== principalId) {
|
|
645
|
+
throw new Error(`CONTROLLER_RELAY_SUCCESSOR_BIND_AUTHORITY_MISMATCH: ${predecessor.workId}`);
|
|
646
|
+
}
|
|
647
|
+
if (relayControllerType(current.value) !== input.identity.controllerType) {
|
|
648
|
+
throw new Error(`CONTROLLER_RELAY_SUCCESSOR_BIND_AUTHORITY_MISMATCH: ${predecessor.workId}`);
|
|
649
|
+
}
|
|
650
|
+
const liveOwner = getControllerSession(options, predecessor.workId);
|
|
651
|
+
if (failedPreclaim) {
|
|
652
|
+
if (current.value.lifecycleStage !== 'dispatching' || current.value.claimGeneration !== 0 || liveOwner) {
|
|
653
|
+
throw new Error(`CONTROLLER_RELAY_FAILED_SUCCESSOR_HANDOFF_PRECLAIM_REQUIRED: ${predecessor.workId}`);
|
|
654
|
+
}
|
|
655
|
+
} else {
|
|
656
|
+
if (current.value.claimGeneration < 1) {
|
|
657
|
+
throw new Error(`CONTROLLER_RELAY_SUCCESSOR_BIND_AUTHORITY_MISMATCH: ${predecessor.workId}`);
|
|
658
|
+
}
|
|
659
|
+
if (liveOwner) {
|
|
660
|
+
const livePrincipal = liveOwner.principalId?.trim() || liveOwner.controllerId;
|
|
661
|
+
if (liveOwner.controllerId !== input.identity.controllerId
|
|
662
|
+
|| liveOwner.controllerType !== input.identity.controllerType
|
|
663
|
+
|| livePrincipal !== principalId) {
|
|
664
|
+
throw new Error(`CONTROLLER_RELAY_SUCCESSOR_BIND_AUTHORITY_MISMATCH: ${predecessor.workId}`);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
} else {
|
|
669
|
+
if (failedPreclaim) throw new Error(`CONTROLLER_RELAY_SUCCESSOR_BIND_AUTHORITY_REQUIRED: ${predecessor.workId}`);
|
|
670
|
+
// Legacy claimed rounds have no opaque per-round capability. Preserve only
|
|
671
|
+
// their exact live owner epoch; rollover is available after the round has
|
|
672
|
+
// been upgraded by the canonical launcher/recovery path.
|
|
673
|
+
const owner = assertControllerOwner(options, predecessor.workId, input.identity);
|
|
674
|
+
if (current.value.controllerId !== owner.controllerId
|
|
675
|
+
|| current.value.principalId !== (owner.principalId?.trim() || owner.controllerId)
|
|
676
|
+
|| current.value.claimGeneration !== owner.claimGeneration) {
|
|
677
|
+
throw new Error(`CONTROLLER_RELAY_SUCCESSOR_BIND_AUTHORITY_MISMATCH: ${predecessor.workId}`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
const successor = assertControllerRoundSuccessorLineage(options, predecessor, input.successorWorkId.trim());
|
|
681
|
+
if (failedPreclaim) {
|
|
682
|
+
const at = nowIso(options);
|
|
683
|
+
const successorStateFingerprint = mechanicalStateFingerprint(options, successor, current.value.requirementId, current.value.relayScopeId, current.value.handoffId);
|
|
684
|
+
return withControlPlaneTransaction(options.controllerHome, (database) => {
|
|
685
|
+
const predecessorRelay = readControlPlaneRecordWithinTransaction<ControllerRoundRelayRecord>(
|
|
686
|
+
database, NAMESPACE, options.repoId, predecessor.workId,
|
|
687
|
+
);
|
|
688
|
+
if (!predecessorRelay || predecessorRelay.value.status !== 'failed' || predecessorRelay.value.claimGeneration !== 0) {
|
|
689
|
+
throw new Error(`CONTROLLER_RELAY_FAILED_SUCCESSOR_HANDOFF_STALE: ${predecessor.workId}`);
|
|
690
|
+
}
|
|
691
|
+
const existingSuccessorRelay = readControlPlaneRecordWithinTransaction<ControllerRoundRelayRecord>(
|
|
692
|
+
database, NAMESPACE, options.repoId, successor.workId,
|
|
693
|
+
);
|
|
694
|
+
if (existingSuccessorRelay) {
|
|
695
|
+
if (existingSuccessorRelay.value.relayScopeId === current.value.relayScopeId
|
|
696
|
+
&& ['dispatching', 'dispatched', 'claimed'].includes(existingSuccessorRelay.value.status)) {
|
|
697
|
+
return existingSuccessorRelay.value;
|
|
698
|
+
}
|
|
699
|
+
throw new Error(`CONTROLLER_RELAY_SUCCESSOR_ALREADY_HAS_ROUND: ${successor.workId}`);
|
|
700
|
+
}
|
|
701
|
+
const decision = atomicTransitionDecisionOrThrow(decideControllerRoundTransition(predecessorRelay.value, {
|
|
702
|
+
type: 'failed_dispatch_successor_handoff',
|
|
703
|
+
at,
|
|
704
|
+
successorWorkId: successor.workId,
|
|
705
|
+
successorStateFingerprint,
|
|
706
|
+
proposedAuthorityId: newControllerRoundAuthorityId(),
|
|
707
|
+
}));
|
|
708
|
+
writeControlPlaneRecordWithinTransaction(database, {
|
|
709
|
+
namespace: NAMESPACE, scope: options.repoId, key: predecessor.workId, schemaVersion: SCHEMA_VERSION,
|
|
710
|
+
value: decision.next, action: decision.action, expectedRevision: predecessorRelay.revision,
|
|
711
|
+
});
|
|
712
|
+
writeControlPlaneRecordWithinTransaction(database, {
|
|
713
|
+
namespace: NAMESPACE, scope: options.repoId, key: decision.relatedWorkId, schemaVersion: SCHEMA_VERSION,
|
|
714
|
+
value: decision.relatedNext, action: decision.relatedAction, expectedRevision: null,
|
|
715
|
+
});
|
|
716
|
+
return decision.relatedNext;
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
return applyControllerRoundTransition(options, current, {
|
|
720
|
+
type: 'successor_bound', at: nowIso(options), successorWorkId: successor.workId,
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function sameAssistantContextSnapshot(
|
|
726
|
+
left: AssistantContextSnapshot | undefined,
|
|
727
|
+
right: AssistantContextSnapshot | null | undefined,
|
|
728
|
+
): boolean {
|
|
729
|
+
if (right === undefined) return true;
|
|
730
|
+
if (right === null) return left === undefined;
|
|
731
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function validatedAssistantContextUsage(
|
|
735
|
+
record: ControllerRoundRelayRecord,
|
|
736
|
+
input: SubmitControllerRoundDispositionInput,
|
|
737
|
+
): AssistantContextUsage[] {
|
|
738
|
+
const usage = input.assistantContextUsage ?? [];
|
|
739
|
+
if (!Array.isArray(usage) || usage.length > 32) throw new Error('CONTROLLER_ASSISTANT_CONTEXT_USAGE_LIMIT');
|
|
740
|
+
const snapshot = record.assistantContextSnapshot;
|
|
741
|
+
const digest = input.assistantContextDigest?.trim();
|
|
742
|
+
if (usage.length > 0 && (!snapshot || !digest)) throw new Error('CONTROLLER_ASSISTANT_CONTEXT_SNAPSHOT_REQUIRED');
|
|
743
|
+
if (digest && (!snapshot || digest !== snapshot.digest)) throw new Error('CONTROLLER_ASSISTANT_CONTEXT_DIGEST_MISMATCH');
|
|
744
|
+
if (!usage.length) return [];
|
|
745
|
+
const expected = new Map(snapshot!.items.map((item) => [`${item.kind}:${item.itemId}`, item]));
|
|
746
|
+
const seen = new Set<string>();
|
|
747
|
+
for (const item of usage) {
|
|
748
|
+
const key = `${item.kind}:${item.itemId}`;
|
|
749
|
+
if (!expected.has(key) || seen.has(key) || !['used', 'rejected'].includes(item.decision)
|
|
750
|
+
|| typeof item.reason !== 'string' || !item.reason.trim() || item.reason.length > 1_000
|
|
751
|
+
|| (item.rejectionKind !== undefined && !['irrelevant', 'stale', 'contradicted'].includes(item.rejectionKind))
|
|
752
|
+
|| (item.decision === 'used' && item.rejectionKind !== undefined)) {
|
|
753
|
+
throw new Error('CONTROLLER_ASSISTANT_CONTEXT_USAGE_INVALID');
|
|
754
|
+
}
|
|
755
|
+
seen.add(key);
|
|
756
|
+
}
|
|
757
|
+
if (seen.size !== expected.size) throw new Error('CONTROLLER_ASSISTANT_CONTEXT_USAGE_INCOMPLETE');
|
|
758
|
+
return usage.map((item) => ({
|
|
759
|
+
...item,
|
|
760
|
+
reason: item.reason.trim(),
|
|
761
|
+
// Missing rejectionKind is accepted only for frozen clients and means ordinary
|
|
762
|
+
// irrelevance. Forge never derives stale/contradicted semantics from prose.
|
|
763
|
+
...(item.decision === 'rejected' ? { rejectionKind: item.rejectionKind ?? 'irrelevant' as const } : {}),
|
|
764
|
+
}));
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function persistClaimedAssistantContextSnapshot(
|
|
768
|
+
options: ControllerRoundRelayStoreOptions,
|
|
769
|
+
record: ControllerRoundRelayRecord,
|
|
770
|
+
requested: AssistantContextSnapshot | null | undefined,
|
|
771
|
+
at: string,
|
|
772
|
+
): ControllerRoundRelayRecord {
|
|
773
|
+
if (requested === undefined || sameAssistantContextSnapshot(record.assistantContextSnapshot, requested)) return record;
|
|
774
|
+
if (record.assistantContextSnapshot) {
|
|
775
|
+
throw new Error(`CONTROLLER_ASSISTANT_CONTEXT_CLAIM_MISMATCH: ${record.originWorkId}`);
|
|
776
|
+
}
|
|
777
|
+
if (requested === null) return record;
|
|
778
|
+
const current = readRelayRecord(options, record.originWorkId);
|
|
779
|
+
if (!current || current.value.relayScopeId !== record.relayScopeId || current.value.status !== 'claimed'
|
|
780
|
+
|| current.value.claimGeneration !== record.claimGeneration) {
|
|
781
|
+
throw new Error(`CONTROLLER_ASSISTANT_CONTEXT_CLAIM_STALE: ${record.originWorkId}`);
|
|
782
|
+
}
|
|
783
|
+
if (current.value.assistantContextSnapshot) {
|
|
784
|
+
if (sameAssistantContextSnapshot(current.value.assistantContextSnapshot, requested)) return current.value;
|
|
785
|
+
throw new Error(`CONTROLLER_ASSISTANT_CONTEXT_CLAIM_MISMATCH: ${record.originWorkId}`);
|
|
786
|
+
}
|
|
787
|
+
const next: ControllerRoundRelayRecord = { ...current.value, assistantContextSnapshot: requested, updatedAt: at };
|
|
788
|
+
writeControlPlaneRecord(options.controllerHome, {
|
|
789
|
+
namespace: NAMESPACE, scope: options.repoId, key: record.originWorkId, schemaVersion: SCHEMA_VERSION, value: next,
|
|
790
|
+
action: 'controller_round_assistant_context_bound', expectedRevision: current.revision,
|
|
791
|
+
});
|
|
792
|
+
return next;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function pendingQualitySignals(record: ControllerRoundRelayRecord): ExecutionQualitySignal[] {
|
|
796
|
+
const handled = new Set(record.qualityDecisions?.map(decision => decision.fingerprint) ?? []);
|
|
797
|
+
return deriveClosedRoundQualitySignals(record.observationWindow ?? [], { repeatedStateCount: record.repeatedStateCount,
|
|
798
|
+
maxRepeatedState: record.maxRepeatedState, waiting: record.status === 'waiting' || record.status === 'waiting_for_user',
|
|
799
|
+
roundRef: `${record.relayScopeId}:${record.roundCount}` }).map(signal => ({ ...signal,
|
|
800
|
+
fingerprint: createHash('sha256').update(JSON.stringify([signal.code, [...signal.evidenceRefs].sort()])).digest('hex'),
|
|
801
|
+
})).filter(signal => !handled.has(signal.fingerprint));
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function closedRoundObservation(work: WorkContract, roundRef: string, stateFingerprint: string, waiting: boolean, assistantContext: AssistantContextSnapshot | undefined, assistantContextUsage: AssistantContextUsage[]): ClosedRoundObservation {
|
|
805
|
+
const coverageGaps: string[] = [];
|
|
806
|
+
const verifications: ClosedRoundObservation['verifications'] = [];
|
|
807
|
+
for (const check of work.checkRefs.slice(-32)) {
|
|
808
|
+
const receipt = check.receipt;
|
|
809
|
+
// HEAD alone does not identify dirty source. Require the exact input fingerprint.
|
|
810
|
+
if (!receipt || !check.verificationInputFingerprint || !receipt.checkDefinitionDigest || !receipt.checkEnvironmentFingerprint) {
|
|
811
|
+
coverageGaps.push('check_identity_incomplete'); continue;
|
|
812
|
+
}
|
|
813
|
+
if (receipt.status !== 'passed' && receipt.status !== 'failed') continue;
|
|
814
|
+
verifications.push({ checkId: `${receipt.checkId}:${receipt.checkDefinitionDigest}`, sourceDigest: check.verificationInputFingerprint,
|
|
815
|
+
environment: receipt.checkEnvironmentFingerprint, outcome: receipt.status, evidenceRef: receipt.receiptId });
|
|
816
|
+
}
|
|
817
|
+
// Generic evidence references without content identity cannot prove absence of new knowledge.
|
|
818
|
+
if (work.evidenceRefs.length) coverageGaps.push('generic_evidence_content_identity_unavailable');
|
|
819
|
+
if (work.checkRefs.length > 32) coverageGaps.push('check_window_truncated');
|
|
820
|
+
if (!assistantContext) coverageGaps.push('assistant_context_snapshot_unavailable');
|
|
821
|
+
else if (assistantContext.items.length > 0 && assistantContextUsage.length === 0) coverageGaps.push('assistant_context_usage_unreported');
|
|
822
|
+
const accepted = work.semanticAcceptanceEvidence ?? [];
|
|
823
|
+
if (accepted.length > 32) coverageGaps.push('accepted_result_window_truncated');
|
|
824
|
+
return { roundRef, workId: work.workId, requirementId: work.requirementId, planId: work.planId,
|
|
825
|
+
designVersion: work.engineeringContext?.sourceIdentity.kind === 'revision' ? work.engineeringContext.sourceIdentity.revision : undefined,
|
|
826
|
+
rootCauses: (work.engineeringContext?.blockerDispositions ?? []).slice(-32).filter(disposition => disposition.classification === 'same_root_cause')
|
|
827
|
+
.map(disposition => ({ dispositionRef: disposition.receiptId, rootCauseId: disposition.blockerId,
|
|
828
|
+
designScope: JSON.stringify([...disposition.semanticScopeKeys].sort()), controllerConfirmed: true })),
|
|
829
|
+
stateFingerprint, waiting, coverageGaps: [...new Set(coverageGaps)], verifications, assistantContext, assistantContextUsage,
|
|
830
|
+
evidenceIdentities: work.checkRefs.slice(-32).flatMap(check => check.receipt ? [check.receipt.resultDigest] : []),
|
|
831
|
+
acceptedResultIdentities: accepted.slice(-32).map(result => createHash('sha256').update(JSON.stringify([result.criterion, [...result.evidenceIds].sort()])).digest('hex')) };
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
export function submitControllerRoundDisposition(
|
|
835
|
+
options: ControllerRoundRelayStoreOptions,
|
|
836
|
+
input: SubmitControllerRoundDispositionInput,
|
|
837
|
+
): ControllerRoundRelayRecord {
|
|
838
|
+
if (!CONTROLLER_ROUND_DISPOSITIONS.includes(input.disposition)) throw new Error('CONTROLLER_RELAY_DISPOSITION_INVALID');
|
|
839
|
+
const work = getWorkContract(options, input.workId);
|
|
840
|
+
if (!work) throw new Error(`WORK_NOT_FOUND: ${input.workId}`);
|
|
841
|
+
const requirementId = resolveRequirementId(options, work, input.requirementId);
|
|
842
|
+
const relayScopeId = resolveRelayScope(work, requirementId, input.relayScopeId);
|
|
843
|
+
const terminal = semanticWorkState(work) !== 'open';
|
|
844
|
+
|
|
845
|
+
return relayLock(options, relayScopeId, `controller-relay-submit:${input.identity.controllerId}`, () => {
|
|
846
|
+
const existing = readRelayRecord(options, work.workId);
|
|
847
|
+
if (!existing) throw new Error(`CONTROLLER_RELAY_ROUND_NOT_OPEN: ${work.workId}`);
|
|
848
|
+
if (existing.value.relayScopeId !== relayScopeId) {
|
|
849
|
+
throw new Error(`CONTROLLER_RELAY_SCOPE_MISMATCH: Work ${work.workId} is already bound to ${existing.value.relayScopeId}`);
|
|
850
|
+
}
|
|
851
|
+
const terminalAuthorityId = bounded(input.terminalAuthorityId, 256);
|
|
852
|
+
const providerWaitTerminalGoalComplete = terminal
|
|
853
|
+
&& semanticWorkState(work) === 'completed'
|
|
854
|
+
&& input.disposition === 'goal_complete'
|
|
855
|
+
&& existing.value.status === 'waiting_for_user'
|
|
856
|
+
&& controllerRoundBlockerClass(existing.value) === 'provider_user_action_required';
|
|
857
|
+
if (providerWaitTerminalGoalComplete) {
|
|
858
|
+
if (!terminalAuthorityId || terminalAuthorityId !== existing.value.authorityId) {
|
|
859
|
+
throw new Error(`CONTROLLER_RELAY_TERMINAL_AUTHORITY_MISMATCH: ${work.workId}`);
|
|
860
|
+
}
|
|
861
|
+
} else if (existing.value.status !== 'claimed') {
|
|
862
|
+
throw new Error(`CONTROLLER_RELAY_ROUND_NOT_CLAIMED: ${existing.value.status}`);
|
|
863
|
+
}
|
|
864
|
+
const terminalSuccessor = terminal
|
|
865
|
+
&& semanticWorkState(work) === 'completed'
|
|
866
|
+
&& input.disposition === 'continue_immediately'
|
|
867
|
+
&& existing.value.successorWorkId
|
|
868
|
+
? assertControllerRoundSuccessorLineage(options, work, existing.value.successorWorkId)
|
|
869
|
+
: undefined;
|
|
870
|
+
const terminalRoundClosureAllowed = terminal
|
|
871
|
+
&& semanticWorkState(work) === 'completed'
|
|
872
|
+
&& (input.disposition === 'goal_complete' || Boolean(terminalSuccessor));
|
|
873
|
+
if (terminal && !terminalRoundClosureAllowed) {
|
|
874
|
+
throw new Error(`CONTROLLER_RELAY_WORK_TERMINAL: ${semanticWorkState(work)}`);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const liveOwner = getControllerSession(options, work.workId);
|
|
878
|
+
const authority = terminal
|
|
879
|
+
? (() => {
|
|
880
|
+
// The physical Work may be completed while its old live lease still exists.
|
|
881
|
+
// goal_complete is semantic round closure, not a terminal Work reclaim: use
|
|
882
|
+
// the authenticated controller/principal plus the already-claimed relay as
|
|
883
|
+
// authority and allow MCP session / Runtime instance rotation. If a live
|
|
884
|
+
// owner remains, it must still agree on controller type and principal.
|
|
885
|
+
const principalId = input.identity.principalId.trim();
|
|
886
|
+
const controllerInstanceId = input.identity.controllerInstanceId.trim();
|
|
887
|
+
const sessionId = input.identity.sessionId.trim();
|
|
888
|
+
if (!principalId || !controllerInstanceId || !sessionId) throw new Error(`CONTROLLER_RELAY_TERMINAL_AUTHORITY_REQUIRED: ${work.workId}`);
|
|
889
|
+
const expectedControllerType = relayControllerType(existing.value);
|
|
890
|
+
if (input.identity.controllerType !== expectedControllerType) throw new Error(`CONTROLLER_RELAY_CONTROLLER_TYPE_MISMATCH: ${work.workId}`);
|
|
891
|
+
if (liveOwner) {
|
|
892
|
+
if (liveOwner.controllerType !== expectedControllerType) throw new Error(`CONTROLLER_RELAY_CONTROLLER_TYPE_MISMATCH: ${work.workId}`);
|
|
893
|
+
if (liveOwner.controllerId !== input.identity.controllerId) throw new Error(`WORK_CONTROLLER_OWNER_MISMATCH: ${work.workId}`);
|
|
894
|
+
if ((liveOwner.principalId?.trim() || liveOwner.controllerId) !== principalId) {
|
|
895
|
+
throw new Error(`WORK_CONTROLLER_PRINCIPAL_MISMATCH: ${work.workId}`);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
if (existing.value.controllerId !== input.identity.controllerId) throw new Error(`CONTROLLER_RELAY_CLAIM_CONTROLLER_MISMATCH: ${work.workId}`);
|
|
899
|
+
if (existing.value.principalId !== principalId) throw new Error(`CONTROLLER_RELAY_CLAIM_PRINCIPAL_MISMATCH: ${work.workId}`);
|
|
900
|
+
if (existing.value.claimGeneration < 1 && !providerWaitTerminalGoalComplete) throw new Error(`CONTROLLER_RELAY_CLAIM_GENERATION_REQUIRED: ${work.workId}`);
|
|
901
|
+
return {
|
|
902
|
+
controllerId: existing.value.controllerId,
|
|
903
|
+
controllerType: expectedControllerType,
|
|
904
|
+
principalId: existing.value.principalId,
|
|
905
|
+
controllerInstanceId,
|
|
906
|
+
sessionId,
|
|
907
|
+
claimGeneration: existing.value.claimGeneration,
|
|
908
|
+
};
|
|
909
|
+
})()
|
|
910
|
+
: (() => {
|
|
911
|
+
const owner = assertControllerOwner(options, work.workId, input.identity);
|
|
912
|
+
return {
|
|
913
|
+
controllerId: owner.controllerId,
|
|
914
|
+
controllerType: owner.controllerType,
|
|
915
|
+
principalId: owner.principalId?.trim() || owner.controllerId,
|
|
916
|
+
controllerInstanceId: owner.controllerInstanceId?.trim() || '',
|
|
917
|
+
sessionId: owner.sessionId,
|
|
918
|
+
claimGeneration: owner.claimGeneration,
|
|
919
|
+
};
|
|
920
|
+
})();
|
|
921
|
+
if (existing.value.controllerId !== authority.controllerId) {
|
|
922
|
+
throw new Error(`CONTROLLER_RELAY_CLAIM_CONTROLLER_MISMATCH: ${work.workId}`);
|
|
923
|
+
}
|
|
924
|
+
if (existing.value.principalId !== authority.principalId) {
|
|
925
|
+
throw new Error(`CONTROLLER_RELAY_CLAIM_PRINCIPAL_MISMATCH: ${work.workId}`);
|
|
926
|
+
}
|
|
927
|
+
// A completed Work may have already released its live controller session before
|
|
928
|
+
// the prompt-required terminal disposition arrives. At that point the exact
|
|
929
|
+
// authenticated controller/principal + Work + relay scope remain the lineage
|
|
930
|
+
// fence; MCP session and canonical Runtime instance are allowed to rotate.
|
|
931
|
+
const terminalLineageMigration = terminal;
|
|
932
|
+
if (!terminalLineageMigration && existing.value.controllerInstanceId !== authority.controllerInstanceId) {
|
|
933
|
+
throw new Error(`CONTROLLER_RELAY_CLAIM_INSTANCE_MISMATCH: ${work.workId}`);
|
|
934
|
+
}
|
|
935
|
+
if (!terminalLineageMigration && existing.value.claimGeneration !== authority.claimGeneration) {
|
|
936
|
+
throw new Error(`CONTROLLER_RELAY_CLAIM_GENERATION_MISMATCH: ${work.workId}`);
|
|
937
|
+
}
|
|
938
|
+
const previous = relayHistory(options, relayScopeId)[0];
|
|
939
|
+
const assistantContextUsage = validatedAssistantContextUsage(existing.value, input);
|
|
940
|
+
const at = nowIso(options);
|
|
941
|
+
const qualityDecisions = input.executionQualityDecisions ?? [];
|
|
942
|
+
if (!Array.isArray(qualityDecisions) || qualityDecisions.length > 8) throw new Error('CONTROLLER_QUALITY_DECISION_LIMIT');
|
|
943
|
+
const pending = new Set(pendingQualitySignals(existing.value).map(signal => signal.fingerprint));
|
|
944
|
+
const normalizedQualityDecisions: ExecutionQualityDecision[] = [];
|
|
945
|
+
for (const decision of qualityDecisions) {
|
|
946
|
+
if (!pending.has(decision.fingerprint) || !['no_adjustment', 'adjustment'].includes(decision.action)
|
|
947
|
+
|| typeof decision.reason !== 'string' || !decision.reason.trim() || decision.reason.length > 1000
|
|
948
|
+
|| decision.action === 'adjustment' && (typeof decision.verificationCondition !== 'string' || !decision.verificationCondition.trim() || decision.verificationCondition.length > 1000)) {
|
|
949
|
+
throw new Error('CONTROLLER_QUALITY_DECISION_INVALID');
|
|
950
|
+
}
|
|
951
|
+
normalizedQualityDecisions.push({ ...decision, decidedAt: at });
|
|
952
|
+
pending.delete(decision.fingerprint);
|
|
953
|
+
}
|
|
954
|
+
const knownDecisions = [...(existing.value.qualityDecisions ?? []), ...normalizedQualityDecisions];
|
|
955
|
+
const submittedAdjustmentResults = input.executionQualityAdjustmentResults ?? [];
|
|
956
|
+
if (!Array.isArray(submittedAdjustmentResults) || submittedAdjustmentResults.length > 8) throw new Error('CONTROLLER_QUALITY_ADJUSTMENT_RESULT_LIMIT');
|
|
957
|
+
const knownResultFingerprints = new Set((existing.value.qualityAdjustmentResults ?? []).map(result => result.fingerprint));
|
|
958
|
+
const normalizedAdjustmentResults: ExecutionQualityAdjustmentResult[] = [];
|
|
959
|
+
for (const submitted of submittedAdjustmentResults) {
|
|
960
|
+
const decision = knownDecisions.find(candidate => candidate.fingerprint === submitted.fingerprint);
|
|
961
|
+
const refs = Array.isArray(submitted.evidenceRefs) ? submitted.evidenceRefs.map(ref => String(ref).trim()).filter(Boolean) : [];
|
|
962
|
+
if (!decision || decision.action !== 'adjustment' || !decision.decidedAt
|
|
963
|
+
|| !['improved', 'not_improved', 'inconclusive'].includes(submitted.outcome)
|
|
964
|
+
|| typeof submitted.reason !== 'string' || !submitted.reason.trim() || submitted.reason.length > 1000
|
|
965
|
+
|| refs.length < 1 || refs.length > 16 || new Set(refs).size !== refs.length
|
|
966
|
+
|| knownResultFingerprints.has(submitted.fingerprint)) {
|
|
967
|
+
throw new Error('CONTROLLER_QUALITY_ADJUSTMENT_RESULT_INVALID');
|
|
968
|
+
}
|
|
969
|
+
const decidedAt = Date.parse(decision.decidedAt);
|
|
970
|
+
if (!Number.isFinite(decidedAt) || refs.some(ref => {
|
|
971
|
+
const check = work.checkRefs.find(candidate => candidate.receipt?.receiptId === ref);
|
|
972
|
+
return !check || !check.receipt || !Number.isFinite(Date.parse(check.recordedAt)) || Date.parse(check.recordedAt) <= decidedAt;
|
|
973
|
+
})) throw new Error('CONTROLLER_QUALITY_ADJUSTMENT_VERIFICATION_EVIDENCE_INVALID');
|
|
974
|
+
normalizedAdjustmentResults.push({ fingerprint: submitted.fingerprint, outcome: submitted.outcome, evidenceRefs: refs, reason: submitted.reason.trim(), verifiedAt: at });
|
|
975
|
+
knownResultFingerprints.add(submitted.fingerprint);
|
|
976
|
+
}
|
|
977
|
+
const requestedMaxRounds = boundedInteger(input.maxRounds, DEFAULT_MAX_ROUNDS, 1, 32);
|
|
978
|
+
const requestedMaxRepeatedState = boundedInteger(input.maxRepeatedState, DEFAULT_MAX_REPEATED_STATE, 1, 8);
|
|
979
|
+
const requestedMaxFailures = boundedInteger(input.maxFailures, DEFAULT_MAX_FAILURES, 1, 8);
|
|
980
|
+
const handoffId = bounded(input.handoffId, 200);
|
|
981
|
+
const stateFingerprint = bounded(input.stateFingerprint, 256)
|
|
982
|
+
?? mechanicalStateFingerprint(options, terminalSuccessor ?? work, requirementId, relayScopeId, handoffId ?? existing.value.handoffId);
|
|
983
|
+
const bindingId = bounded(input.bindingId, 500) ?? existing.value.bindingId;
|
|
984
|
+
if (input.disposition === 'wait_for_user') {
|
|
985
|
+
if (!handoffId) throw new Error('CONTROLLER_RELAY_WAIT_FOR_USER_HANDOFF_REQUIRED');
|
|
986
|
+
const handoff = getHandoffItem(options, handoffId);
|
|
987
|
+
if (!handoff) throw new Error(`HANDOFF_NOT_FOUND: ${handoffId}`);
|
|
988
|
+
if (isTerminalHandoffStatus(handoff.status)) throw new Error(`CONTROLLER_RELAY_HANDOFF_TERMINAL: ${handoff.status}`);
|
|
989
|
+
if (handoff.workId && handoff.workId !== work.workId) throw new Error(`CONTROLLER_RELAY_HANDOFF_WORK_MISMATCH: ${handoffId}`);
|
|
990
|
+
}
|
|
991
|
+
const observationWindow = [
|
|
992
|
+
...(existing.value.observationWindow ?? []),
|
|
993
|
+
closedRoundObservation(work, `${relayScopeId}:${existing.value.roundCount}`, stateFingerprint, input.disposition === 'wait' || input.disposition === 'wait_for_user', existing.value.assistantContextSnapshot, assistantContextUsage),
|
|
994
|
+
].slice(-8);
|
|
995
|
+
const accumulatedQualityDecisions = [...(existing.value.qualityDecisions ?? []), ...normalizedQualityDecisions].slice(-64);
|
|
996
|
+
const accumulatedQualityAdjustmentResults = [...(existing.value.qualityAdjustmentResults ?? []), ...normalizedAdjustmentResults].slice(-64);
|
|
997
|
+
return applyControllerRoundTransition(options, existing, {
|
|
998
|
+
type: 'semantic_disposition_submitted', at, disposition: input.disposition, stateFingerprint,
|
|
999
|
+
maxRounds: requestedMaxRounds, maxRepeatedState: requestedMaxRepeatedState, maxFailures: requestedMaxFailures,
|
|
1000
|
+
controllerSession: authority,
|
|
1001
|
+
...(providerWaitTerminalGoalComplete ? { terminalGoalComplete: true } : {}),
|
|
1002
|
+
qualityDecisions: accumulatedQualityDecisions, qualityAdjustmentResults: accumulatedQualityAdjustmentResults, observationWindow,
|
|
1003
|
+
...(handoffId ? { handoffId } : {}),
|
|
1004
|
+
...(bounded(input.reason, 1_000) ? { reason: bounded(input.reason, 1_000) } : {}),
|
|
1005
|
+
...(bindingId ? { bindingId } : {}),
|
|
1006
|
+
});
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* Observe that the exact provider Controller turn has settled. If the Work is
|
|
1012
|
+
* still nonterminal and the Controller did not explicitly close the round,
|
|
1013
|
+
* canonical Kernel policy creates the continuation obligation. Provider
|
|
1014
|
+
* adapters supply only completion evidence; they do not choose the disposition.
|
|
1015
|
+
*/
|
|
1016
|
+
export function settleControllerRoundAfterTurn(
|
|
1017
|
+
options: ControllerRoundRelayStoreOptions,
|
|
1018
|
+
input: { workId: string; completionEvidenceId: string },
|
|
1019
|
+
): ControllerRoundRelayRecord | undefined {
|
|
1020
|
+
const initial = readRelayRecord(options, input.workId);
|
|
1021
|
+
if (!initial) return undefined;
|
|
1022
|
+
return relayLock(options, initial.value.relayScopeId, `controller-turn-settled:${input.workId}`, () => {
|
|
1023
|
+
const current = readRelayRecord(options, input.workId);
|
|
1024
|
+
if (!current) return undefined;
|
|
1025
|
+
const completionEvidenceId = bounded(input.completionEvidenceId, 500);
|
|
1026
|
+
if (!completionEvidenceId) throw new Error('CONTROLLER_RELAY_TURN_COMPLETION_EVIDENCE_REQUIRED');
|
|
1027
|
+
const work = getWorkContract(options, input.workId);
|
|
1028
|
+
if (!work) throw new Error(`WORK_NOT_FOUND: ${input.workId}`);
|
|
1029
|
+
const at = nowIso(options);
|
|
1030
|
+
const successor = semanticWorkState(work) === 'completed' && current.value.successorWorkId
|
|
1031
|
+
? getWorkContract(options, current.value.successorWorkId)
|
|
1032
|
+
: undefined;
|
|
1033
|
+
const terminalSuccessorContinuation = Boolean(successor && semanticWorkState(successor) === 'open');
|
|
1034
|
+
if (semanticWorkState(work) !== 'open' && !terminalSuccessorContinuation) {
|
|
1035
|
+
return applyControllerRoundTransition(options, current, {
|
|
1036
|
+
type: 'terminal_work_observed', at, error: `Controller turn settled after terminal Work ${semanticWorkState(work)}`,
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
const semanticWork = successor ?? work;
|
|
1040
|
+
const blockingHandoff = relevantHandoffs(options, relevantWork(options, current.value), current.value.handoffId)
|
|
1041
|
+
.find((handoff) => Boolean(handoff.blockingDecision?.trim())
|
|
1042
|
+
&& (handoff.workId === semanticWork.workId || handoff.id === current.value.handoffId));
|
|
1043
|
+
const stateFingerprint = mechanicalStateFingerprint(options, semanticWork, current.value.requirementId, current.value.relayScopeId, current.value.handoffId);
|
|
1044
|
+
return applyControllerRoundTransition(options, current, {
|
|
1045
|
+
type: 'controller_turn_settled', at, stateFingerprint, completionEvidenceId,
|
|
1046
|
+
...(blockingHandoff ? { blockingHandoffId: blockingHandoff.id } : {}),
|
|
1047
|
+
});
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
export function beginInitialControllerRoundDispatch(
|
|
1052
|
+
options: ControllerRoundRelayStoreOptions,
|
|
1053
|
+
input: BeginInitialControllerRoundDispatchInput,
|
|
1054
|
+
): ControllerRoundRelayRecord {
|
|
1055
|
+
const work = getWorkContract(options, input.workId);
|
|
1056
|
+
if (!work) throw new Error(`WORK_NOT_FOUND: ${input.workId}`);
|
|
1057
|
+
if (semanticWorkState(work) !== 'open') throw new Error(`CONTROLLER_RELAY_WORK_TERMINAL: ${semanticWorkState(work)}`);
|
|
1058
|
+
const requirementId = resolveRequirementId(options, work, input.requirementId);
|
|
1059
|
+
const requirement = requirementForRelay(options, requirementId);
|
|
1060
|
+
if (requirement && !['planned', 'active'].includes(requirement.state)) {
|
|
1061
|
+
throw new Error(`CONTROLLER_RELAY_REQUIREMENT_TERMINAL: ${requirement.state}`);
|
|
1062
|
+
}
|
|
1063
|
+
const relayScopeId = resolveRelayScope(work, requirementId, input.relayScopeId);
|
|
1064
|
+
const occurrenceId = bounded(input.occurrenceId, 500);
|
|
1065
|
+
|
|
1066
|
+
return relayLock(options, relayScopeId, `controller-relay-launch:${input.identity.controllerId}`, () => {
|
|
1067
|
+
const existing = readRelayRecord(options, work.workId);
|
|
1068
|
+
if (existing && existing.value.relayScopeId !== relayScopeId) {
|
|
1069
|
+
throw new Error(`CONTROLLER_RELAY_SCOPE_MISMATCH: Work ${work.workId} is already bound to ${existing.value.relayScopeId}`);
|
|
1070
|
+
}
|
|
1071
|
+
if (existing) {
|
|
1072
|
+
const requestedControllerId = input.identity.controllerId.trim().slice(0, 240) || 'controller-host';
|
|
1073
|
+
const requestedPrincipalId = input.identity.principalId.trim().slice(0, 240) || requestedControllerId;
|
|
1074
|
+
const reusableUnsubmittedRelay = existing.value.originWorkId === work.workId
|
|
1075
|
+
&& ['pending_release', 'dispatching'].includes(existing.value.status)
|
|
1076
|
+
&& existing.value.controllerId === requestedControllerId
|
|
1077
|
+
&& relayControllerType(existing.value) === input.identity.controllerType
|
|
1078
|
+
&& existing.value.principalId === requestedPrincipalId
|
|
1079
|
+
&& Boolean(existing.value.authorityId?.trim())
|
|
1080
|
+
&& !existing.value.providerDispatchEffectId
|
|
1081
|
+
&& !existing.value.providerDispatchStartedAt
|
|
1082
|
+
&& !existing.value.providerDispatchReceiptId
|
|
1083
|
+
&& (existing.value.providerDispatchAttempt ?? 0) === 0
|
|
1084
|
+
&& (existing.value.occurrenceId ?? '') === (occurrenceId ?? '');
|
|
1085
|
+
if (reusableUnsubmittedRelay) return existing.value;
|
|
1086
|
+
}
|
|
1087
|
+
const previous = relayHistory(options, relayScopeId)[0];
|
|
1088
|
+
const abandonedReleasedRound = previous?.status === 'failed' && previous.failureClass === 'abandoned_release';
|
|
1089
|
+
const stateFingerprint = mechanicalStateFingerprint(options, work, requirementId, relayScopeId);
|
|
1090
|
+
return applyControllerRoundTransition(options, existing, {
|
|
1091
|
+
type: 'occurrence_requested', at: nowIso(options), repoId: options.repoId, relayScopeId, originWorkId: work.workId,
|
|
1092
|
+
...(requirementId ? { requirementId } : {}), identity: input.identity, stateFingerprint, proposedAuthorityId: newControllerRoundAuthorityId(),
|
|
1093
|
+
maxRounds: boundedInteger(input.maxRounds, DEFAULT_MAX_ROUNDS, 1, 32), maxRepeatedState: boundedInteger(input.maxRepeatedState, DEFAULT_MAX_REPEATED_STATE, 1, 8),
|
|
1094
|
+
maxFailures: boundedInteger(input.maxFailures, DEFAULT_MAX_FAILURES, 1, 8),
|
|
1095
|
+
...(bounded(input.bindingId, 500) ? { bindingId: bounded(input.bindingId, 500) } : {}), ...(occurrenceId ? { occurrenceId } : {}),
|
|
1096
|
+
...(input.allowSemanticWaitRecovery ? { allowSemanticWaitRecovery: true } : {}),
|
|
1097
|
+
...(input.authorizeRoundBudgetOccurrence ? { authorizeRoundBudgetOccurrence: true } : {}),
|
|
1098
|
+
abandonedReleaseRecovery: Boolean(abandonedReleasedRound),
|
|
1099
|
+
});
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
export function beginControllerRoundRelayAfterRelease(
|
|
1104
|
+
options: ControllerRoundRelayStoreOptions,
|
|
1105
|
+
input: { workId: string; releasedSession: ControllerSession },
|
|
1106
|
+
): ControllerRoundRelayRecord | undefined {
|
|
1107
|
+
const initial = readRelayRecord(options, input.workId);
|
|
1108
|
+
if (!initial || initial.value.status !== 'pending_release') return undefined;
|
|
1109
|
+
return relayLock(options, initial.value.relayScopeId, `controller-relay-begin:${input.releasedSession.controllerId}`, () => {
|
|
1110
|
+
const current = readRelayRecord(options, input.workId);
|
|
1111
|
+
if (!current || current.value.status !== 'pending_release') return undefined;
|
|
1112
|
+
const record = current.value;
|
|
1113
|
+
const releasedPrincipal = input.releasedSession.principalId?.trim() || input.releasedSession.controllerId;
|
|
1114
|
+
if (
|
|
1115
|
+
record.claimGeneration !== input.releasedSession.claimGeneration
|
|
1116
|
+
|| record.controllerId !== input.releasedSession.controllerId
|
|
1117
|
+
|| record.principalId !== releasedPrincipal
|
|
1118
|
+
) {
|
|
1119
|
+
throw new Error(`CONTROLLER_RELAY_RELEASE_FENCE_MISMATCH: ${input.workId}`);
|
|
1120
|
+
}
|
|
1121
|
+
const at = nowIso(options);
|
|
1122
|
+
if (record.successorWorkId) {
|
|
1123
|
+
const predecessor = getWorkContract(options, input.workId);
|
|
1124
|
+
if (!predecessor || semanticWorkState(predecessor) !== 'completed') {
|
|
1125
|
+
throw new Error(`CONTROLLER_RELAY_SUCCESSOR_PREDECESSOR_NOT_COMPLETED: ${input.workId}:${predecessor ? semanticWorkState(predecessor) : 'missing'}`);
|
|
1126
|
+
}
|
|
1127
|
+
const successor = assertControllerRoundSuccessorLineage(options, predecessor, record.successorWorkId);
|
|
1128
|
+
const successorStateFingerprint = mechanicalStateFingerprint(options, successor, record.requirementId, record.relayScopeId, record.handoffId);
|
|
1129
|
+
return withControlPlaneTransaction(options.controllerHome, (database) => {
|
|
1130
|
+
const predecessorRelay = readControlPlaneRecordWithinTransaction<ControllerRoundRelayRecord>(
|
|
1131
|
+
database, NAMESPACE, options.repoId, input.workId,
|
|
1132
|
+
);
|
|
1133
|
+
if (!predecessorRelay || predecessorRelay.value.status !== 'pending_release' || predecessorRelay.value.successorWorkId !== successor.workId) {
|
|
1134
|
+
throw new Error(`CONTROLLER_RELAY_SUCCESSOR_HANDOFF_STALE: ${input.workId}`);
|
|
1135
|
+
}
|
|
1136
|
+
const existingSuccessorRelay = readControlPlaneRecordWithinTransaction<ControllerRoundRelayRecord>(
|
|
1137
|
+
database, NAMESPACE, options.repoId, successor.workId,
|
|
1138
|
+
);
|
|
1139
|
+
if (existingSuccessorRelay) {
|
|
1140
|
+
if (existingSuccessorRelay.value.relayScopeId === record.relayScopeId
|
|
1141
|
+
&& ['dispatching', 'dispatched', 'claimed'].includes(existingSuccessorRelay.value.status)) {
|
|
1142
|
+
return existingSuccessorRelay.value;
|
|
1143
|
+
}
|
|
1144
|
+
throw new Error(`CONTROLLER_RELAY_SUCCESSOR_ALREADY_HAS_ROUND: ${successor.workId}`);
|
|
1145
|
+
}
|
|
1146
|
+
const decision = atomicTransitionDecisionOrThrow(decideControllerRoundTransition(predecessorRelay.value, {
|
|
1147
|
+
type: 'successor_release_handoff', at, successorWorkId: successor.workId, successorStateFingerprint, proposedAuthorityId: newControllerRoundAuthorityId(),
|
|
1148
|
+
}));
|
|
1149
|
+
writeControlPlaneRecordWithinTransaction(database, {
|
|
1150
|
+
namespace: NAMESPACE, scope: options.repoId, key: predecessor.workId, schemaVersion: SCHEMA_VERSION,
|
|
1151
|
+
value: decision.next, action: decision.action, expectedRevision: predecessorRelay.revision,
|
|
1152
|
+
});
|
|
1153
|
+
writeControlPlaneRecordWithinTransaction(database, {
|
|
1154
|
+
namespace: NAMESPACE, scope: options.repoId, key: decision.relatedWorkId, schemaVersion: SCHEMA_VERSION,
|
|
1155
|
+
value: decision.relatedNext, action: decision.relatedAction, expectedRevision: null,
|
|
1156
|
+
});
|
|
1157
|
+
return decision.relatedNext;
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
return applyControllerRoundTransition(options, current, {
|
|
1161
|
+
type: 'controller_release_observed', at, proposedAuthorityId: newControllerRoundAuthorityId(),
|
|
1162
|
+
});
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Mechanically close a claimed controller round only after its exact controller
|
|
1168
|
+
* epoch has been released and durable state proves no controller lease remains.
|
|
1169
|
+
* This never invents a semantic disposition; launcher_start must open a new round.
|
|
1170
|
+
*/
|
|
1171
|
+
export function reconcileControllerRoundAfterAbandonedRelease(
|
|
1172
|
+
options: ControllerRoundRelayStoreOptions,
|
|
1173
|
+
input: { workId: string; releasedSession: ControllerSession },
|
|
1174
|
+
): ControllerRoundRelayRecord | undefined {
|
|
1175
|
+
const initial = readRelayRecord(options, input.workId);
|
|
1176
|
+
if (!initial || initial.value.status !== 'claimed') return undefined;
|
|
1177
|
+
return relayLock(options, initial.value.relayScopeId, `controller-relay-abandoned-release:${input.releasedSession.controllerId}`, () => {
|
|
1178
|
+
const current = readRelayRecord(options, input.workId);
|
|
1179
|
+
if (!current || current.value.status !== 'claimed') return undefined;
|
|
1180
|
+
const record = current.value;
|
|
1181
|
+
const releasedPrincipal = input.releasedSession.principalId?.trim() || input.releasedSession.controllerId;
|
|
1182
|
+
const releasedInstanceId = input.releasedSession.controllerInstanceId?.trim() || '';
|
|
1183
|
+
if (
|
|
1184
|
+
record.claimGeneration !== input.releasedSession.claimGeneration
|
|
1185
|
+
|| record.controllerId !== input.releasedSession.controllerId
|
|
1186
|
+
|| record.principalId !== releasedPrincipal
|
|
1187
|
+
|| record.controllerInstanceId !== releasedInstanceId
|
|
1188
|
+
) {
|
|
1189
|
+
throw new Error(`CONTROLLER_RELAY_RELEASE_FENCE_MISMATCH: ${input.workId}`);
|
|
1190
|
+
}
|
|
1191
|
+
if (getControllerSession(options, input.workId)) {
|
|
1192
|
+
throw new Error(`CONTROLLER_RELAY_ABANDONED_RELEASE_ACTIVE_CLAIM: ${input.workId}`);
|
|
1193
|
+
}
|
|
1194
|
+
const work = getWorkContract(options, input.workId);
|
|
1195
|
+
// Completed Work still requires semantic goal_complete or a valid terminal
|
|
1196
|
+
// successor. Cancelled Work has no legal semantic continuation, so
|
|
1197
|
+
// its exact released controller epoch may mechanically abandon the claimed
|
|
1198
|
+
// round and free the relay scope for later recovery/replanning.
|
|
1199
|
+
if (!work || semanticWorkState(work) === 'completed') return undefined;
|
|
1200
|
+
|
|
1201
|
+
return applyControllerRoundTransition(options, current, {
|
|
1202
|
+
type: 'abandoned_release_observed', at: nowIso(options), error: CONTROLLER_RELAY_ABANDONED_RELEASE_ERROR,
|
|
1203
|
+
});
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* Mechanically retire any surviving ControllerRound once Work lifecycle authority
|
|
1209
|
+
* is durably cancelled and no Controller lease remains. This is cleanup,
|
|
1210
|
+
* not a semantic disposition: completed Work is intentionally excluded.
|
|
1211
|
+
*/
|
|
1212
|
+
export function reconcileControllerRoundAfterTerminalWork(
|
|
1213
|
+
options: ControllerRoundRelayStoreOptions,
|
|
1214
|
+
input: { workId: string; actor?: string },
|
|
1215
|
+
): ControllerRoundRelayRecord | undefined {
|
|
1216
|
+
const initial = readRelayRecord(options, input.workId);
|
|
1217
|
+
if (!initial) return undefined;
|
|
1218
|
+
const work = getWorkContract(options, input.workId);
|
|
1219
|
+
if (!work || semanticWorkState(work) !== 'cancelled') return undefined;
|
|
1220
|
+
if (getControllerSession(options, input.workId)) {
|
|
1221
|
+
throw new Error(`CONTROLLER_RELAY_TERMINAL_WORK_ACTIVE_CLAIM: ${input.workId}`);
|
|
1222
|
+
}
|
|
1223
|
+
if (initial.value.status === 'failed') return initial.value;
|
|
1224
|
+
return relayLock(options, initial.value.relayScopeId, input.actor ?? `controller-relay-terminal-work:${input.workId}`, () => {
|
|
1225
|
+
const current = readRelayRecord(options, input.workId);
|
|
1226
|
+
if (!current) return undefined;
|
|
1227
|
+
const currentWork = getWorkContract(options, input.workId);
|
|
1228
|
+
if (!currentWork || semanticWorkState(currentWork) !== 'cancelled') return undefined;
|
|
1229
|
+
if (getControllerSession(options, input.workId)) {
|
|
1230
|
+
throw new Error(`CONTROLLER_RELAY_TERMINAL_WORK_ACTIVE_CLAIM: ${input.workId}`);
|
|
1231
|
+
}
|
|
1232
|
+
if (current.value.status === 'failed') return current.value;
|
|
1233
|
+
return applyControllerRoundTransition(options, current, {
|
|
1234
|
+
type: 'terminal_work_observed', at: nowIso(options), error: `CONTROLLER_RELAY_TERMINAL_WORK_RETIRED:${input.workId}`,
|
|
1235
|
+
});
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
export function beginControllerRoundProviderDispatch(
|
|
1240
|
+
options: ControllerRoundRelayStoreOptions,
|
|
1241
|
+
input: { workId: string; authorityId: string; expectedUpdatedAt: string; bindingId?: string },
|
|
1242
|
+
): ControllerRoundRelayRecord {
|
|
1243
|
+
const initial = readRelayRecord(options, input.workId);
|
|
1244
|
+
if (!initial) throw new Error(`CONTROLLER_RELAY_PROVIDER_DISPATCH_RELAY_REQUIRED: ${input.workId}`);
|
|
1245
|
+
return relayLock(options, initial.value.relayScopeId, `controller-provider-dispatch-start:${input.workId}`, () => {
|
|
1246
|
+
const current = readRelayRecord(options, input.workId);
|
|
1247
|
+
if (!current) throw new Error(`CONTROLLER_RELAY_PROVIDER_DISPATCH_RELAY_REQUIRED: ${input.workId}`);
|
|
1248
|
+
if (current.value.status !== 'dispatching') throw new Error(`CONTROLLER_RELAY_DISPATCH_STATE_INVALID:${current.value.status}`);
|
|
1249
|
+
if (current.value.authorityId !== input.authorityId.trim()) throw new Error(`CONTROLLER_RELAY_PROVIDER_DISPATCH_AUTHORITY_MISMATCH: ${input.workId}`);
|
|
1250
|
+
if (current.value.updatedAt !== input.expectedUpdatedAt.trim()) {
|
|
1251
|
+
if (current.value.providerDispatchStartedAt) throw new Error(`CONTROLLER_CONTINUATION_ALREADY_DISPATCHING:${input.workId}`);
|
|
1252
|
+
throw new Error(`CONTROLLER_RELAY_PROVIDER_DISPATCH_STALE: ${input.workId}`);
|
|
1253
|
+
}
|
|
1254
|
+
if (current.value.providerDispatchStartedAt) throw new Error(`CONTROLLER_CONTINUATION_ALREADY_DISPATCHING:${input.workId}`);
|
|
1255
|
+
const providerDispatchEffectId = current.value.providerDispatchEffectId ?? controllerRoundProviderEffectId(current.value);
|
|
1256
|
+
return applyControllerRoundTransition(options, current, {
|
|
1257
|
+
type: 'provider_dispatch_started', at: nowIso(options), providerDispatchEffectId,
|
|
1258
|
+
...(bounded(input.bindingId, 500) ? { bindingId: bounded(input.bindingId, 500) } : {}),
|
|
1259
|
+
});
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
export function finishControllerRoundRelayDispatch(
|
|
1264
|
+
options: ControllerRoundRelayStoreOptions,
|
|
1265
|
+
input: { workId: string; ok: boolean; bindingId?: string; providerDispatchEffectId?: string; providerDispatchReceiptId?: string; error?: string; recovery?: boolean; outcomeUnknown?: boolean; waitForUser?: boolean; handoffId?: string; nowMs?: number },
|
|
1266
|
+
): ControllerRoundRelayRecord | undefined {
|
|
1267
|
+
const initial = readRelayRecord(options, input.workId);
|
|
1268
|
+
const initialCanFinish = Boolean(initial && (
|
|
1269
|
+
initial.value.status === 'dispatching'
|
|
1270
|
+
|| (input.ok && initial.value.status === 'blocked' && controllerRoundBlockerClass(initial.value) === 'provider_dispatch_outcome_unknown')
|
|
1271
|
+
));
|
|
1272
|
+
if (!initial || !initialCanFinish) return initial?.value;
|
|
1273
|
+
return relayLock(options, initial.value.relayScopeId, `controller-relay-finish:${input.workId}`, () => {
|
|
1274
|
+
const current = readRelayRecord(options, input.workId);
|
|
1275
|
+
const currentCanFinish = Boolean(current && (
|
|
1276
|
+
current.value.status === 'dispatching'
|
|
1277
|
+
|| (input.ok && current.value.status === 'blocked' && controllerRoundBlockerClass(current.value) === 'provider_dispatch_outcome_unknown')
|
|
1278
|
+
));
|
|
1279
|
+
if (!current || !currentCanFinish) return current?.value;
|
|
1280
|
+
const at = typeof input.nowMs === 'number' ? new Date(input.nowMs).toISOString() : nowIso(options);
|
|
1281
|
+
const handoffId = bounded(input.handoffId, 200);
|
|
1282
|
+
if (input.waitForUser) {
|
|
1283
|
+
if (!handoffId) throw new Error('CONTROLLER_RELAY_WAIT_FOR_USER_HANDOFF_REQUIRED');
|
|
1284
|
+
const handoff = getHandoffItem(options, handoffId);
|
|
1285
|
+
if (!handoff) throw new Error(`HANDOFF_NOT_FOUND: ${handoffId}`);
|
|
1286
|
+
if (isTerminalHandoffStatus(handoff.status)) throw new Error(`CONTROLLER_RELAY_HANDOFF_TERMINAL: ${handoff.status}`);
|
|
1287
|
+
if (handoff.workId && handoff.workId !== input.workId) throw new Error(`CONTROLLER_RELAY_HANDOFF_WORK_MISMATCH: ${handoffId}`);
|
|
1288
|
+
}
|
|
1289
|
+
const error = bounded(input.error, 2_000) ?? (input.outcomeUnknown
|
|
1290
|
+
? 'CONTROLLER_RELAY_PROVIDER_DISPATCH_OUTCOME_UNKNOWN'
|
|
1291
|
+
: input.waitForUser
|
|
1292
|
+
? 'CONTROLLER_RELAY_WAIT_FOR_USER'
|
|
1293
|
+
: input.recovery
|
|
1294
|
+
? 'CONTROLLER_RELAY_RECOVERY_FAILED'
|
|
1295
|
+
: 'CONTROLLER_RELAY_DISPATCH_FAILED');
|
|
1296
|
+
const providerDispatchEffectId = bounded(input.providerDispatchEffectId, 500)
|
|
1297
|
+
?? current.value.providerDispatchEffectId
|
|
1298
|
+
?? controllerRoundProviderEffectId(current.value);
|
|
1299
|
+
let event: ControllerRoundTransitionEvent;
|
|
1300
|
+
if (input.ok) {
|
|
1301
|
+
if (!providerDispatchEffectId) throw new Error('CONTROLLER_RELAY_PROVIDER_EFFECT_ID_REQUIRED');
|
|
1302
|
+
event = { type: 'provider_dispatch_succeeded', at, providerDispatchEffectId, ...(bounded(input.bindingId, 500) ? { bindingId: bounded(input.bindingId, 500) } : {}), ...(bounded(input.providerDispatchReceiptId, 500) ? { providerDispatchReceiptId: bounded(input.providerDispatchReceiptId, 500) } : {}) };
|
|
1303
|
+
} else if (input.outcomeUnknown) {
|
|
1304
|
+
const effectIdentity = providerDispatchEffectId;
|
|
1305
|
+
if (!effectIdentity) throw new Error('CONTROLLER_RELAY_PROVIDER_EFFECT_ID_REQUIRED');
|
|
1306
|
+
event = { type: 'provider_dispatch_outcome_unknown', at, error, providerDispatchEffectId: effectIdentity };
|
|
1307
|
+
} else if (input.waitForUser) {
|
|
1308
|
+
event = { type: 'provider_user_action_required', at, error, handoffId: handoffId! };
|
|
1309
|
+
} else {
|
|
1310
|
+
const nextFailureCount = current.value.consecutiveFailures + 1;
|
|
1311
|
+
const recoveryDelayMs = Math.min(MAX_STALLED_RECOVERY_BACKOFF_MS, DEFAULT_STALLED_RECOVERY_BACKOFF_MS * 2 ** Math.max(0, nextFailureCount - 1));
|
|
1312
|
+
event = { type: 'provider_dispatch_failed', at, error, recovery: input.recovery === true, ...(input.recovery ? { nextRecoveryAt: new Date(Date.parse(at) + recoveryDelayMs).toISOString() } : {}) };
|
|
1313
|
+
}
|
|
1314
|
+
return applyControllerRoundTransition(options, current, event);
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
export interface ClaimControllerRoundSessionInput {
|
|
1319
|
+
workId: string;
|
|
1320
|
+
relayWorkId: string;
|
|
1321
|
+
sessionClaim: ControllerSessionClaimInput & { principalId: string; controllerInstanceId: string };
|
|
1322
|
+
assistantContextSnapshot?: AssistantContextSnapshot | null;
|
|
1323
|
+
allowUserResume?: boolean;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
/**
|
|
1327
|
+
* Atomically claim one relay-bound Work. Relay claimability is rechecked inside
|
|
1328
|
+
* the same BEGIN IMMEDIATE transaction that persists ControllerSession ownership.
|
|
1329
|
+
* A Requirement-scope inherited relay is fenced but not rewritten for a sibling Work.
|
|
1330
|
+
*/
|
|
1331
|
+
export function claimControllerRoundSession(
|
|
1332
|
+
options: ControllerRoundRelayStoreOptions,
|
|
1333
|
+
input: ClaimControllerRoundSessionInput,
|
|
1334
|
+
): { session: ClaimedControllerSession; relay?: ControllerRoundRelayRecord } {
|
|
1335
|
+
const relayWorkId = input.relayWorkId.trim();
|
|
1336
|
+
const initial = readRelayRecord(options, relayWorkId);
|
|
1337
|
+
if (!initial) throw new Error(`CONTROLLER_RELAY_ROUND_NOT_OPEN: ${relayWorkId}`);
|
|
1338
|
+
return relayLock(options, initial.value.relayScopeId, `controller-relay-session-claim:${input.sessionClaim.controllerId}`, () => {
|
|
1339
|
+
const lockedRelay = readRelayRecord(options, relayWorkId);
|
|
1340
|
+
if (!lockedRelay || !controllerRoundRelayClaimable(lockedRelay.value, { allowUserResume: input.allowUserResume })) {
|
|
1341
|
+
throw new Error(`CONTROLLER_RELAY_CLAIM_STATE_INVALID:${lockedRelay?.value.status ?? 'missing'}`);
|
|
1342
|
+
}
|
|
1343
|
+
const repeatedStateFingerprint = controllerRoundBlockerClass(lockedRelay.value) === 'repeated_state'
|
|
1344
|
+
? (() => {
|
|
1345
|
+
const work = getWorkContract(options, input.workId);
|
|
1346
|
+
if (!work) throw new Error(`WORK_CONTROLLER_CONTEXT_MISSING: ${input.workId}`);
|
|
1347
|
+
return mechanicalStateFingerprint(options, work, lockedRelay.value.requirementId, lockedRelay.value.relayScopeId, lockedRelay.value.handoffId);
|
|
1348
|
+
})()
|
|
1349
|
+
: undefined;
|
|
1350
|
+
return withControllerSessionMutationLock(options, input.workId, `controller-relay-session-claim:${input.sessionClaim.controllerId}`, () => {
|
|
1351
|
+
assertControllerSessionWorkClaimable(options, input.workId);
|
|
1352
|
+
return withControlPlaneTransaction(options.controllerHome, (database) => {
|
|
1353
|
+
const current = readControlPlaneRecordWithinTransaction<ControllerRoundRelayRecord>(
|
|
1354
|
+
database, NAMESPACE, options.repoId, relayWorkId,
|
|
1355
|
+
);
|
|
1356
|
+
if (!current || !controllerRoundRelayClaimable(current.value, { allowUserResume: input.allowUserResume })) {
|
|
1357
|
+
throw new Error(`CONTROLLER_RELAY_CLAIM_STATE_INVALID:${current?.value.status ?? 'missing'}`);
|
|
1358
|
+
}
|
|
1359
|
+
if (input.sessionClaim.controllerType !== relayControllerType(current.value)) {
|
|
1360
|
+
throw new Error(`CONTROLLER_RELAY_CONTROLLER_TYPE_MISMATCH: ${input.workId}`);
|
|
1361
|
+
}
|
|
1362
|
+
const session = resumeControllerSessionWithinTransaction(database, options, input.sessionClaim);
|
|
1363
|
+
if (relayWorkId !== input.workId) return { session };
|
|
1364
|
+
|
|
1365
|
+
const at = nowIso(options);
|
|
1366
|
+
const blocker = controllerRoundBlockerClass(current.value);
|
|
1367
|
+
const event: ControllerRoundTransitionEvent = blocker === 'repeated_state'
|
|
1368
|
+
? {
|
|
1369
|
+
type: 'semantic_state_changed', at,
|
|
1370
|
+
stateFingerprint: repeatedStateFingerprint!, session,
|
|
1371
|
+
principalId: input.sessionClaim.principalId,
|
|
1372
|
+
controllerInstanceId: input.sessionClaim.controllerInstanceId,
|
|
1373
|
+
}
|
|
1374
|
+
: {
|
|
1375
|
+
type: 'controller_claim_observed', at, session,
|
|
1376
|
+
principalId: input.sessionClaim.principalId,
|
|
1377
|
+
controllerInstanceId: input.sessionClaim.controllerInstanceId,
|
|
1378
|
+
...(input.allowUserResume ? { userResume: true } : {}),
|
|
1379
|
+
};
|
|
1380
|
+
const decided = transitionDecisionOrThrow(decideControllerRoundTransition(current.value, event));
|
|
1381
|
+
let next = decided.record;
|
|
1382
|
+
const requested = input.assistantContextSnapshot;
|
|
1383
|
+
if (requested !== undefined && !sameAssistantContextSnapshot(next.assistantContextSnapshot, requested)) {
|
|
1384
|
+
if (next.assistantContextSnapshot) throw new Error(`CONTROLLER_ASSISTANT_CONTEXT_CLAIM_MISMATCH: ${input.workId}`);
|
|
1385
|
+
if (requested !== null) next = { ...next, assistantContextSnapshot: requested, updatedAt: at };
|
|
1386
|
+
}
|
|
1387
|
+
if (decided.changed || next !== decided.record) {
|
|
1388
|
+
writeControlPlaneRecordWithinTransaction(database, {
|
|
1389
|
+
namespace: NAMESPACE,
|
|
1390
|
+
scope: options.repoId,
|
|
1391
|
+
key: relayWorkId,
|
|
1392
|
+
schemaVersion: SCHEMA_VERSION,
|
|
1393
|
+
value: next,
|
|
1394
|
+
action: decided.action ?? 'controller_round_relay_claim_acknowledged',
|
|
1395
|
+
expectedRevision: current.revision,
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
return { session, relay: next };
|
|
1399
|
+
});
|
|
1400
|
+
});
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
export function acknowledgeControllerRoundClaim(
|
|
1405
|
+
options: ControllerRoundRelayStoreOptions,
|
|
1406
|
+
input: { workId: string; session: ControllerSession; assistantContextSnapshot?: AssistantContextSnapshot | null },
|
|
1407
|
+
): ControllerRoundRelayRecord | undefined {
|
|
1408
|
+
const initial = readRelayRecord(options, input.workId);
|
|
1409
|
+
if (!initial) return undefined;
|
|
1410
|
+
const expectedControllerType = relayControllerType(initial.value);
|
|
1411
|
+
if (input.session.controllerType !== expectedControllerType) throw new Error(`CONTROLLER_RELAY_CONTROLLER_TYPE_MISMATCH: ${input.workId}`);
|
|
1412
|
+
|
|
1413
|
+
return relayLock(options, initial.value.relayScopeId, `controller-relay-claim:${input.session.controllerId}`, () => {
|
|
1414
|
+
const current = readRelayRecord(options, input.workId);
|
|
1415
|
+
if (!current) return undefined;
|
|
1416
|
+
if (current.value.status === 'claimed'
|
|
1417
|
+
&& current.value.controllerId === input.session.controllerId
|
|
1418
|
+
&& current.value.sessionId === input.session.sessionId
|
|
1419
|
+
&& current.value.claimGeneration === input.session.claimGeneration
|
|
1420
|
+
&& sameAssistantContextSnapshot(current.value.assistantContextSnapshot, input.assistantContextSnapshot)) return current.value;
|
|
1421
|
+
|
|
1422
|
+
const blocker = controllerRoundBlockerClass(current.value);
|
|
1423
|
+
if (!controllerRoundRelayClaimable(current.value)) return current.value;
|
|
1424
|
+
|
|
1425
|
+
const owner = getControllerSession(options, input.workId);
|
|
1426
|
+
const ownerPrincipal = owner?.principalId?.trim() || owner?.controllerId;
|
|
1427
|
+
const sessionPrincipal = input.session.principalId?.trim() || input.session.controllerId;
|
|
1428
|
+
if (
|
|
1429
|
+
!owner
|
|
1430
|
+
|| owner.controllerType !== relayControllerType(current.value)
|
|
1431
|
+
|| owner.controllerId !== input.session.controllerId
|
|
1432
|
+
|| owner.sessionId !== input.session.sessionId
|
|
1433
|
+
|| owner.claimGeneration !== input.session.claimGeneration
|
|
1434
|
+
|| ownerPrincipal !== sessionPrincipal
|
|
1435
|
+
|| (owner.controllerInstanceId?.trim() || '') !== (input.session.controllerInstanceId?.trim() || '')
|
|
1436
|
+
) throw new Error(`CONTROLLER_RELAY_CLAIM_IDENTITY_MISMATCH: ${input.workId}`);
|
|
1437
|
+
// The dispatching relay records the provider/launcher identity until the
|
|
1438
|
+
// authenticated controller actually claims the round. The domain transition
|
|
1439
|
+
// policy owns that one-way identity handoff; duplicating an equality guard
|
|
1440
|
+
// here would turn provider dispatch metadata into a second claim authority.
|
|
1441
|
+
if (typeof owner.claimGeneration !== 'number' || owner.claimGeneration < 1) {
|
|
1442
|
+
throw new Error(`CONTROLLER_RELAY_CLAIM_GENERATION_REQUIRED: ${input.workId}`);
|
|
1443
|
+
}
|
|
1444
|
+
const controllerInstanceId = owner.controllerInstanceId?.trim();
|
|
1445
|
+
if (!controllerInstanceId) throw new Error(`CONTROLLER_RELAY_CLAIM_INSTANCE_REQUIRED: ${input.workId}`);
|
|
1446
|
+
const at = nowIso(options);
|
|
1447
|
+
const session = owner as ControllerSession & { claimGeneration: number };
|
|
1448
|
+
|
|
1449
|
+
if (blocker === 'repeated_state') {
|
|
1450
|
+
const work = getWorkContract(options, input.workId);
|
|
1451
|
+
if (!work || semanticWorkState(work) !== 'open') return current.value;
|
|
1452
|
+
const stateFingerprint = mechanicalStateFingerprint(options, work, current.value.requirementId, current.value.relayScopeId, current.value.handoffId);
|
|
1453
|
+
const transitioned = applyControllerRoundTransition(options, current, {
|
|
1454
|
+
type: 'semantic_state_changed', at, stateFingerprint, session, principalId: ownerPrincipal!, controllerInstanceId,
|
|
1455
|
+
});
|
|
1456
|
+
return persistClaimedAssistantContextSnapshot(options, transitioned, input.assistantContextSnapshot, at);
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
const transitioned = applyControllerRoundTransition(options, current, {
|
|
1460
|
+
type: 'controller_claim_observed', at, session, principalId: ownerPrincipal!, controllerInstanceId,
|
|
1461
|
+
});
|
|
1462
|
+
return persistClaimedAssistantContextSnapshot(options, transitioned, input.assistantContextSnapshot, at);
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/**
|
|
1467
|
+
* Explicitly rekey one exact relay-bound Work after the caller lost the opaque
|
|
1468
|
+
* per-round capability with its MCP/ChatGPT transport. This is not a semantic
|
|
1469
|
+
* continuation and never dispatches a provider: it preserves the current
|
|
1470
|
+
* relay lineage and budgets, rotates only the capability epoch, and leaves the
|
|
1471
|
+
* standard controller_claim path responsible for mechanically acknowledging
|
|
1472
|
+
* the recovered round.
|
|
1473
|
+
*/
|
|
1474
|
+
export function recoverControllerRoundRelayAuthority(
|
|
1475
|
+
options: ControllerRoundRelayStoreOptions,
|
|
1476
|
+
input: RecoverControllerRoundRelayAuthorityInput,
|
|
1477
|
+
): ControllerRoundRelayRecord {
|
|
1478
|
+
const workId = input.workId.trim();
|
|
1479
|
+
if (!workId) throw new Error('WORK_CONTROLLER_AUTHORITY_RECOVERY_WORK_REQUIRED');
|
|
1480
|
+
if (input.requestedBy !== 'user') {
|
|
1481
|
+
throw new Error('WORK_CONTROLLER_AUTHORITY_RECOVERY_USER_REQUIRED: relay authority rekey is an explicit user-directed recovery only.');
|
|
1482
|
+
}
|
|
1483
|
+
const work = getWorkContract(options, workId);
|
|
1484
|
+
if (!work) throw new Error(`WORK_NOT_FOUND: ${workId}`);
|
|
1485
|
+
if (semanticWorkState(work) !== 'open') {
|
|
1486
|
+
throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_TERMINAL: ${workId}:${semanticWorkState(work)}`);
|
|
1487
|
+
}
|
|
1488
|
+
const initial = readRelayRecord(options, workId);
|
|
1489
|
+
if (!initial) throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_RELAY_REQUIRED: ${workId}`);
|
|
1490
|
+
const expectedType = relayControllerType(initial.value);
|
|
1491
|
+
if (expectedType !== input.identity.controllerType) {
|
|
1492
|
+
throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_CONTROLLER_TYPE_MISMATCH: ${workId}`);
|
|
1493
|
+
}
|
|
1494
|
+
if (initial.value.controllerId !== input.identity.controllerId || initial.value.principalId !== input.identity.principalId) {
|
|
1495
|
+
throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_PRINCIPAL_MISMATCH: ${workId}`);
|
|
1496
|
+
}
|
|
1497
|
+
const recoverableStatuses: readonly ControllerRoundRelayStatus[] = ['pending_release', 'dispatching', 'dispatched', 'claimed', 'waiting_for_user', 'failed'];
|
|
1498
|
+
const initialRepeatedStateBlock = initial.value.status === 'blocked' && initial.value.blockedReason?.startsWith('repeated_state:');
|
|
1499
|
+
const initialConsecutiveFailureBlock = controllerRoundBlockerClass(initial.value) === 'consecutive_failures';
|
|
1500
|
+
const initialRecoveryReason = input.recoveryReason?.trim() ?? '';
|
|
1501
|
+
if (initialRepeatedStateBlock && !initialRecoveryReason) {
|
|
1502
|
+
throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_REASON_REQUIRED: ${workId}; repeated-state recovery must state why the bounded recovery is being opened.`);
|
|
1503
|
+
}
|
|
1504
|
+
if (!recoverableStatuses.includes(initial.value.status) && !initialRepeatedStateBlock && !initialConsecutiveFailureBlock) {
|
|
1505
|
+
throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_RELAY_STATE_INVALID: ${workId}:${initial.value.status}`);
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
return relayLock(options, initial.value.relayScopeId, `controller-relay-explicit-recover:${workId}`, () => {
|
|
1509
|
+
const current = readRelayRecord(options, workId);
|
|
1510
|
+
if (!current) throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_RELAY_REQUIRED: ${workId}`);
|
|
1511
|
+
const latest = relayHistory(options, current.value.relayScopeId)[0];
|
|
1512
|
+
if (!latest || latest.originWorkId !== workId || latest.updatedAt !== current.value.updatedAt) {
|
|
1513
|
+
throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_RELAY_NOT_CURRENT: ${workId}`);
|
|
1514
|
+
}
|
|
1515
|
+
const currentRepeatedStateBlock = current.value.status === 'blocked' && current.value.blockedReason?.startsWith('repeated_state:');
|
|
1516
|
+
const currentConsecutiveFailureBlock = controllerRoundBlockerClass(current.value) === 'consecutive_failures';
|
|
1517
|
+
if (currentRepeatedStateBlock && !initialRecoveryReason) {
|
|
1518
|
+
throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_REASON_REQUIRED: ${workId}; repeated-state recovery must state why the bounded recovery is being opened.`);
|
|
1519
|
+
}
|
|
1520
|
+
if (!recoverableStatuses.includes(current.value.status) && !currentRepeatedStateBlock && !currentConsecutiveFailureBlock) {
|
|
1521
|
+
throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_RELAY_STATE_INVALID: ${workId}:${current.value.status}`);
|
|
1522
|
+
}
|
|
1523
|
+
if (relayControllerType(current.value) !== input.identity.controllerType
|
|
1524
|
+
|| current.value.controllerId !== input.identity.controllerId
|
|
1525
|
+
|| current.value.principalId !== input.identity.principalId) {
|
|
1526
|
+
throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_PRINCIPAL_MISMATCH: ${workId}`);
|
|
1527
|
+
}
|
|
1528
|
+
const currentWork = getWorkContract(options, workId);
|
|
1529
|
+
if (!currentWork) throw new Error(`WORK_NOT_FOUND: ${workId}`);
|
|
1530
|
+
if (semanticWorkState(currentWork) !== 'open') {
|
|
1531
|
+
throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_TERMINAL: ${workId}:${semanticWorkState(currentWork)}`);
|
|
1532
|
+
}
|
|
1533
|
+
const activeWorks = recoveryFenceWork(options, current.value).filter((entry) => semanticWorkState(entry) === 'open');
|
|
1534
|
+
if (activeWorks.some((entry) => workHasActiveExecution(options.controllerHome, options.repoId, entry.workId))) {
|
|
1535
|
+
throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_ACTIVE_EXECUTION: ${workId}`);
|
|
1536
|
+
}
|
|
1537
|
+
const claimedWorks = activeWorks
|
|
1538
|
+
.map((entry) => ({ work: entry, owner: getControllerSession(options, entry.workId) }))
|
|
1539
|
+
.filter((entry): entry is typeof entry & { owner: NonNullable<typeof entry.owner> } => Boolean(entry.owner));
|
|
1540
|
+
if (claimedWorks.some((entry) => entry.work.workId !== workId)) {
|
|
1541
|
+
throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_ACTIVE_CLAIM: ${workId}`);
|
|
1542
|
+
}
|
|
1543
|
+
const currentOwner = claimedWorks.find((entry) => entry.work.workId === workId)?.owner;
|
|
1544
|
+
if (currentConsecutiveFailureBlock && currentOwner) {
|
|
1545
|
+
throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_ACTIVE_CLAIM: ${workId}`);
|
|
1546
|
+
}
|
|
1547
|
+
if (currentOwner) {
|
|
1548
|
+
const ownerPrincipal = controllerSessionPrincipalId(currentOwner);
|
|
1549
|
+
const ownerInstanceId = currentOwner.controllerInstanceId?.trim() || '';
|
|
1550
|
+
const samePrincipalOwner = currentOwner.controllerId === input.identity.controllerId
|
|
1551
|
+
&& currentOwner.controllerType === input.identity.controllerType
|
|
1552
|
+
&& ownerPrincipal === input.identity.principalId;
|
|
1553
|
+
const sameRuntimeOwner = ownerInstanceId === input.identity.controllerInstanceId;
|
|
1554
|
+
const canonicalRuntimeMigration = input.allowCanonicalRuntimeMigration === true
|
|
1555
|
+
&& ownerInstanceId !== input.identity.controllerInstanceId;
|
|
1556
|
+
if (!samePrincipalOwner || (!sameRuntimeOwner && !canonicalRuntimeMigration)) {
|
|
1557
|
+
throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_ACTIVE_CLAIM: ${workId}`);
|
|
1558
|
+
}
|
|
1559
|
+
const released = releaseObservedControllerSession(options, {
|
|
1560
|
+
workId,
|
|
1561
|
+
actor: `controller-relay-explicit-authority-recovery:${workId}`,
|
|
1562
|
+
owner: currentOwner,
|
|
1563
|
+
});
|
|
1564
|
+
if (!released.allowed) {
|
|
1565
|
+
throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_ACTIVE_CLAIM: ${workId}:${released.reason}`);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
return applyControllerRoundTransition(options, current, {
|
|
1570
|
+
type: 'authority_recovery_requested', at: nowIso(options), proposedAuthorityId: newControllerRoundAuthorityId(),
|
|
1571
|
+
keepsConfirmedDispatch: current.value.status === 'dispatched',
|
|
1572
|
+
preserveBlockedState: currentConsecutiveFailureBlock,
|
|
1573
|
+
preserveWaitingForUserState: current.value.status === 'waiting_for_user',
|
|
1574
|
+
...(initialRecoveryReason ? { reason: initialRecoveryReason } : {}),
|
|
1575
|
+
});
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
export interface RetryFailedControllerRoundProviderDispatchInput {
|
|
1580
|
+
workId: string;
|
|
1581
|
+
relayScopeId: string;
|
|
1582
|
+
authorityId: string;
|
|
1583
|
+
expectedUpdatedAt: string;
|
|
1584
|
+
occurrenceId?: string;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/** Retry one known, non-ambiguous provider failure without creating a new semantic round or authority. */
|
|
1588
|
+
export function retryFailedControllerRoundProviderDispatch(
|
|
1589
|
+
options: ControllerRoundRelayStoreOptions,
|
|
1590
|
+
input: RetryFailedControllerRoundProviderDispatchInput,
|
|
1591
|
+
): ControllerRoundRelayRecord {
|
|
1592
|
+
const workId = input.workId.trim();
|
|
1593
|
+
const initial = readRelayRecord(options, workId);
|
|
1594
|
+
if (!initial) throw new Error(`CONTROLLER_RELAY_PROVIDER_RETRY_RELAY_REQUIRED:${workId}`);
|
|
1595
|
+
if (initial.value.relayScopeId !== input.relayScopeId.trim()) throw new Error(`CONTROLLER_RELAY_PROVIDER_RETRY_SCOPE_MISMATCH:${workId}`);
|
|
1596
|
+
return relayLock(options, initial.value.relayScopeId, `controller-relay-provider-retry:${workId}`, () => {
|
|
1597
|
+
const current = readRelayRecord(options, workId);
|
|
1598
|
+
if (!current) throw new Error(`CONTROLLER_RELAY_PROVIDER_RETRY_RELAY_REQUIRED:${workId}`);
|
|
1599
|
+
if (current.value.updatedAt !== input.expectedUpdatedAt.trim()) throw new Error(`CONTROLLER_RELAY_PROVIDER_RETRY_STALE:${workId}`);
|
|
1600
|
+
if (current.value.relayScopeId !== input.relayScopeId.trim()) throw new Error(`CONTROLLER_RELAY_PROVIDER_RETRY_SCOPE_MISMATCH:${workId}`);
|
|
1601
|
+
if ((current.value.authorityId?.trim() || '') !== input.authorityId.trim()) throw new Error(`CONTROLLER_RELAY_PROVIDER_RETRY_AUTHORITY_MISMATCH:${workId}`);
|
|
1602
|
+
const work = getWorkContract(options, workId);
|
|
1603
|
+
if (!work || semanticWorkState(work) !== 'open') throw new Error(`CONTROLLER_RELAY_PROVIDER_RETRY_WORK_TERMINAL:${workId}:${work ? semanticWorkState(work) : 'missing'}`);
|
|
1604
|
+
const requirement = requirementForRelay(options, current.value.requirementId);
|
|
1605
|
+
if (requirement && !['planned', 'active'].includes(requirement.state)) throw new Error(`CONTROLLER_RELAY_PROVIDER_RETRY_REQUIREMENT_TERMINAL:${requirement.state}`);
|
|
1606
|
+
if (workHasActiveExecution(options.controllerHome, options.repoId, workId)) throw new Error(`CONTROLLER_RELAY_PROVIDER_RETRY_ACTIVE_EXECUTION:${workId}`);
|
|
1607
|
+
if (getControllerSession(options, workId)) throw new Error(`CONTROLLER_RELAY_PROVIDER_RETRY_ACTIVE_CLAIM:${workId}`);
|
|
1608
|
+
return applyControllerRoundTransition(options, current, {
|
|
1609
|
+
type: 'provider_retry_requested', at: nowIso(options), occurrenceId: input.occurrenceId?.trim() || undefined,
|
|
1610
|
+
});
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
export interface RearmControllerRoundAfterProviderRecoveryInput {
|
|
1615
|
+
workId: string;
|
|
1616
|
+
relayScopeId: string;
|
|
1617
|
+
authorityId: string;
|
|
1618
|
+
expectedUpdatedAt: string;
|
|
1619
|
+
evidenceId: string;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
export interface BindLegacyControllerRoundOccurrenceInput {
|
|
1623
|
+
workId: string;
|
|
1624
|
+
relayScopeId: string;
|
|
1625
|
+
occurrenceId: string;
|
|
1626
|
+
authorityId: string;
|
|
1627
|
+
expectedUpdatedAt: string;
|
|
1628
|
+
identity: Pick<ControllerRoundRelayIdentity, 'controllerId' | 'controllerType' | 'principalId'>;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
export interface RearmControllerRoundAfterProviderUserActionInput {
|
|
1632
|
+
workId: string;
|
|
1633
|
+
handoffId: string;
|
|
1634
|
+
/** Compatibility fence for explicit callers; automatic Handoff resolution reads durable authority internally. */
|
|
1635
|
+
authorityId?: string;
|
|
1636
|
+
/** Compatibility CAS for explicit callers; automatic Handoff resolution validates current state under the relay lock. */
|
|
1637
|
+
expectedUpdatedAt?: string;
|
|
1638
|
+
/** Compatibility override only. Omit to preserve the existing semantic occurrence identity. */
|
|
1639
|
+
occurrenceId?: string;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/** Rearm the same provider dispatch responsibility only after its exact user-action Handoff is resolved. */
|
|
1643
|
+
export function rearmControllerRoundAfterProviderUserAction(
|
|
1644
|
+
options: ControllerRoundRelayStoreOptions,
|
|
1645
|
+
input: RearmControllerRoundAfterProviderUserActionInput,
|
|
1646
|
+
): ControllerRoundRelayRecord {
|
|
1647
|
+
const workId = input.workId.trim();
|
|
1648
|
+
const initial = readRelayRecord(options, workId);
|
|
1649
|
+
if (!initial) throw new Error(`CONTROLLER_RELAY_PROVIDER_USER_ACTION_RELAY_REQUIRED: ${workId}`);
|
|
1650
|
+
return relayLock(options, initial.value.relayScopeId, `controller-relay-provider-user-action-resolved:${workId}`, () => {
|
|
1651
|
+
const current = readRelayRecord(options, workId);
|
|
1652
|
+
if (!current) throw new Error(`CONTROLLER_RELAY_PROVIDER_USER_ACTION_RELAY_REQUIRED: ${workId}`);
|
|
1653
|
+
if (input.expectedUpdatedAt?.trim() && current.value.updatedAt !== input.expectedUpdatedAt.trim()) throw new Error(`CONTROLLER_RELAY_PROVIDER_USER_ACTION_STALE: ${workId}`);
|
|
1654
|
+
if (input.authorityId?.trim() && (current.value.authorityId?.trim() || '') !== input.authorityId.trim()) throw new Error(`CONTROLLER_RELAY_PROVIDER_USER_ACTION_AUTHORITY_MISMATCH: ${workId}`);
|
|
1655
|
+
if (controllerRoundBlockerClass(current.value) !== 'provider_user_action_required') throw new Error(`CONTROLLER_RELAY_PROVIDER_USER_ACTION_BLOCKER_MISMATCH: ${workId}`);
|
|
1656
|
+
const handoffId = bounded(input.handoffId, 200);
|
|
1657
|
+
if (!handoffId || current.value.handoffId !== handoffId) throw new Error(`CONTROLLER_RELAY_PROVIDER_USER_ACTION_HANDOFF_MISMATCH: ${workId}`);
|
|
1658
|
+
const handoff = getHandoffItem(options, handoffId);
|
|
1659
|
+
if (!handoff) throw new Error(`HANDOFF_NOT_FOUND: ${handoffId}`);
|
|
1660
|
+
if (handoff.workId && handoff.workId !== workId) throw new Error(`CONTROLLER_RELAY_HANDOFF_WORK_MISMATCH: ${handoffId}`);
|
|
1661
|
+
if (handoff.status !== 'resolved') throw new Error(`CONTROLLER_RELAY_PROVIDER_USER_ACTION_HANDOFF_NOT_RESOLVED: ${handoff.status}`);
|
|
1662
|
+
const requestedOccurrenceId = bounded(input.occurrenceId, 240);
|
|
1663
|
+
if (requestedOccurrenceId && current.value.occurrenceId && requestedOccurrenceId !== current.value.occurrenceId) {
|
|
1664
|
+
throw new Error(`CONTROLLER_RELAY_PROVIDER_USER_ACTION_OCCURRENCE_MISMATCH: ${workId}`);
|
|
1665
|
+
}
|
|
1666
|
+
const occurrenceId = requestedOccurrenceId ?? current.value.occurrenceId;
|
|
1667
|
+
if (!occurrenceId) throw new Error('CONTROLLER_RELAY_OCCURRENCE_ID_REQUIRED');
|
|
1668
|
+
const work = getWorkContract(options, workId);
|
|
1669
|
+
if (!work || semanticWorkState(work) !== 'open') throw new Error(`CONTROLLER_RELAY_PROVIDER_USER_ACTION_WORK_TERMINAL: ${workId}:${work ? semanticWorkState(work) : 'missing'}`);
|
|
1670
|
+
return applyControllerRoundTransition(options, current, {
|
|
1671
|
+
type: 'provider_user_action_resolved', at: nowIso(options), handoffId, occurrenceId,
|
|
1672
|
+
});
|
|
1673
|
+
});
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
/** Exact evidence-gated provider/environment recovery for one exhausted same-round dispatch responsibility. */
|
|
1677
|
+
export function rearmControllerRoundAfterProviderRecovery(
|
|
1678
|
+
options: ControllerRoundRelayStoreOptions,
|
|
1679
|
+
input: RearmControllerRoundAfterProviderRecoveryInput,
|
|
1680
|
+
): ControllerRoundRelayRecord {
|
|
1681
|
+
const workId = input.workId.trim();
|
|
1682
|
+
const initial = readRelayRecord(options, workId);
|
|
1683
|
+
if (!initial) throw new Error(`CONTROLLER_RELAY_PROVIDER_RECOVERY_RELAY_REQUIRED: ${workId}`);
|
|
1684
|
+
if (initial.value.relayScopeId !== input.relayScopeId.trim()) throw new Error(`CONTROLLER_RELAY_PROVIDER_RECOVERY_SCOPE_MISMATCH: ${workId}`);
|
|
1685
|
+
return relayLock(options, initial.value.relayScopeId, `controller-relay-provider-recovered:${workId}`, () => {
|
|
1686
|
+
const current = readRelayRecord(options, workId);
|
|
1687
|
+
if (!current) throw new Error(`CONTROLLER_RELAY_PROVIDER_RECOVERY_RELAY_REQUIRED: ${workId}`);
|
|
1688
|
+
if (current.value.updatedAt !== input.expectedUpdatedAt.trim()) throw new Error(`CONTROLLER_RELAY_PROVIDER_RECOVERY_STALE: ${workId}`);
|
|
1689
|
+
if (current.value.relayScopeId !== input.relayScopeId.trim()) throw new Error(`CONTROLLER_RELAY_PROVIDER_RECOVERY_SCOPE_MISMATCH: ${workId}`);
|
|
1690
|
+
if ((current.value.authorityId?.trim() || '') !== input.authorityId.trim()) throw new Error(`CONTROLLER_RELAY_PROVIDER_RECOVERY_AUTHORITY_MISMATCH: ${workId}`);
|
|
1691
|
+
if (controllerRoundBlockerClass(current.value) !== 'consecutive_failures') throw new Error(`CONTROLLER_RELAY_PROVIDER_RECOVERY_BLOCKER_MISMATCH: ${workId}`);
|
|
1692
|
+
const work = getWorkContract(options, workId);
|
|
1693
|
+
if (!work || semanticWorkState(work) !== 'open') throw new Error(`CONTROLLER_RELAY_PROVIDER_RECOVERY_WORK_TERMINAL: ${workId}:${work ? semanticWorkState(work) : 'missing'}`);
|
|
1694
|
+
const requirement = requirementForRelay(options, current.value.requirementId);
|
|
1695
|
+
if (requirement && !['planned', 'active'].includes(requirement.state)) throw new Error(`CONTROLLER_RELAY_PROVIDER_RECOVERY_REQUIREMENT_TERMINAL: ${requirement.state}`);
|
|
1696
|
+
const activeWorks = recoveryFenceWork(options, current.value).filter((entry) => semanticWorkState(entry) === 'open');
|
|
1697
|
+
if (activeWorks.some((entry) => workHasActiveExecution(options.controllerHome, options.repoId, entry.workId))) throw new Error(`CONTROLLER_RELAY_PROVIDER_RECOVERY_ACTIVE_EXECUTION: ${workId}`);
|
|
1698
|
+
if (activeWorks.some((entry) => Boolean(getControllerSession(options, entry.workId)))) throw new Error(`CONTROLLER_RELAY_PROVIDER_RECOVERY_ACTIVE_CLAIM: ${workId}`);
|
|
1699
|
+
const evidenceId = bounded(input.evidenceId, 500);
|
|
1700
|
+
if (!evidenceId) throw new Error('CONTROLLER_RELAY_PROVIDER_RECOVERY_EVIDENCE_REQUIRED');
|
|
1701
|
+
return applyControllerRoundTransition(options, current, { type: 'provider_environment_recovered', at: nowIso(options), evidenceId });
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
/**
|
|
1706
|
+
* One-time migration of a pre-occurrence-era dispatch responsibility onto the
|
|
1707
|
+
* first explicit post-recovery Scheduler occurrence. This does not create a
|
|
1708
|
+
* round or provider effect: exact round authority, CAS and controller lineage
|
|
1709
|
+
* fence the single occurrenceId write before normal strict occurrence matching
|
|
1710
|
+
* resumes.
|
|
1711
|
+
*/
|
|
1712
|
+
export function bindLegacyControllerRoundOccurrence(
|
|
1713
|
+
options: ControllerRoundRelayStoreOptions,
|
|
1714
|
+
input: BindLegacyControllerRoundOccurrenceInput,
|
|
1715
|
+
): ControllerRoundRelayRecord {
|
|
1716
|
+
const workId = input.workId.trim();
|
|
1717
|
+
const relayScopeId = input.relayScopeId.trim();
|
|
1718
|
+
const occurrenceId = bounded(input.occurrenceId, 500);
|
|
1719
|
+
if (!occurrenceId) throw new Error('CONTROLLER_RELAY_OCCURRENCE_ID_REQUIRED');
|
|
1720
|
+
const initial = readRelayRecord(options, workId);
|
|
1721
|
+
if (!initial) throw new Error(`CONTROLLER_RELAY_LEGACY_OCCURRENCE_RELAY_REQUIRED: ${workId}`);
|
|
1722
|
+
if (initial.value.relayScopeId !== relayScopeId) throw new Error(`CONTROLLER_RELAY_LEGACY_OCCURRENCE_SCOPE_MISMATCH: ${workId}`);
|
|
1723
|
+
return relayLock(options, initial.value.relayScopeId, `controller-relay-bind-legacy-occurrence:${workId}`, () => {
|
|
1724
|
+
const current = readRelayRecord(options, workId);
|
|
1725
|
+
if (!current) throw new Error(`CONTROLLER_RELAY_LEGACY_OCCURRENCE_RELAY_REQUIRED: ${workId}`);
|
|
1726
|
+
if (current.value.updatedAt !== input.expectedUpdatedAt.trim()) throw new Error(`CONTROLLER_RELAY_LEGACY_OCCURRENCE_STALE: ${workId}`);
|
|
1727
|
+
if (current.value.relayScopeId !== relayScopeId) throw new Error(`CONTROLLER_RELAY_LEGACY_OCCURRENCE_SCOPE_MISMATCH: ${workId}`);
|
|
1728
|
+
if (current.value.originWorkId !== workId) throw new Error(`CONTROLLER_RELAY_LEGACY_OCCURRENCE_WORK_MISMATCH: ${workId}`);
|
|
1729
|
+
if ((current.value.authorityId?.trim() || '') !== input.authorityId.trim()) throw new Error(`CONTROLLER_RELAY_LEGACY_OCCURRENCE_AUTHORITY_MISMATCH: ${workId}`);
|
|
1730
|
+
const expectedPrincipalId = input.identity.principalId.trim() || input.identity.controllerId.trim();
|
|
1731
|
+
const currentPrincipalId = current.value.principalId?.trim() || current.value.controllerId;
|
|
1732
|
+
if (current.value.controllerId !== input.identity.controllerId.trim()
|
|
1733
|
+
|| relayControllerType(current.value) !== input.identity.controllerType
|
|
1734
|
+
|| currentPrincipalId !== expectedPrincipalId) {
|
|
1735
|
+
throw new Error(`CONTROLLER_RELAY_LEGACY_OCCURRENCE_CONTROLLER_MISMATCH: ${workId}`);
|
|
1736
|
+
}
|
|
1737
|
+
const work = getWorkContract(options, workId);
|
|
1738
|
+
if (!work || semanticWorkState(work) !== 'open') throw new Error(`CONTROLLER_RELAY_LEGACY_OCCURRENCE_WORK_TERMINAL: ${workId}:${work ? semanticWorkState(work) : 'missing'}`);
|
|
1739
|
+
const requirement = requirementForRelay(options, current.value.requirementId);
|
|
1740
|
+
if (requirement && !['planned', 'active'].includes(requirement.state)) throw new Error(`CONTROLLER_RELAY_LEGACY_OCCURRENCE_REQUIREMENT_TERMINAL: ${requirement.state}`);
|
|
1741
|
+
return applyControllerRoundTransition(options, current, { type: 'legacy_occurrence_bound', at: nowIso(options), occurrenceId });
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
export function claimStalledControllerRoundRelays(
|
|
1746
|
+
options: ControllerRoundRelayStoreOptions,
|
|
1747
|
+
input: { nowMs?: number; graceMs?: number; limit?: number; controllerTypes?: readonly ControllerType[] } = {},
|
|
1748
|
+
): ControllerRoundRelayRecord[] {
|
|
1749
|
+
const nowMs = input.nowMs ?? Date.now();
|
|
1750
|
+
const graceMs = Math.max(60_000, Math.min(input.graceMs ?? DEFAULT_UNCLOSED_ROUND_GRACE_MS, MAX_UNCLOSED_ROUND_GRACE_MS));
|
|
1751
|
+
const limit = Math.max(1, Math.min(Math.trunc(input.limit ?? 2), 16));
|
|
1752
|
+
const claimed: ControllerRoundRelayRecord[] = [];
|
|
1753
|
+
// A recovery pass evaluates many relay candidates for the same repository.
|
|
1754
|
+
// Work contracts are immutable for this read phase, so share one snapshot
|
|
1755
|
+
// across candidates. The locked transition below still refreshes the Work
|
|
1756
|
+
// snapshot before deriving the durable recovery decision.
|
|
1757
|
+
let scanWorkContracts: readonly WorkContract[] | undefined;
|
|
1758
|
+
let scanActiveWorkSnapshot: ReturnType<typeof readActiveWorkCandidates> | undefined;
|
|
1759
|
+
const activeWorkSnapshotForScan = (): ReturnType<typeof readActiveWorkCandidates> => {
|
|
1760
|
+
scanActiveWorkSnapshot ??= readActiveWorkCandidates({ controllerHome: options.controllerHome, repoId: options.repoId, limit: 1_000 });
|
|
1761
|
+
return scanActiveWorkSnapshot;
|
|
1762
|
+
};
|
|
1763
|
+
const workSnapshotForScan = (): readonly WorkContract[] => {
|
|
1764
|
+
scanWorkContracts ??= readWorkContractStore({ controllerHome: options.controllerHome, repoId: options.repoId }).contracts;
|
|
1765
|
+
return scanWorkContracts;
|
|
1766
|
+
};
|
|
1767
|
+
|
|
1768
|
+
for (const candidate of latestRelayRecordsByScope(options)) {
|
|
1769
|
+
if (claimed.length >= limit) break;
|
|
1770
|
+
const repeatedStateBlocked = candidate.status === 'blocked' && candidate.blockedReason?.startsWith('repeated_state:') === true;
|
|
1771
|
+
if (!['pending_release', 'dispatching', 'dispatched', 'claimed'].includes(candidate.status) && !repeatedStateBlocked) continue;
|
|
1772
|
+
if (input.controllerTypes && !input.controllerTypes.includes(relayControllerType(candidate))) continue;
|
|
1773
|
+
const scheduledRecoveryAtMs = candidate.nextRecoveryAt ? Date.parse(candidate.nextRecoveryAt) : Number.NaN;
|
|
1774
|
+
if (Number.isFinite(scheduledRecoveryAtMs)) {
|
|
1775
|
+
if (nowMs < scheduledRecoveryAtMs) continue;
|
|
1776
|
+
} else {
|
|
1777
|
+
const roundOpenedAtMs = Date.parse(candidate.claimedAt ?? candidate.dispatchedAt ?? candidate.updatedAt);
|
|
1778
|
+
if (!Number.isFinite(roundOpenedAtMs) || nowMs - roundOpenedAtMs < graceMs) continue;
|
|
1779
|
+
}
|
|
1780
|
+
const requirement = requirementForRelay(options, candidate.requirementId);
|
|
1781
|
+
if (requirement && !['planned', 'active'].includes(requirement.state)) continue;
|
|
1782
|
+
if (!relayMayHaveActiveWork(options, candidate, activeWorkSnapshotForScan())) continue;
|
|
1783
|
+
const candidateWorks = recoveryFenceWork(options, candidate, workSnapshotForScan());
|
|
1784
|
+
const activeCandidateWorks = candidateWorks.filter((work) => semanticWorkState(work) === 'open');
|
|
1785
|
+
if (activeCandidateWorks.length === 0) continue;
|
|
1786
|
+
if (activeCandidateWorks.some((work) => workHasActiveExecution(options.controllerHome, options.repoId, work.workId) || controllerSessionBlocksRecovery(options, work.workId, { nowMs, graceMs }))) continue;
|
|
1787
|
+
if (repeatedStateBlocked) {
|
|
1788
|
+
const fingerprintWork = activeCandidateWorks[0] ?? getWorkContract(options, candidate.originWorkId);
|
|
1789
|
+
const currentFingerprint = fingerprintWork
|
|
1790
|
+
? mechanicalStateFingerprint(options, fingerprintWork, candidate.requirementId, candidate.relayScopeId, candidate.handoffId)
|
|
1791
|
+
: candidate.stateFingerprint;
|
|
1792
|
+
if (currentFingerprint === candidate.stateFingerprint) continue;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
const next = relayLock(options, candidate.relayScopeId, `controller-relay-recover:${candidate.originWorkId}`, () => {
|
|
1796
|
+
const latest = relayHistory(options, candidate.relayScopeId)[0];
|
|
1797
|
+
if (!latest || latest.originWorkId !== candidate.originWorkId || latest.updatedAt !== candidate.updatedAt || latest.status !== candidate.status) return undefined;
|
|
1798
|
+
const latestRepeatedStateBlocked = latest.status === 'blocked' && latest.blockedReason?.startsWith('repeated_state:') === true;
|
|
1799
|
+
if (latest.status === 'blocked' && !latestRepeatedStateBlocked) return undefined;
|
|
1800
|
+
if (input.controllerTypes && !input.controllerTypes.includes(relayControllerType(latest))) return undefined;
|
|
1801
|
+
const latestRecoveryAtMs = latest.nextRecoveryAt ? Date.parse(latest.nextRecoveryAt) : Number.NaN;
|
|
1802
|
+
if (Number.isFinite(latestRecoveryAtMs)) {
|
|
1803
|
+
if (nowMs < latestRecoveryAtMs) return undefined;
|
|
1804
|
+
} else {
|
|
1805
|
+
const latestRoundOpenedAtMs = Date.parse(latest.claimedAt ?? latest.dispatchedAt ?? latest.updatedAt);
|
|
1806
|
+
if (!Number.isFinite(latestRoundOpenedAtMs) || nowMs - latestRoundOpenedAtMs < graceMs) return undefined;
|
|
1807
|
+
}
|
|
1808
|
+
const latestRequirement = requirementForRelay(options, latest.requirementId);
|
|
1809
|
+
if (latestRequirement && !['planned', 'active'].includes(latestRequirement.state)) return undefined;
|
|
1810
|
+
const lockedWorkContracts = readWorkContractStore({ controllerHome: options.controllerHome, repoId: options.repoId }).contracts;
|
|
1811
|
+
const works = recoveryFenceWork(options, latest, lockedWorkContracts);
|
|
1812
|
+
const activeWorks = works.filter((work) => semanticWorkState(work) === 'open');
|
|
1813
|
+
if (activeWorks.length === 0) return undefined;
|
|
1814
|
+
if (activeWorks.some((work) => workHasActiveExecution(options.controllerHome, options.repoId, work.workId) || controllerSessionBlocksRecovery(options, work.workId, { nowMs, graceMs }))) return undefined;
|
|
1815
|
+
|
|
1816
|
+
// A claimed ControllerRound may become recoverable after its execution
|
|
1817
|
+
// activity grace expires while the durable owner lease itself is still
|
|
1818
|
+
// unexpired. Never rotate the opaque per-round capability while leaving
|
|
1819
|
+
// that old owner authoritative: doing so makes release/recovery mutually
|
|
1820
|
+
// inaccessible. Fence the exact observed stale owner first. A crash after
|
|
1821
|
+
// this release but before relay transition is safe and retryable because
|
|
1822
|
+
// the old relay authority remains unchanged and no stale writer survives.
|
|
1823
|
+
for (const work of activeWorks) {
|
|
1824
|
+
const staleOwner = getControllerSession(options, work.workId);
|
|
1825
|
+
if (!staleOwner) continue;
|
|
1826
|
+
const released = releaseObservedControllerSession(options, {
|
|
1827
|
+
workId: work.workId,
|
|
1828
|
+
actor: `controller-relay-stalled-recovery:${latest.originWorkId}`,
|
|
1829
|
+
owner: staleOwner,
|
|
1830
|
+
});
|
|
1831
|
+
if (!released.allowed) return undefined;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
const currentRecord = readRelayRecord(options, latest.originWorkId);
|
|
1835
|
+
if (!currentRecord || currentRecord.value.updatedAt !== latest.updatedAt || currentRecord.value.status !== latest.status) return undefined;
|
|
1836
|
+
const fingerprintWork = activeWorks[0] ?? getWorkContract(options, latest.originWorkId);
|
|
1837
|
+
const stateFingerprint = fingerprintWork
|
|
1838
|
+
? mechanicalStateFingerprint(options, fingerprintWork, latest.requirementId, latest.relayScopeId, latest.handoffId, lockedWorkContracts)
|
|
1839
|
+
: latest.stateFingerprint;
|
|
1840
|
+
const at = new Date(nowMs).toISOString();
|
|
1841
|
+
const lastError = latestRepeatedStateBlocked
|
|
1842
|
+
? undefined
|
|
1843
|
+
: latest.nextRecoveryAt
|
|
1844
|
+
? latest.lastError
|
|
1845
|
+
: latest.status === 'pending_release'
|
|
1846
|
+
? 'CONTROLLER_RELAY_RELEASE_TRANSITION_INCOMPLETE'
|
|
1847
|
+
: latest.status === 'dispatching'
|
|
1848
|
+
? 'CONTROLLER_RELAY_DISPATCH_TRANSITION_INCOMPLETE'
|
|
1849
|
+
: latest.status === 'claimed' ? 'CONTROLLER_RELAY_CLAIMED_ROUND_UNCLOSED' : 'CONTROLLER_RELAY_ROUND_UNCLOSED';
|
|
1850
|
+
if (latest.status === 'dispatching' && latest.providerDispatchStartedAt && latest.providerDispatchEffectId) {
|
|
1851
|
+
applyControllerRoundTransition(options, currentRecord, {
|
|
1852
|
+
type: 'provider_dispatch_outcome_unknown',
|
|
1853
|
+
at,
|
|
1854
|
+
error: 'CONTROLLER_RELAY_PROVIDER_DISPATCH_STALLED_AFTER_EFFECT_START',
|
|
1855
|
+
providerDispatchEffectId: latest.providerDispatchEffectId,
|
|
1856
|
+
});
|
|
1857
|
+
return undefined;
|
|
1858
|
+
}
|
|
1859
|
+
const recovered = applyControllerRoundTransition(options, currentRecord, {
|
|
1860
|
+
type: 'stalled_round_observed', at, stateFingerprint, proposedAuthorityId: newControllerRoundAuthorityId(),
|
|
1861
|
+
...(lastError ? { lastError } : {}),
|
|
1862
|
+
});
|
|
1863
|
+
return recovered.status === 'dispatching' ? recovered : undefined;
|
|
1864
|
+
});
|
|
1865
|
+
if (next) claimed.push(next);
|
|
1866
|
+
}
|
|
1867
|
+
return claimed;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
const CONTROLLER_ROUND_ORIGIN_OBJECTIVE_MAX_CHARS = 4_000;
|
|
1871
|
+
const CONTROLLER_ROUND_RELATED_OBJECTIVE_MAX_CHARS = 500;
|
|
1872
|
+
const CONTROLLER_ROUND_ORIGIN_ACCEPTANCE_MAX_ITEMS = 16;
|
|
1873
|
+
const CONTROLLER_ROUND_ACCEPTANCE_CRITERION_MAX_CHARS = 1_000;
|
|
1874
|
+
|
|
1875
|
+
export interface ControllerRoundContextSnapshot {
|
|
1876
|
+
repoId: string;
|
|
1877
|
+
relayScopeId: string;
|
|
1878
|
+
originWorkId: string;
|
|
1879
|
+
requirement?: {
|
|
1880
|
+
requirementId: string;
|
|
1881
|
+
state: string;
|
|
1882
|
+
outcomeStatement: string;
|
|
1883
|
+
};
|
|
1884
|
+
works: Array<{
|
|
1885
|
+
workId: string;
|
|
1886
|
+
state: string;
|
|
1887
|
+
phase: string;
|
|
1888
|
+
updatedAt: string;
|
|
1889
|
+
objective: string;
|
|
1890
|
+
acceptanceCriteria: string[];
|
|
1891
|
+
}>;
|
|
1892
|
+
handoffs: Array<{
|
|
1893
|
+
id: string;
|
|
1894
|
+
status: string;
|
|
1895
|
+
workId?: string;
|
|
1896
|
+
title: string;
|
|
1897
|
+
reason: string;
|
|
1898
|
+
}>;
|
|
1899
|
+
round: {
|
|
1900
|
+
count: number;
|
|
1901
|
+
maxRounds: number;
|
|
1902
|
+
repeatedStateCount: number;
|
|
1903
|
+
maxRepeatedState: number;
|
|
1904
|
+
consecutiveFailures: number;
|
|
1905
|
+
maxFailures: number;
|
|
1906
|
+
};
|
|
1907
|
+
recoveryReason?: string;
|
|
1908
|
+
assistantContext?: string;
|
|
1909
|
+
executionQualitySignals?: ExecutionQualitySignal[];
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
/** Provider-neutral launch context. ControllerHost adapters decide how to render it. */
|
|
1913
|
+
export function readControllerRoundContextSnapshot(
|
|
1914
|
+
options: ControllerRoundRelayStoreOptions,
|
|
1915
|
+
record: ControllerRoundRelayRecord,
|
|
1916
|
+
): ControllerRoundContextSnapshot {
|
|
1917
|
+
const requirement = requirementForRelay(options, record.requirementId);
|
|
1918
|
+
const relevantWorks = relevantWork(options, record);
|
|
1919
|
+
const works = relevantWorks.slice(0, 8);
|
|
1920
|
+
const handoffs = relevantHandoffs(options, relevantWorks, record.handoffId).slice(0, 8);
|
|
1921
|
+
return {
|
|
1922
|
+
repoId: record.repoId,
|
|
1923
|
+
relayScopeId: record.relayScopeId,
|
|
1924
|
+
originWorkId: record.originWorkId,
|
|
1925
|
+
assistantContext: options.prepareAssistantContext?.(record.originWorkId),
|
|
1926
|
+
executionQualitySignals: pendingQualitySignals(record),
|
|
1927
|
+
...(requirement ? {
|
|
1928
|
+
requirement: {
|
|
1929
|
+
requirementId: requirement.requirementId,
|
|
1930
|
+
state: requirement.state,
|
|
1931
|
+
outcomeStatement: requirement.outcomeStatement.slice(0, 800),
|
|
1932
|
+
},
|
|
1933
|
+
} : {}),
|
|
1934
|
+
works: works.map((work) => {
|
|
1935
|
+
const origin = work.workId === record.originWorkId;
|
|
1936
|
+
return {
|
|
1937
|
+
workId: work.workId,
|
|
1938
|
+
state: semanticWorkState(work),
|
|
1939
|
+
phase: work.phase,
|
|
1940
|
+
updatedAt: work.updatedAt,
|
|
1941
|
+
objective: work.objective.slice(0, origin
|
|
1942
|
+
? CONTROLLER_ROUND_ORIGIN_OBJECTIVE_MAX_CHARS
|
|
1943
|
+
: CONTROLLER_ROUND_RELATED_OBJECTIVE_MAX_CHARS),
|
|
1944
|
+
acceptanceCriteria: origin
|
|
1945
|
+
? work.acceptanceCriteria
|
|
1946
|
+
.slice(0, CONTROLLER_ROUND_ORIGIN_ACCEPTANCE_MAX_ITEMS)
|
|
1947
|
+
.map((criterion) => criterion.slice(0, CONTROLLER_ROUND_ACCEPTANCE_CRITERION_MAX_CHARS))
|
|
1948
|
+
: [],
|
|
1949
|
+
};
|
|
1950
|
+
}),
|
|
1951
|
+
handoffs: handoffs.map((handoff) => ({
|
|
1952
|
+
id: handoff.id,
|
|
1953
|
+
status: handoff.status,
|
|
1954
|
+
...(handoff.workId ? { workId: handoff.workId } : {}),
|
|
1955
|
+
title: handoff.title,
|
|
1956
|
+
reason: handoff.reason.slice(0, 300),
|
|
1957
|
+
})),
|
|
1958
|
+
round: {
|
|
1959
|
+
count: record.roundCount,
|
|
1960
|
+
maxRounds: record.maxRounds,
|
|
1961
|
+
repeatedStateCount: record.repeatedStateCount,
|
|
1962
|
+
maxRepeatedState: record.maxRepeatedState,
|
|
1963
|
+
consecutiveFailures: record.consecutiveFailures,
|
|
1964
|
+
maxFailures: record.maxFailures,
|
|
1965
|
+
},
|
|
1966
|
+
...(['CONTROLLER_RELAY_ROUND_UNCLOSED', 'CONTROLLER_RELAY_CLAIMED_ROUND_UNCLOSED'].includes(record.lastError ?? '')
|
|
1967
|
+
? { recoveryReason: record.lastError }
|
|
1968
|
+
: {}),
|
|
1969
|
+
};
|
|
1970
|
+
}
|