@moretea-labs/forge 1.8.0 → 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 +70 -7
- package/CHANGELOG.md +14 -0
- package/CLAUDE.md +17 -2
- package/CONTRIBUTING.md +3 -2
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/adapters/chatgpt/browser-delivery-host.ts +7 -1
- package/adapters/chatgpt/browser-delivery-runtime.ts +529 -56
- package/adapters/chatgpt/controller-binding-store.ts +101 -22
- package/adapters/chatgpt/controller-round-host.ts +15 -25
- package/adapters/chatgpt/controller-round-settlement-store.ts +58 -4
- package/adapters/chatgpt/provider-delivery.ts +228 -1
- package/adapters/chatgpt/work-conversation-binding-store.ts +79 -53
- 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-discovery.ts +11 -31
- package/adapters/computer/interaction-target-authority.ts +142 -18
- package/adapters/mcp/auth.ts +0 -1
- package/adapters/mcp/instructions.ts +5 -4
- package/adapters/mcp/multi-repository.ts +3 -19
- package/adapters/mcp/oauth.ts +73 -0
- package/adapters/mcp/paths.ts +4 -1
- 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 +411 -73
- package/adapters/mcp/runtime-gateway/context-pack-sidecar.ts +91 -0
- package/adapters/mcp/runtime-gateway/controller-authority-adapter.ts +20 -5
- package/adapters/mcp/runtime-gateway/core-capability-adapter.ts +258 -0
- package/adapters/mcp/runtime-gateway/engineering-preconditions.ts +10 -27
- package/adapters/mcp/runtime-gateway/engineering-tool-contract.ts +15 -0
- package/adapters/mcp/runtime-gateway/execution-tools.ts +8 -42
- package/adapters/mcp/runtime-gateway/gateway-execution-adapter.ts +2 -1
- package/adapters/mcp/runtime-gateway/plugin-adapter.ts +117 -45
- package/adapters/mcp/runtime-gateway/process-tools.ts +190 -22
- package/adapters/mcp/runtime-gateway/protected-computer-adapter.ts +16 -4
- package/adapters/mcp/runtime-gateway/recovery-adapter.ts +69 -52
- package/adapters/mcp/runtime-gateway/repository-compat-adapter.ts +4 -35
- package/adapters/mcp/runtime-gateway/result-adapter.ts +10 -4
- package/adapters/mcp/runtime-gateway/router.ts +18 -504
- package/adapters/mcp/runtime-gateway/routing-policy.ts +499 -0
- package/adapters/mcp/runtime-gateway/runtime-observation-adapter.ts +14 -65
- package/adapters/mcp/runtime-gateway/runtime-readiness-observation.ts +218 -0
- package/adapters/mcp/runtime-gateway/runtime-tool-definitions.ts +113 -173
- package/adapters/mcp/runtime-gateway/runtime-tools.ts +15 -5
- package/adapters/mcp/runtime-gateway/scheduler-adapter.ts +1 -0
- package/adapters/mcp/runtime-gateway/shared-adapter.ts +20 -0
- package/adapters/mcp/runtime-gateway/status-inbox-adapter.ts +358 -266
- package/adapters/mcp/runtime-gateway/typescript-navigation-sidecar.ts +136 -0
- package/adapters/mcp/runtime-gateway/work-adapter.ts +261 -3122
- 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-workflow-operations.ts +113 -0
- package/adapters/mcp/runtime-gateway/workflow-supervisor-adapter.ts +156 -0
- package/adapters/mcp/server.ts +67 -23
- package/adapters/mcp/setup.ts +1 -3
- package/adapters/mcp/tool-mapping/legacy-tool-service.ts +44 -170
- package/adapters/mcp/tool-mapping/post-finalize-work-attribution.ts +4 -9
- package/adapters/mcp/tool-mapping/repository-tools.ts +133 -142
- package/adapters/mcp/toolset-names.ts +5 -0
- package/adapters/mcp/toolset.ts +6 -5
- package/adapters/mcp/transports/http-observation.ts +365 -0
- package/adapters/mcp/transports/http.ts +154 -677
- package/adapters/mcp/transports/oauth-http.ts +322 -0
- package/adapters/mcp/transports/session-registry.ts +6 -0
- package/adapters/mcp/tunnels/openai-secure-tunnel.ts +89 -5
- package/assets/plugin-registry.v1.json +6 -4
- package/assets/reference-configs/agentic-development-flow.md +25 -0
- package/assets/reference-configs/global-working-rules.md +19 -0
- package/assets/skill-version.json +2 -2
- package/docs/architecture/CURRENT.md +111 -41
- 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/20260907-assistant-learning-loop.md +2 -0
- package/docs/architecture/decisions/20260907-controller-round-transition-policy.md +19 -4
- 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 +1 -0
- package/docs/architecture/domains/workflow-engine.md +6 -0
- package/docs/architecture/modules/controller-runtime/transport-lifecycle.md +1 -1
- package/docs/forge-plugin-management.md +2 -2
- 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/releasing.md +2 -0
- package/docs/operations/standalone-disaster-recovery.md +14 -6
- package/docs/wiki/Implementation.md +1 -1
- package/package.json +25 -14
- 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 +1 -0
- package/packages/kernel/controller/application/continuation-service.ts +281 -0
- package/packages/kernel/controller/application/controller-round-service.ts +12 -0
- package/packages/kernel/controller/domain/controller-round-transition-policy.ts +242 -21
- package/packages/kernel/controller/domain/controller-round.ts +4 -1
- package/packages/kernel/controller/domain/execution-quality.ts +3 -1
- package/packages/kernel/controller/infrastructure/controller-round-store.ts +689 -103
- package/packages/kernel/controller/infrastructure/controller-session-store.ts +297 -71
- package/packages/kernel/controller/ports/controller-host.ts +2 -0
- package/packages/kernel/identity/api/index.ts +10 -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/user-request.ts +93 -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/owned-resource-store.ts +259 -0
- package/packages/kernel/identity/infrastructure/user-request-store.ts +182 -0
- package/packages/kernel/scheduler/api/index.ts +0 -2
- package/packages/kernel/scheduler/application/schedule-service.ts +3 -0
- package/packages/kernel/scheduler/application/settlement.ts +0 -15
- package/packages/kernel/scheduler/domain/schedule.ts +87 -0
- package/packages/kernel/scheduler/infrastructure/schedule-store.ts +153 -133
- package/packages/kernel/work/api/index.ts +3 -2
- package/packages/kernel/work/application/work-service.ts +17 -42
- package/packages/kernel/work/domain/current-task-lineage.ts +104 -0
- package/packages/kernel/work/domain/engineering-design.ts +10 -5
- package/packages/kernel/work/domain/engineering-profile.ts +8 -12
- package/packages/kernel/work/domain/evaluation-promotion-receipt.ts +186 -0
- package/packages/kernel/work/domain/execution-concurrency.ts +19 -26
- package/packages/kernel/work/domain/execution-snapshot.ts +0 -31
- package/packages/kernel/work/domain/implementation-review.ts +14 -331
- package/packages/kernel/work/domain/objective-graph.ts +119 -0
- package/packages/kernel/work/domain/project-engineering-contract.ts +23 -1
- package/packages/kernel/work/domain/repository-completion-receipt.ts +1 -1
- package/packages/kernel/work/domain/state-machine.ts +25 -63
- package/packages/kernel/work/domain/types.ts +57 -51
- package/packages/kernel/work/infrastructure/work-contract-store.ts +819 -728
- package/packages/kernel/work/ports/work-contract-store.ts +5 -2
- package/packages/plugin-runtime/browser/session-authority.ts +2 -1
- 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 +2 -0
- package/packages/plugin-runtime/computer/target-authority.ts +26 -1
- package/packages/plugin-runtime/external/unix-jsonl-transport.ts +142 -18
- package/packages/protocols/computer/contract.ts +27 -1
- package/scripts/activate-source-baseline.ts +7 -9
- package/scripts/canary-controller-semantic-wait.ts +12 -24
- package/scripts/check-mcp-compatibility.ts +48 -136
- package/scripts/check-runtime-architecture.mjs +622 -217
- package/scripts/forge-local-recovery-helper.mjs +93 -15
- package/scripts/lib/project-init-lib.sh +3 -0
- package/scripts/open-source-audit-allowlist.txt +1 -2
- package/scripts/run-bounded-command.mjs +5 -0
- package/scripts/run-governed-gate.ts +0 -1
- 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 +5 -0
- package/scripts/smoke-runtime-recovery.ts +12 -43
- package/scripts/smoke-schedule-engine.ts +23 -15
- 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/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 +7 -3
- package/src/cli/commands/brain-assistant.ts +27 -13
- package/src/cli/commands/brain.ts +13 -15
- package/src/cli/commands/chatgpt.ts +1 -163
- package/src/cli/commands/recovery.ts +135 -121
- package/src/cli/commands/runtime.ts +3 -3
- package/src/cli/commands/setup-profile.ts +8 -0
- package/src/cli/controller/check-runner.ts +13 -2
- 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-pack.ts +10 -8
- 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 +39 -1
- package/src/cli/editing/edit-session.ts +1 -7
- package/src/cli/local-bridge/console-automations.ts +18 -13
- package/src/cli/local-bridge/console-view-models.ts +3 -12
- package/src/cli/local-bridge/facade-api.ts +64 -191
- package/src/cli/local-bridge/server.ts +24 -147
- package/src/cli/local-bridge/types.ts +0 -1
- package/src/cli/mcp/oauth-client-retirement.ts +74 -0
- package/src/cli/repositories/command-executor.ts +29 -23
- package/src/cli/repositories/command-scope.ts +35 -16
- package/src/cli/repositories/controller-home.ts +48 -0
- package/src/cli/repositories/ephemeral-workspace.ts +20 -30
- package/src/cli/repositories/git-command-executor.ts +2 -2
- package/src/cli/repositories/registry.ts +77 -11
- package/src/cli/repositories/structured-git.ts +62 -2
- package/src/cli/repository/session-cache.ts +92 -6
- package/src/runtime/context/assistant-context.ts +20 -7
- package/src/runtime/context/assistant-work-context.ts +89 -27
- package/src/runtime/context/automatic-learning.ts +565 -0
- 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 +46 -9
- package/src/runtime/context/context-pack-process.ts +103 -0
- package/src/runtime/context/context-plane.ts +1 -9
- package/src/runtime/context/direct-controller-learning.ts +298 -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/cleanup-artifact-retention.ts +212 -9
- package/src/runtime/control-plane/concurrency/work-execution-concurrency.ts +9 -47
- package/src/runtime/control-plane/execution/controller-authority-recovery.ts +34 -4
- package/src/runtime/control-plane/execution/edit-validation-coordinator.ts +29 -20
- package/src/runtime/control-plane/execution/execution-identity.ts +23 -3
- package/src/runtime/control-plane/execution/repository-work-attribution.ts +7 -30
- package/src/runtime/control-plane/execution/retained-work-resume.ts +206 -199
- package/src/runtime/control-plane/execution/session-store.ts +31 -1
- package/src/runtime/control-plane/execution/validation.ts +6 -8
- package/src/runtime/control-plane/execution/work-authority-reconciler.ts +13 -42
- package/src/runtime/control-plane/execution/work-check-evidence.ts +137 -0
- package/src/runtime/control-plane/execution/work-completion-authority.ts +30 -85
- package/src/runtime/control-plane/execution/work-delivery-service.ts +497 -0
- package/src/runtime/control-plane/execution/work-evidence-policy.ts +12 -50
- package/src/runtime/control-plane/execution/work-execution-support.ts +50 -149
- package/src/runtime/control-plane/execution/work-handle-authority.ts +188 -109
- package/src/runtime/control-plane/execution/work-handle-store.ts +19 -43
- package/src/runtime/control-plane/execution/work-head-settlement.ts +53 -20
- package/src/runtime/control-plane/execution/work-lifecycle-audit.ts +52 -29
- package/src/runtime/control-plane/execution/work-operation-service.ts +43 -27
- package/src/runtime/control-plane/execution/work-preparation-service.ts +126 -266
- 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 +173 -186
- package/src/runtime/control-plane/execution/work-validation-reconciler.ts +28 -84
- package/src/runtime/control-plane/execution/work-verification-service.ts +406 -50
- package/src/runtime/control-plane/execution/work-verification-snapshot.ts +5 -1
- package/src/runtime/control-plane/facade/capability-registry.ts +362 -37
- 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/goal-workloop-access.ts +1 -20
- package/src/runtime/control-plane/facade/goal-workloop.ts +281 -1753
- package/src/runtime/control-plane/facade/handoff-inbox-application.ts +12 -25
- package/src/runtime/control-plane/facade/handoff-inbox-store.ts +256 -52
- package/src/runtime/control-plane/facade/index.ts +0 -1
- package/src/runtime/control-plane/facade/operation-digest.ts +12 -15
- package/src/runtime/control-plane/facade/plan-contract-store.ts +451 -991
- package/src/runtime/control-plane/facade/policy-gate.ts +0 -23
- package/src/runtime/control-plane/facade/repository-work-admission.ts +12 -22
- package/src/runtime/control-plane/facade/requirement-authority.ts +102 -199
- package/src/runtime/control-plane/facade/requirement-board.ts +3 -3
- package/src/runtime/control-plane/facade/rh-work-operation-contract.ts +51 -14
- package/src/runtime/control-plane/facade/self-healing-loop.ts +18 -53
- package/src/runtime/control-plane/facade/suggested-actions.ts +15 -18
- package/src/runtime/control-plane/facade/types.ts +49 -108
- package/src/runtime/control-plane/facade/work-continuation.ts +8 -8
- package/src/runtime/control-plane/generated-cache-retention.ts +97 -1
- package/src/runtime/control-plane/global-scheduler/autonomous-continuation.ts +248 -0
- package/src/runtime/control-plane/global-scheduler/durable-admission.ts +1 -1
- package/src/runtime/control-plane/global-scheduler/maintenance.ts +239 -112
- package/src/runtime/control-plane/global-scheduler/reconciliation.ts +2 -1
- package/src/runtime/control-plane/global-scheduler/scheduler.ts +66 -40
- package/src/runtime/control-plane/global-scheduler/wake-signal.ts +42 -2
- 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 +63 -300
- package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +28 -300
- package/src/runtime/control-plane/launcher/local-conversation-controller.ts +238 -0
- package/src/runtime/control-plane/launcher/provider-mcp-bootstrap.ts +5 -2
- package/src/runtime/control-plane/launcher/thin-launcher.ts +96 -240
- package/src/runtime/control-plane/persistence/cognition-store.ts +820 -0
- package/src/runtime/control-plane/persistence/experience-store.ts +162 -29
- package/src/runtime/control-plane/persistence/requirement-store.ts +216 -74
- package/src/runtime/control-plane/persistence/sqlite-store.ts +113 -6
- package/src/runtime/control-plane/release-retention.ts +135 -47
- package/src/runtime/control-plane/repository-namespace-retention.ts +2 -2
- package/src/runtime/control-plane/runtime-cleanup.ts +290 -21
- package/src/runtime/control-plane/workspace/project-onboarding.ts +80 -0
- package/src/runtime/control-plane/workspace/semantic-sync-service.ts +19 -5
- package/src/runtime/control-plane/workspace/workspace-store.ts +169 -0
- package/src/runtime/diagnostics/incident-repair.ts +313 -146
- package/src/runtime/diagnostics/mcp-timing.ts +114 -0
- package/src/runtime/evidence/artifact-store.ts +158 -40
- package/src/runtime/evidence/evidence-store.ts +112 -8
- package/src/runtime/evidence/output-handle-index.ts +225 -0
- package/src/runtime/evidence/result-store.ts +158 -44
- package/src/runtime/execution/jobs/store.ts +3 -4
- package/src/runtime/execution/managed-workspace.ts +57 -0
- package/src/runtime/execution/process-runtime/check-facade.ts +1 -1
- package/src/runtime/execution/process-runtime/check-result-retention.ts +2 -2
- package/src/runtime/execution/process-runtime/check-result.ts +105 -1
- package/src/runtime/execution/process-runtime/check-runner-sidecar.ts +18 -8
- package/src/runtime/execution/process-runtime/command-facade.ts +79 -27
- 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 +3 -0
- package/src/runtime/execution/process-runtime/lightweight-managed.ts +251 -72
- package/src/runtime/execution/process-runtime/persisted-check.ts +3 -3
- 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 +65 -61
- package/src/runtime/execution/process-runtime/runtime.ts +35 -2
- package/src/runtime/execution/process-runtime/store.ts +30 -2
- package/src/runtime/execution/process-runtime/types.ts +6 -1
- package/src/runtime/execution/workers/executor.ts +2 -6
- package/src/runtime/platform/service-inventory.ts +130 -0
- package/src/runtime/platform/service-manager.ts +8 -0
- package/src/runtime/plugins/action-application.ts +85 -28
- package/src/runtime/plugins/app-store-connect-adapter.ts +38 -14
- package/src/runtime/plugins/browser-adapter.ts +757 -34
- package/src/runtime/plugins/browser-automation-service.ts +23 -3
- package/src/runtime/plugins/browser-macos-bridge.ts +321 -29
- package/src/runtime/plugins/browser-manifest-surface.ts +45 -11
- package/src/runtime/plugins/browser-native-session-service.ts +63 -13
- package/src/runtime/plugins/browser-registration.ts +16 -0
- package/src/runtime/plugins/browser-runtime.ts +14 -1
- package/src/runtime/plugins/browser-session-authority.ts +2 -2
- package/src/runtime/plugins/browser-session-store.ts +46 -76
- package/src/runtime/plugins/capability-authorization-grants.ts +258 -62
- 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 +222 -11
- package/src/runtime/plugins/desktop-operator-external-policy.ts +36 -4
- package/src/runtime/plugins/desktop-operator-registration.ts +12 -4
- package/src/runtime/plugins/external-adapter.ts +42 -6
- package/src/runtime/plugins/external-registration.ts +21 -1
- package/src/runtime/plugins/gmail-adapter.ts +23 -2
- package/src/runtime/plugins/google-calendar-adapter.ts +2 -2
- package/src/runtime/plugins/google-shared.ts +14 -7
- package/src/runtime/plugins/google-tasks-adapter.ts +2 -2
- package/src/runtime/plugins/ios-adapter.ts +1 -1
- package/src/runtime/plugins/lightweight-action.ts +208 -2
- package/src/runtime/plugins/local-system-adapter.ts +12 -2
- 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/store.ts +602 -428
- package/src/runtime/plugins/types.ts +20 -0
- package/src/runtime/plugins/xiaohongshu-publish.ts +16 -0
- package/src/runtime/projections/invalidation.ts +7 -3
- package/src/runtime/projections/materialized-view.ts +80 -36
- package/src/runtime/recovery/actions.ts +3 -3
- package/src/runtime/recovery/capability-action-application.ts +46 -45
- package/src/runtime/recovery/lifecycle-inventory.ts +29 -29
- package/src/runtime/recovery/maintenance-executor.ts +374 -123
- package/src/runtime/recovery/store.ts +29 -11
- 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/computer-composition.ts +191 -6
- package/src/runtime/root/controller-progression-composition.ts +192 -0
- package/src/runtime/root/controller-round-composition.ts +20 -1
- package/src/runtime/root/deployment-topology.ts +106 -0
- package/src/runtime/root/entry.ts +4 -3
- package/src/runtime/root/known-good-recovery.ts +184 -0
- package/src/runtime/root/local-bridge.ts +0 -1
- package/src/runtime/root/mcp-transport.ts +111 -18
- package/src/runtime/root/ownership.ts +261 -9
- package/src/runtime/root/package-connector-service.ts +105 -19
- package/src/runtime/root/package-runtime-release.ts +117 -18
- package/src/runtime/root/package-runtime-service.ts +17 -11
- package/src/runtime/root/release-execution-canary.ts +44 -12
- package/src/runtime/root/release-loader.ts +90 -8
- package/src/runtime/root/release-manifest.ts +80 -6
- package/src/runtime/root/release-materialize.ts +372 -28
- package/src/runtime/root/release-store.ts +395 -36
- package/src/runtime/root/runtime-lane.ts +191 -0
- package/src/runtime/root/runtime.ts +159 -27
- package/src/runtime/root/scheduled-controller-composition.ts +28 -3
- package/src/runtime/root/service-runner.ts +2 -2
- package/src/runtime/root/service.ts +36 -33
- package/src/runtime/root/status.ts +48 -0
- package/src/runtime/root/types.ts +32 -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/process-environment.ts +20 -9
- package/src/runtime/shared/response-budget.ts +1 -3
- package/src/runtime/standalone-recovery/controller-home-migration.ts +16 -24
- package/src/runtime/standalone-recovery/core.ts +3702 -342
- package/src/runtime/standalone-recovery/entry.ts +469 -39
- package/src/runtime/standalone-recovery/installer.ts +262 -148
- package/src/runtime/standalone-recovery/mcp-server.ts +29 -193
- package/src/runtime/standalone-recovery/operation-lock.ts +1 -1
- package/src/runtime/standalone-recovery/performance.ts +54 -16
- package/src/runtime/standalone-recovery/release.ts +53 -5
- package/src/runtime/standalone-recovery/service-labels.ts +5 -2
- package/src/runtime/standalone-recovery/watchdog-heartbeat.ts +5 -2
- package/src/runtime/workflow/schedules/browser-probe.ts +17 -6
- package/src/runtime/workflow/schedules/chatgpt-automation-migration.ts +10 -3
- package/src/runtime/workflow/schedules/engine.ts +138 -172
- package/src/runtime/workflow/schedules/work-continuation.ts +44 -5
- package/src/runtime/workflows/runtime.ts +15 -19
- package/src/testing/test-governance.ts +5 -1
- 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/adapters/chatgpt/controller-host.ts +0 -119
- package/adapters/mcp/controller-round-compatibility.ts +0 -184
- package/adapters/mcp/frozen-client-semantic-compatibility.ts +0 -292
- package/adapters/mcp/runtime-gateway/work-compat-adapter.ts +0 -502
- package/packages/kernel/progression/api/index.ts +0 -2
- package/packages/kernel/progression/application/projector.ts +0 -211
- package/packages/kernel/progression/domain/types.ts +0 -116
- package/packages/kernel/progression/index.ts +0 -1
- package/packages/kernel/scheduler/application/continuation-service.ts +0 -212
- package/packages/kernel/scheduler/domain/continuation.ts +0 -38
- package/packages/kernel/scheduler/infrastructure/continuation-dispatch-store.ts +0 -38
- package/packages/kernel/work/domain/admission-policy.ts +0 -34
- package/scripts/benchmark-background-check-overlap.ts +0 -228
- package/scripts/benchmark-computer-hotpath.ts +0 -255
- package/scripts/benchmark-controller-hotpath.ts +0 -212
- package/scripts/benchmark-controller-round2.ts +0 -191
- package/scripts/benchmark-ios-agent-device.ts +0 -511
- package/scripts/benchmark-mcp-host.sh +0 -117
- package/scripts/benchmark-mcp-transport.ts +0 -169
- package/scripts/benchmark-operational-memory-activation.ts +0 -235
- package/scripts/benchmark-operational-shadow.ts +0 -28
- 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 -150
- 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/stage-runtime-release.ts +0 -44
- 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/content-equivalent-commit-authority.ts +0 -169
- package/src/runtime/control-plane/execution/direct-canonical-work-reconciliation.ts +0 -85
- package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +0 -655
- package/src/runtime/control-plane/execution/work-finalization-service.ts +0 -2850
- package/src/runtime/control-plane/execution/work-path-scope.ts +0 -37
- package/src/runtime/control-plane/execution/work-task-receipt.ts +0 -468
- package/src/runtime/control-plane/facade/controller-round-relay.ts +0 -3
- package/src/runtime/control-plane/facade/work-admission-policy.ts +0 -142
- package/src/runtime/control-plane/facade/work-implementation-review.ts +0 -2
- package/src/runtime/control-plane/facade/work-state-machine.ts +0 -2
- 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 -401
- package/src/runtime/control-plane/routing/workspace-admission.ts +0 -70
|
@@ -8,12 +8,23 @@ import {
|
|
|
8
8
|
withControlPlaneTransaction,
|
|
9
9
|
writeControlPlaneRecord,
|
|
10
10
|
writeControlPlaneRecordWithinTransaction,
|
|
11
|
+
withControlPlaneReadDatabase,
|
|
11
12
|
type ControlPlaneRecord,
|
|
12
13
|
} from '../../../../src/runtime/control-plane/persistence/sqlite-store';
|
|
13
14
|
import { workHasActiveExecution } from '../../../../src/runtime/execution/work-activity';
|
|
14
|
-
import {
|
|
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';
|
|
15
26
|
import { getHandoffItem, listHandoffItems } from '../../../../src/runtime/control-plane/facade/handoff-inbox-store';
|
|
16
|
-
import { getWorkContract, readWorkContractStore,
|
|
27
|
+
import { currentTaskLineageWorkIds, getWorkContract, readActiveWorkCandidates, readWorkContractLineageSnapshot, readWorkContractStore, semanticWorkState, type WorkContract } from '../../work/api/index';
|
|
17
28
|
import { isTerminalHandoffStatus } from '../../../protocols/handoff/index';
|
|
18
29
|
import type { ControllerSession, ControllerType } from '../domain/types';
|
|
19
30
|
import { deriveClosedRoundQualitySignals, type AssistantContextSnapshot, type AssistantContextUsage, type ClosedRoundObservation, type ExecutionQualityAdjustmentResult, type ExecutionQualityDecision, type ExecutionQualitySignal } from '../domain/execution-quality';
|
|
@@ -28,7 +39,9 @@ import {
|
|
|
28
39
|
import {
|
|
29
40
|
controllerRoundBlockerClass,
|
|
30
41
|
controllerRoundProviderEffectId,
|
|
42
|
+
controllerRoundRelayClaimable,
|
|
31
43
|
decideControllerRoundTransition,
|
|
44
|
+
type ControllerRoundBlockerClass,
|
|
32
45
|
type ControllerRoundTransitionDecision,
|
|
33
46
|
type ControllerRoundTransitionEvent,
|
|
34
47
|
} from '../domain/controller-round-transition-policy';
|
|
@@ -54,6 +67,8 @@ export interface SubmitControllerRoundDispositionInput {
|
|
|
54
67
|
handoffId?: string;
|
|
55
68
|
stateFingerprint?: string;
|
|
56
69
|
reason?: string;
|
|
70
|
+
/** Exact facade-authenticated authority for completed-Work provider-wait goal closure only. */
|
|
71
|
+
terminalAuthorityId?: string;
|
|
57
72
|
/** Opaque provider binding owned by a ControllerHost adapter. */
|
|
58
73
|
bindingId?: string;
|
|
59
74
|
maxRounds?: number;
|
|
@@ -73,12 +88,24 @@ export interface BeginInitialControllerRoundDispatchInput {
|
|
|
73
88
|
maxFailures?: number;
|
|
74
89
|
/** Required for any later schedule/manual/replan occurrence after a prior semantic state exists. */
|
|
75
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;
|
|
76
100
|
}
|
|
77
101
|
|
|
78
102
|
export interface RecoverControllerRoundRelayAuthorityInput {
|
|
79
103
|
workId: string;
|
|
80
104
|
requestedBy?: string;
|
|
105
|
+
recoveryReason?: string;
|
|
81
106
|
identity: ControllerRoundRelayIdentity;
|
|
107
|
+
/** Set only after the caller proves this identity is served by the live canonical Runtime. */
|
|
108
|
+
allowCanonicalRuntimeMigration?: boolean;
|
|
82
109
|
}
|
|
83
110
|
|
|
84
111
|
/** Legacy ControllerRound rows predate explicit controllerType and were ChatGPT-only. */
|
|
@@ -95,6 +122,14 @@ const DEFAULT_UNCLOSED_ROUND_GRACE_MS = 10 * 60_000;
|
|
|
95
122
|
const MAX_UNCLOSED_ROUND_GRACE_MS = 60 * 60_000;
|
|
96
123
|
const DEFAULT_STALLED_RECOVERY_BACKOFF_MS = 60_000;
|
|
97
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>();
|
|
98
133
|
|
|
99
134
|
|
|
100
135
|
function transitionDecisionOrThrow(decision: ControllerRoundTransitionDecision): { record: ControllerRoundRelayRecord; action?: string; changed: boolean } {
|
|
@@ -179,6 +214,18 @@ export function getControllerRoundRelay(
|
|
|
179
214
|
return readRelayRecord(options, workId)?.value;
|
|
180
215
|
}
|
|
181
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
|
+
|
|
182
229
|
/**
|
|
183
230
|
* Resolve an existing Requirement-scoped ControllerRound authority for another
|
|
184
231
|
* Work that is mechanically part of the same durable Requirement/Work graph.
|
|
@@ -199,7 +246,11 @@ export function resolveRequirementControllerRoundRelayForWork(
|
|
|
199
246
|
&& Boolean(entry.requirementId)
|
|
200
247
|
&& `requirement:${entry.requirementId}` === relayScopeId);
|
|
201
248
|
if (!candidate) return undefined;
|
|
202
|
-
|
|
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;
|
|
203
254
|
}
|
|
204
255
|
|
|
205
256
|
function relayHistory(options: ControllerRoundRelayStoreOptions, relayScopeId: string): ControllerRoundRelayRecord[] {
|
|
@@ -214,7 +265,28 @@ function relayHistory(options: ControllerRoundRelayStoreOptions, relayScopeId: s
|
|
|
214
265
|
}
|
|
215
266
|
|
|
216
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
|
+
|
|
217
287
|
const latest = new Map<string, ControllerRoundRelayRecord>();
|
|
288
|
+
const retiredTerminalWork = (entry: ControllerRoundRelayRecord): boolean =>
|
|
289
|
+
entry.status === 'failed' && entry.failureClass === 'terminal_work';
|
|
218
290
|
for (const entry of listControlPlaneRecords<ControllerRoundRelayRecord>(options.controllerHome, {
|
|
219
291
|
namespace: NAMESPACE,
|
|
220
292
|
scope: options.repoId,
|
|
@@ -222,48 +294,156 @@ function latestRelayRecordsByScope(options: ControllerRoundRelayStoreOptions): C
|
|
|
222
294
|
}).map((record) => record.value)) {
|
|
223
295
|
if (entry.status === 'handed_off') continue;
|
|
224
296
|
const current = latest.get(entry.relayScopeId);
|
|
225
|
-
if (!current
|
|
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);
|
|
226
315
|
}
|
|
227
|
-
return
|
|
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);
|
|
228
371
|
}
|
|
229
372
|
|
|
373
|
+
|
|
230
374
|
function requirementForRelay(options: ControllerRoundRelayStoreOptions, requirementId: string | undefined) {
|
|
231
375
|
return requirementId ? readRequirement({ controllerHome: options.controllerHome }, requirementId)?.value : undefined;
|
|
232
376
|
}
|
|
233
377
|
|
|
234
|
-
function relevantWork(
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
expanded = true;
|
|
259
|
-
}
|
|
260
|
-
}
|
|
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}`);
|
|
261
402
|
}
|
|
262
403
|
return all
|
|
263
404
|
.filter((work) => linkedWorkIds.has(work.workId))
|
|
264
405
|
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
265
406
|
}
|
|
266
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
|
+
|
|
267
447
|
function semanticVerificationFacts(work: WorkContract): Array<{
|
|
268
448
|
checkId: string; verificationInputFingerprint: string; checkDefinitionDigest: string;
|
|
269
449
|
checkEnvironmentFingerprint: string; checkCacheKey: string; outcome: string; status: string;
|
|
@@ -297,13 +477,15 @@ function mechanicalStateFingerprint(
|
|
|
297
477
|
work: WorkContract,
|
|
298
478
|
requirementId: string | undefined,
|
|
299
479
|
relayScopeId: string,
|
|
480
|
+
explicitHandoffId?: string,
|
|
481
|
+
allWorkContracts?: readonly WorkContract[],
|
|
300
482
|
): string {
|
|
301
483
|
const requirement = requirementForRelay(options, requirementId);
|
|
302
|
-
const works = relevantWork(options, { relayScopeId, originWorkId: work.workId, requirementId })
|
|
484
|
+
const works = relevantWork(options, { relayScopeId, originWorkId: work.workId, requirementId }, allWorkContracts)
|
|
303
485
|
.map((entry) => ({
|
|
304
486
|
workId: entry.workId,
|
|
305
487
|
parentWorkId: entry.parentWorkId,
|
|
306
|
-
|
|
488
|
+
state: entry.semanticState,
|
|
307
489
|
phase: entry.phase,
|
|
308
490
|
dispatchState: entry.dispatchState,
|
|
309
491
|
evidenceState: entry.evidenceState,
|
|
@@ -322,9 +504,7 @@ function mechanicalStateFingerprint(
|
|
|
322
504
|
} : undefined,
|
|
323
505
|
}))
|
|
324
506
|
.sort((left, right) => left.workId.localeCompare(right.workId));
|
|
325
|
-
const
|
|
326
|
-
const handoffs = listHandoffItems({ controllerHome: options.controllerHome, repoId: options.repoId, status: 'active', limit: 100 })
|
|
327
|
-
.filter((handoff) => !handoff.workId || linkedWorkIds.has(handoff.workId))
|
|
507
|
+
const handoffs = relevantHandoffs(options, works, explicitHandoffId)
|
|
328
508
|
.map((handoff) => ({
|
|
329
509
|
id: handoff.id,
|
|
330
510
|
workId: handoff.workId,
|
|
@@ -355,7 +535,7 @@ export function readControllerRoundSemanticStateFingerprint(
|
|
|
355
535
|
const work = getWorkContract(options, workId);
|
|
356
536
|
const current = readRelayRecord(options, workId)?.value;
|
|
357
537
|
if (!work || !current) return undefined;
|
|
358
|
-
return mechanicalStateFingerprint(options, work, current.requirementId, current.relayScopeId);
|
|
538
|
+
return mechanicalStateFingerprint(options, work, current.requirementId, current.relayScopeId, current.handoffId);
|
|
359
539
|
}
|
|
360
540
|
|
|
361
541
|
function resolveRequirementId(
|
|
@@ -416,8 +596,8 @@ function assertControllerRoundSuccessorLineage(
|
|
|
416
596
|
): WorkContract {
|
|
417
597
|
const successor = getWorkContract(options, successorWorkId);
|
|
418
598
|
if (!successor) throw new Error(`CONTROLLER_RELAY_SUCCESSOR_WORK_NOT_FOUND: ${successorWorkId}`);
|
|
419
|
-
if (
|
|
420
|
-
throw new Error(`CONTROLLER_RELAY_SUCCESSOR_WORK_TERMINAL: ${successor.workId}:${successor
|
|
599
|
+
if (semanticWorkState(successor) !== 'open') {
|
|
600
|
+
throw new Error(`CONTROLLER_RELAY_SUCCESSOR_WORK_TERMINAL: ${successor.workId}:${semanticWorkState(successor)}`);
|
|
421
601
|
}
|
|
422
602
|
if (successor.predecessorWorkId !== predecessor.workId) {
|
|
423
603
|
throw new Error(`CONTROLLER_RELAY_SUCCESSOR_LINEAGE_MISMATCH: ${successor.workId}:predecessor=${successor.predecessorWorkId ?? 'none'}:expected=${predecessor.workId}`);
|
|
@@ -444,16 +624,17 @@ export function bindControllerRoundSuccessorWork(
|
|
|
444
624
|
): ControllerRoundRelayRecord {
|
|
445
625
|
const predecessor = getWorkContract(options, input.workId);
|
|
446
626
|
if (!predecessor) throw new Error(`WORK_NOT_FOUND: ${input.workId}`);
|
|
447
|
-
if (predecessor
|
|
448
|
-
throw new Error(`CONTROLLER_RELAY_SUCCESSOR_PREDECESSOR_NOT_COMPLETED: ${predecessor.workId}:${predecessor
|
|
627
|
+
if (semanticWorkState(predecessor) !== 'completed') {
|
|
628
|
+
throw new Error(`CONTROLLER_RELAY_SUCCESSOR_PREDECESSOR_NOT_COMPLETED: ${predecessor.workId}:${semanticWorkState(predecessor)}`);
|
|
449
629
|
}
|
|
450
630
|
const initial = readRelayRecord(options, predecessor.workId);
|
|
451
631
|
if (!initial) throw new Error(`CONTROLLER_RELAY_ROUND_NOT_OPEN: ${predecessor.workId}`);
|
|
452
632
|
return relayLock(options, initial.value.relayScopeId, `controller-relay-bind-successor:${input.identity.controllerId}`, () => {
|
|
453
633
|
const current = readRelayRecord(options, predecessor.workId);
|
|
454
|
-
if (!current || current.value.status
|
|
455
|
-
throw new Error(`
|
|
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'}`);
|
|
456
636
|
}
|
|
637
|
+
const failedPreclaim = current.value.status === 'failed';
|
|
457
638
|
const expectedAuthorityId = current.value.authorityId?.trim() || '';
|
|
458
639
|
const requestedAuthorityId = input.controllerAuthorityId?.trim() || '';
|
|
459
640
|
if (expectedAuthorityId) {
|
|
@@ -463,19 +644,29 @@ export function bindControllerRoundSuccessorWork(
|
|
|
463
644
|
if (!principalId || current.value.controllerId !== input.identity.controllerId || current.value.principalId !== principalId) {
|
|
464
645
|
throw new Error(`CONTROLLER_RELAY_SUCCESSOR_BIND_AUTHORITY_MISMATCH: ${predecessor.workId}`);
|
|
465
646
|
}
|
|
466
|
-
if (relayControllerType(current.value) !== input.identity.controllerType
|
|
647
|
+
if (relayControllerType(current.value) !== input.identity.controllerType) {
|
|
467
648
|
throw new Error(`CONTROLLER_RELAY_SUCCESSOR_BIND_AUTHORITY_MISMATCH: ${predecessor.workId}`);
|
|
468
649
|
}
|
|
469
650
|
const liveOwner = getControllerSession(options, predecessor.workId);
|
|
470
|
-
if (
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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) {
|
|
475
657
|
throw new Error(`CONTROLLER_RELAY_SUCCESSOR_BIND_AUTHORITY_MISMATCH: ${predecessor.workId}`);
|
|
476
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
|
+
}
|
|
477
667
|
}
|
|
478
668
|
} else {
|
|
669
|
+
if (failedPreclaim) throw new Error(`CONTROLLER_RELAY_SUCCESSOR_BIND_AUTHORITY_REQUIRED: ${predecessor.workId}`);
|
|
479
670
|
// Legacy claimed rounds have no opaque per-round capability. Preserve only
|
|
480
671
|
// their exact live owner epoch; rollover is available after the round has
|
|
481
672
|
// been upgraded by the canonical launcher/recovery path.
|
|
@@ -487,6 +678,44 @@ export function bindControllerRoundSuccessorWork(
|
|
|
487
678
|
}
|
|
488
679
|
}
|
|
489
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
|
+
}
|
|
490
719
|
return applyControllerRoundTransition(options, current, {
|
|
491
720
|
type: 'successor_bound', at: nowIso(options), successorWorkId: successor.workId,
|
|
492
721
|
});
|
|
@@ -518,13 +747,21 @@ function validatedAssistantContextUsage(
|
|
|
518
747
|
for (const item of usage) {
|
|
519
748
|
const key = `${item.kind}:${item.itemId}`;
|
|
520
749
|
if (!expected.has(key) || seen.has(key) || !['used', 'rejected'].includes(item.decision)
|
|
521
|
-
|| typeof item.reason !== 'string' || !item.reason.trim() || item.reason.length > 1_000
|
|
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)) {
|
|
522
753
|
throw new Error('CONTROLLER_ASSISTANT_CONTEXT_USAGE_INVALID');
|
|
523
754
|
}
|
|
524
755
|
seen.add(key);
|
|
525
756
|
}
|
|
526
757
|
if (seen.size !== expected.size) throw new Error('CONTROLLER_ASSISTANT_CONTEXT_USAGE_INCOMPLETE');
|
|
527
|
-
return usage.map((item) => ({
|
|
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
|
+
}));
|
|
528
765
|
}
|
|
529
766
|
|
|
530
767
|
function persistClaimedAssistantContextSnapshot(
|
|
@@ -603,7 +840,7 @@ export function submitControllerRoundDisposition(
|
|
|
603
840
|
if (!work) throw new Error(`WORK_NOT_FOUND: ${input.workId}`);
|
|
604
841
|
const requirementId = resolveRequirementId(options, work, input.requirementId);
|
|
605
842
|
const relayScopeId = resolveRelayScope(work, requirementId, input.relayScopeId);
|
|
606
|
-
const terminal =
|
|
843
|
+
const terminal = semanticWorkState(work) !== 'open';
|
|
607
844
|
|
|
608
845
|
return relayLock(options, relayScopeId, `controller-relay-submit:${input.identity.controllerId}`, () => {
|
|
609
846
|
const existing = readRelayRecord(options, work.workId);
|
|
@@ -611,20 +848,30 @@ export function submitControllerRoundDisposition(
|
|
|
611
848
|
if (existing.value.relayScopeId !== relayScopeId) {
|
|
612
849
|
throw new Error(`CONTROLLER_RELAY_SCOPE_MISMATCH: Work ${work.workId} is already bound to ${existing.value.relayScopeId}`);
|
|
613
850
|
}
|
|
614
|
-
|
|
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') {
|
|
615
862
|
throw new Error(`CONTROLLER_RELAY_ROUND_NOT_CLAIMED: ${existing.value.status}`);
|
|
616
863
|
}
|
|
617
864
|
const terminalSuccessor = terminal
|
|
618
|
-
&& work
|
|
865
|
+
&& semanticWorkState(work) === 'completed'
|
|
619
866
|
&& input.disposition === 'continue_immediately'
|
|
620
867
|
&& existing.value.successorWorkId
|
|
621
868
|
? assertControllerRoundSuccessorLineage(options, work, existing.value.successorWorkId)
|
|
622
869
|
: undefined;
|
|
623
870
|
const terminalRoundClosureAllowed = terminal
|
|
624
|
-
&& work
|
|
871
|
+
&& semanticWorkState(work) === 'completed'
|
|
625
872
|
&& (input.disposition === 'goal_complete' || Boolean(terminalSuccessor));
|
|
626
873
|
if (terminal && !terminalRoundClosureAllowed) {
|
|
627
|
-
throw new Error(`CONTROLLER_RELAY_WORK_TERMINAL: ${work
|
|
874
|
+
throw new Error(`CONTROLLER_RELAY_WORK_TERMINAL: ${semanticWorkState(work)}`);
|
|
628
875
|
}
|
|
629
876
|
|
|
630
877
|
const liveOwner = getControllerSession(options, work.workId);
|
|
@@ -650,7 +897,7 @@ export function submitControllerRoundDisposition(
|
|
|
650
897
|
}
|
|
651
898
|
if (existing.value.controllerId !== input.identity.controllerId) throw new Error(`CONTROLLER_RELAY_CLAIM_CONTROLLER_MISMATCH: ${work.workId}`);
|
|
652
899
|
if (existing.value.principalId !== principalId) throw new Error(`CONTROLLER_RELAY_CLAIM_PRINCIPAL_MISMATCH: ${work.workId}`);
|
|
653
|
-
if (existing.value.claimGeneration < 1) throw new Error(`CONTROLLER_RELAY_CLAIM_GENERATION_REQUIRED: ${work.workId}`);
|
|
900
|
+
if (existing.value.claimGeneration < 1 && !providerWaitTerminalGoalComplete) throw new Error(`CONTROLLER_RELAY_CLAIM_GENERATION_REQUIRED: ${work.workId}`);
|
|
654
901
|
return {
|
|
655
902
|
controllerId: existing.value.controllerId,
|
|
656
903
|
controllerType: expectedControllerType,
|
|
@@ -730,9 +977,10 @@ export function submitControllerRoundDisposition(
|
|
|
730
977
|
const requestedMaxRounds = boundedInteger(input.maxRounds, DEFAULT_MAX_ROUNDS, 1, 32);
|
|
731
978
|
const requestedMaxRepeatedState = boundedInteger(input.maxRepeatedState, DEFAULT_MAX_REPEATED_STATE, 1, 8);
|
|
732
979
|
const requestedMaxFailures = boundedInteger(input.maxFailures, DEFAULT_MAX_FAILURES, 1, 8);
|
|
733
|
-
const stateFingerprint = bounded(input.stateFingerprint, 256) ?? mechanicalStateFingerprint(options, terminalSuccessor ?? work, requirementId, relayScopeId);
|
|
734
|
-
const bindingId = bounded(input.bindingId, 500) ?? existing.value.bindingId;
|
|
735
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;
|
|
736
984
|
if (input.disposition === 'wait_for_user') {
|
|
737
985
|
if (!handoffId) throw new Error('CONTROLLER_RELAY_WAIT_FOR_USER_HANDOFF_REQUIRED');
|
|
738
986
|
const handoff = getHandoffItem(options, handoffId);
|
|
@@ -750,6 +998,7 @@ export function submitControllerRoundDisposition(
|
|
|
750
998
|
type: 'semantic_disposition_submitted', at, disposition: input.disposition, stateFingerprint,
|
|
751
999
|
maxRounds: requestedMaxRounds, maxRepeatedState: requestedMaxRepeatedState, maxFailures: requestedMaxFailures,
|
|
752
1000
|
controllerSession: authority,
|
|
1001
|
+
...(providerWaitTerminalGoalComplete ? { terminalGoalComplete: true } : {}),
|
|
753
1002
|
qualityDecisions: accumulatedQualityDecisions, qualityAdjustmentResults: accumulatedQualityAdjustmentResults, observationWindow,
|
|
754
1003
|
...(handoffId ? { handoffId } : {}),
|
|
755
1004
|
...(bounded(input.reason, 1_000) ? { reason: bounded(input.reason, 1_000) } : {}),
|
|
@@ -758,35 +1007,94 @@ export function submitControllerRoundDisposition(
|
|
|
758
1007
|
});
|
|
759
1008
|
}
|
|
760
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
|
+
|
|
761
1051
|
export function beginInitialControllerRoundDispatch(
|
|
762
1052
|
options: ControllerRoundRelayStoreOptions,
|
|
763
1053
|
input: BeginInitialControllerRoundDispatchInput,
|
|
764
1054
|
): ControllerRoundRelayRecord {
|
|
765
1055
|
const work = getWorkContract(options, input.workId);
|
|
766
1056
|
if (!work) throw new Error(`WORK_NOT_FOUND: ${input.workId}`);
|
|
767
|
-
if (
|
|
1057
|
+
if (semanticWorkState(work) !== 'open') throw new Error(`CONTROLLER_RELAY_WORK_TERMINAL: ${semanticWorkState(work)}`);
|
|
768
1058
|
const requirementId = resolveRequirementId(options, work, input.requirementId);
|
|
769
1059
|
const requirement = requirementForRelay(options, requirementId);
|
|
770
1060
|
if (requirement && !['planned', 'active'].includes(requirement.state)) {
|
|
771
1061
|
throw new Error(`CONTROLLER_RELAY_REQUIREMENT_TERMINAL: ${requirement.state}`);
|
|
772
1062
|
}
|
|
773
1063
|
const relayScopeId = resolveRelayScope(work, requirementId, input.relayScopeId);
|
|
1064
|
+
const occurrenceId = bounded(input.occurrenceId, 500);
|
|
774
1065
|
|
|
775
1066
|
return relayLock(options, relayScopeId, `controller-relay-launch:${input.identity.controllerId}`, () => {
|
|
776
1067
|
const existing = readRelayRecord(options, work.workId);
|
|
777
1068
|
if (existing && existing.value.relayScopeId !== relayScopeId) {
|
|
778
1069
|
throw new Error(`CONTROLLER_RELAY_SCOPE_MISMATCH: Work ${work.workId} is already bound to ${existing.value.relayScopeId}`);
|
|
779
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
|
+
}
|
|
780
1087
|
const previous = relayHistory(options, relayScopeId)[0];
|
|
781
1088
|
const abandonedReleasedRound = previous?.status === 'failed' && previous.failureClass === 'abandoned_release';
|
|
782
1089
|
const stateFingerprint = mechanicalStateFingerprint(options, work, requirementId, relayScopeId);
|
|
783
|
-
const occurrenceId = bounded(input.occurrenceId, 500);
|
|
784
1090
|
return applyControllerRoundTransition(options, existing, {
|
|
785
1091
|
type: 'occurrence_requested', at: nowIso(options), repoId: options.repoId, relayScopeId, originWorkId: work.workId,
|
|
786
1092
|
...(requirementId ? { requirementId } : {}), identity: input.identity, stateFingerprint, proposedAuthorityId: newControllerRoundAuthorityId(),
|
|
787
1093
|
maxRounds: boundedInteger(input.maxRounds, DEFAULT_MAX_ROUNDS, 1, 32), maxRepeatedState: boundedInteger(input.maxRepeatedState, DEFAULT_MAX_REPEATED_STATE, 1, 8),
|
|
788
1094
|
maxFailures: boundedInteger(input.maxFailures, DEFAULT_MAX_FAILURES, 1, 8),
|
|
789
1095
|
...(bounded(input.bindingId, 500) ? { bindingId: bounded(input.bindingId, 500) } : {}), ...(occurrenceId ? { occurrenceId } : {}),
|
|
1096
|
+
...(input.allowSemanticWaitRecovery ? { allowSemanticWaitRecovery: true } : {}),
|
|
1097
|
+
...(input.authorizeRoundBudgetOccurrence ? { authorizeRoundBudgetOccurrence: true } : {}),
|
|
790
1098
|
abandonedReleaseRecovery: Boolean(abandonedReleasedRound),
|
|
791
1099
|
});
|
|
792
1100
|
});
|
|
@@ -813,11 +1121,11 @@ export function beginControllerRoundRelayAfterRelease(
|
|
|
813
1121
|
const at = nowIso(options);
|
|
814
1122
|
if (record.successorWorkId) {
|
|
815
1123
|
const predecessor = getWorkContract(options, input.workId);
|
|
816
|
-
if (!predecessor || predecessor
|
|
817
|
-
throw new Error(`CONTROLLER_RELAY_SUCCESSOR_PREDECESSOR_NOT_COMPLETED: ${input.workId}:${predecessor
|
|
1124
|
+
if (!predecessor || semanticWorkState(predecessor) !== 'completed') {
|
|
1125
|
+
throw new Error(`CONTROLLER_RELAY_SUCCESSOR_PREDECESSOR_NOT_COMPLETED: ${input.workId}:${predecessor ? semanticWorkState(predecessor) : 'missing'}`);
|
|
818
1126
|
}
|
|
819
1127
|
const successor = assertControllerRoundSuccessorLineage(options, predecessor, record.successorWorkId);
|
|
820
|
-
const successorStateFingerprint = mechanicalStateFingerprint(options, successor, record.requirementId, record.relayScopeId);
|
|
1128
|
+
const successorStateFingerprint = mechanicalStateFingerprint(options, successor, record.requirementId, record.relayScopeId, record.handoffId);
|
|
821
1129
|
return withControlPlaneTransaction(options.controllerHome, (database) => {
|
|
822
1130
|
const predecessorRelay = readControlPlaneRecordWithinTransaction<ControllerRoundRelayRecord>(
|
|
823
1131
|
database, NAMESPACE, options.repoId, input.workId,
|
|
@@ -885,10 +1193,10 @@ export function reconcileControllerRoundAfterAbandonedRelease(
|
|
|
885
1193
|
}
|
|
886
1194
|
const work = getWorkContract(options, input.workId);
|
|
887
1195
|
// Completed Work still requires semantic goal_complete or a valid terminal
|
|
888
|
-
// successor.
|
|
1196
|
+
// successor. Cancelled Work has no legal semantic continuation, so
|
|
889
1197
|
// its exact released controller epoch may mechanically abandon the claimed
|
|
890
1198
|
// round and free the relay scope for later recovery/replanning.
|
|
891
|
-
if (!work || work
|
|
1199
|
+
if (!work || semanticWorkState(work) === 'completed') return undefined;
|
|
892
1200
|
|
|
893
1201
|
return applyControllerRoundTransition(options, current, {
|
|
894
1202
|
type: 'abandoned_release_observed', at: nowIso(options), error: CONTROLLER_RELAY_ABANDONED_RELEASE_ERROR,
|
|
@@ -898,7 +1206,7 @@ export function reconcileControllerRoundAfterAbandonedRelease(
|
|
|
898
1206
|
|
|
899
1207
|
/**
|
|
900
1208
|
* Mechanically retire any surviving ControllerRound once Work lifecycle authority
|
|
901
|
-
* is durably
|
|
1209
|
+
* is durably cancelled and no Controller lease remains. This is cleanup,
|
|
902
1210
|
* not a semantic disposition: completed Work is intentionally excluded.
|
|
903
1211
|
*/
|
|
904
1212
|
export function reconcileControllerRoundAfterTerminalWork(
|
|
@@ -908,7 +1216,7 @@ export function reconcileControllerRoundAfterTerminalWork(
|
|
|
908
1216
|
const initial = readRelayRecord(options, input.workId);
|
|
909
1217
|
if (!initial) return undefined;
|
|
910
1218
|
const work = getWorkContract(options, input.workId);
|
|
911
|
-
if (!work ||
|
|
1219
|
+
if (!work || semanticWorkState(work) !== 'cancelled') return undefined;
|
|
912
1220
|
if (getControllerSession(options, input.workId)) {
|
|
913
1221
|
throw new Error(`CONTROLLER_RELAY_TERMINAL_WORK_ACTIVE_CLAIM: ${input.workId}`);
|
|
914
1222
|
}
|
|
@@ -917,7 +1225,7 @@ export function reconcileControllerRoundAfterTerminalWork(
|
|
|
917
1225
|
const current = readRelayRecord(options, input.workId);
|
|
918
1226
|
if (!current) return undefined;
|
|
919
1227
|
const currentWork = getWorkContract(options, input.workId);
|
|
920
|
-
if (!currentWork ||
|
|
1228
|
+
if (!currentWork || semanticWorkState(currentWork) !== 'cancelled') return undefined;
|
|
921
1229
|
if (getControllerSession(options, input.workId)) {
|
|
922
1230
|
throw new Error(`CONTROLLER_RELAY_TERMINAL_WORK_ACTIVE_CLAIM: ${input.workId}`);
|
|
923
1231
|
}
|
|
@@ -928,15 +1236,47 @@ export function reconcileControllerRoundAfterTerminalWork(
|
|
|
928
1236
|
});
|
|
929
1237
|
}
|
|
930
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
|
+
|
|
931
1263
|
export function finishControllerRoundRelayDispatch(
|
|
932
1264
|
options: ControllerRoundRelayStoreOptions,
|
|
933
1265
|
input: { workId: string; ok: boolean; bindingId?: string; providerDispatchEffectId?: string; providerDispatchReceiptId?: string; error?: string; recovery?: boolean; outcomeUnknown?: boolean; waitForUser?: boolean; handoffId?: string; nowMs?: number },
|
|
934
1266
|
): ControllerRoundRelayRecord | undefined {
|
|
935
1267
|
const initial = readRelayRecord(options, input.workId);
|
|
936
|
-
|
|
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;
|
|
937
1273
|
return relayLock(options, initial.value.relayScopeId, `controller-relay-finish:${input.workId}`, () => {
|
|
938
1274
|
const current = readRelayRecord(options, input.workId);
|
|
939
|
-
|
|
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;
|
|
940
1280
|
const at = typeof input.nowMs === 'number' ? new Date(input.nowMs).toISOString() : nowIso(options);
|
|
941
1281
|
const handoffId = bounded(input.handoffId, 200);
|
|
942
1282
|
if (input.waitForUser) {
|
|
@@ -954,7 +1294,8 @@ export function finishControllerRoundRelayDispatch(
|
|
|
954
1294
|
? 'CONTROLLER_RELAY_RECOVERY_FAILED'
|
|
955
1295
|
: 'CONTROLLER_RELAY_DISPATCH_FAILED');
|
|
956
1296
|
const providerDispatchEffectId = bounded(input.providerDispatchEffectId, 500)
|
|
957
|
-
??
|
|
1297
|
+
?? current.value.providerDispatchEffectId
|
|
1298
|
+
?? controllerRoundProviderEffectId(current.value);
|
|
958
1299
|
let event: ControllerRoundTransitionEvent;
|
|
959
1300
|
if (input.ok) {
|
|
960
1301
|
if (!providerDispatchEffectId) throw new Error('CONTROLLER_RELAY_PROVIDER_EFFECT_ID_REQUIRED');
|
|
@@ -974,6 +1315,92 @@ export function finishControllerRoundRelayDispatch(
|
|
|
974
1315
|
});
|
|
975
1316
|
}
|
|
976
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
|
+
|
|
977
1404
|
export function acknowledgeControllerRoundClaim(
|
|
978
1405
|
options: ControllerRoundRelayStoreOptions,
|
|
979
1406
|
input: { workId: string; session: ControllerSession; assistantContextSnapshot?: AssistantContextSnapshot | null },
|
|
@@ -993,10 +1420,7 @@ export function acknowledgeControllerRoundClaim(
|
|
|
993
1420
|
&& sameAssistantContextSnapshot(current.value.assistantContextSnapshot, input.assistantContextSnapshot)) return current.value;
|
|
994
1421
|
|
|
995
1422
|
const blocker = controllerRoundBlockerClass(current.value);
|
|
996
|
-
|
|
997
|
-
|| blocker === 'provider_dispatch_outcome_unknown'
|
|
998
|
-
|| blocker === 'repeated_state';
|
|
999
|
-
if (!policyClaimable) return current.value;
|
|
1423
|
+
if (!controllerRoundRelayClaimable(current.value)) return current.value;
|
|
1000
1424
|
|
|
1001
1425
|
const owner = getControllerSession(options, input.workId);
|
|
1002
1426
|
const ownerPrincipal = owner?.principalId?.trim() || owner?.controllerId;
|
|
@@ -1024,8 +1448,8 @@ export function acknowledgeControllerRoundClaim(
|
|
|
1024
1448
|
|
|
1025
1449
|
if (blocker === 'repeated_state') {
|
|
1026
1450
|
const work = getWorkContract(options, input.workId);
|
|
1027
|
-
if (!work ||
|
|
1028
|
-
const stateFingerprint = mechanicalStateFingerprint(options, work, current.value.requirementId, current.value.relayScopeId);
|
|
1451
|
+
if (!work || semanticWorkState(work) !== 'open') return current.value;
|
|
1452
|
+
const stateFingerprint = mechanicalStateFingerprint(options, work, current.value.requirementId, current.value.relayScopeId, current.value.handoffId);
|
|
1029
1453
|
const transitioned = applyControllerRoundTransition(options, current, {
|
|
1030
1454
|
type: 'semantic_state_changed', at, stateFingerprint, session, principalId: ownerPrincipal!, controllerInstanceId,
|
|
1031
1455
|
});
|
|
@@ -1058,8 +1482,8 @@ export function recoverControllerRoundRelayAuthority(
|
|
|
1058
1482
|
}
|
|
1059
1483
|
const work = getWorkContract(options, workId);
|
|
1060
1484
|
if (!work) throw new Error(`WORK_NOT_FOUND: ${workId}`);
|
|
1061
|
-
if (
|
|
1062
|
-
throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_TERMINAL: ${workId}:${work
|
|
1485
|
+
if (semanticWorkState(work) !== 'open') {
|
|
1486
|
+
throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_TERMINAL: ${workId}:${semanticWorkState(work)}`);
|
|
1063
1487
|
}
|
|
1064
1488
|
const initial = readRelayRecord(options, workId);
|
|
1065
1489
|
if (!initial) throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_RELAY_REQUIRED: ${workId}`);
|
|
@@ -1070,8 +1494,14 @@ export function recoverControllerRoundRelayAuthority(
|
|
|
1070
1494
|
if (initial.value.controllerId !== input.identity.controllerId || initial.value.principalId !== input.identity.principalId) {
|
|
1071
1495
|
throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_PRINCIPAL_MISMATCH: ${workId}`);
|
|
1072
1496
|
}
|
|
1073
|
-
const recoverableStatuses: readonly ControllerRoundRelayStatus[] = ['pending_release', 'dispatching', 'dispatched', 'claimed', 'failed'];
|
|
1074
|
-
|
|
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) {
|
|
1075
1505
|
throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_RELAY_STATE_INVALID: ${workId}:${initial.value.status}`);
|
|
1076
1506
|
}
|
|
1077
1507
|
|
|
@@ -1082,7 +1512,12 @@ export function recoverControllerRoundRelayAuthority(
|
|
|
1082
1512
|
if (!latest || latest.originWorkId !== workId || latest.updatedAt !== current.value.updatedAt) {
|
|
1083
1513
|
throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_RELAY_NOT_CURRENT: ${workId}`);
|
|
1084
1514
|
}
|
|
1085
|
-
|
|
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) {
|
|
1086
1521
|
throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_RELAY_STATE_INVALID: ${workId}:${current.value.status}`);
|
|
1087
1522
|
}
|
|
1088
1523
|
if (relayControllerType(current.value) !== input.identity.controllerType
|
|
@@ -1092,20 +1527,86 @@ export function recoverControllerRoundRelayAuthority(
|
|
|
1092
1527
|
}
|
|
1093
1528
|
const currentWork = getWorkContract(options, workId);
|
|
1094
1529
|
if (!currentWork) throw new Error(`WORK_NOT_FOUND: ${workId}`);
|
|
1095
|
-
if (
|
|
1096
|
-
throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_TERMINAL: ${workId}:${currentWork
|
|
1530
|
+
if (semanticWorkState(currentWork) !== 'open') {
|
|
1531
|
+
throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_TERMINAL: ${workId}:${semanticWorkState(currentWork)}`);
|
|
1097
1532
|
}
|
|
1098
|
-
const activeWorks =
|
|
1533
|
+
const activeWorks = recoveryFenceWork(options, current.value).filter((entry) => semanticWorkState(entry) === 'open');
|
|
1099
1534
|
if (activeWorks.some((entry) => workHasActiveExecution(options.controllerHome, options.repoId, entry.workId))) {
|
|
1100
1535
|
throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_ACTIVE_EXECUTION: ${workId}`);
|
|
1101
1536
|
}
|
|
1102
|
-
|
|
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) {
|
|
1103
1545
|
throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_ACTIVE_CLAIM: ${workId}`);
|
|
1104
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
|
+
}
|
|
1105
1568
|
|
|
1106
1569
|
return applyControllerRoundTransition(options, current, {
|
|
1107
1570
|
type: 'authority_recovery_requested', at: nowIso(options), proposedAuthorityId: newControllerRoundAuthorityId(),
|
|
1108
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,
|
|
1109
1610
|
});
|
|
1110
1611
|
});
|
|
1111
1612
|
}
|
|
@@ -1127,6 +1628,51 @@ export interface BindLegacyControllerRoundOccurrenceInput {
|
|
|
1127
1628
|
identity: Pick<ControllerRoundRelayIdentity, 'controllerId' | 'controllerType' | 'principalId'>;
|
|
1128
1629
|
}
|
|
1129
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
|
+
|
|
1130
1676
|
/** Exact evidence-gated provider/environment recovery for one exhausted same-round dispatch responsibility. */
|
|
1131
1677
|
export function rearmControllerRoundAfterProviderRecovery(
|
|
1132
1678
|
options: ControllerRoundRelayStoreOptions,
|
|
@@ -1144,10 +1690,10 @@ export function rearmControllerRoundAfterProviderRecovery(
|
|
|
1144
1690
|
if ((current.value.authorityId?.trim() || '') !== input.authorityId.trim()) throw new Error(`CONTROLLER_RELAY_PROVIDER_RECOVERY_AUTHORITY_MISMATCH: ${workId}`);
|
|
1145
1691
|
if (controllerRoundBlockerClass(current.value) !== 'consecutive_failures') throw new Error(`CONTROLLER_RELAY_PROVIDER_RECOVERY_BLOCKER_MISMATCH: ${workId}`);
|
|
1146
1692
|
const work = getWorkContract(options, workId);
|
|
1147
|
-
if (!work ||
|
|
1693
|
+
if (!work || semanticWorkState(work) !== 'open') throw new Error(`CONTROLLER_RELAY_PROVIDER_RECOVERY_WORK_TERMINAL: ${workId}:${work ? semanticWorkState(work) : 'missing'}`);
|
|
1148
1694
|
const requirement = requirementForRelay(options, current.value.requirementId);
|
|
1149
1695
|
if (requirement && !['planned', 'active'].includes(requirement.state)) throw new Error(`CONTROLLER_RELAY_PROVIDER_RECOVERY_REQUIREMENT_TERMINAL: ${requirement.state}`);
|
|
1150
|
-
const activeWorks =
|
|
1696
|
+
const activeWorks = recoveryFenceWork(options, current.value).filter((entry) => semanticWorkState(entry) === 'open');
|
|
1151
1697
|
if (activeWorks.some((entry) => workHasActiveExecution(options.controllerHome, options.repoId, entry.workId))) throw new Error(`CONTROLLER_RELAY_PROVIDER_RECOVERY_ACTIVE_EXECUTION: ${workId}`);
|
|
1152
1698
|
if (activeWorks.some((entry) => Boolean(getControllerSession(options, entry.workId)))) throw new Error(`CONTROLLER_RELAY_PROVIDER_RECOVERY_ACTIVE_CLAIM: ${workId}`);
|
|
1153
1699
|
const evidenceId = bounded(input.evidenceId, 500);
|
|
@@ -1189,7 +1735,7 @@ export function bindLegacyControllerRoundOccurrence(
|
|
|
1189
1735
|
throw new Error(`CONTROLLER_RELAY_LEGACY_OCCURRENCE_CONTROLLER_MISMATCH: ${workId}`);
|
|
1190
1736
|
}
|
|
1191
1737
|
const work = getWorkContract(options, workId);
|
|
1192
|
-
if (!work ||
|
|
1738
|
+
if (!work || semanticWorkState(work) !== 'open') throw new Error(`CONTROLLER_RELAY_LEGACY_OCCURRENCE_WORK_TERMINAL: ${workId}:${work ? semanticWorkState(work) : 'missing'}`);
|
|
1193
1739
|
const requirement = requirementForRelay(options, current.value.requirementId);
|
|
1194
1740
|
if (requirement && !['planned', 'active'].includes(requirement.state)) throw new Error(`CONTROLLER_RELAY_LEGACY_OCCURRENCE_REQUIREMENT_TERMINAL: ${requirement.state}`);
|
|
1195
1741
|
return applyControllerRoundTransition(options, current, { type: 'legacy_occurrence_bound', at: nowIso(options), occurrenceId });
|
|
@@ -1204,6 +1750,20 @@ export function claimStalledControllerRoundRelays(
|
|
|
1204
1750
|
const graceMs = Math.max(60_000, Math.min(input.graceMs ?? DEFAULT_UNCLOSED_ROUND_GRACE_MS, MAX_UNCLOSED_ROUND_GRACE_MS));
|
|
1205
1751
|
const limit = Math.max(1, Math.min(Math.trunc(input.limit ?? 2), 16));
|
|
1206
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
|
+
};
|
|
1207
1767
|
|
|
1208
1768
|
for (const candidate of latestRelayRecordsByScope(options)) {
|
|
1209
1769
|
if (claimed.length >= limit) break;
|
|
@@ -1219,14 +1779,15 @@ export function claimStalledControllerRoundRelays(
|
|
|
1219
1779
|
}
|
|
1220
1780
|
const requirement = requirementForRelay(options, candidate.requirementId);
|
|
1221
1781
|
if (requirement && !['planned', 'active'].includes(requirement.state)) continue;
|
|
1222
|
-
|
|
1223
|
-
const
|
|
1782
|
+
if (!relayMayHaveActiveWork(options, candidate, activeWorkSnapshotForScan())) continue;
|
|
1783
|
+
const candidateWorks = recoveryFenceWork(options, candidate, workSnapshotForScan());
|
|
1784
|
+
const activeCandidateWorks = candidateWorks.filter((work) => semanticWorkState(work) === 'open');
|
|
1224
1785
|
if (activeCandidateWorks.length === 0) continue;
|
|
1225
1786
|
if (activeCandidateWorks.some((work) => workHasActiveExecution(options.controllerHome, options.repoId, work.workId) || controllerSessionBlocksRecovery(options, work.workId, { nowMs, graceMs }))) continue;
|
|
1226
1787
|
if (repeatedStateBlocked) {
|
|
1227
1788
|
const fingerprintWork = activeCandidateWorks[0] ?? getWorkContract(options, candidate.originWorkId);
|
|
1228
1789
|
const currentFingerprint = fingerprintWork
|
|
1229
|
-
? mechanicalStateFingerprint(options, fingerprintWork, candidate.requirementId, candidate.relayScopeId)
|
|
1790
|
+
? mechanicalStateFingerprint(options, fingerprintWork, candidate.requirementId, candidate.relayScopeId, candidate.handoffId)
|
|
1230
1791
|
: candidate.stateFingerprint;
|
|
1231
1792
|
if (currentFingerprint === candidate.stateFingerprint) continue;
|
|
1232
1793
|
}
|
|
@@ -1246,16 +1807,35 @@ export function claimStalledControllerRoundRelays(
|
|
|
1246
1807
|
}
|
|
1247
1808
|
const latestRequirement = requirementForRelay(options, latest.requirementId);
|
|
1248
1809
|
if (latestRequirement && !['planned', 'active'].includes(latestRequirement.state)) return undefined;
|
|
1249
|
-
const
|
|
1250
|
-
const
|
|
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');
|
|
1251
1813
|
if (activeWorks.length === 0) return undefined;
|
|
1252
1814
|
if (activeWorks.some((work) => workHasActiveExecution(options.controllerHome, options.repoId, work.workId) || controllerSessionBlocksRecovery(options, work.workId, { nowMs, graceMs }))) return undefined;
|
|
1253
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
|
+
|
|
1254
1834
|
const currentRecord = readRelayRecord(options, latest.originWorkId);
|
|
1255
1835
|
if (!currentRecord || currentRecord.value.updatedAt !== latest.updatedAt || currentRecord.value.status !== latest.status) return undefined;
|
|
1256
1836
|
const fingerprintWork = activeWorks[0] ?? getWorkContract(options, latest.originWorkId);
|
|
1257
1837
|
const stateFingerprint = fingerprintWork
|
|
1258
|
-
? mechanicalStateFingerprint(options, fingerprintWork, latest.requirementId, latest.relayScopeId)
|
|
1838
|
+
? mechanicalStateFingerprint(options, fingerprintWork, latest.requirementId, latest.relayScopeId, latest.handoffId, lockedWorkContracts)
|
|
1259
1839
|
: latest.stateFingerprint;
|
|
1260
1840
|
const at = new Date(nowMs).toISOString();
|
|
1261
1841
|
const lastError = latestRepeatedStateBlocked
|
|
@@ -1267,6 +1847,15 @@ export function claimStalledControllerRoundRelays(
|
|
|
1267
1847
|
: latest.status === 'dispatching'
|
|
1268
1848
|
? 'CONTROLLER_RELAY_DISPATCH_TRANSITION_INCOMPLETE'
|
|
1269
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
|
+
}
|
|
1270
1859
|
const recovered = applyControllerRoundTransition(options, currentRecord, {
|
|
1271
1860
|
type: 'stalled_round_observed', at, stateFingerprint, proposedAuthorityId: newControllerRoundAuthorityId(),
|
|
1272
1861
|
...(lastError ? { lastError } : {}),
|
|
@@ -1294,7 +1883,7 @@ export interface ControllerRoundContextSnapshot {
|
|
|
1294
1883
|
};
|
|
1295
1884
|
works: Array<{
|
|
1296
1885
|
workId: string;
|
|
1297
|
-
|
|
1886
|
+
state: string;
|
|
1298
1887
|
phase: string;
|
|
1299
1888
|
updatedAt: string;
|
|
1300
1889
|
objective: string;
|
|
@@ -1328,10 +1917,7 @@ export function readControllerRoundContextSnapshot(
|
|
|
1328
1917
|
const requirement = requirementForRelay(options, record.requirementId);
|
|
1329
1918
|
const relevantWorks = relevantWork(options, record);
|
|
1330
1919
|
const works = relevantWorks.slice(0, 8);
|
|
1331
|
-
const
|
|
1332
|
-
const handoffs = listHandoffItems({ controllerHome: options.controllerHome, repoId: options.repoId, status: 'active', limit: 100 })
|
|
1333
|
-
.filter((handoff) => !handoff.workId || workIds.has(handoff.workId))
|
|
1334
|
-
.slice(0, 8);
|
|
1920
|
+
const handoffs = relevantHandoffs(options, relevantWorks, record.handoffId).slice(0, 8);
|
|
1335
1921
|
return {
|
|
1336
1922
|
repoId: record.repoId,
|
|
1337
1923
|
relayScopeId: record.relayScopeId,
|
|
@@ -1349,7 +1935,7 @@ export function readControllerRoundContextSnapshot(
|
|
|
1349
1935
|
const origin = work.workId === record.originWorkId;
|
|
1350
1936
|
return {
|
|
1351
1937
|
workId: work.workId,
|
|
1352
|
-
|
|
1938
|
+
state: semanticWorkState(work),
|
|
1353
1939
|
phase: work.phase,
|
|
1354
1940
|
updatedAt: work.updatedAt,
|
|
1355
1941
|
objective: work.objective.slice(0, origin
|