@paperclipai/server 2026.824.0 → 2026.825.0-beta.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/dist/adapters/builtin-adapter-types.d.ts.map +1 -1
- package/dist/adapters/builtin-adapter-types.js +1 -0
- package/dist/adapters/builtin-adapter-types.js.map +1 -1
- package/dist/adapters/plugin-loader.d.ts +1 -0
- package/dist/adapters/plugin-loader.d.ts.map +1 -1
- package/dist/adapters/plugin-loader.js +12 -1
- package/dist/adapters/plugin-loader.js.map +1 -1
- package/dist/adapters/plugin-loader.test.d.ts +2 -0
- package/dist/adapters/plugin-loader.test.d.ts.map +1 -0
- package/dist/adapters/plugin-loader.test.js +37 -0
- package/dist/adapters/plugin-loader.test.js.map +1 -0
- package/dist/adapters/registry.d.ts.map +1 -1
- package/dist/adapters/registry.js +68 -5
- package/dist/adapters/registry.js.map +1 -1
- package/dist/adapters/registry.test.d.ts +2 -0
- package/dist/adapters/registry.test.d.ts.map +1 -0
- package/dist/adapters/registry.test.js +35 -0
- package/dist/adapters/registry.test.js.map +1 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +5 -7
- package/dist/app.js.map +1 -1
- package/dist/auth/better-auth.d.ts +10 -0
- package/dist/auth/better-auth.d.ts.map +1 -1
- package/dist/auth/better-auth.js +47 -0
- package/dist/auth/better-auth.js.map +1 -1
- package/dist/auth/workspace-login-handoff-exchange.d.ts +75 -0
- package/dist/auth/workspace-login-handoff-exchange.d.ts.map +1 -0
- package/dist/auth/workspace-login-handoff-exchange.js +73 -0
- package/dist/auth/workspace-login-handoff-exchange.js.map +1 -0
- package/dist/auth/workspace-login-handoff-plugin.d.ts +40 -0
- package/dist/auth/workspace-login-handoff-plugin.d.ts.map +1 -0
- package/dist/auth/workspace-login-handoff-plugin.js +140 -0
- package/dist/auth/workspace-login-handoff-plugin.js.map +1 -0
- package/dist/auth/workspace-login-handoff.d.ts +189 -0
- package/dist/auth/workspace-login-handoff.d.ts.map +1 -0
- package/dist/auth/workspace-login-handoff.js +319 -0
- package/dist/auth/workspace-login-handoff.js.map +1 -0
- package/dist/build-info.json +3 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +14 -0
- package/dist/config.js.map +1 -1
- package/dist/dev-runner-worktree.d.ts.map +1 -1
- package/dist/dev-runner-worktree.js +5 -0
- package/dist/dev-runner-worktree.js.map +1 -1
- package/dist/duplex-aggregate-ceiling-env.d.ts +21 -0
- package/dist/duplex-aggregate-ceiling-env.d.ts.map +1 -0
- package/dist/duplex-aggregate-ceiling-env.js +23 -0
- package/dist/duplex-aggregate-ceiling-env.js.map +1 -0
- package/dist/embedded-postgres-supervisor.d.ts +27 -0
- package/dist/embedded-postgres-supervisor.d.ts.map +1 -0
- package/dist/embedded-postgres-supervisor.js +72 -0
- package/dist/embedded-postgres-supervisor.js.map +1 -0
- package/dist/errors.d.ts +2 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +6 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +115 -7
- package/dist/index.js.map +1 -1
- package/dist/instrumentation.d.ts +33 -0
- package/dist/instrumentation.d.ts.map +1 -1
- package/dist/instrumentation.js +68 -1
- package/dist/instrumentation.js.map +1 -1
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +35 -10
- package/dist/middleware/auth.js.map +1 -1
- package/dist/middleware/error-handler.d.ts.map +1 -1
- package/dist/middleware/error-handler.js +8 -1
- package/dist/middleware/error-handler.js.map +1 -1
- package/dist/middleware/logger.d.ts.map +1 -1
- package/dist/middleware/logger.js +5 -2
- package/dist/middleware/logger.js.map +1 -1
- package/dist/middleware/redact-sensitive.d.ts.map +1 -1
- package/dist/middleware/redact-sensitive.js +4 -0
- package/dist/middleware/redact-sensitive.js.map +1 -1
- package/dist/middleware/validate.d.ts +3 -1
- package/dist/middleware/validate.d.ts.map +1 -1
- package/dist/middleware/validate.js +37 -0
- package/dist/middleware/validate.js.map +1 -1
- package/dist/routes/access.d.ts.map +1 -1
- package/dist/routes/access.js +20 -0
- package/dist/routes/access.js.map +1 -1
- package/dist/routes/activity.js +4 -4
- package/dist/routes/activity.js.map +1 -1
- package/dist/routes/adapter-login-route-spine.d.ts +81 -0
- package/dist/routes/adapter-login-route-spine.d.ts.map +1 -0
- package/dist/routes/adapter-login-route-spine.js +40 -0
- package/dist/routes/adapter-login-route-spine.js.map +1 -0
- package/dist/routes/adapters.d.ts +33 -0
- package/dist/routes/adapters.d.ts.map +1 -1
- package/dist/routes/adapters.js +35 -1
- package/dist/routes/adapters.js.map +1 -1
- package/dist/routes/adapters.test.d.ts +2 -0
- package/dist/routes/adapters.test.d.ts.map +1 -0
- package/dist/routes/adapters.test.js +58 -0
- package/dist/routes/adapters.test.js.map +1 -0
- package/dist/routes/agents.d.ts.map +1 -1
- package/dist/routes/agents.js +281 -91
- package/dist/routes/agents.js.map +1 -1
- package/dist/routes/cases.d.ts +11 -22
- package/dist/routes/cases.d.ts.map +1 -1
- package/dist/routes/cases.js +13 -13
- package/dist/routes/companies.js +3 -3
- package/dist/routes/decision-training.js +6 -6
- package/dist/routes/decisions.js +4 -4
- package/dist/routes/decisions.js.map +1 -1
- package/dist/routes/environments.d.ts.map +1 -1
- package/dist/routes/environments.js +79 -6
- package/dist/routes/environments.js.map +1 -1
- package/dist/routes/execution-workspaces.d.ts.map +1 -1
- package/dist/routes/execution-workspaces.js +384 -16
- package/dist/routes/execution-workspaces.js.map +1 -1
- package/dist/routes/file-resources.js +2 -2
- package/dist/routes/health.d.ts.map +1 -1
- package/dist/routes/health.js +58 -4
- package/dist/routes/health.js.map +1 -1
- package/dist/routes/instance-settings.d.ts.map +1 -1
- package/dist/routes/instance-settings.js +40 -0
- package/dist/routes/instance-settings.js.map +1 -1
- package/dist/routes/issues.d.ts +10 -0
- package/dist/routes/issues.d.ts.map +1 -1
- package/dist/routes/issues.js +495 -65
- package/dist/routes/issues.js.map +1 -1
- package/dist/routes/openapi.d.ts.map +1 -1
- package/dist/routes/openapi.js +241 -122
- package/dist/routes/openapi.js.map +1 -1
- package/dist/routes/pipelines.js +19 -19
- package/dist/routes/plugins.d.ts.map +1 -1
- package/dist/routes/plugins.js +22 -0
- package/dist/routes/plugins.js.map +1 -1
- package/dist/routes/projects.d.ts.map +1 -1
- package/dist/routes/projects.js +2 -0
- package/dist/routes/projects.js.map +1 -1
- package/dist/routes/routines.js +2 -2
- package/dist/routes/routines.js.map +1 -1
- package/dist/routes/secrets.d.ts +1 -1
- package/dist/routes/secrets.d.ts.map +1 -1
- package/dist/routes/secrets.js +39 -50
- package/dist/routes/secrets.js.map +1 -1
- package/dist/routes/setup-token-route.test.js +50 -3
- package/dist/routes/setup-token-route.test.js.map +1 -1
- package/dist/services/adapter-login-lease.d.ts +50 -0
- package/dist/services/adapter-login-lease.d.ts.map +1 -0
- package/dist/services/adapter-login-lease.js +26 -0
- package/dist/services/adapter-login-lease.js.map +1 -0
- package/dist/services/adapter-login-lease.test.d.ts +2 -0
- package/dist/services/adapter-login-lease.test.d.ts.map +1 -0
- package/dist/services/adapter-login-lease.test.js +68 -0
- package/dist/services/adapter-login-lease.test.js.map +1 -0
- package/dist/services/agent-action-audit.js +1 -1
- package/dist/services/agent-secret-bindings.d.ts +15 -0
- package/dist/services/agent-secret-bindings.d.ts.map +1 -1
- package/dist/services/agent-secret-bindings.js +2 -2
- package/dist/services/agent-secret-bindings.js.map +1 -1
- package/dist/services/agents.d.ts +13 -13
- package/dist/services/agents.d.ts.map +1 -1
- package/dist/services/agents.js +46 -5
- package/dist/services/agents.js.map +1 -1
- package/dist/services/artifact-review-documents.d.ts +34 -0
- package/dist/services/artifact-review-documents.d.ts.map +1 -0
- package/dist/services/artifact-review-documents.js +196 -0
- package/dist/services/artifact-review-documents.js.map +1 -0
- package/dist/services/assets.d.ts +1 -1
- package/dist/services/codex-device-login-credential-read.d.ts +58 -0
- package/dist/services/codex-device-login-credential-read.d.ts.map +1 -0
- package/dist/services/codex-device-login-credential-read.js +118 -0
- package/dist/services/codex-device-login-credential-read.js.map +1 -0
- package/dist/services/codex-device-login-reaper.d.ts.map +1 -1
- package/dist/services/codex-device-login-reaper.js +8 -5
- package/dist/services/codex-device-login-reaper.js.map +1 -1
- package/dist/services/codex-device-login-service.d.ts +127 -36
- package/dist/services/codex-device-login-service.d.ts.map +1 -1
- package/dist/services/codex-device-login-service.js +204 -84
- package/dist/services/codex-device-login-service.js.map +1 -1
- package/dist/services/company-portability.d.ts +3 -1
- package/dist/services/company-portability.d.ts.map +1 -1
- package/dist/services/company-portability.js +76 -20
- package/dist/services/company-portability.js.map +1 -1
- package/dist/services/company-skill-policy.d.ts +1 -1
- package/dist/services/costs.d.ts +1 -1
- package/dist/services/decisions.d.ts +6 -6
- package/dist/services/duplex-observability-recorder.d.ts +62 -0
- package/dist/services/duplex-observability-recorder.d.ts.map +1 -0
- package/dist/services/duplex-observability-recorder.js +54 -0
- package/dist/services/duplex-observability-recorder.js.map +1 -0
- package/dist/services/environment-config.js +5 -5
- package/dist/services/environment-config.js.map +1 -1
- package/dist/services/environment-custom-image-runtime.d.ts +116 -0
- package/dist/services/environment-custom-image-runtime.d.ts.map +1 -1
- package/dist/services/environment-custom-image-runtime.js +255 -1
- package/dist/services/environment-custom-image-runtime.js.map +1 -1
- package/dist/services/environment-custom-images.d.ts +39 -0
- package/dist/services/environment-custom-images.d.ts.map +1 -1
- package/dist/services/environment-custom-images.js +219 -17
- package/dist/services/environment-custom-images.js.map +1 -1
- package/dist/services/environment-driver-traits.d.ts +109 -0
- package/dist/services/environment-driver-traits.d.ts.map +1 -0
- package/dist/services/environment-driver-traits.js +102 -0
- package/dist/services/environment-driver-traits.js.map +1 -0
- package/dist/services/environment-execution-target.d.ts +4 -0
- package/dist/services/environment-execution-target.d.ts.map +1 -1
- package/dist/services/environment-execution-target.js +63 -7
- package/dist/services/environment-execution-target.js.map +1 -1
- package/dist/services/environment-run-orchestrator.d.ts +27 -1
- package/dist/services/environment-run-orchestrator.d.ts.map +1 -1
- package/dist/services/environment-run-orchestrator.js +24 -4
- package/dist/services/environment-run-orchestrator.js.map +1 -1
- package/dist/services/environment-runtime.d.ts +118 -20
- package/dist/services/environment-runtime.d.ts.map +1 -1
- package/dist/services/environment-runtime.js +436 -91
- package/dist/services/environment-runtime.js.map +1 -1
- package/dist/services/environments.d.ts.map +1 -1
- package/dist/services/environments.js +20 -2
- package/dist/services/environments.js.map +1 -1
- package/dist/services/execution-workspace-branch-ownership.d.ts +4 -0
- package/dist/services/execution-workspace-branch-ownership.d.ts.map +1 -0
- package/dist/services/execution-workspace-branch-ownership.js +9 -0
- package/dist/services/execution-workspace-branch-ownership.js.map +1 -0
- package/dist/services/execution-workspace-policy.d.ts.map +1 -1
- package/dist/services/execution-workspace-policy.js +3 -0
- package/dist/services/execution-workspace-policy.js.map +1 -1
- package/dist/services/execution-workspaces.d.ts +2 -0
- package/dist/services/execution-workspaces.d.ts.map +1 -1
- package/dist/services/execution-workspaces.js +90 -2
- package/dist/services/execution-workspaces.js.map +1 -1
- package/dist/services/external-objects.d.ts +19 -19
- package/dist/services/finance.d.ts +1 -1
- package/dist/services/goals.d.ts +2 -2
- package/dist/services/heartbeat-policy.d.ts +3 -0
- package/dist/services/heartbeat-policy.d.ts.map +1 -0
- package/dist/services/heartbeat-policy.js +10 -0
- package/dist/services/heartbeat-policy.js.map +1 -0
- package/dist/services/heartbeat-run-runtime-status.test.js +1 -1
- package/dist/services/heartbeat-run-runtime-status.test.js.map +1 -1
- package/dist/services/heartbeat.d.ts +23 -1
- package/dist/services/heartbeat.d.ts.map +1 -1
- package/dist/services/heartbeat.js +192 -11
- package/dist/services/heartbeat.js.map +1 -1
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +1 -0
- package/dist/services/index.js.map +1 -1
- package/dist/services/instance-settings.d.ts.map +1 -1
- package/dist/services/instance-settings.js +2 -0
- package/dist/services/instance-settings.js.map +1 -1
- package/dist/services/issue-dependency-wakeups.d.ts +57 -9
- package/dist/services/issue-dependency-wakeups.d.ts.map +1 -1
- package/dist/services/issue-dependency-wakeups.js +146 -14
- package/dist/services/issue-dependency-wakeups.js.map +1 -1
- package/dist/services/issue-dependency-wakeups.test.d.ts +2 -0
- package/dist/services/issue-dependency-wakeups.test.d.ts.map +1 -0
- package/dist/services/issue-dependency-wakeups.test.js +183 -0
- package/dist/services/issue-dependency-wakeups.test.js.map +1 -0
- package/dist/services/issue-recovery-actions.d.ts +6 -1
- package/dist/services/issue-recovery-actions.d.ts.map +1 -1
- package/dist/services/issue-recovery-actions.js +123 -44
- package/dist/services/issue-recovery-actions.js.map +1 -1
- package/dist/services/issue-thread-interactions.d.ts +8 -0
- package/dist/services/issue-thread-interactions.d.ts.map +1 -1
- package/dist/services/issue-thread-interactions.js +220 -10
- package/dist/services/issue-thread-interactions.js.map +1 -1
- package/dist/services/issues.d.ts +8 -7
- package/dist/services/issues.d.ts.map +1 -1
- package/dist/services/issues.js +84 -12
- package/dist/services/issues.js.map +1 -1
- package/dist/services/local-service-supervisor.d.ts +61 -1
- package/dist/services/local-service-supervisor.d.ts.map +1 -1
- package/dist/services/local-service-supervisor.js +221 -25
- package/dist/services/local-service-supervisor.js.map +1 -1
- package/dist/services/login-command.d.ts +46 -0
- package/dist/services/login-command.d.ts.map +1 -0
- package/dist/services/login-command.js +87 -0
- package/dist/services/login-command.js.map +1 -0
- package/dist/services/login-command.test.d.ts +2 -0
- package/dist/services/login-command.test.d.ts.map +1 -0
- package/dist/services/login-command.test.js +71 -0
- package/dist/services/login-command.test.js.map +1 -0
- package/dist/services/managed-workspace-identity.d.ts +140 -0
- package/dist/services/managed-workspace-identity.d.ts.map +1 -0
- package/dist/services/managed-workspace-identity.js +277 -0
- package/dist/services/managed-workspace-identity.js.map +1 -0
- package/dist/services/plugin-environment-driver.d.ts +15 -1
- package/dist/services/plugin-environment-driver.d.ts.map +1 -1
- package/dist/services/plugin-environment-driver.js +23 -1
- package/dist/services/plugin-environment-driver.js.map +1 -1
- package/dist/services/plugin-host-services.d.ts +3 -2
- package/dist/services/plugin-host-services.d.ts.map +1 -1
- package/dist/services/plugin-host-services.js +11 -2
- package/dist/services/plugin-host-services.js.map +1 -1
- package/dist/services/plugin-loader.d.ts.map +1 -1
- package/dist/services/plugin-loader.js +39 -8
- package/dist/services/plugin-loader.js.map +1 -1
- package/dist/services/plugin-managed-agents.d.ts.map +1 -1
- package/dist/services/plugin-managed-agents.js +50 -5
- package/dist/services/plugin-managed-agents.js.map +1 -1
- package/dist/services/plugin-manifest-validator.d.ts.map +1 -1
- package/dist/services/plugin-manifest-validator.js +5 -2
- package/dist/services/plugin-manifest-validator.js.map +1 -1
- package/dist/services/plugin-registry.d.ts +5 -5
- package/dist/services/plugin-worker-manager.d.ts +158 -11
- package/dist/services/plugin-worker-manager.d.ts.map +1 -1
- package/dist/services/plugin-worker-manager.js +1139 -79
- package/dist/services/plugin-worker-manager.js.map +1 -1
- package/dist/services/projects.d.ts +1 -1
- package/dist/services/recovery/disposition-repair.d.ts +24 -0
- package/dist/services/recovery/disposition-repair.d.ts.map +1 -0
- package/dist/services/recovery/disposition-repair.js +125 -0
- package/dist/services/recovery/disposition-repair.js.map +1 -0
- package/dist/services/recovery/service.d.ts +6 -6
- package/dist/services/recovery/service.d.ts.map +1 -1
- package/dist/services/recovery/service.js +856 -384
- package/dist/services/recovery/service.js.map +1 -1
- package/dist/services/recovery/stranded-notice.js +4 -4
- package/dist/services/recovery/stranded-notice.js.map +1 -1
- package/dist/services/recovery/stranded-notice.test.js +1 -1
- package/dist/services/recovery/stranded-notice.test.js.map +1 -1
- package/dist/services/recovery/successful-run-handoff.d.ts +1 -1
- package/dist/services/recovery/successful-run-handoff.d.ts.map +1 -1
- package/dist/services/recovery/successful-run-handoff.js +6 -4
- package/dist/services/recovery/successful-run-handoff.js.map +1 -1
- package/dist/services/recovery/successful-run-handoff.test.js +1 -1
- package/dist/services/recovery/successful-run-handoff.test.js.map +1 -1
- package/dist/services/recovery-observability.d.ts +1 -1
- package/dist/services/recovery-observability.d.ts.map +1 -1
- package/dist/services/recovery-observability.js +4 -1
- package/dist/services/recovery-observability.js.map +1 -1
- package/dist/services/routines.d.ts.map +1 -1
- package/dist/services/routines.js +86 -1
- package/dist/services/routines.js.map +1 -1
- package/dist/services/runtime-exposure/loopback-listener.js +1 -1
- package/dist/services/runtime-exposure/loopback-listener.js.map +1 -1
- package/dist/services/runtime-exposure/loopback-listener.test.js +1 -1
- package/dist/services/runtime-exposure/loopback-listener.test.js.map +1 -1
- package/dist/services/scripts/codex-auth-read.cjs +93 -0
- package/dist/services/secret-proposal-authorization.d.ts +15 -0
- package/dist/services/secret-proposal-authorization.d.ts.map +1 -0
- package/dist/services/secret-proposal-authorization.js +29 -0
- package/dist/services/secret-proposal-authorization.js.map +1 -0
- package/dist/services/secret-proposal-notifications.d.ts +18 -0
- package/dist/services/secret-proposal-notifications.d.ts.map +1 -0
- package/dist/services/secret-proposal-notifications.js +37 -0
- package/dist/services/secret-proposal-notifications.js.map +1 -0
- package/dist/services/secret-proposals.d.ts +20 -10
- package/dist/services/secret-proposals.d.ts.map +1 -1
- package/dist/services/secret-proposals.js +205 -14
- package/dist/services/secret-proposals.js.map +1 -1
- package/dist/services/secrets.d.ts +3 -3
- package/dist/services/settings-visibility.d.ts +17 -0
- package/dist/services/settings-visibility.d.ts.map +1 -0
- package/dist/services/settings-visibility.js +29 -0
- package/dist/services/settings-visibility.js.map +1 -0
- package/dist/services/setup-token-reaper.d.ts +36 -0
- package/dist/services/setup-token-reaper.d.ts.map +1 -0
- package/dist/services/setup-token-reaper.js +38 -0
- package/dist/services/setup-token-reaper.js.map +1 -0
- package/dist/services/setup-token-reaper.test.d.ts +2 -0
- package/dist/services/setup-token-reaper.test.d.ts.map +1 -0
- package/dist/services/setup-token-reaper.test.js +123 -0
- package/dist/services/setup-token-reaper.test.js.map +1 -0
- package/dist/services/setup-token-session.d.ts +57 -45
- package/dist/services/setup-token-session.d.ts.map +1 -1
- package/dist/services/setup-token-session.js +118 -110
- package/dist/services/setup-token-session.js.map +1 -1
- package/dist/services/setup-token-session.test.js +88 -47
- package/dist/services/setup-token-session.test.js.map +1 -1
- package/dist/services/setup-token-transport-binding.d.ts +20 -12
- package/dist/services/setup-token-transport-binding.d.ts.map +1 -1
- package/dist/services/setup-token-transport-binding.js +59 -32
- package/dist/services/setup-token-transport-binding.js.map +1 -1
- package/dist/services/setup-token-transport-binding.test.js +50 -17
- package/dist/services/setup-token-transport-binding.test.js.map +1 -1
- package/dist/services/smoke-lab.d.ts +1 -1
- package/dist/services/stalled-review-decisions.d.ts +1 -1
- package/dist/services/status-cards.d.ts +168 -168
- package/dist/services/tool-access-policy.d.ts +2 -2
- package/dist/services/tool-gateway.d.ts +1 -1
- package/dist/services/work-products.d.ts +19 -0
- package/dist/services/work-products.d.ts.map +1 -1
- package/dist/services/work-products.js +50 -3
- package/dist/services/work-products.js.map +1 -1
- package/dist/services/workspace-git-operation-scheduler.test.js +10 -3
- package/dist/services/workspace-git-operation-scheduler.test.js.map +1 -1
- package/dist/services/workspace-instance-cleanup.d.ts +9 -0
- package/dist/services/workspace-instance-cleanup.d.ts.map +1 -1
- package/dist/services/workspace-instance-cleanup.js +29 -3
- package/dist/services/workspace-instance-cleanup.js.map +1 -1
- package/dist/services/workspace-login-handoff-issuer.d.ts +95 -0
- package/dist/services/workspace-login-handoff-issuer.d.ts.map +1 -0
- package/dist/services/workspace-login-handoff-issuer.js +147 -0
- package/dist/services/workspace-login-handoff-issuer.js.map +1 -0
- package/dist/services/workspace-operations.d.ts +4 -1
- package/dist/services/workspace-operations.d.ts.map +1 -1
- package/dist/services/workspace-operations.js +29 -15
- package/dist/services/workspace-operations.js.map +1 -1
- package/dist/services/workspace-readiness.d.ts +56 -0
- package/dist/services/workspace-readiness.d.ts.map +1 -0
- package/dist/services/workspace-readiness.js +250 -0
- package/dist/services/workspace-readiness.js.map +1 -0
- package/dist/services/workspace-realization.d.ts.map +1 -1
- package/dist/services/workspace-realization.js +5 -33
- package/dist/services/workspace-realization.js.map +1 -1
- package/dist/services/workspace-runtime-exposure.test.js +321 -12
- package/dist/services/workspace-runtime-exposure.test.js.map +1 -1
- package/dist/services/workspace-runtime-leases.d.ts.map +1 -1
- package/dist/services/workspace-runtime-leases.js +1 -1
- package/dist/services/workspace-runtime-leases.js.map +1 -1
- package/dist/services/workspace-runtime-read-model.d.ts +8 -8
- package/dist/services/workspace-runtime-read-model.test.js +20 -0
- package/dist/services/workspace-runtime-read-model.test.js.map +1 -1
- package/dist/services/workspace-runtime-ready-comment.test.js +1 -0
- package/dist/services/workspace-runtime-ready-comment.test.js.map +1 -1
- package/dist/services/workspace-runtime.d.ts +77 -4
- package/dist/services/workspace-runtime.d.ts.map +1 -1
- package/dist/services/workspace-runtime.js +1020 -126
- package/dist/services/workspace-runtime.js.map +1 -1
- package/dist/worktree-config.d.ts.map +1 -1
- package/dist/worktree-config.js +3 -76
- package/dist/worktree-config.js.map +1 -1
- package/dist/worktree-seed-manifest.d.ts +8 -0
- package/dist/worktree-seed-manifest.d.ts.map +1 -0
- package/dist/worktree-seed-manifest.js +47 -0
- package/dist/worktree-seed-manifest.js.map +1 -0
- package/package.json +34 -30
- package/skills/paperclip/SKILL.md +2 -0
- package/skills/paperclip/references/api-reference.md +44 -2
- package/ui-dist/assets/CompanyExport-DnEsO-St.js +4 -0
- package/ui-dist/assets/{abnfDiagram-N423BO3Z-BvgoV-u5.js → abnfDiagram-N423BO3Z-2ff8TsQ0.js} +1 -1
- package/ui-dist/assets/{arc-BInnUBUU.js → arc-B2AXsH34.js} +1 -1
- package/ui-dist/assets/architectureDiagram-T3A2C74G-BDxtKMxk.js +36 -0
- package/ui-dist/assets/{blockDiagram-VBNYF7ZC-D9kzAkLN.js → blockDiagram-VBNYF7ZC-BB8T5Mj5.js} +1 -1
- package/ui-dist/assets/{c4Diagram-5PPSVZJV-BwzsgV_0.js → c4Diagram-5PPSVZJV-lMo8qk-x.js} +1 -1
- package/ui-dist/assets/channel-DCTVZpyq.js +1 -0
- package/ui-dist/assets/{chunk-2GRJ4B5K-D2EQ9vrl.js → chunk-2GRJ4B5K-CsS7EB3G.js} +1 -1
- package/ui-dist/assets/{chunk-2Q5K7J3B-DOA_8fSd.js → chunk-2Q5K7J3B-DFyQPjoR.js} +1 -1
- package/ui-dist/assets/{chunk-5RXB4S5H-CT5ukRiT.js → chunk-5RXB4S5H-No2SHOQR.js} +1 -1
- package/ui-dist/assets/{chunk-5VM5RSS4-D2gOlkyB.js → chunk-5VM5RSS4-DdxQO7V9.js} +1 -1
- package/ui-dist/assets/{chunk-6Q2QTUOP-DUb6aG8c.js → chunk-6Q2QTUOP-gbHjh-aK.js} +1 -1
- package/ui-dist/assets/{chunk-GF5L2VYU-BPabG3Cx.js → chunk-GF5L2VYU-h-BBl6NY.js} +1 -1
- package/ui-dist/assets/{chunk-JWPE2WC7-scNfhlZz.js → chunk-JWPE2WC7-Q3pJdTz5.js} +1 -1
- package/ui-dist/assets/{chunk-KBJHAD2P-BcRAzRpq.js → chunk-KBJHAD2P-Ba8LGLSx.js} +1 -1
- package/ui-dist/assets/{chunk-RYQCIY6F-BiZ0-EGh.js → chunk-RYQCIY6F-9VcvykBo.js} +1 -1
- package/ui-dist/assets/{chunk-XXDRQBXY-lJULxFIE.js → chunk-XXDRQBXY-NVbjDH4W.js} +1 -1
- package/ui-dist/assets/classDiagram-JCYQIIEL-CzJ88u6h.js +1 -0
- package/ui-dist/assets/classDiagram-v2-OCEON4UE-CzJ88u6h.js +1 -0
- package/ui-dist/assets/cose-bilkent-JH36ORCC-Cdz4wtUh.js +1 -0
- package/ui-dist/assets/{cynefin-VYW2F7L2-B34jJY1x.js → cynefin-VYW2F7L2-MpzJkW5s.js} +1 -1
- package/ui-dist/assets/{cynefinDiagram-MW4NZA55-DQ8QK8JK.js → cynefinDiagram-MW4NZA55-_Nx8av7i.js} +1 -1
- package/ui-dist/assets/{dagre-VZM6K2ZE-B7lSKgxH.js → dagre-VZM6K2ZE-DcZTQ4Oh.js} +1 -1
- package/ui-dist/assets/{diagram-7IWD3JNH-B3hLAFGE.js → diagram-7IWD3JNH-UyLi2mee.js} +1 -1
- package/ui-dist/assets/{diagram-B4RE2ZJO-CH9Ro9e0.js → diagram-B4RE2ZJO-B6p0Ivqg.js} +1 -1
- package/ui-dist/assets/{diagram-LBJQPF4R-DdPVMii7.js → diagram-LBJQPF4R-C8u6pa97.js} +1 -1
- package/ui-dist/assets/{diagram-Q27KOJAE-f5VXswjc.js → diagram-Q27KOJAE-DbwyagW0.js} +1 -1
- package/ui-dist/assets/{diagram-UB23O5K3-DGqxixsR.js → diagram-UB23O5K3-Cn5BMEH7.js} +1 -1
- package/ui-dist/assets/{ebnfDiagram-BXEA7PRR-C-1klg_w.js → ebnfDiagram-BXEA7PRR-vE0IZma9.js} +1 -1
- package/ui-dist/assets/{erDiagram-JOGREHBK-Dt4qK4gv.js → erDiagram-JOGREHBK-3OZVIlVG.js} +1 -1
- package/ui-dist/assets/{flowDiagram-UKHOOZJN-9ZlrTuET.js → flowDiagram-UKHOOZJN-C94FiPyr.js} +1 -1
- package/ui-dist/assets/ganttDiagram-PKOTCBZU-DMVaPmF-.js +292 -0
- package/ui-dist/assets/{gitGraphDiagram-DS77QQ5N-HH2fSgad.js → gitGraphDiagram-DS77QQ5N-yeFtJ689.js} +1 -1
- package/ui-dist/assets/{index-Cm6iEYPt.js → index-B4ZF3vY8.js} +288 -224
- package/ui-dist/assets/{index-CHXG4GPL.js → index-Bi_vqkcQ.js} +1 -1
- package/ui-dist/assets/{index-Bc9g6q63.js → index-BjDGQg9-.js} +1 -1
- package/ui-dist/assets/{index-FHzrJ7iQ.js → index-BnpyUMTb.js} +1 -1
- package/ui-dist/assets/{index-C1mpWCcn.js → index-BoqQTVMA.js} +1 -1
- package/ui-dist/assets/index-Bq-Mr-4x.js +1 -0
- package/ui-dist/assets/{index-DceuCrr8.js → index-Brb2U7N4.js} +1 -1
- package/ui-dist/assets/{index-DQIrrn2L.js → index-BsBybVwH.js} +1 -1
- package/ui-dist/assets/{index-CQhaM6qd.js → index-C2iv4Wgu.js} +1 -1
- package/ui-dist/assets/{index-DV2FOgyD.js → index-CKxIu7uO.js} +1 -1
- package/ui-dist/assets/{index-DU4U1fuv.js → index-Cb4VGIwp.js} +1 -1
- package/ui-dist/assets/{index-B61ORkIb.js → index-ChpszwcO.js} +1 -1
- package/ui-dist/assets/{index-DlVBBF8o.js → index-CoQFdT8N.js} +1 -1
- package/ui-dist/assets/index-D6P8_f0W.css +1 -0
- package/ui-dist/assets/index-DKdmghUG.js +1 -0
- package/ui-dist/assets/{index-CVTL3aAL.js → index-DaBci7Bx.js} +1 -1
- package/ui-dist/assets/{index-diFs0CNC.js → index-DjYaNiXZ.js} +1 -1
- package/ui-dist/assets/{index-Bf7tyGxE.js → index-DxTBJl4h.js} +1 -1
- package/ui-dist/assets/{index-DsdVneqe.js → index-Dz3E8nn0.js} +1 -1
- package/ui-dist/assets/{index-DvFUXI-Q.js → index-J7dz_hap.js} +1 -1
- package/ui-dist/assets/{index-CLu3Tfpv.js → index-JMp2Gpnz.js} +1 -1
- package/ui-dist/assets/{index-CcRTJ93J.js → index-Kp6wavQt.js} +1 -1
- package/ui-dist/assets/{index-BvFx-4KW.js → index-LBhYZ0L4.js} +1 -1
- package/ui-dist/assets/{index-BbAhzCq8.js → index-c3C0P6l8.js} +4 -4
- package/ui-dist/assets/{index-BY5ITKmG.js → index-pBEPheTW.js} +1 -1
- package/ui-dist/assets/{infoDiagram-6WML65LV-2jai2fw-.js → infoDiagram-6WML65LV-BiL7jKLA.js} +1 -1
- package/ui-dist/assets/{ishikawaDiagram-WSZJBQD7-CUmGhmEb.js → ishikawaDiagram-WSZJBQD7-DYcQy41D.js} +1 -1
- package/ui-dist/assets/{journeyDiagram-NVQOT4AX-CGQuPDO1.js → journeyDiagram-NVQOT4AX-Bxva6JgA.js} +1 -1
- package/ui-dist/assets/{kanban-definition-27J2QSJJ-zgEPcbHl.js → kanban-definition-27J2QSJJ-B2HpN34Q.js} +1 -1
- package/ui-dist/assets/{linear-BVf5VtLS.js → linear-Dq8qSESw.js} +1 -1
- package/ui-dist/assets/{mermaid.core-D2TFkGfI.js → mermaid.core-Ikay8Cpl.js} +35 -35
- package/ui-dist/assets/{mindmap-definition-FAOFIHXS-xoAQ0Lb_.js → mindmap-definition-FAOFIHXS-C-SIdYSY.js} +1 -1
- package/ui-dist/assets/{pegDiagram-VL7TDLO6-CBR1PWLr.js → pegDiagram-VL7TDLO6-C7rIJ_13.js} +1 -1
- package/ui-dist/assets/{pieDiagram-7S7Q4E2Y-BMPnzK_e.js → pieDiagram-7S7Q4E2Y-3gx0a0ve.js} +1 -1
- package/ui-dist/assets/{quadrantDiagram-CIZ2JOQS-CPtVUPkR.js → quadrantDiagram-CIZ2JOQS-BiwM9QGv.js} +1 -1
- package/ui-dist/assets/{railroadDiagram-AXF67PYL-DC0lsZV1.js → railroadDiagram-AXF67PYL-rFS8XcFA.js} +1 -1
- package/ui-dist/assets/{requirementDiagram-LRYGKXZP-BF6k05SB.js → requirementDiagram-LRYGKXZP-CEAUDUTQ.js} +1 -1
- package/ui-dist/assets/{sankeyDiagram-W5VNT64P-CRN0ibiP.js → sankeyDiagram-W5VNT64P-B7anpjlM.js} +1 -1
- package/ui-dist/assets/{sequenceDiagram-SI44F4Z6-CQtTiPWm.js → sequenceDiagram-SI44F4Z6-CJEdJvm_.js} +1 -1
- package/ui-dist/assets/{sizeCapture-X5ZJPWSS-BdjuGZd1.js → sizeCapture-X5ZJPWSS-CU1Vm6c1.js} +1 -1
- package/ui-dist/assets/{stateDiagram-OKZ733FA-B99onxyh.js → stateDiagram-OKZ733FA-wxVV9WIi.js} +1 -1
- package/ui-dist/assets/stateDiagram-v2-UEYNNEHI-BIFLqlqR.js +1 -0
- package/ui-dist/assets/{swimlanes-SLNWSIFB-BIByPbrL.js → swimlanes-SLNWSIFB-Cd-C1Z_k.js} +2 -2
- package/ui-dist/assets/swimlanesDiagram-ULZ7WXOC-CoBROkkd.js +8 -0
- package/ui-dist/assets/{timeline-definition-Z64GVDOM-I1NgOcn9.js → timeline-definition-Z64GVDOM-SWngk4aR.js} +1 -1
- package/ui-dist/assets/{vennDiagram-T6HMQDX7-D6t2Njcl.js → vennDiagram-T6HMQDX7-DFdPUr36.js} +1 -1
- package/ui-dist/assets/{wardleyDiagram-T6FBY63Y-BiYKm9gh.js → wardleyDiagram-T6FBY63Y-C6IlrZ7t.js} +1 -1
- package/ui-dist/assets/{xychartDiagram-ELKLHX3M-BhwXKWIa.js → xychartDiagram-ELKLHX3M-BNm6hHGl.js} +1 -1
- package/ui-dist/index.html +2 -2
- package/ui-dist/assets/architectureDiagram-T3A2C74G-Dg4MziOm.js +0 -36
- package/ui-dist/assets/channel-qoSYbCn0.js +0 -1
- package/ui-dist/assets/classDiagram-JCYQIIEL-B24u1REs.js +0 -1
- package/ui-dist/assets/classDiagram-v2-OCEON4UE-B24u1REs.js +0 -1
- package/ui-dist/assets/cose-bilkent-JH36ORCC-Do8pe8JB.js +0 -1
- package/ui-dist/assets/ganttDiagram-PKOTCBZU-CE3lyvFq.js +0 -292
- package/ui-dist/assets/index-BHiUiToL.css +0 -1
- package/ui-dist/assets/index-hkanj4t6.js +0 -1
- package/ui-dist/assets/index-yRJnh4vx.js +0 -1
- package/ui-dist/assets/stateDiagram-v2-UEYNNEHI-bzFlYBZ1.js +0 -1
- package/ui-dist/assets/swimlanesDiagram-ULZ7WXOC-DgdkBnuK.js +0 -8
|
@@ -12,16 +12,19 @@ import { and, desc, eq, gte, inArray, isNull, lte, ne, or } from "drizzle-orm";
|
|
|
12
12
|
import { asNumber, asString, parseObject, renderTemplate } from "../adapters/utils.js";
|
|
13
13
|
import { conflict } from "../errors.js";
|
|
14
14
|
import { resolveHomeAwarePath } from "../home-paths.js";
|
|
15
|
-
import {
|
|
15
|
+
import { hasVerifiedWorktreeSeedManifest, isVerifiedWorktreeSeedManifest } from "../worktree-seed-manifest.js";
|
|
16
|
+
import { buildManagedWorkspaceGuestEnv, logManagedWorkspaceReadinessRejection, probeManagedWorkspaceHandoffSubjects, probeManagedWorkspaceReadiness, resolveManagedWorkspaceIdentity, shouldBlockPublicationOnReadiness, waitForManagedWorkspaceReadiness, } from "./managed-workspace-identity.js";
|
|
17
|
+
import { createLocalServiceKey, findLocalServiceRegistryRecordByRuntimeServiceId, findAdoptableLocalService, isLocalServiceProcessOwnedBy, isLocalServiceProcessInWorkspace, openLocalServiceLogFile, readLocalServiceProcessCwd, readLocalServiceProcessGroupId, readLocalServicePortOwner, removeLocalServiceRegistryRecord, terminateLocalService, touchLocalServiceRegistryRecord, writeLocalServiceRegistryRecord, } from "./local-service-supervisor.js";
|
|
16
18
|
import { workspaceOperationService } from "./workspace-operations.js";
|
|
17
19
|
import { executionWorkspaceService, readExecutionWorkspaceConfig } from "./execution-workspaces.js";
|
|
20
|
+
import { isRuntimeOwnedGitBranch } from "./execution-workspace-branch-ownership.js";
|
|
18
21
|
import { logActivity } from "./activity-log.js";
|
|
19
22
|
import { readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
|
|
20
23
|
import { workspaceGitOperationScheduler } from "./workspace-git-operation-scheduler.js";
|
|
21
24
|
import { cleanupWorktreeInstanceArtifacts, deriveWorktreeInstanceId, readWorktreeInstancePointer, WORKTREE_INSTANCE_ROOT_METADATA_KEY, } from "./workspace-instance-cleanup.js";
|
|
22
25
|
import { UnixBrokerClient } from "./runtime-exposure/broker-client.js";
|
|
23
26
|
import { deprovisionExposure, provisionExposure, reserveExposure, } from "./runtime-exposure/exposure-manager.js";
|
|
24
|
-
import { diagnoseRuntimeListenerBinds } from "./runtime-exposure/loopback-listener.js";
|
|
27
|
+
import { diagnoseRuntimeListenerBinds, readListenerBindFacts } from "./runtime-exposure/loopback-listener.js";
|
|
25
28
|
import { allocateExposurePortPair } from "./runtime-exposure/port-pair.js";
|
|
26
29
|
import { buildExposureReservationLedger, collectRowExposurePorts, describeExposureReservationDrift, ExposurePortOwnershipConflictError, ExposurePortPairClaims, findExposurePairConflict, findExposureReservationDrift, isExposureAdoptionPermitted, } from "./runtime-exposure/port-reservation.js";
|
|
27
30
|
import { resolveTailscaleDnsName } from "./runtime-exposure/tailscale-hostname.js";
|
|
@@ -47,6 +50,8 @@ const runtimeServicesById = new Map();
|
|
|
47
50
|
const runtimeServicesByReuseKey = new Map();
|
|
48
51
|
const runtimeServiceLeasesByRun = new Map();
|
|
49
52
|
const runtimeProvisionByWorkspace = new Map();
|
|
53
|
+
const runtimeControlStartByOwner = new Map();
|
|
54
|
+
const runtimeReplacementClaimsByReuseKey = new Map();
|
|
50
55
|
const quarantinedRuntimeExposurePorts = new Set();
|
|
51
56
|
/**
|
|
52
57
|
* Pair-atomic in-process claims for exposure allocations that have not bound a
|
|
@@ -67,10 +72,24 @@ const ACTIVE_RUNTIME_PORT_RESERVATION_STATUSES = ["provisioning", "starting", "r
|
|
|
67
72
|
const DEFAULT_TAILSCALE_BROKER_SOCKET = "/run/paperclip-tailscale-broker/broker.sock";
|
|
68
73
|
class RuntimeServicePortBindCollision extends Error {
|
|
69
74
|
port;
|
|
70
|
-
|
|
71
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Who held the port when the collision was seen, captured at failure time.
|
|
77
|
+
* Null when no owner remained (a transient racer that already released it).
|
|
78
|
+
*/
|
|
79
|
+
diagnosis;
|
|
80
|
+
/**
|
|
81
|
+
* True when the port is only a preference and the caller may re-allocate a
|
|
82
|
+
* different one. Exposed runtimes always draw from the dedicated broker range,
|
|
83
|
+
* so a collision on the assigned port is recoverable by taking the next pair.
|
|
84
|
+
*/
|
|
85
|
+
exposureReallocatable;
|
|
86
|
+
constructor(port, diagnosis = null, exposureReallocatable = false) {
|
|
87
|
+
super(`Runtime service could not bind allocated port ${port}` +
|
|
88
|
+
(diagnosis ? ` (${diagnosis})` : ""));
|
|
72
89
|
this.name = "RuntimeServicePortBindCollision";
|
|
73
90
|
this.port = port;
|
|
91
|
+
this.diagnosis = diagnosis;
|
|
92
|
+
this.exposureReallocatable = exposureReallocatable;
|
|
74
93
|
}
|
|
75
94
|
}
|
|
76
95
|
async function isLoopbackPortAvailable(port) {
|
|
@@ -221,11 +240,20 @@ export async function resetRuntimeServicesForTests(opts = {}) {
|
|
|
221
240
|
}
|
|
222
241
|
for (const record of runtimeServicesById.values()) {
|
|
223
242
|
clearIdleTimer(record);
|
|
243
|
+
if (opts.simulateSupervisorExit) {
|
|
244
|
+
// A real supervisor exit closes its side of every inherited pipe. Tests
|
|
245
|
+
// use this to prove surviving request-logging services do not depend on
|
|
246
|
+
// Paperclip keeping an anonymous stdio peer alive.
|
|
247
|
+
record.child?.stdout?.destroy();
|
|
248
|
+
record.child?.stderr?.destroy();
|
|
249
|
+
}
|
|
224
250
|
}
|
|
225
251
|
runtimeServicesById.clear();
|
|
226
252
|
runtimeServicesByReuseKey.clear();
|
|
227
253
|
runtimeServiceLeasesByRun.clear();
|
|
228
254
|
runtimeProvisionByWorkspace.clear();
|
|
255
|
+
runtimeControlStartByOwner.clear();
|
|
256
|
+
runtimeReplacementClaimsByReuseKey.clear();
|
|
229
257
|
quarantinedRuntimeExposurePorts.clear();
|
|
230
258
|
exposurePortPairClaims.clear();
|
|
231
259
|
workspaceRuntimeExposureDeps = defaultWorkspaceRuntimeExposureDeps();
|
|
@@ -1709,20 +1737,83 @@ export async function ensureGitWorktreeBranchCoherent(input) {
|
|
|
1709
1737
|
],
|
|
1710
1738
|
};
|
|
1711
1739
|
}
|
|
1740
|
+
// A configured base ref that does not resolve to a commit, even after an
|
|
1741
|
+
// authenticated fetch of its `origin/<branch>` counterpart. The caller must
|
|
1742
|
+
// stop before `git worktree add` and raise a pre-dispatch configuration
|
|
1743
|
+
// failure. `requestedRef` keeps the operator spelling for the human notice.
|
|
1744
|
+
// `recoveryIdentityRef` is the canonical remote ref the resolver probed, so two
|
|
1745
|
+
// equivalent spellings of one remote branch map to one recovery identity.
|
|
1746
|
+
// `attemptedRefs` names each ref the resolver tried, and `fetchError` carries
|
|
1747
|
+
// the first fetch warning (masked) when the fetch itself failed.
|
|
1748
|
+
export class UnresolvedWorkspaceBaseRefError extends Error {
|
|
1749
|
+
requestedRef;
|
|
1750
|
+
recoveryIdentityRef;
|
|
1751
|
+
attemptedRefs;
|
|
1752
|
+
fetchError;
|
|
1753
|
+
constructor(input) {
|
|
1754
|
+
super(`Configured workspace base ref "${input.requestedRef}" did not resolve to a commit on origin after an authenticated fetch.`);
|
|
1755
|
+
this.name = "UnresolvedWorkspaceBaseRefError";
|
|
1756
|
+
this.requestedRef = input.requestedRef;
|
|
1757
|
+
this.recoveryIdentityRef = input.recoveryIdentityRef;
|
|
1758
|
+
this.attemptedRefs = input.attemptedRefs;
|
|
1759
|
+
this.fetchError = input.fetchError ?? null;
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
export function isUnresolvedWorkspaceBaseRefError(error) {
|
|
1763
|
+
return error instanceof UnresolvedWorkspaceBaseRefError;
|
|
1764
|
+
}
|
|
1712
1765
|
// Resolve the authoritative base ref for a fresh worktree. A configured local
|
|
1713
1766
|
// branch is mapped to its `origin/<branch>` counterpart so unpushed local
|
|
1714
|
-
// divergence never leaks into the task branch;
|
|
1715
|
-
//
|
|
1716
|
-
//
|
|
1767
|
+
// divergence never leaks into the task branch; SHAs and tags are used verbatim,
|
|
1768
|
+
// and an unset/`HEAD` base falls back to the detected default branch (which
|
|
1769
|
+
// already prefers `origin/master`).
|
|
1770
|
+
//
|
|
1771
|
+
// A remote-only feature branch never has a local ref or a remote-tracking ref
|
|
1772
|
+
// yet. The resolver fetches `origin/<branch>` with the authenticated helper,
|
|
1773
|
+
// then re-checks the commit. This covers both the unqualified form (`fix/foo`)
|
|
1774
|
+
// and the remote-tracking form (`origin/fix/foo`). A ref that still does not
|
|
1775
|
+
// resolve returns `resolved: false`, so the caller stops before the worktree
|
|
1776
|
+
// add instead of passing an invalid reference to git.
|
|
1717
1777
|
async function resolveAuthoritativeBaseRef(repoRoot, configuredBaseRef, resolveGitAuth) {
|
|
1718
1778
|
const warnings = [];
|
|
1719
1779
|
const detectOrHead = async () => (await detectDefaultBranch(repoRoot, resolveGitAuth)) ?? "HEAD";
|
|
1720
1780
|
const configured = configuredBaseRef?.trim();
|
|
1721
1781
|
if (!configured || configured === "HEAD") {
|
|
1722
|
-
return { baseRef: await detectOrHead(), warnings, refreshed: false };
|
|
1782
|
+
return { resolved: true, baseRef: await detectOrHead(), warnings, refreshed: false };
|
|
1723
1783
|
}
|
|
1724
|
-
|
|
1725
|
-
|
|
1784
|
+
// A remote-tracking ref supplied directly (for example `origin/fix/foo`).
|
|
1785
|
+
// Use it verbatim when it already resolves. When it does not, fetch it once
|
|
1786
|
+
// and re-check, then stop if it is still absent on the remote.
|
|
1787
|
+
//
|
|
1788
|
+
// `parseRemoteTrackingRef` only checks the `remote/branch` shape. An
|
|
1789
|
+
// unqualified branch name that contains a slash (for example `fix/foo`) has
|
|
1790
|
+
// the same shape but names no real remote, so it is not a remote-tracking
|
|
1791
|
+
// ref. Gate this branch on the first segment naming an existing remote, and
|
|
1792
|
+
// let a name like `fix/foo` fall through to the remote-only branch handling
|
|
1793
|
+
// below.
|
|
1794
|
+
const remoteTracking = parseRemoteTrackingRef(configured);
|
|
1795
|
+
if (remoteTracking && await resolveBaseRefSha(repoRoot, configured)) {
|
|
1796
|
+
return { resolved: true, baseRef: configured, warnings, refreshed: false };
|
|
1797
|
+
}
|
|
1798
|
+
if (remoteTracking && await remoteExists(repoRoot, remoteTracking.remote)) {
|
|
1799
|
+
const fetchWarnings = await refreshRemoteTrackingBaseRef(repoRoot, configured, resolveGitAuth);
|
|
1800
|
+
warnings.push(...fetchWarnings);
|
|
1801
|
+
if (await resolveBaseRefSha(repoRoot, configured)) {
|
|
1802
|
+
return { resolved: true, baseRef: configured, warnings, refreshed: true };
|
|
1803
|
+
}
|
|
1804
|
+
// Build the recovery identity from the parsed remote and branch. The raw
|
|
1805
|
+
// ref and its remote-tracking spelling (`origin/fix/foo` and
|
|
1806
|
+
// `refs/remotes/origin/fix/foo`) then share one recovery fingerprint, so
|
|
1807
|
+
// recovery treats them as one identity instead of two.
|
|
1808
|
+
const canonicalRemoteRef = `${remoteTracking.remote}/${remoteTracking.branch}`;
|
|
1809
|
+
return {
|
|
1810
|
+
resolved: false,
|
|
1811
|
+
requestedRef: configured,
|
|
1812
|
+
recoveryIdentityRef: canonicalRemoteRef,
|
|
1813
|
+
attemptedRefs: [configured],
|
|
1814
|
+
warnings,
|
|
1815
|
+
fetchError: fetchWarnings[0] ?? null,
|
|
1816
|
+
};
|
|
1726
1817
|
}
|
|
1727
1818
|
if (await localBranchExists(repoRoot, configured)) {
|
|
1728
1819
|
const remoteCandidate = `origin/${configured}`;
|
|
@@ -1730,14 +1821,34 @@ async function resolveAuthoritativeBaseRef(repoRoot, configuredBaseRef, resolveG
|
|
|
1730
1821
|
// the returned ref (see `refreshed`) so we never fetch the same ref twice.
|
|
1731
1822
|
warnings.push(...await refreshRemoteTrackingBaseRef(repoRoot, remoteCandidate, resolveGitAuth));
|
|
1732
1823
|
if (await resolveBaseRefSha(repoRoot, remoteCandidate)) {
|
|
1733
|
-
return { baseRef: remoteCandidate, warnings, refreshed: true };
|
|
1824
|
+
return { resolved: true, baseRef: remoteCandidate, warnings, refreshed: true };
|
|
1734
1825
|
}
|
|
1735
1826
|
if (await remoteExists(repoRoot, "origin")) {
|
|
1736
1827
|
warnings.push(`Configured base ref "${configured}" is a local branch with no matching origin/${configured}; basing the execution workspace on the local ref, which may include unpushed commits.`);
|
|
1737
1828
|
}
|
|
1738
|
-
return { baseRef: configured, warnings, refreshed: false };
|
|
1829
|
+
return { resolved: true, baseRef: configured, warnings, refreshed: false };
|
|
1739
1830
|
}
|
|
1740
|
-
|
|
1831
|
+
// Fall-through: an unqualified ref (for example `fix/foo`) that is not `HEAD`,
|
|
1832
|
+
// not a remote-tracking ref, and not a local branch. A full SHA or a tag that
|
|
1833
|
+
// already resolves stays verbatim. Otherwise treat it as a remote-only branch
|
|
1834
|
+
// name: fetch `origin/<ref>` and base the worktree on the remote counterpart.
|
|
1835
|
+
if (await resolveBaseRefSha(repoRoot, configured)) {
|
|
1836
|
+
return { resolved: true, baseRef: configured, warnings, refreshed: false };
|
|
1837
|
+
}
|
|
1838
|
+
const remoteCandidate = `origin/${configured}`;
|
|
1839
|
+
const fetchWarnings = await refreshRemoteTrackingBaseRef(repoRoot, remoteCandidate, resolveGitAuth);
|
|
1840
|
+
warnings.push(...fetchWarnings);
|
|
1841
|
+
if (await resolveBaseRefSha(repoRoot, remoteCandidate)) {
|
|
1842
|
+
return { resolved: true, baseRef: remoteCandidate, warnings, refreshed: true };
|
|
1843
|
+
}
|
|
1844
|
+
return {
|
|
1845
|
+
resolved: false,
|
|
1846
|
+
requestedRef: configured,
|
|
1847
|
+
recoveryIdentityRef: remoteCandidate,
|
|
1848
|
+
attemptedRefs: [remoteCandidate],
|
|
1849
|
+
warnings,
|
|
1850
|
+
fetchError: fetchWarnings[0] ?? null,
|
|
1851
|
+
};
|
|
1741
1852
|
}
|
|
1742
1853
|
// Auto-refresh a reused worktree to the latest base only when it is provably
|
|
1743
1854
|
// unstarted: no task commits past the base and a clean tree (including untracked
|
|
@@ -2026,6 +2137,15 @@ function buildWorkspaceCommandEnv(input) {
|
|
|
2026
2137
|
function quoteShellArg(value) {
|
|
2027
2138
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
2028
2139
|
}
|
|
2140
|
+
const BUILTIN_WORKSPACE_PROVISION_COMMAND = "bash ./scripts/provision-worktree.sh";
|
|
2141
|
+
function resolveWorkspaceProvisionCommand(strategy, repoRoot) {
|
|
2142
|
+
const configuredCommand = asString(strategy.provisionCommand, "").trim();
|
|
2143
|
+
if (configuredCommand)
|
|
2144
|
+
return configuredCommand;
|
|
2145
|
+
return existsSync(path.join(repoRoot, "scripts", "provision-worktree.sh"))
|
|
2146
|
+
? BUILTIN_WORKSPACE_PROVISION_COMMAND
|
|
2147
|
+
: "";
|
|
2148
|
+
}
|
|
2029
2149
|
function resolveRepoManagedWorkspaceCommand(command, repoRoot) {
|
|
2030
2150
|
const patterns = [
|
|
2031
2151
|
/^(?<prefix>(?:bash|sh|zsh)\s+)(?<quote>["']?)(?<relative>\.\/[^"'\s]+)\k<quote>(?<suffix>(?:\s.*)?)$/s,
|
|
@@ -2131,27 +2251,33 @@ async function recordWorkspaceCommandOperation(recorder, input) {
|
|
|
2131
2251
|
cwd: input.cwd,
|
|
2132
2252
|
env: input.env,
|
|
2133
2253
|
});
|
|
2254
|
+
const seedEvidence = input.phase === "workspace_seed"
|
|
2255
|
+
? readWorkspaceSeedOperationEvidence(input.cwd)
|
|
2256
|
+
: null;
|
|
2134
2257
|
stdout = result.stdout;
|
|
2135
|
-
stderr = result.stderr;
|
|
2136
|
-
code = result.code;
|
|
2258
|
+
stderr = [result.stderr, seedEvidence?.error].filter(Boolean).join("\n");
|
|
2259
|
+
code = result.code === 0 && seedEvidence && !seedEvidence.verified ? 1 : result.code;
|
|
2137
2260
|
if (result.stdout && input.onLog)
|
|
2138
2261
|
await input.onLog("stdout", `[runtime-provision] ${result.stdout}`);
|
|
2139
|
-
if (
|
|
2140
|
-
await input.onLog("stderr", `[runtime-provision] ${
|
|
2262
|
+
if (stderr && input.onLog)
|
|
2263
|
+
await input.onLog("stderr", `[runtime-provision] ${stderr}`);
|
|
2264
|
+
const truncationMetadata = result.stdoutTruncated || result.stderrTruncated
|
|
2265
|
+
? {
|
|
2266
|
+
stdoutTruncated: result.stdoutTruncated,
|
|
2267
|
+
stderrTruncated: result.stderrTruncated,
|
|
2268
|
+
stdoutBytes: result.stdoutBytes,
|
|
2269
|
+
stderrBytes: result.stderrBytes,
|
|
2270
|
+
}
|
|
2271
|
+
: null;
|
|
2141
2272
|
return {
|
|
2142
|
-
status:
|
|
2143
|
-
exitCode:
|
|
2273
|
+
status: code === 0 ? "succeeded" : "failed",
|
|
2274
|
+
exitCode: code,
|
|
2144
2275
|
stdout: result.stdout,
|
|
2145
|
-
stderr
|
|
2146
|
-
system:
|
|
2147
|
-
metadata:
|
|
2148
|
-
? {
|
|
2149
|
-
|
|
2150
|
-
stderrTruncated: result.stderrTruncated,
|
|
2151
|
-
stdoutBytes: result.stdoutBytes,
|
|
2152
|
-
stderrBytes: result.stderrBytes,
|
|
2153
|
-
}
|
|
2154
|
-
: null,
|
|
2276
|
+
stderr,
|
|
2277
|
+
system: code === 0 ? input.successMessage ?? null : null,
|
|
2278
|
+
metadata: seedEvidence
|
|
2279
|
+
? { ...seedEvidence.metadata, ...(truncationMetadata ?? {}) }
|
|
2280
|
+
: truncationMetadata,
|
|
2155
2281
|
};
|
|
2156
2282
|
},
|
|
2157
2283
|
});
|
|
@@ -2163,7 +2289,7 @@ async function recordWorkspaceCommandOperation(recorder, input) {
|
|
|
2163
2289
|
: `${input.label} failed with exit code ${code ?? -1}`);
|
|
2164
2290
|
}
|
|
2165
2291
|
async function provisionExecutionWorktree(input) {
|
|
2166
|
-
const provisionCommand =
|
|
2292
|
+
const provisionCommand = resolveWorkspaceProvisionCommand(input.strategy, input.repoRoot);
|
|
2167
2293
|
if (!provisionCommand)
|
|
2168
2294
|
return;
|
|
2169
2295
|
const resolvedProvisionCommand = resolveRepoManagedWorkspaceCommand(provisionCommand, input.repoRoot);
|
|
@@ -2227,7 +2353,17 @@ async function resolveGitRepoRootForWorkspaceCleanup(worktreePath, projectWorksp
|
|
|
2227
2353
|
export async function realizeExecutionWorkspace(input) {
|
|
2228
2354
|
const rawStrategy = parseObject(input.config.workspaceStrategy);
|
|
2229
2355
|
const strategyType = asString(rawStrategy.type, "project_primary");
|
|
2356
|
+
const requestedExistingBranch = asString(rawStrategy.existingBranch, "").trim();
|
|
2230
2357
|
if (strategyType !== "git_worktree") {
|
|
2358
|
+
if (requestedExistingBranch) {
|
|
2359
|
+
throw new WorkspaceRuntimeValidationFailure(`Workspace strategy pins existing branch "${requestedExistingBranch}" but has type "${strategyType}"; an exact-branch workspace requires strategy type "git_worktree". Set workspaceStrategy.type to "git_worktree" or remove existingBranch.`, {
|
|
2360
|
+
workspaceValidation: {
|
|
2361
|
+
reason: "existing_branch_requires_git_worktree",
|
|
2362
|
+
requestedExistingBranch,
|
|
2363
|
+
strategyType,
|
|
2364
|
+
},
|
|
2365
|
+
});
|
|
2366
|
+
}
|
|
2231
2367
|
return {
|
|
2232
2368
|
...input.base,
|
|
2233
2369
|
strategy: "project_primary",
|
|
@@ -2236,28 +2372,66 @@ export async function realizeExecutionWorkspace(input) {
|
|
|
2236
2372
|
worktreePath: null,
|
|
2237
2373
|
warnings: [],
|
|
2238
2374
|
created: false,
|
|
2375
|
+
branchCreatedByRuntime: false,
|
|
2239
2376
|
baseRefSha: null,
|
|
2240
2377
|
};
|
|
2241
2378
|
}
|
|
2242
2379
|
const repoRoot = await resolveGitOwnerRepoRoot(input.base.baseCwd);
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2380
|
+
let branchName;
|
|
2381
|
+
if (requestedExistingBranch) {
|
|
2382
|
+
// Exact-branch mode: attach the requested pre-existing branch verbatim.
|
|
2383
|
+
// The branch must already exist; realization never creates, renames, or
|
|
2384
|
+
// resets it, and any mismatch below fails closed instead of falling back
|
|
2385
|
+
// to a derived branch or the shared checkout.
|
|
2386
|
+
const existingBranchSha = await runGit(["rev-parse", "--verify", "--quiet", `refs/heads/${requestedExistingBranch}`], repoRoot).catch(() => null);
|
|
2387
|
+
if (!existingBranchSha) {
|
|
2388
|
+
throw new WorkspaceRuntimeValidationFailure(`Workspace strategy pins existing branch "${requestedExistingBranch}", but no local branch with that name exists in "${repoRoot}". Create or fetch the branch first, or remove workspaceStrategy.existingBranch; exact-branch realization never creates a branch.`, {
|
|
2389
|
+
workspaceValidation: {
|
|
2390
|
+
reason: "existing_branch_not_found",
|
|
2391
|
+
requestedExistingBranch,
|
|
2392
|
+
repoRoot,
|
|
2393
|
+
},
|
|
2394
|
+
});
|
|
2395
|
+
}
|
|
2396
|
+
branchName = requestedExistingBranch;
|
|
2397
|
+
}
|
|
2398
|
+
else {
|
|
2399
|
+
const branchTemplate = asString(rawStrategy.branchTemplate, "{{issue.identifier}}-{{slug}}");
|
|
2400
|
+
const renderedBranch = renderWorkspaceTemplate(branchTemplate, {
|
|
2401
|
+
issue: input.issue,
|
|
2402
|
+
agent: input.agent,
|
|
2403
|
+
projectId: input.base.projectId,
|
|
2404
|
+
repoRef: input.base.repoRef,
|
|
2405
|
+
});
|
|
2406
|
+
branchName = sanitizeBranchName(renderedBranch);
|
|
2407
|
+
}
|
|
2251
2408
|
const configuredParentDir = asString(rawStrategy.worktreeParentDir, "");
|
|
2252
2409
|
const worktreeParentDir = configuredParentDir
|
|
2253
2410
|
? resolveConfiguredPath(configuredParentDir, repoRoot)
|
|
2254
2411
|
: path.join(repoRoot, ".paperclip", "worktrees");
|
|
2255
2412
|
const worktreePath = path.join(worktreeParentDir, branchName);
|
|
2413
|
+
if (path.relative(worktreeParentDir, worktreePath).startsWith("..")) {
|
|
2414
|
+
throw new WorkspaceRuntimeValidationFailure(`Workspace branch "${branchName}" resolves to a worktree path outside the managed worktree parent directory "${worktreeParentDir}".`, {
|
|
2415
|
+
workspaceValidation: {
|
|
2416
|
+
reason: "worktree_path_escapes_parent_dir",
|
|
2417
|
+
branchName,
|
|
2418
|
+
worktreeParentDir,
|
|
2419
|
+
},
|
|
2420
|
+
});
|
|
2421
|
+
}
|
|
2256
2422
|
let pendingForwardBranchReconcile = null;
|
|
2257
2423
|
const configuredBaseRef = typeof rawStrategy.baseRef === "string" && rawStrategy.baseRef.length > 0
|
|
2258
2424
|
? rawStrategy.baseRef
|
|
2259
2425
|
: input.base.repoRef ?? null;
|
|
2260
|
-
const
|
|
2426
|
+
const baseRefResolution = await resolveAuthoritativeBaseRef(repoRoot, configuredBaseRef, input.resolveGitAuth);
|
|
2427
|
+
// Keep a usable base ref for the reuse and drift paths even when the ref is
|
|
2428
|
+
// unresolved: those paths tolerate a null base-ref SHA and never run
|
|
2429
|
+
// `git worktree add -b <branch> <baseRef>`. Only the fresh-create path below
|
|
2430
|
+
// stops on an unresolved ref. `baseRefAlreadyRefreshed` is true for the
|
|
2431
|
+
// unresolved case because the resolver already attempted the fetch.
|
|
2432
|
+
const baseRef = baseRefResolution.resolved ? baseRefResolution.baseRef : baseRefResolution.requestedRef;
|
|
2433
|
+
const baseRefResolutionWarnings = baseRefResolution.warnings;
|
|
2434
|
+
const baseRefAlreadyRefreshed = baseRefResolution.resolved ? baseRefResolution.refreshed : true;
|
|
2261
2435
|
const baseRefreshWarnings = [
|
|
2262
2436
|
...baseRefResolutionWarnings,
|
|
2263
2437
|
...(baseRefAlreadyRefreshed ? [] : await refreshRemoteTrackingBaseRef(repoRoot, baseRef, input.resolveGitAuth)),
|
|
@@ -2265,7 +2439,9 @@ export async function realizeExecutionWorkspace(input) {
|
|
|
2265
2439
|
const currentBaseRefSha = await resolveBaseRefSha(repoRoot, baseRef);
|
|
2266
2440
|
await fs.mkdir(worktreeParentDir, { recursive: true });
|
|
2267
2441
|
async function reuseExistingWorktree(reusablePath, effectiveBranchName = branchName, extraWarnings = []) {
|
|
2268
|
-
|
|
2442
|
+
// An exact-branch attach must never move the requested branch, so skip
|
|
2443
|
+
// the unstarted-worktree fast-forward that template-derived reuse gets.
|
|
2444
|
+
const refresh = currentBaseRefSha && !requestedExistingBranch
|
|
2269
2445
|
? await refreshUnstartedWorktreeToBase({
|
|
2270
2446
|
repoRoot,
|
|
2271
2447
|
worktreePath: reusablePath,
|
|
@@ -2324,6 +2500,14 @@ export async function realizeExecutionWorkspace(input) {
|
|
|
2324
2500
|
worktreePath: reusablePath,
|
|
2325
2501
|
warnings: [...extraWarnings, ...baseRefreshWarnings, ...baseDrift.warnings],
|
|
2326
2502
|
created: false,
|
|
2503
|
+
// A fresh realization may still land on the worktree recorded by a
|
|
2504
|
+
// previous heartbeat. Preserve that branch's ownership only when the
|
|
2505
|
+
// recorded branch matches the checkout being reused. Exact-branch mode
|
|
2506
|
+
// remains operator-owned by contract; every mismatch likewise fails
|
|
2507
|
+
// safe and leaves the branch behind during terminal cleanup.
|
|
2508
|
+
branchCreatedByRuntime: !requestedExistingBranch
|
|
2509
|
+
&& input.recordedBranchOwnership?.branchName === effectiveBranchName
|
|
2510
|
+
&& input.recordedBranchOwnership.createdByRuntime === true,
|
|
2327
2511
|
baseRefSha: refresh.baseRefSha ?? baseDrift.branchBaseRefSha ?? baseDrift.currentBaseRefSha,
|
|
2328
2512
|
pendingForwardBranchReconcile,
|
|
2329
2513
|
};
|
|
@@ -2335,6 +2519,11 @@ export async function realizeExecutionWorkspace(input) {
|
|
|
2335
2519
|
expectedBranchName: branchName,
|
|
2336
2520
|
}).catch(() => null);
|
|
2337
2521
|
if (validation && !validation.valid && validation.reasonCode === "branch_mismatch") {
|
|
2522
|
+
if (requestedExistingBranch) {
|
|
2523
|
+
// Exact-branch mode never reconciles a mismatched checkout onto
|
|
2524
|
+
// another branch; the caller fails closed with the mismatch reason.
|
|
2525
|
+
return { validation, branchName, warnings: [] };
|
|
2526
|
+
}
|
|
2338
2527
|
const coherence = await ensureGitWorktreeBranchCoherent({
|
|
2339
2528
|
db: input.db ?? null,
|
|
2340
2529
|
repoRoot,
|
|
@@ -2375,6 +2564,16 @@ export async function realizeExecutionWorkspace(input) {
|
|
|
2375
2564
|
}
|
|
2376
2565
|
const validation = reusable.validation;
|
|
2377
2566
|
const reason = validation && !validation.valid ? ` (${validation.reason})` : "";
|
|
2567
|
+
if (requestedExistingBranch) {
|
|
2568
|
+
throw new WorkspaceRuntimeValidationFailure(`Workspace strategy pins existing branch "${requestedExistingBranch}", but the worktree path "${worktreePath}" already exists and is not a reusable checkout of that branch${reason}. Repair or remove that worktree, then retry; exact-branch realization never reconciles it onto another branch.`, {
|
|
2569
|
+
workspaceValidation: {
|
|
2570
|
+
reason: "existing_branch_worktree_not_reusable",
|
|
2571
|
+
reasonCode: validation && !validation.valid ? validation.reasonCode : null,
|
|
2572
|
+
requestedExistingBranch,
|
|
2573
|
+
worktreePath,
|
|
2574
|
+
},
|
|
2575
|
+
});
|
|
2576
|
+
}
|
|
2378
2577
|
throw new Error(`Configured worktree path "${worktreePath}" already exists and is not a reusable git worktree${reason}.`);
|
|
2379
2578
|
}
|
|
2380
2579
|
const registeredBranchWorktree = await findRegisteredGitWorktreeByBranch(repoRoot, branchName);
|
|
@@ -2385,8 +2584,86 @@ export async function realizeExecutionWorkspace(input) {
|
|
|
2385
2584
|
}
|
|
2386
2585
|
const validation = reusable.validation;
|
|
2387
2586
|
const reason = validation && !validation.valid ? ` (${validation.reason})` : "";
|
|
2587
|
+
if (requestedExistingBranch) {
|
|
2588
|
+
throw new WorkspaceRuntimeValidationFailure(`Workspace strategy pins existing branch "${requestedExistingBranch}", which is already checked out at "${registeredBranchWorktree}", but that worktree is not reusable${reason}. Repair or remove that worktree, then retry.`, {
|
|
2589
|
+
workspaceValidation: {
|
|
2590
|
+
reason: "existing_branch_worktree_not_reusable",
|
|
2591
|
+
reasonCode: validation && !validation.valid ? validation.reasonCode : null,
|
|
2592
|
+
requestedExistingBranch,
|
|
2593
|
+
worktreePath: registeredBranchWorktree,
|
|
2594
|
+
},
|
|
2595
|
+
});
|
|
2596
|
+
}
|
|
2388
2597
|
throw new Error(`Registered worktree for branch "${branchName}" at "${registeredBranchWorktree}" is not reusable${reason}.`);
|
|
2389
2598
|
}
|
|
2599
|
+
if (requestedExistingBranch) {
|
|
2600
|
+
try {
|
|
2601
|
+
await recordGitOperation(input.recorder, {
|
|
2602
|
+
phase: "worktree_prepare",
|
|
2603
|
+
args: ["worktree", "add", worktreePath, branchName],
|
|
2604
|
+
cwd: repoRoot,
|
|
2605
|
+
metadata: {
|
|
2606
|
+
repoRoot,
|
|
2607
|
+
worktreePath,
|
|
2608
|
+
branchName,
|
|
2609
|
+
baseRef,
|
|
2610
|
+
baseRefSha: currentBaseRefSha,
|
|
2611
|
+
created: false,
|
|
2612
|
+
attachedExistingBranch: true,
|
|
2613
|
+
},
|
|
2614
|
+
successMessage: `Attached existing branch ${branchName} at ${worktreePath}\n`,
|
|
2615
|
+
failureLabel: `git worktree add ${worktreePath}`,
|
|
2616
|
+
});
|
|
2617
|
+
}
|
|
2618
|
+
catch (attachError) {
|
|
2619
|
+
const message = attachError instanceof Error ? attachError.message : String(attachError);
|
|
2620
|
+
throw new WorkspaceRuntimeValidationFailure(`Could not attach existing branch "${requestedExistingBranch}" as a git worktree at "${worktreePath}": ${message}`, {
|
|
2621
|
+
workspaceValidation: {
|
|
2622
|
+
reason: "existing_branch_attach_failed",
|
|
2623
|
+
requestedExistingBranch,
|
|
2624
|
+
worktreePath,
|
|
2625
|
+
},
|
|
2626
|
+
});
|
|
2627
|
+
}
|
|
2628
|
+
await provisionExecutionWorktree({
|
|
2629
|
+
strategy: rawStrategy,
|
|
2630
|
+
base: input.base,
|
|
2631
|
+
repoRoot,
|
|
2632
|
+
worktreePath,
|
|
2633
|
+
branchName,
|
|
2634
|
+
issue: input.issue,
|
|
2635
|
+
agent: input.agent,
|
|
2636
|
+
created: true,
|
|
2637
|
+
recorder: input.recorder ?? null,
|
|
2638
|
+
});
|
|
2639
|
+
return {
|
|
2640
|
+
...input.base,
|
|
2641
|
+
repoRef: baseRef,
|
|
2642
|
+
strategy: "git_worktree",
|
|
2643
|
+
cwd: worktreePath,
|
|
2644
|
+
branchName,
|
|
2645
|
+
worktreePath,
|
|
2646
|
+
warnings: baseRefreshWarnings,
|
|
2647
|
+
// The worktree is new, but the pinned branch pre-existed: it stays
|
|
2648
|
+
// operator-owned so terminal cleanup never deletes it.
|
|
2649
|
+
created: true,
|
|
2650
|
+
branchCreatedByRuntime: false,
|
|
2651
|
+
baseRefSha: currentBaseRefSha,
|
|
2652
|
+
};
|
|
2653
|
+
}
|
|
2654
|
+
// No reusable worktree exists, so a fresh `git worktree add -b <branch> <baseRef>`
|
|
2655
|
+
// must run next. An unresolved base ref would make git fail with
|
|
2656
|
+
// `fatal: invalid reference`. Stop here instead and raise a pre-dispatch
|
|
2657
|
+
// configuration failure that the setup catch routes to a human owner.
|
|
2658
|
+
if (!baseRefResolution.resolved) {
|
|
2659
|
+
throw new UnresolvedWorkspaceBaseRefError({
|
|
2660
|
+
requestedRef: baseRefResolution.requestedRef,
|
|
2661
|
+
recoveryIdentityRef: baseRefResolution.recoveryIdentityRef,
|
|
2662
|
+
attemptedRefs: baseRefResolution.attemptedRefs,
|
|
2663
|
+
fetchError: baseRefResolution.fetchError,
|
|
2664
|
+
});
|
|
2665
|
+
}
|
|
2666
|
+
let branchCreatedByRuntime = true;
|
|
2390
2667
|
try {
|
|
2391
2668
|
await recordGitOperation(input.recorder, {
|
|
2392
2669
|
phase: "worktree_prepare",
|
|
@@ -2425,6 +2702,9 @@ export async function realizeExecutionWorkspace(input) {
|
|
|
2425
2702
|
successMessage: `Attached existing branch ${branchName} at ${worktreePath}\n`,
|
|
2426
2703
|
failureLabel: `git worktree add ${worktreePath}`,
|
|
2427
2704
|
});
|
|
2705
|
+
// The template rendered to a branch that already existed, so this
|
|
2706
|
+
// attach did not create the branch and cleanup must not delete it.
|
|
2707
|
+
branchCreatedByRuntime = false;
|
|
2428
2708
|
}
|
|
2429
2709
|
catch (attachError) {
|
|
2430
2710
|
if (!gitErrorIncludes(attachError, "already checked out")) {
|
|
@@ -2457,6 +2737,7 @@ export async function realizeExecutionWorkspace(input) {
|
|
|
2457
2737
|
worktreePath,
|
|
2458
2738
|
warnings: baseRefreshWarnings,
|
|
2459
2739
|
created: true,
|
|
2740
|
+
branchCreatedByRuntime,
|
|
2460
2741
|
baseRefSha: currentBaseRefSha,
|
|
2461
2742
|
};
|
|
2462
2743
|
}
|
|
@@ -2479,6 +2760,11 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input) {
|
|
|
2479
2760
|
worktreePath: strategy === "git_worktree" ? (input.workspace.providerRef ?? cwd) : null,
|
|
2480
2761
|
warnings: [],
|
|
2481
2762
|
created: false,
|
|
2763
|
+
// Only the versioned ownership record introduced with branch-level
|
|
2764
|
+
// ownership semantics can authorize branch recreation or deletion. Older
|
|
2765
|
+
// createdByRuntime=true rows described worktree ownership, so trusting
|
|
2766
|
+
// them here could recreate or later delete an operator-owned branch.
|
|
2767
|
+
branchCreatedByRuntime: isRuntimeOwnedGitBranch(input.workspace.metadata),
|
|
2482
2768
|
baseRefSha: readRecordedBaseRefSha(input.workspace.metadata),
|
|
2483
2769
|
};
|
|
2484
2770
|
const provisionCommand = asString(input.workspace.config?.provisionCommand, "").trim();
|
|
@@ -2507,7 +2793,12 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input) {
|
|
|
2507
2793
|
const reuseBaseRef = input.workspace.baseRef ?? input.base.repoRef ?? null;
|
|
2508
2794
|
const reuseWorktreePath = realized.worktreePath ?? cwd;
|
|
2509
2795
|
const repairWarnings = [];
|
|
2510
|
-
if (await isGitCheckout(reuseWorktreePath)) {
|
|
2796
|
+
if (await isGitCheckout(reuseWorktreePath) && realized.branchCreatedByRuntime) {
|
|
2797
|
+
// Branch-coherence repair may check out another branch, adopt a forward
|
|
2798
|
+
// branch, or move the recorded ref from a detached HEAD. Those repairs
|
|
2799
|
+
// are valid only for a branch that this runtime created. An attached
|
|
2800
|
+
// operator-owned branch must retain its exact identity and tip; the
|
|
2801
|
+
// validation below rejects any mismatch without mutating Git state.
|
|
2511
2802
|
const coherence = await ensureGitWorktreeBranchCoherent({
|
|
2512
2803
|
db: input.db ?? null,
|
|
2513
2804
|
repoRoot,
|
|
@@ -2549,7 +2840,9 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input) {
|
|
|
2549
2840
|
? await refreshRemoteTrackingBaseRef(repoRoot, reuseBaseRef, input.resolveGitAuth)
|
|
2550
2841
|
: [];
|
|
2551
2842
|
const currentBaseRefSha = reuseBaseRef ? await resolveBaseRefSha(repoRoot, reuseBaseRef) : null;
|
|
2552
|
-
|
|
2843
|
+
// An unstarted-worktree refresh can fast-forward the checked-out branch.
|
|
2844
|
+
// Never run it for an attached operator-owned ref.
|
|
2845
|
+
const refresh = realized.branchCreatedByRuntime && reuseBaseRef && currentBaseRefSha
|
|
2553
2846
|
? await refreshUnstartedWorktreeToBase({
|
|
2554
2847
|
repoRoot,
|
|
2555
2848
|
worktreePath: reuseWorktreePath,
|
|
@@ -2569,22 +2862,20 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input) {
|
|
|
2569
2862
|
});
|
|
2570
2863
|
realized.warnings = [...repairWarnings, ...baseRefreshWarnings, ...baseDrift.warnings];
|
|
2571
2864
|
realized.baseRefSha = refresh.baseRefSha ?? recordedBaseRefSha ?? baseDrift.branchBaseRefSha ?? baseDrift.currentBaseRefSha;
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
});
|
|
2587
|
-
}
|
|
2865
|
+
await provisionExecutionWorktree({
|
|
2866
|
+
strategy: {
|
|
2867
|
+
type: "git_worktree",
|
|
2868
|
+
...(provisionCommand ? { provisionCommand } : {}),
|
|
2869
|
+
},
|
|
2870
|
+
base: input.base,
|
|
2871
|
+
repoRoot,
|
|
2872
|
+
worktreePath: realized.worktreePath ?? cwd,
|
|
2873
|
+
branchName: realized.branchName ?? "",
|
|
2874
|
+
issue: input.issue,
|
|
2875
|
+
agent: input.agent,
|
|
2876
|
+
created: false,
|
|
2877
|
+
recorder: input.recorder ?? null,
|
|
2878
|
+
});
|
|
2588
2879
|
return realized;
|
|
2589
2880
|
}
|
|
2590
2881
|
const worktreePath = realized.worktreePath ?? cwd;
|
|
@@ -2624,6 +2915,9 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input) {
|
|
|
2624
2915
|
&& !gitErrorIncludes(error, "unknown revision")) {
|
|
2625
2916
|
throw error;
|
|
2626
2917
|
}
|
|
2918
|
+
if (!realized.branchCreatedByRuntime) {
|
|
2919
|
+
throw new Error(`Execution workspace "${worktreePath}" cannot be restored because its operator-owned branch "${branchName}" no longer exists.`);
|
|
2920
|
+
}
|
|
2627
2921
|
const baseRef = input.workspace.baseRef ?? await detectDefaultBranch(repoRoot) ?? "HEAD";
|
|
2628
2922
|
const recreatedBaseRefSha = await resolveBaseRefSha(repoRoot, baseRef);
|
|
2629
2923
|
await recordGitOperation(input.recorder, {
|
|
@@ -2672,6 +2966,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input) {
|
|
|
2672
2966
|
worktreePath,
|
|
2673
2967
|
warnings: [...restoreRefreshWarnings, ...baseDrift.warnings],
|
|
2674
2968
|
created,
|
|
2969
|
+
branchCreatedByRuntime: realized.branchCreatedByRuntime || created,
|
|
2675
2970
|
baseRefSha: recordedBaseRefSha
|
|
2676
2971
|
?? (created ? restoreCurrentBaseRefSha : baseDrift.branchBaseRefSha)
|
|
2677
2972
|
?? baseDrift.currentBaseRefSha,
|
|
@@ -2789,7 +3084,12 @@ export async function cleanupExecutionWorkspaceArtifacts(input) {
|
|
|
2789
3084
|
warnings.push(`Could not read worktree instance pointer: ${err instanceof Error ? err.message : String(err)}`);
|
|
2790
3085
|
}
|
|
2791
3086
|
}
|
|
3087
|
+
// Local-directory ownership keeps the historical createdByRuntime signal.
|
|
3088
|
+
// Git branch deletion additionally requires the version marker introduced
|
|
3089
|
+
// with branch-level ownership semantics. Unmarked legacy rows fail closed:
|
|
3090
|
+
// their worktrees are removable, but their branch refs are operator-owned.
|
|
2792
3091
|
const createdByRuntime = input.workspace.metadata?.createdByRuntime === true;
|
|
3092
|
+
const branchCreatedByRuntime = isRuntimeOwnedGitBranch(input.workspace.metadata);
|
|
2793
3093
|
const cleanupCommands = input.runCleanupCommands === false
|
|
2794
3094
|
? []
|
|
2795
3095
|
: [
|
|
@@ -2877,7 +3177,7 @@ export async function cleanupExecutionWorkspaceArtifacts(input) {
|
|
|
2877
3177
|
}
|
|
2878
3178
|
}
|
|
2879
3179
|
}
|
|
2880
|
-
if (
|
|
3180
|
+
if (branchCreatedByRuntime && input.workspace.branchName) {
|
|
2881
3181
|
if (!repoRoot) {
|
|
2882
3182
|
warnings.push(`Could not resolve git repo root to delete branch "${input.workspace.branchName}".`);
|
|
2883
3183
|
}
|
|
@@ -3269,6 +3569,83 @@ async function canBindRuntimePort(port) {
|
|
|
3269
3569
|
});
|
|
3270
3570
|
});
|
|
3271
3571
|
}
|
|
3572
|
+
/**
|
|
3573
|
+
* True when a loopback listener is present on the port, read WITHOUT binding it.
|
|
3574
|
+
*
|
|
3575
|
+
* The readiness wait must never bind the exact port the guest is about to bind.
|
|
3576
|
+
* A probe `listen()` holds the port for the length of one bind/close, and if the
|
|
3577
|
+
* guest's own `listen()` lands in that window the guest fails with EADDRINUSE on
|
|
3578
|
+
* its assigned port. That self-inflicted race is the runtime exposure port flake
|
|
3579
|
+
* (a slow guest under load loses the race to the parent probe). A `/proc` read
|
|
3580
|
+
* carries the same "a listener appeared" signal with no bind. Where `/proc` is
|
|
3581
|
+
* absent (non-Linux dev hosts), fall back to the bind probe; those hosts do not
|
|
3582
|
+
* run the concurrent managed lanes that expose the race.
|
|
3583
|
+
*/
|
|
3584
|
+
async function hasLoopbackPortListener(port) {
|
|
3585
|
+
const facts = await readListenerBindFacts(port).catch(() => null);
|
|
3586
|
+
if (facts)
|
|
3587
|
+
return facts.present;
|
|
3588
|
+
return !(await canBindRuntimePort(port));
|
|
3589
|
+
}
|
|
3590
|
+
/**
|
|
3591
|
+
* True when one line of the failure text reports an EADDRINUSE bind conflict on
|
|
3592
|
+
* the given port.
|
|
3593
|
+
*
|
|
3594
|
+
* Node prints the failing bind on a single line, for example
|
|
3595
|
+
* `Error: listen EADDRINUSE: address already in use 127.0.0.1:42000`. The match
|
|
3596
|
+
* requires the EADDRINUSE marker and the port on the SAME line. So an
|
|
3597
|
+
* auxiliary-port conflict on one line cannot combine with an unrelated
|
|
3598
|
+
* assigned-port mention on a different, benign line and trigger a wrong
|
|
3599
|
+
* quarantine.
|
|
3600
|
+
*
|
|
3601
|
+
* Node formats a bind address as `host:port`, so the failing port always
|
|
3602
|
+
* follows a colon (for example `127.0.0.1:42000` or `:::42000`). The match
|
|
3603
|
+
* requires that colon and a full-number boundary. So a different port in the
|
|
3604
|
+
* same line cannot look like the assigned app or HMR port, and port 4200 never
|
|
3605
|
+
* matches `:42000`.
|
|
3606
|
+
*/
|
|
3607
|
+
function eaddrinuseTextNamesPort(text, port) {
|
|
3608
|
+
const eaddrinusePattern = /EADDRINUSE|address already in use/i;
|
|
3609
|
+
const portPattern = new RegExp(`:${port}(?![0-9])`);
|
|
3610
|
+
return text
|
|
3611
|
+
.split(/\r?\n/)
|
|
3612
|
+
.some((line) => eaddrinusePattern.test(line) && portPattern.test(line));
|
|
3613
|
+
}
|
|
3614
|
+
/**
|
|
3615
|
+
* Decide which assigned exposure ports a real host listener owns after a guest
|
|
3616
|
+
* start failure.
|
|
3617
|
+
*
|
|
3618
|
+
* The guest owns its own output, so an assigned-port EADDRINUSE line is a claim,
|
|
3619
|
+
* not proof. A managed guest can print a synthetic EADDRINUSE line for its
|
|
3620
|
+
* assigned port with no host listener behind it. A quarantine on that text alone
|
|
3621
|
+
* would drain the shared exposure-port pool across repeated starts. So a port
|
|
3622
|
+
* counts as a host collision only when all of the following hold:
|
|
3623
|
+
* - the failure text names the port with EADDRINUSE, and
|
|
3624
|
+
* - a real listener is present on the port now, and
|
|
3625
|
+
* - the listener owner is not the guest process the runtime just launched, nor a
|
|
3626
|
+
* descendant of it.
|
|
3627
|
+
*
|
|
3628
|
+
* The runtime launches the guest as a shell process group leader, so the real dev
|
|
3629
|
+
* server usually binds the port from a descendant, not the shell pid itself. The
|
|
3630
|
+
* ownership test therefore matches the shell pid against both the owner pid and
|
|
3631
|
+
* the owner process group id. This is the same process-group attribution that
|
|
3632
|
+
* `isLocalServiceProcessOwnedBy` applies on the host platform. A raw pid equality
|
|
3633
|
+
* would treat a guest descendant as an external owner and quarantine a valid pair.
|
|
3634
|
+
*
|
|
3635
|
+
* An unknown owner with a present listener counts as a host owner: the /proc read
|
|
3636
|
+
* proves a listener, and a guest that lost its assigned port does not hold it.
|
|
3637
|
+
*/
|
|
3638
|
+
export function classifyExposureHostCollisions(input) {
|
|
3639
|
+
const hostCollisionPorts = [];
|
|
3640
|
+
for (const state of input.ports) {
|
|
3641
|
+
const guestOwnsPort = input.childPid != null &&
|
|
3642
|
+
(state.ownerPid === input.childPid || state.ownerProcessGroupId === input.childPid);
|
|
3643
|
+
if (state.named && state.listenerPresent && !guestOwnsPort) {
|
|
3644
|
+
hostCollisionPorts.push(state.port);
|
|
3645
|
+
}
|
|
3646
|
+
}
|
|
3647
|
+
return { hostCollisionPorts, hostCollision: hostCollisionPorts.length > 0 };
|
|
3648
|
+
}
|
|
3272
3649
|
async function readReservedRuntimePorts(input) {
|
|
3273
3650
|
if (!input.db || input.ports.length === 0)
|
|
3274
3651
|
return new Set();
|
|
@@ -3373,6 +3750,14 @@ export async function allocateRuntimeServicePort(overrides) {
|
|
|
3373
3750
|
for (let attempt = 0; attempt < PORT_ALLOCATION_ATTEMPTS; attempt += 1) {
|
|
3374
3751
|
const candidate = await probe();
|
|
3375
3752
|
lastCandidate = candidate;
|
|
3753
|
+
// Never hand back a port inside the runtime exposure app-port range. The
|
|
3754
|
+
// reconciler classifies a persisted row by its port. A port in that range
|
|
3755
|
+
// marks the row as an exposure reservation, not a managed auto port. An auto
|
|
3756
|
+
// port from that range makes the reconciler read a stopped managed row as an
|
|
3757
|
+
// exposure reservation and report drift instead of the adoption. The kernel
|
|
3758
|
+
// can hand out an ephemeral port in that band, so skip the candidate here.
|
|
3759
|
+
if (isRuntimeExposureAppPort(candidate))
|
|
3760
|
+
continue;
|
|
3376
3761
|
if (!reservePortIfFree(candidate))
|
|
3377
3762
|
continue;
|
|
3378
3763
|
const ownerPid = await portOwnerLookup(candidate);
|
|
@@ -3614,9 +3999,10 @@ async function waitForAllocatedPortBind(input) {
|
|
|
3614
3999
|
}
|
|
3615
4000
|
return;
|
|
3616
4001
|
}
|
|
3617
|
-
// A
|
|
4002
|
+
// A present listener proves only that some listener appeared. If listener ownership cannot
|
|
3618
4003
|
// be attributed to this child after a stability delay, retry instead of accepting a sibling.
|
|
3619
|
-
|
|
4004
|
+
// The presence read never binds the port, so it cannot steal the port from the child.
|
|
4005
|
+
if (await hasLoopbackPortListener(input.port)) {
|
|
3620
4006
|
await delay(250);
|
|
3621
4007
|
if (input.child.exitCode !== null || input.child.signalCode !== null) {
|
|
3622
4008
|
throw new Error("service process exited after losing its allocated port");
|
|
@@ -3655,15 +4041,73 @@ function resolveRuntimeServiceHealthUrl(url, input) {
|
|
|
3655
4041
|
}
|
|
3656
4042
|
return url;
|
|
3657
4043
|
}
|
|
4044
|
+
/**
|
|
4045
|
+
* Whether a managed workspace runtime satisfies the *user* readiness contract.
|
|
4046
|
+
*
|
|
4047
|
+
* Returns null when this service is not an identity-resolvable managed workspace
|
|
4048
|
+
* runtime, so non-workspace services keep their existing behavior.
|
|
4049
|
+
*
|
|
4050
|
+
* For a workspace runtime this *replaces* the semantic transport check rather
|
|
4051
|
+
* than adding to it. The probe reads the same `/api/health` response the legacy
|
|
4052
|
+
* check read, so every legacy verdict is already implied: reaching
|
|
4053
|
+
* `readiness_missing` means the response was `200` with `status: ok` (exactly
|
|
4054
|
+
* what the legacy check asserted), and every other rejection means it was not.
|
|
4055
|
+
* Stacking a second request on top would double the latency of every reuse
|
|
4056
|
+
* decision for no extra information.
|
|
4057
|
+
*/
|
|
4058
|
+
async function probeManagedWorkspaceRuntimeReadiness(healthUrl, input) {
|
|
4059
|
+
if (!isPaperclipDevRuntimeService(input))
|
|
4060
|
+
return null;
|
|
4061
|
+
const identity = resolveManagedWorkspaceIdentity({
|
|
4062
|
+
workspaceCwd: input.cwd ?? null,
|
|
4063
|
+
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
|
4064
|
+
companyId: input.companyId ?? null,
|
|
4065
|
+
});
|
|
4066
|
+
if (!identity)
|
|
4067
|
+
return null;
|
|
4068
|
+
const result = await probeManagedWorkspaceReadiness({ healthUrl, identity });
|
|
4069
|
+
const verified = !result.ok
|
|
4070
|
+
? result
|
|
4071
|
+
: input.db
|
|
4072
|
+
? await probeManagedWorkspaceHandoffSubjects({ db: input.db, healthUrl, identity })
|
|
4073
|
+
: {
|
|
4074
|
+
ok: false,
|
|
4075
|
+
reason: "not_ready",
|
|
4076
|
+
readiness: result.readiness,
|
|
4077
|
+
detail: "control-plane database is unavailable for board identity verification",
|
|
4078
|
+
};
|
|
4079
|
+
if (verified.ok)
|
|
4080
|
+
return true;
|
|
4081
|
+
logManagedWorkspaceReadinessRejection({
|
|
4082
|
+
executionWorkspaceId: identity.executionWorkspaceId,
|
|
4083
|
+
healthUrl,
|
|
4084
|
+
result: verified,
|
|
4085
|
+
});
|
|
4086
|
+
// A guest that does not implement the readiness contract yet is not evidence of
|
|
4087
|
+
// an unhealthy clone; it is only evidence that the contract cannot be checked.
|
|
4088
|
+
return !shouldBlockPublicationOnReadiness(verified);
|
|
4089
|
+
}
|
|
3658
4090
|
async function isRuntimeServiceUrlHealthy(url, input) {
|
|
3659
|
-
|
|
4091
|
+
const localProbeUrl = input?.provider === "local_process" && input.port && isPaperclipDevRuntimeService(input)
|
|
4092
|
+
? `http://127.0.0.1:${input.port}`
|
|
4093
|
+
: null;
|
|
4094
|
+
const probeUrl = localProbeUrl ?? url;
|
|
4095
|
+
if (!probeUrl)
|
|
3660
4096
|
return true;
|
|
3661
|
-
const healthUrl = resolveRuntimeServiceHealthUrl(
|
|
4097
|
+
const healthUrl = resolveRuntimeServiceHealthUrl(probeUrl, input);
|
|
3662
4098
|
if (!healthUrl)
|
|
3663
4099
|
return false;
|
|
4100
|
+
const readiness = await probeManagedWorkspaceRuntimeReadiness(healthUrl, input ?? {});
|
|
4101
|
+
if (readiness !== null)
|
|
4102
|
+
return readiness;
|
|
3664
4103
|
try {
|
|
3665
4104
|
const response = await fetch(healthUrl, { signal: AbortSignal.timeout(2_000) });
|
|
3666
|
-
|
|
4105
|
+
if (!response.ok)
|
|
4106
|
+
return false;
|
|
4107
|
+
if (!isPaperclipDevRuntimeService(input ?? {}))
|
|
4108
|
+
return true;
|
|
4109
|
+
const payload = await response.json().catch(() => null);
|
|
4110
|
+
return payload?.status === "ok";
|
|
3667
4111
|
}
|
|
3668
4112
|
catch {
|
|
3669
4113
|
return false;
|
|
@@ -3845,6 +4289,42 @@ function readRuntimeProvisionCommand(config) {
|
|
|
3845
4289
|
const workspaceStrategy = parseObject(config.workspaceStrategy);
|
|
3846
4290
|
return asString(config.runtimeProvisionCommand, asString(workspaceStrategy.runtimeProvisionCommand, "")).trim();
|
|
3847
4291
|
}
|
|
4292
|
+
const BUILTIN_WORKSPACE_SEED_COMMAND = "bash ./scripts/provision-worktree-runtime.sh";
|
|
4293
|
+
function readWorkspaceSeedOperationEvidence(worktreePath) {
|
|
4294
|
+
const manifestPath = path.join(worktreePath, ".paperclip", "seed-manifest.json");
|
|
4295
|
+
try {
|
|
4296
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
4297
|
+
const state = typeof manifest.state === "string" ? manifest.state : "unknown";
|
|
4298
|
+
const phase = typeof manifest.phase === "string" ? manifest.phase : null;
|
|
4299
|
+
const verified = isVerifiedWorktreeSeedManifest(manifest);
|
|
4300
|
+
return {
|
|
4301
|
+
verified,
|
|
4302
|
+
error: verified
|
|
4303
|
+
? null
|
|
4304
|
+
: phase
|
|
4305
|
+
? `Workspace seed command returned without a verified manifest (state: ${state}, phase: ${phase}).`
|
|
4306
|
+
: `Workspace seed command returned without a verified manifest (state: ${state}).`,
|
|
4307
|
+
metadata: {
|
|
4308
|
+
provisionKind: "workspace_seed",
|
|
4309
|
+
seedState: state,
|
|
4310
|
+
seedPhase: phase,
|
|
4311
|
+
seedFailurePhase: state === "failed" ? phase : null,
|
|
4312
|
+
},
|
|
4313
|
+
};
|
|
4314
|
+
}
|
|
4315
|
+
catch {
|
|
4316
|
+
return {
|
|
4317
|
+
verified: false,
|
|
4318
|
+
error: "Workspace seed command returned without a readable seed manifest.",
|
|
4319
|
+
metadata: {
|
|
4320
|
+
provisionKind: "workspace_seed",
|
|
4321
|
+
seedState: existsSync(manifestPath) ? "unreadable" : "absent",
|
|
4322
|
+
seedPhase: null,
|
|
4323
|
+
seedFailurePhase: "seed_manifest_unreadable",
|
|
4324
|
+
},
|
|
4325
|
+
};
|
|
4326
|
+
}
|
|
4327
|
+
}
|
|
3848
4328
|
export function resolveRuntimeProvisionCommand(input) {
|
|
3849
4329
|
const configuredCommand = readRuntimeProvisionCommand(input.config);
|
|
3850
4330
|
if (configuredCommand)
|
|
@@ -3852,15 +4332,24 @@ export function resolveRuntimeProvisionCommand(input) {
|
|
|
3852
4332
|
if (input.workspace.strategy !== "git_worktree")
|
|
3853
4333
|
return "";
|
|
3854
4334
|
const stateDir = path.join(input.workspace.cwd, ".paperclip");
|
|
3855
|
-
const
|
|
3856
|
-
const completeMarker = path.join(stateDir, "seed-complete");
|
|
4335
|
+
const manifestPath = path.join(stateDir, "seed-manifest.json");
|
|
3857
4336
|
const provisionScript = path.join(input.workspace.baseCwd, "scripts", "provision-worktree-runtime.sh");
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|| !existsSync(provisionScript)) {
|
|
4337
|
+
const needsSeed = !existsSync(manifestPath) || !hasVerifiedWorktreeSeedManifest(manifestPath);
|
|
4338
|
+
if (!needsSeed || !existsSync(provisionScript)) {
|
|
3861
4339
|
return "";
|
|
3862
4340
|
}
|
|
3863
|
-
return
|
|
4341
|
+
return BUILTIN_WORKSPACE_SEED_COMMAND;
|
|
4342
|
+
}
|
|
4343
|
+
function resolveRuntimeProvision(input) {
|
|
4344
|
+
const command = resolveRuntimeProvisionCommand(input);
|
|
4345
|
+
if (!command)
|
|
4346
|
+
return { command, kind: null };
|
|
4347
|
+
return {
|
|
4348
|
+
command,
|
|
4349
|
+
kind: readRuntimeProvisionCommand(input.config)
|
|
4350
|
+
? "runtime_dependencies"
|
|
4351
|
+
: "workspace_seed",
|
|
4352
|
+
};
|
|
3864
4353
|
}
|
|
3865
4354
|
function runtimeProvisionWorkspaceKey(input) {
|
|
3866
4355
|
return input.executionWorkspaceId
|
|
@@ -3888,8 +4377,9 @@ async function runRuntimeProvisionWithWorkspaceMutex(input) {
|
|
|
3888
4377
|
})
|
|
3889
4378
|
: null);
|
|
3890
4379
|
const resolvedCommand = resolveRepoManagedWorkspaceCommand(command, input.workspace.baseCwd);
|
|
4380
|
+
const workspaceSeed = input.runtimeProvisionKind === "workspace_seed";
|
|
3891
4381
|
const promise = recordWorkspaceCommandOperation(recorder, {
|
|
3892
|
-
phase: "workspace_runtime_provision",
|
|
4382
|
+
phase: workspaceSeed ? "workspace_seed" : "workspace_runtime_provision",
|
|
3893
4383
|
command,
|
|
3894
4384
|
resolvedCommand,
|
|
3895
4385
|
cwd: input.workspace.cwd,
|
|
@@ -3902,14 +4392,19 @@ async function runRuntimeProvisionWithWorkspaceMutex(input) {
|
|
|
3902
4392
|
agent: input.agent,
|
|
3903
4393
|
created: input.workspace.created,
|
|
3904
4394
|
}),
|
|
3905
|
-
label:
|
|
4395
|
+
label: workspaceSeed
|
|
4396
|
+
? `Workspace seed command "${command}"`
|
|
4397
|
+
: `Runtime provision command "${command}"`,
|
|
3906
4398
|
metadata: {
|
|
3907
4399
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
|
3908
4400
|
projectWorkspaceId: input.workspace.workspaceId,
|
|
3909
4401
|
serviceName: asString(input.service.name, "service"),
|
|
4402
|
+
provisionKind: workspaceSeed ? "workspace_seed" : "runtime_dependencies",
|
|
3910
4403
|
resolvedCommand: resolvedCommand === command ? null : resolvedCommand,
|
|
3911
4404
|
},
|
|
3912
|
-
successMessage:
|
|
4405
|
+
successMessage: workspaceSeed
|
|
4406
|
+
? `Verified the workspace database seed for ${input.workspace.cwd}\n`
|
|
4407
|
+
: `Provisioned runtime dependencies for ${input.workspace.cwd}\n`,
|
|
3913
4408
|
onLog: input.onLog,
|
|
3914
4409
|
}).then(() => undefined);
|
|
3915
4410
|
runtimeProvisionByWorkspace.set(workspaceKey, promise);
|
|
@@ -4192,12 +4687,26 @@ async function spawnLocalRuntimeService(input) {
|
|
|
4192
4687
|
const portEnvKey = asString(portConfig.envKey, "PORT");
|
|
4193
4688
|
env[portEnvKey] = String(port);
|
|
4194
4689
|
}
|
|
4690
|
+
// Per-workspace handoff key, readiness token, and workspace id. Injected for
|
|
4691
|
+
// the Paperclip dev runtime whether or not it is HTTPS-exposed, because the
|
|
4692
|
+
// password-independent login handoff and the protected readiness probe are
|
|
4693
|
+
// both needed for a plain-HTTP loopback workspace too (PAP-17572).
|
|
4694
|
+
const managedWorkspaceIdentity = isPaperclipDevRuntimeService({ serviceName, command })
|
|
4695
|
+
? resolveManagedWorkspaceIdentity({
|
|
4696
|
+
workspaceCwd: input.workspace.cwd,
|
|
4697
|
+
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
|
4698
|
+
companyId: input.agent.companyId,
|
|
4699
|
+
})
|
|
4700
|
+
: null;
|
|
4701
|
+
if (managedWorkspaceIdentity) {
|
|
4702
|
+
Object.assign(env, buildManagedWorkspaceGuestEnv(managedWorkspaceIdentity));
|
|
4703
|
+
}
|
|
4195
4704
|
if (exposureConfig) {
|
|
4196
4705
|
// Paperclip dev-runtime-specific hardening. Other managed processes are
|
|
4197
4706
|
// still rejected by the broker unless /proc proves loopback-only listeners.
|
|
4198
4707
|
//
|
|
4199
4708
|
// Three independent layers force the loopback bind, because a guest checkout
|
|
4200
|
-
// can be arbitrarily old (PAP-17256): the `--bind
|
|
4709
|
+
// can be arbitrarily old (PAP-17256): the `--bind loopback` argv
|
|
4201
4710
|
// added above, these env vars for a runner that reads them, and HOST for one
|
|
4202
4711
|
// old enough to ignore both and infer its bind mode from HOST alone.
|
|
4203
4712
|
env.PAPERCLIP_BIND = RUNTIME_EXPOSURE_BIND_MODE;
|
|
@@ -4245,7 +4754,14 @@ async function spawnLocalRuntimeService(input) {
|
|
|
4245
4754
|
});
|
|
4246
4755
|
if (adoptedRecord) {
|
|
4247
4756
|
const adoptedUrl = adoptedRecord.url ?? backendUrl;
|
|
4248
|
-
if (!(await isRuntimeServiceUrlHealthy(adoptedUrl, {
|
|
4757
|
+
if (!(await isRuntimeServiceUrlHealthy(adoptedUrl, {
|
|
4758
|
+
db: input.db,
|
|
4759
|
+
serviceName,
|
|
4760
|
+
command,
|
|
4761
|
+
cwd: input.workspace.cwd,
|
|
4762
|
+
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
|
4763
|
+
companyId: input.agent.companyId,
|
|
4764
|
+
}))) {
|
|
4249
4765
|
await terminateLocalService(adoptedRecord);
|
|
4250
4766
|
await removeLocalServiceRegistryRecord(adoptedRecord.serviceKey);
|
|
4251
4767
|
}
|
|
@@ -4396,12 +4912,22 @@ async function spawnLocalRuntimeService(input) {
|
|
|
4396
4912
|
throw error;
|
|
4397
4913
|
}
|
|
4398
4914
|
const shell = resolveShell();
|
|
4399
|
-
const
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
4915
|
+
const serviceLog = await openLocalServiceLogFile(serviceKey);
|
|
4916
|
+
let child;
|
|
4917
|
+
try {
|
|
4918
|
+
child = spawn(shell, ["-lc", command], {
|
|
4919
|
+
cwd: serviceCwd,
|
|
4920
|
+
env,
|
|
4921
|
+
detached: process.platform !== "win32",
|
|
4922
|
+
// The service receives duplicate append-only file descriptors. Closing
|
|
4923
|
+
// Paperclip (or this parent handle below) cannot strand a request logger
|
|
4924
|
+
// on an orphaned socketpair during startup reconciliation.
|
|
4925
|
+
stdio: ["ignore", serviceLog.handle.fd, serviceLog.handle.fd],
|
|
4926
|
+
});
|
|
4927
|
+
}
|
|
4928
|
+
finally {
|
|
4929
|
+
await serviceLog.handle.close();
|
|
4930
|
+
}
|
|
4405
4931
|
record.child = child;
|
|
4406
4932
|
record.providerRef = child.pid ? String(child.pid) : null;
|
|
4407
4933
|
record.processGroupId = child.pid ?? null;
|
|
@@ -4411,24 +4937,22 @@ async function spawnLocalRuntimeService(input) {
|
|
|
4411
4937
|
});
|
|
4412
4938
|
});
|
|
4413
4939
|
const earlyExitPromise = new Promise((_, reject) => {
|
|
4414
|
-
|
|
4940
|
+
// `close` follows `exit` after the child's inherited stdout/stderr file
|
|
4941
|
+
// descriptors are closed. Waiting for it makes the startup log excerpt
|
|
4942
|
+
// deterministic instead of racing the final validation line.
|
|
4943
|
+
child.once("close", (code, signal) => {
|
|
4415
4944
|
reject(new Error(`service process exited before readiness (code ${code ?? "unknown"}, signal ${signal ?? "none"})`));
|
|
4416
4945
|
});
|
|
4417
4946
|
});
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
const text = String(chunk);
|
|
4428
|
-
stderrExcerpt = (stderrExcerpt + text).slice(-4096);
|
|
4429
|
-
if (input.onLog)
|
|
4430
|
-
await input.onLog("stderr", `[service:${serviceName}] ${text}`);
|
|
4431
|
-
});
|
|
4947
|
+
const readServiceOutputExcerpt = async () => {
|
|
4948
|
+
try {
|
|
4949
|
+
const contents = await fs.readFile(serviceLog.logPath);
|
|
4950
|
+
return contents.subarray(Math.max(serviceLog.startOffset, contents.length - 4096)).toString("utf8");
|
|
4951
|
+
}
|
|
4952
|
+
catch {
|
|
4953
|
+
return "";
|
|
4954
|
+
}
|
|
4955
|
+
};
|
|
4432
4956
|
if (child.pid) {
|
|
4433
4957
|
await writeLocalServiceRegistryRecord({
|
|
4434
4958
|
version: 1,
|
|
@@ -4506,10 +5030,48 @@ async function spawnLocalRuntimeService(input) {
|
|
|
4506
5030
|
throw new Error(`HTTPS exposure failed: ${code}${provisioned.errorDetail ? ` — ${provisioned.errorDetail}` : ""}`);
|
|
4507
5031
|
}
|
|
4508
5032
|
}
|
|
5033
|
+
// Transport readiness only proves a listener answered. A managed workspace
|
|
5034
|
+
// must additionally satisfy the protected readiness contract — own database,
|
|
5035
|
+
// cloned rows, login handoff, and matching instance/workspace identity —
|
|
5036
|
+
// before it may be published as running/healthy (PAP-17572).
|
|
5037
|
+
if (managedWorkspaceIdentity) {
|
|
5038
|
+
const publishHealthUrl = resolveRuntimeServiceHealthUrl(record.port ? `http://127.0.0.1:${record.port}` : rewriteUrlHostToLoopback(record.url ?? backendUrl), { serviceName, command });
|
|
5039
|
+
if (!publishHealthUrl) {
|
|
5040
|
+
throw new Error("Managed workspace readiness gate could not resolve a health URL");
|
|
5041
|
+
}
|
|
5042
|
+
let gate = await waitForManagedWorkspaceReadiness({
|
|
5043
|
+
healthUrl: publishHealthUrl,
|
|
5044
|
+
identity: managedWorkspaceIdentity,
|
|
5045
|
+
});
|
|
5046
|
+
if (gate.ok) {
|
|
5047
|
+
if (!record.db) {
|
|
5048
|
+
throw new Error("Managed workspace readiness gate could not resolve the control-plane database");
|
|
5049
|
+
}
|
|
5050
|
+
gate = await probeManagedWorkspaceHandoffSubjects({
|
|
5051
|
+
db: record.db,
|
|
5052
|
+
healthUrl: publishHealthUrl,
|
|
5053
|
+
identity: managedWorkspaceIdentity,
|
|
5054
|
+
});
|
|
5055
|
+
}
|
|
5056
|
+
if (!gate.ok) {
|
|
5057
|
+
logManagedWorkspaceReadinessRejection({
|
|
5058
|
+
executionWorkspaceId: managedWorkspaceIdentity.executionWorkspaceId,
|
|
5059
|
+
healthUrl: publishHealthUrl,
|
|
5060
|
+
result: gate,
|
|
5061
|
+
});
|
|
5062
|
+
if (shouldBlockPublicationOnReadiness(gate)) {
|
|
5063
|
+
throw new Error(`Workspace is not ready to publish (${gate.reason}${gate.detail ? `: ${gate.detail}` : ""})`);
|
|
5064
|
+
}
|
|
5065
|
+
}
|
|
5066
|
+
}
|
|
4509
5067
|
record.status = "running";
|
|
4510
5068
|
record.healthStatus = "healthy";
|
|
4511
5069
|
record.lastUsedAt = new Date().toISOString();
|
|
4512
5070
|
record.stoppedAt = null;
|
|
5071
|
+
const serviceOutputExcerpt = await readServiceOutputExcerpt();
|
|
5072
|
+
if (serviceOutputExcerpt && input.onLog) {
|
|
5073
|
+
await input.onLog("stdout", `[service:${serviceName}] ${serviceOutputExcerpt}`);
|
|
5074
|
+
}
|
|
4513
5075
|
await touchLocalServiceRegistryRecord(record.serviceKey, {
|
|
4514
5076
|
runtimeServiceId: record.id,
|
|
4515
5077
|
lastSeenAt: record.lastUsedAt,
|
|
@@ -4518,13 +5080,70 @@ async function spawnLocalRuntimeService(input) {
|
|
|
4518
5080
|
releasePortReservation(reservedPort);
|
|
4519
5081
|
releasePortReservation(claimedIdentityPort);
|
|
4520
5082
|
const failureMessage = err instanceof Error ? err.message : String(err);
|
|
5083
|
+
const serviceOutputExcerpt = await readServiceOutputExcerpt();
|
|
4521
5084
|
const bindCollision = !exposureConfig && (err instanceof RuntimeServicePortBindCollision || Boolean(port
|
|
4522
5085
|
&& (input.allowFixedPortFallback || portType === "auto")
|
|
4523
|
-
&& /(?:EADDRINUSE|address already in use)/i.test(`${failureMessage}\n${
|
|
5086
|
+
&& /(?:EADDRINUSE|address already in use)/i.test(`${failureMessage}\n${serviceOutputExcerpt}`)));
|
|
5087
|
+
// An exposed guest that exits with EADDRINUSE on its ASSIGNED port lost the
|
|
5088
|
+
// port after allocation. Only the assigned app or HMR port is re-allocatable
|
|
5089
|
+
// from the dedicated broker range, so quarantine and re-allocation apply only
|
|
5090
|
+
// when the failure names one of those ports. An unrelated auxiliary-port
|
|
5091
|
+
// conflict (a different port the guest also bound) leaves the valid pair
|
|
5092
|
+
// intact and surfaces as a terminal error, not a quarantine that burns the
|
|
5093
|
+
// bounded retries.
|
|
5094
|
+
const exposureAssignedPorts = exposureConfig && port
|
|
5095
|
+
? [port, ...(exposureConfig.includePaperclipViteHmr ? [deriveViteHmrPort(port)] : [])]
|
|
5096
|
+
: [];
|
|
5097
|
+
const collisionText = `${failureMessage}\n${serviceOutputExcerpt}`;
|
|
5098
|
+
const exposureNamedPorts = exposureAssignedPorts.filter((candidate) => eaddrinuseTextNamesPort(collisionText, candidate));
|
|
5099
|
+
// The guest owns its own output, so an assigned-port EADDRINUSE line is a
|
|
5100
|
+
// claim, not proof. A managed guest can print a synthetic EADDRINUSE line for
|
|
5101
|
+
// its assigned port with no host listener behind it. A quarantine on that text
|
|
5102
|
+
// alone would burn the shared exposure-port pool across repeated starts. So the
|
|
5103
|
+
// runtime reads live host listener state and quarantines only after it confirms
|
|
5104
|
+
// that a real host listener owns the port.
|
|
5105
|
+
const exposureTextNamesAssignedPort = Boolean(exposureConfig && port && exposureNamedPorts.length > 0);
|
|
5106
|
+
let exposureCollisionDiagnosis = null;
|
|
5107
|
+
let exposureHostCollision = false;
|
|
5108
|
+
if (exposureTextNamesAssignedPort) {
|
|
5109
|
+
const facts = [];
|
|
5110
|
+
const hostStates = [];
|
|
5111
|
+
for (const collisionPort of exposureAssignedPorts) {
|
|
5112
|
+
// `readLocalServicePortOwner` reads lsof and returns the listener pid, so a
|
|
5113
|
+
// non-null pid also proves a present listener. `readListenerBindFacts` reads
|
|
5114
|
+
// /proc for the same presence signal and the bound addresses.
|
|
5115
|
+
const ownerPid = await readLocalServicePortOwner(collisionPort).catch(() => null);
|
|
5116
|
+
const bind = await readListenerBindFacts(collisionPort).catch(() => null);
|
|
5117
|
+
// Read the owner process group id too. The guest runs as a shell process
|
|
5118
|
+
// group leader, so the real dev server usually binds the port from a
|
|
5119
|
+
// descendant. The classify step matches the shell pid against the owner pgid
|
|
5120
|
+
// to keep a guest descendant from looking like an external owner.
|
|
5121
|
+
const ownerProcessGroupId = ownerPid != null ? await readLocalServiceProcessGroupId(ownerPid).catch(() => null) : null;
|
|
5122
|
+
hostStates.push({
|
|
5123
|
+
port: collisionPort,
|
|
5124
|
+
named: exposureNamedPorts.includes(collisionPort),
|
|
5125
|
+
listenerPresent: Boolean(bind?.present) || ownerPid != null,
|
|
5126
|
+
ownerPid,
|
|
5127
|
+
ownerProcessGroupId,
|
|
5128
|
+
});
|
|
5129
|
+
const boundTo = bind?.present ? bind.addresses.join(", ") : "no listener";
|
|
5130
|
+
facts.push(`port ${collisionPort} bound to ${boundTo}, owner pid ${ownerPid ?? "none"}`);
|
|
5131
|
+
}
|
|
5132
|
+
exposureHostCollision = classifyExposureHostCollisions({
|
|
5133
|
+
childPid: child.pid ?? null,
|
|
5134
|
+
ports: hostStates,
|
|
5135
|
+
}).hostCollision;
|
|
5136
|
+
exposureCollisionDiagnosis = facts.join("; ");
|
|
5137
|
+
}
|
|
5138
|
+
// Quarantine the whole assigned pair, not only the named port. The broker
|
|
5139
|
+
// allocates the app and HMR ports as one unit, so a re-allocation must skip
|
|
5140
|
+
// both to land on the next free pair.
|
|
5141
|
+
const exposureCollisionPorts = exposureHostCollision ? exposureAssignedPorts : [];
|
|
4524
5142
|
if (child.pid) {
|
|
4525
5143
|
await terminateLocalService({
|
|
4526
5144
|
pid: child.pid,
|
|
4527
5145
|
processGroupId: child.pid,
|
|
5146
|
+
port,
|
|
4528
5147
|
});
|
|
4529
5148
|
}
|
|
4530
5149
|
await cleanupRecordExposure(record, { preserveFailure: true });
|
|
@@ -4538,7 +5157,33 @@ async function spawnLocalRuntimeService(input) {
|
|
|
4538
5157
|
}
|
|
4539
5158
|
if (bindCollision && port)
|
|
4540
5159
|
throw new RuntimeServicePortBindCollision(port);
|
|
4541
|
-
|
|
5160
|
+
if (exposureHostCollision && port) {
|
|
5161
|
+
// A verified host listener holds the assigned exposure port. Quarantine the
|
|
5162
|
+
// pair so the bounded re-allocation never re-offers it, then throw a retryable
|
|
5163
|
+
// collision. `startLocalRuntimeService` re-runs allocation, which skips the
|
|
5164
|
+
// quarantined pair and takes the next free pair inside the dedicated range.
|
|
5165
|
+
// This hardens a real host that races an external process for a range port.
|
|
5166
|
+
for (const collisionPort of exposureCollisionPorts) {
|
|
5167
|
+
quarantinedRuntimeExposurePorts.add(collisionPort);
|
|
5168
|
+
}
|
|
5169
|
+
if (input.onLog) {
|
|
5170
|
+
await input.onLog("stderr", `[service:${serviceName}] exposure port ${port} collided during startup (EADDRINUSE); `
|
|
5171
|
+
+ `${exposureCollisionDiagnosis ?? "owner unavailable"}. `
|
|
5172
|
+
+ `Quarantined pair ${exposureCollisionPorts.join("/")} and reallocating.\n`).catch(() => undefined);
|
|
5173
|
+
}
|
|
5174
|
+
throw new RuntimeServicePortBindCollision(port, exposureCollisionDiagnosis, true);
|
|
5175
|
+
}
|
|
5176
|
+
const deploymentBindConflict = /local_trusted requires server\.bind=loopback/i.test(`${failureMessage}\n${serviceOutputExcerpt}`);
|
|
5177
|
+
// The guest reported an assigned-port EADDRINUSE, but no host listener owned
|
|
5178
|
+
// the port. Explain that the runtime did not quarantine the pair, so a future
|
|
5179
|
+
// occurrence needs no diagnostic cycle and the pool stays intact.
|
|
5180
|
+
const unverifiedExposureCollision = exposureTextNamesAssignedPort && !exposureHostCollision;
|
|
5181
|
+
const actionableFailure = deploymentBindConflict
|
|
5182
|
+
? `${failureMessage} | deployment/bind conflict: local_trusted requires server.bind=loopback; the managed runtime requested an incompatible bind mode`
|
|
5183
|
+
: unverifiedExposureCollision
|
|
5184
|
+
? `${failureMessage} | exposure port collision not verified: the guest reported EADDRINUSE on assigned port ${exposureNamedPorts.join("/")}, but no host listener owns it (${exposureCollisionDiagnosis ?? "owner unavailable"}); the runtime did not quarantine the pair`
|
|
5185
|
+
: failureMessage;
|
|
5186
|
+
throw new Error(`Failed to start runtime service "${serviceName}": ${actionableFailure}${serviceOutputExcerpt ? ` | output: ${serviceOutputExcerpt.trim()}` : ""}`);
|
|
4542
5187
|
});
|
|
4543
5188
|
return { record, readiness: readinessPromise };
|
|
4544
5189
|
}
|
|
@@ -4623,8 +5268,10 @@ async function startLocalRuntimeService(input, options) {
|
|
|
4623
5268
|
return started;
|
|
4624
5269
|
}
|
|
4625
5270
|
catch (error) {
|
|
4626
|
-
if (!(error instanceof RuntimeServicePortBindCollision)
|
|
5271
|
+
if (!(error instanceof RuntimeServicePortBindCollision)
|
|
5272
|
+
|| !(retryBindCollisions || error.exposureReallocatable)) {
|
|
4627
5273
|
throw error;
|
|
5274
|
+
}
|
|
4628
5275
|
excludedPorts.add(error.port);
|
|
4629
5276
|
started = null;
|
|
4630
5277
|
}
|
|
@@ -4708,19 +5355,14 @@ async function stopRuntimeService(serviceId) {
|
|
|
4708
5355
|
if (!record)
|
|
4709
5356
|
return;
|
|
4710
5357
|
clearIdleTimer(record);
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
record.lastUsedAt = new Date().toISOString();
|
|
4714
|
-
record.stoppedAt = new Date().toISOString();
|
|
4715
|
-
runtimeServicesById.delete(serviceId);
|
|
4716
|
-
if (record.reuseKey && runtimeServicesByReuseKey.get(record.reuseKey) === record.id) {
|
|
4717
|
-
runtimeServicesByReuseKey.delete(record.reuseKey);
|
|
4718
|
-
}
|
|
5358
|
+
// Remove any public exposure first, but keep the process registered and the
|
|
5359
|
+
// row non-stopped until verified termination succeeds.
|
|
4719
5360
|
await cleanupRecordExposure(record);
|
|
4720
5361
|
if (record.child && record.child.pid) {
|
|
4721
5362
|
await terminateLocalService({
|
|
4722
5363
|
pid: record.child.pid,
|
|
4723
5364
|
processGroupId: record.processGroupId ?? record.child.pid,
|
|
5365
|
+
port: record.port,
|
|
4724
5366
|
});
|
|
4725
5367
|
}
|
|
4726
5368
|
else if (record.providerRef) {
|
|
@@ -4729,12 +5371,57 @@ async function stopRuntimeService(serviceId) {
|
|
|
4729
5371
|
await terminateLocalService({
|
|
4730
5372
|
pid,
|
|
4731
5373
|
processGroupId: record.processGroupId,
|
|
5374
|
+
port: record.port,
|
|
4732
5375
|
});
|
|
4733
5376
|
}
|
|
4734
5377
|
}
|
|
5378
|
+
record.status = "stopped";
|
|
5379
|
+
record.healthStatus = "unknown";
|
|
5380
|
+
record.lastUsedAt = new Date().toISOString();
|
|
5381
|
+
record.stoppedAt = new Date().toISOString();
|
|
5382
|
+
runtimeServicesById.delete(serviceId);
|
|
5383
|
+
if (record.reuseKey && runtimeServicesByReuseKey.get(record.reuseKey) === record.id) {
|
|
5384
|
+
runtimeServicesByReuseKey.delete(record.reuseKey);
|
|
5385
|
+
}
|
|
4735
5386
|
await removeLocalServiceRegistryRecord(record.serviceKey);
|
|
4736
5387
|
await persistRuntimeServiceRecord(record.db, record);
|
|
4737
5388
|
}
|
|
5389
|
+
async function findHealthyRunningRuntimeService(reuseKey) {
|
|
5390
|
+
const existingId = reuseKey ? runtimeServicesByReuseKey.get(reuseKey) : null;
|
|
5391
|
+
const existing = existingId ? runtimeServicesById.get(existingId) : null;
|
|
5392
|
+
if (!existing || existing.status !== "running")
|
|
5393
|
+
return null;
|
|
5394
|
+
const healthInput = {
|
|
5395
|
+
db: existing.db,
|
|
5396
|
+
serviceName: existing.serviceName,
|
|
5397
|
+
command: existing.command,
|
|
5398
|
+
provider: existing.provider,
|
|
5399
|
+
port: existing.port,
|
|
5400
|
+
cwd: existing.cwd,
|
|
5401
|
+
executionWorkspaceId: existing.executionWorkspaceId,
|
|
5402
|
+
companyId: existing.companyId,
|
|
5403
|
+
};
|
|
5404
|
+
let healthy = await isRuntimeServiceUrlHealthy(existing.url, healthInput);
|
|
5405
|
+
if (!healthy) {
|
|
5406
|
+
// A single timeout or connection reset is not enough evidence to destroy a
|
|
5407
|
+
// shared runtime that active runs may still use. Confirm the failure after
|
|
5408
|
+
// a short bounded delay before entering the destructive replacement path.
|
|
5409
|
+
await delay(250);
|
|
5410
|
+
healthy = await isRuntimeServiceUrlHealthy(existing.url, healthInput);
|
|
5411
|
+
}
|
|
5412
|
+
if (healthy)
|
|
5413
|
+
return existing;
|
|
5414
|
+
if (existing.leaseRunIds.size > 0) {
|
|
5415
|
+
existing.healthStatus = "unhealthy";
|
|
5416
|
+
if (reuseKey && runtimeServicesByReuseKey.get(reuseKey) === existing.id) {
|
|
5417
|
+
runtimeServicesByReuseKey.delete(reuseKey);
|
|
5418
|
+
}
|
|
5419
|
+
await persistRuntimeServiceRecord(existing.db, existing);
|
|
5420
|
+
return null;
|
|
5421
|
+
}
|
|
5422
|
+
await stopRuntimeService(existing.id);
|
|
5423
|
+
return null;
|
|
5424
|
+
}
|
|
4738
5425
|
async function markPersistedRuntimeServicesStoppedForExecutionWorkspace(input) {
|
|
4739
5426
|
const now = new Date();
|
|
4740
5427
|
const exposureRows = await input.db
|
|
@@ -4893,9 +5580,21 @@ function registerRuntimeService(db, record) {
|
|
|
4893
5580
|
runtimeServicesByReuseKey.delete(current.reuseKey);
|
|
4894
5581
|
}
|
|
4895
5582
|
void (async () => {
|
|
4896
|
-
|
|
4897
|
-
|
|
4898
|
-
|
|
5583
|
+
// The child exited on its own. Record the terminal status as best effort.
|
|
5584
|
+
// The persist can fail when a parent row is already gone: a caller can
|
|
5585
|
+
// delete the project or the company while this service still runs, and the
|
|
5586
|
+
// `project_id` foreign key then rejects the write. Catch every error here,
|
|
5587
|
+
// or the detached persist becomes an unhandled rejection and crashes the
|
|
5588
|
+
// host. This path runs off the child `exit` event, so no caller awaits it.
|
|
5589
|
+
try {
|
|
5590
|
+
await cleanupRecordExposure(current);
|
|
5591
|
+
await removeLocalServiceRegistryRecord(current.serviceKey);
|
|
5592
|
+
await persistRuntimeServiceRecord(db, current);
|
|
5593
|
+
}
|
|
5594
|
+
catch (err) {
|
|
5595
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
5596
|
+
console.warn(`[workspace-runtime] runtime service exit cleanup failed for ${current.id}: ${detail}`);
|
|
5597
|
+
}
|
|
4899
5598
|
})();
|
|
4900
5599
|
});
|
|
4901
5600
|
}
|
|
@@ -4977,7 +5676,7 @@ async function isPersistedIsolatedExecutionWorkspace(input) {
|
|
|
4977
5676
|
.then((rows) => rows[0] ?? null);
|
|
4978
5677
|
return row?.mode === "isolated_workspace";
|
|
4979
5678
|
}
|
|
4980
|
-
|
|
5679
|
+
async function ensureRuntimeServicesForRunInvocation(input) {
|
|
4981
5680
|
const rawServices = selectRuntimeServiceEntries({
|
|
4982
5681
|
config: input.config,
|
|
4983
5682
|
respectDesiredStates: true,
|
|
@@ -4986,7 +5685,8 @@ export async function ensureRuntimeServicesForRun(input) {
|
|
|
4986
5685
|
});
|
|
4987
5686
|
const acquiredServiceIds = [];
|
|
4988
5687
|
const refs = [];
|
|
4989
|
-
const
|
|
5688
|
+
const runtimeProvision = resolveRuntimeProvision(input);
|
|
5689
|
+
const runtimeProvisionCommand = runtimeProvision.command;
|
|
4990
5690
|
const provisionCoordinator = createRuntimeProvisionCoordinator();
|
|
4991
5691
|
const allowFixedPortFallback = await isPersistedIsolatedExecutionWorkspace({
|
|
4992
5692
|
db: input.db,
|
|
@@ -5014,9 +5714,8 @@ export async function ensureRuntimeServicesForRun(input) {
|
|
|
5014
5714
|
scopeId,
|
|
5015
5715
|
}).reuseKey;
|
|
5016
5716
|
if (reuseKey) {
|
|
5017
|
-
const
|
|
5018
|
-
|
|
5019
|
-
if (existing && existing.status === "running") {
|
|
5717
|
+
const existing = await findHealthyRunningRuntimeService(reuseKey);
|
|
5718
|
+
if (existing) {
|
|
5020
5719
|
existing.leaseRunIds.add(input.runId);
|
|
5021
5720
|
existing.lastUsedAt = new Date().toISOString();
|
|
5022
5721
|
existing.stoppedAt = null;
|
|
@@ -5042,6 +5741,7 @@ export async function ensureRuntimeServicesForRun(input) {
|
|
|
5042
5741
|
service,
|
|
5043
5742
|
onLog: input.onLog,
|
|
5044
5743
|
runtimeProvisionCommand,
|
|
5744
|
+
runtimeProvisionKind: runtimeProvision.kind,
|
|
5045
5745
|
recorder: input.recorder,
|
|
5046
5746
|
provisionCoordinator,
|
|
5047
5747
|
allowFixedPortFallback,
|
|
@@ -5062,6 +5762,104 @@ export async function ensureRuntimeServicesForRun(input) {
|
|
|
5062
5762
|
}
|
|
5063
5763
|
return refs;
|
|
5064
5764
|
}
|
|
5765
|
+
async function withRuntimeStartMutex(ownerKey, start) {
|
|
5766
|
+
const previous = runtimeControlStartByOwner.get(ownerKey) ?? Promise.resolve();
|
|
5767
|
+
let release;
|
|
5768
|
+
const current = new Promise((resolve) => {
|
|
5769
|
+
release = resolve;
|
|
5770
|
+
});
|
|
5771
|
+
const queued = previous.then(() => current);
|
|
5772
|
+
runtimeControlStartByOwner.set(ownerKey, queued);
|
|
5773
|
+
await previous;
|
|
5774
|
+
try {
|
|
5775
|
+
return await start();
|
|
5776
|
+
}
|
|
5777
|
+
finally {
|
|
5778
|
+
release();
|
|
5779
|
+
if (runtimeControlStartByOwner.get(ownerKey) === queued)
|
|
5780
|
+
runtimeControlStartByOwner.delete(ownerKey);
|
|
5781
|
+
}
|
|
5782
|
+
}
|
|
5783
|
+
function resolveRuntimeStartMutexPlan(input) {
|
|
5784
|
+
const fallbackOwnerId = input.executionWorkspaceId
|
|
5785
|
+
?? input.workspace.workspaceId
|
|
5786
|
+
?? path.resolve(input.workspace.cwd);
|
|
5787
|
+
const replacementReuseKeys = [];
|
|
5788
|
+
const keys = input.services.map((service) => {
|
|
5789
|
+
const { scopeType, scopeId } = resolveServiceScopeId({
|
|
5790
|
+
service,
|
|
5791
|
+
workspace: input.workspace,
|
|
5792
|
+
executionWorkspaceId: input.executionWorkspaceId,
|
|
5793
|
+
issue: input.issue,
|
|
5794
|
+
runId: input.runId,
|
|
5795
|
+
agent: input.agent,
|
|
5796
|
+
});
|
|
5797
|
+
const reuseKey = resolveRuntimeServiceReuseIdentity({
|
|
5798
|
+
service,
|
|
5799
|
+
workspace: input.workspace,
|
|
5800
|
+
agent: input.agent,
|
|
5801
|
+
issue: input.issue,
|
|
5802
|
+
adapterEnv: input.adapterEnv,
|
|
5803
|
+
scopeType,
|
|
5804
|
+
scopeId,
|
|
5805
|
+
}).reuseKey;
|
|
5806
|
+
// Converge all callers that can replace an existing shared runtime on its
|
|
5807
|
+
// reuse identity. For an initial start, retain owner-level concurrency so
|
|
5808
|
+
// the exposure allocator's in-flight pair claims remain authoritative.
|
|
5809
|
+
if (reuseKey
|
|
5810
|
+
&& (runtimeServicesByReuseKey.has(reuseKey) || runtimeReplacementClaimsByReuseKey.has(reuseKey))) {
|
|
5811
|
+
runtimeReplacementClaimsByReuseKey.set(reuseKey, (runtimeReplacementClaimsByReuseKey.get(reuseKey) ?? 0) + 1);
|
|
5812
|
+
replacementReuseKeys.push(reuseKey);
|
|
5813
|
+
return `reuse:${reuseKey}`;
|
|
5814
|
+
}
|
|
5815
|
+
return `${input.agent.companyId}:owner:${fallbackOwnerId}`;
|
|
5816
|
+
});
|
|
5817
|
+
return {
|
|
5818
|
+
ownerKeys: [...new Set(keys)].sort(),
|
|
5819
|
+
replacementReuseKeys,
|
|
5820
|
+
};
|
|
5821
|
+
}
|
|
5822
|
+
function releaseRuntimeReplacementClaims(reuseKeys) {
|
|
5823
|
+
for (const reuseKey of reuseKeys) {
|
|
5824
|
+
const next = (runtimeReplacementClaimsByReuseKey.get(reuseKey) ?? 1) - 1;
|
|
5825
|
+
if (next <= 0)
|
|
5826
|
+
runtimeReplacementClaimsByReuseKey.delete(reuseKey);
|
|
5827
|
+
else
|
|
5828
|
+
runtimeReplacementClaimsByReuseKey.set(reuseKey, next);
|
|
5829
|
+
}
|
|
5830
|
+
}
|
|
5831
|
+
async function withRuntimeStartMutexes(ownerKeys, start) {
|
|
5832
|
+
const acquire = async (index) => {
|
|
5833
|
+
const ownerKey = ownerKeys[index];
|
|
5834
|
+
if (!ownerKey)
|
|
5835
|
+
return await start();
|
|
5836
|
+
return await withRuntimeStartMutex(ownerKey, () => acquire(index + 1));
|
|
5837
|
+
};
|
|
5838
|
+
return await acquire(0);
|
|
5839
|
+
}
|
|
5840
|
+
export async function ensureRuntimeServicesForRun(input) {
|
|
5841
|
+
const services = selectRuntimeServiceEntries({
|
|
5842
|
+
config: input.config,
|
|
5843
|
+
respectDesiredStates: true,
|
|
5844
|
+
defaultDesiredState: readDesiredRuntimeState(input.config.desiredState) ?? "running",
|
|
5845
|
+
serviceStates: readConfiguredServiceStates(input.config),
|
|
5846
|
+
});
|
|
5847
|
+
const mutexPlan = resolveRuntimeStartMutexPlan({
|
|
5848
|
+
services,
|
|
5849
|
+
workspace: input.workspace,
|
|
5850
|
+
executionWorkspaceId: input.executionWorkspaceId,
|
|
5851
|
+
issue: input.issue,
|
|
5852
|
+
runId: input.runId,
|
|
5853
|
+
agent: input.agent,
|
|
5854
|
+
adapterEnv: input.adapterEnv,
|
|
5855
|
+
});
|
|
5856
|
+
try {
|
|
5857
|
+
return await withRuntimeStartMutexes(mutexPlan.ownerKeys, () => ensureRuntimeServicesForRunInvocation(input));
|
|
5858
|
+
}
|
|
5859
|
+
finally {
|
|
5860
|
+
releaseRuntimeReplacementClaims(mutexPlan.replacementReuseKeys);
|
|
5861
|
+
}
|
|
5862
|
+
}
|
|
5065
5863
|
async function startRuntimeServicesForWorkspaceControlUnlocked(input, rawServices, invocationId, persistenceDb = input.db, registryDb = input.db, options) {
|
|
5066
5864
|
const refs = [];
|
|
5067
5865
|
const pendingReadiness = [];
|
|
@@ -5085,9 +5883,8 @@ async function startRuntimeServicesForWorkspaceControlUnlocked(input, rawService
|
|
|
5085
5883
|
scopeId,
|
|
5086
5884
|
}).reuseKey;
|
|
5087
5885
|
if (reuseKey) {
|
|
5088
|
-
const
|
|
5089
|
-
|
|
5090
|
-
if (existing && existing.status === "running") {
|
|
5886
|
+
const existing = await findHealthyRunningRuntimeService(reuseKey);
|
|
5887
|
+
if (existing) {
|
|
5091
5888
|
const prepared = options?.preparedProvisioning;
|
|
5092
5889
|
if (prepared?.service === service && prepared.record.id !== existing.id && persistenceDb) {
|
|
5093
5890
|
await persistenceDb
|
|
@@ -5119,6 +5916,7 @@ async function startRuntimeServicesForWorkspaceControlUnlocked(input, rawService
|
|
|
5119
5916
|
service,
|
|
5120
5917
|
onLog: input.onLog,
|
|
5121
5918
|
runtimeProvisionCommand: options?.runtimeProvisionCommand,
|
|
5919
|
+
runtimeProvisionKind: options?.runtimeProvisionKind,
|
|
5122
5920
|
recorder: input.recorder,
|
|
5123
5921
|
provisionCoordinator: options?.provisionCoordinator,
|
|
5124
5922
|
preparedProvisioningRecord: options?.preparedProvisioning?.service === service
|
|
@@ -5187,7 +5985,7 @@ async function discardFailedDeferredRuntimeStart(db, record) {
|
|
|
5187
5985
|
await removeLocalServiceRegistryRecord(record.serviceKey).catch(() => undefined);
|
|
5188
5986
|
await persistRuntimeServiceRecord(db, record);
|
|
5189
5987
|
}
|
|
5190
|
-
|
|
5988
|
+
async function startRuntimeServicesForWorkspaceControlInvocation(input) {
|
|
5191
5989
|
const rawServices = selectRuntimeServiceEntries({
|
|
5192
5990
|
config: input.config,
|
|
5193
5991
|
serviceIndex: input.serviceIndex,
|
|
@@ -5196,7 +5994,8 @@ export async function startRuntimeServicesForWorkspaceControl(input) {
|
|
|
5196
5994
|
serviceStates: readConfiguredServiceStates(input.config),
|
|
5197
5995
|
});
|
|
5198
5996
|
const invocationId = input.invocationId ?? randomUUID();
|
|
5199
|
-
const
|
|
5997
|
+
const runtimeProvision = resolveRuntimeProvision(input);
|
|
5998
|
+
const runtimeProvisionCommand = runtimeProvision.command;
|
|
5200
5999
|
const provisionCoordinator = createRuntimeProvisionCoordinator();
|
|
5201
6000
|
const hasHttpsExposure = await anyRuntimeServiceUsesHttpsExposure(rawServices);
|
|
5202
6001
|
if (rawServices.length === 0
|
|
@@ -5206,7 +6005,11 @@ export async function startRuntimeServicesForWorkspaceControl(input) {
|
|
|
5206
6005
|
// path outside the parent-row transaction avoids a crash window where the
|
|
5207
6006
|
// broker lease exists but the DB transaction has not committed its handle.
|
|
5208
6007
|
|| hasHttpsExposure) {
|
|
5209
|
-
const batch = await startRuntimeServicesForWorkspaceControlUnlocked(input, rawServices, invocationId, input.db, input.db, {
|
|
6008
|
+
const batch = await startRuntimeServicesForWorkspaceControlUnlocked(input, rawServices, invocationId, input.db, input.db, {
|
|
6009
|
+
runtimeProvisionCommand,
|
|
6010
|
+
runtimeProvisionKind: runtimeProvision.kind,
|
|
6011
|
+
provisionCoordinator,
|
|
6012
|
+
});
|
|
5210
6013
|
return batch.refs;
|
|
5211
6014
|
}
|
|
5212
6015
|
let startBatch = {
|
|
@@ -5236,9 +6039,8 @@ export async function startRuntimeServicesForWorkspaceControl(input) {
|
|
|
5236
6039
|
scopeType,
|
|
5237
6040
|
scopeId,
|
|
5238
6041
|
}).reuseKey;
|
|
5239
|
-
const
|
|
5240
|
-
|
|
5241
|
-
if (existing?.status === "running")
|
|
6042
|
+
const existing = await findHealthyRunningRuntimeService(reuseKey);
|
|
6043
|
+
if (existing)
|
|
5242
6044
|
continue;
|
|
5243
6045
|
const record = await prepareRuntimeProvisioning({
|
|
5244
6046
|
db: input.db,
|
|
@@ -5253,6 +6055,7 @@ export async function startRuntimeServicesForWorkspaceControl(input) {
|
|
|
5253
6055
|
service,
|
|
5254
6056
|
onLog: input.onLog,
|
|
5255
6057
|
runtimeProvisionCommand,
|
|
6058
|
+
runtimeProvisionKind: runtimeProvision.kind,
|
|
5256
6059
|
recorder: input.recorder,
|
|
5257
6060
|
provisionCoordinator,
|
|
5258
6061
|
reuseKey,
|
|
@@ -5274,6 +6077,7 @@ export async function startRuntimeServicesForWorkspaceControl(input) {
|
|
|
5274
6077
|
deferReadiness: true,
|
|
5275
6078
|
allowFixedPortFallback,
|
|
5276
6079
|
runtimeProvisionCommand,
|
|
6080
|
+
runtimeProvisionKind: runtimeProvision.kind,
|
|
5277
6081
|
provisionCoordinator,
|
|
5278
6082
|
preparedProvisioning,
|
|
5279
6083
|
});
|
|
@@ -5370,6 +6174,31 @@ export async function startRuntimeServicesForWorkspaceControl(input) {
|
|
|
5370
6174
|
throw error;
|
|
5371
6175
|
}
|
|
5372
6176
|
}
|
|
6177
|
+
export async function startRuntimeServicesForWorkspaceControl(input) {
|
|
6178
|
+
const services = selectRuntimeServiceEntries({
|
|
6179
|
+
config: input.config,
|
|
6180
|
+
serviceIndex: input.serviceIndex,
|
|
6181
|
+
respectDesiredStates: input.respectDesiredStates,
|
|
6182
|
+
defaultDesiredState: readDesiredRuntimeState(input.config.desiredState) ?? "stopped",
|
|
6183
|
+
serviceStates: readConfiguredServiceStates(input.config),
|
|
6184
|
+
});
|
|
6185
|
+
const invocationId = input.invocationId ?? "workspace_control";
|
|
6186
|
+
const mutexPlan = resolveRuntimeStartMutexPlan({
|
|
6187
|
+
services,
|
|
6188
|
+
workspace: input.workspace,
|
|
6189
|
+
executionWorkspaceId: input.executionWorkspaceId,
|
|
6190
|
+
issue: input.issue,
|
|
6191
|
+
runId: invocationId,
|
|
6192
|
+
agent: input.actor,
|
|
6193
|
+
adapterEnv: input.adapterEnv,
|
|
6194
|
+
});
|
|
6195
|
+
try {
|
|
6196
|
+
return await withRuntimeStartMutexes(mutexPlan.ownerKeys, () => startRuntimeServicesForWorkspaceControlInvocation(input));
|
|
6197
|
+
}
|
|
6198
|
+
finally {
|
|
6199
|
+
releaseRuntimeReplacementClaims(mutexPlan.replacementReuseKeys);
|
|
6200
|
+
}
|
|
6201
|
+
}
|
|
5373
6202
|
export async function releaseRuntimeServicesForRun(runId) {
|
|
5374
6203
|
const acquired = runtimeServiceLeasesByRun.get(runId) ?? [];
|
|
5375
6204
|
runtimeServiceLeasesByRun.delete(runId);
|
|
@@ -5382,7 +6211,12 @@ export async function releaseRuntimeServicesForRun(runId) {
|
|
|
5382
6211
|
const stopType = asString(record.stopPolicy?.type, record.lifecycle === "ephemeral" ? "on_run_finish" : "manual");
|
|
5383
6212
|
await persistRuntimeServiceRecord(record.db, record);
|
|
5384
6213
|
if (record.leaseRunIds.size === 0) {
|
|
5385
|
-
|
|
6214
|
+
const detachedUnhealthySharedRuntime = record.healthStatus === "unhealthy"
|
|
6215
|
+
&& Boolean(record.reuseKey)
|
|
6216
|
+
&& runtimeServicesByReuseKey.get(record.reuseKey) !== record.id;
|
|
6217
|
+
if (record.lifecycle === "ephemeral"
|
|
6218
|
+
|| stopType === "on_run_finish"
|
|
6219
|
+
|| detachedUnhealthySharedRuntime) {
|
|
5386
6220
|
await stopRuntimeService(serviceId);
|
|
5387
6221
|
continue;
|
|
5388
6222
|
}
|
|
@@ -5571,6 +6405,45 @@ async function buildPersistedRuntimeExposureIntentLookup(db) {
|
|
|
5571
6405
|
return readRuntimeExposureIntent(parseObject(entry.expose));
|
|
5572
6406
|
};
|
|
5573
6407
|
}
|
|
6408
|
+
export async function refreshPersistedRuntimeServiceHealth(input) {
|
|
6409
|
+
const ownershipCondition = input.projectWorkspaceId
|
|
6410
|
+
? or(eq(workspaceRuntimeServices.executionWorkspaceId, input.executionWorkspaceId), and(eq(workspaceRuntimeServices.projectWorkspaceId, input.projectWorkspaceId), eq(workspaceRuntimeServices.scopeType, "project_workspace")))
|
|
6411
|
+
: eq(workspaceRuntimeServices.executionWorkspaceId, input.executionWorkspaceId);
|
|
6412
|
+
const rows = await input.db
|
|
6413
|
+
.select({
|
|
6414
|
+
id: workspaceRuntimeServices.id,
|
|
6415
|
+
serviceName: workspaceRuntimeServices.serviceName,
|
|
6416
|
+
command: workspaceRuntimeServices.command,
|
|
6417
|
+
provider: workspaceRuntimeServices.provider,
|
|
6418
|
+
port: workspaceRuntimeServices.port,
|
|
6419
|
+
url: workspaceRuntimeServices.url,
|
|
6420
|
+
healthStatus: workspaceRuntimeServices.healthStatus,
|
|
6421
|
+
cwd: workspaceRuntimeServices.cwd,
|
|
6422
|
+
executionWorkspaceId: workspaceRuntimeServices.executionWorkspaceId,
|
|
6423
|
+
companyId: workspaceRuntimeServices.companyId,
|
|
6424
|
+
})
|
|
6425
|
+
.from(workspaceRuntimeServices)
|
|
6426
|
+
.where(and(eq(workspaceRuntimeServices.companyId, input.companyId), eq(workspaceRuntimeServices.provider, "local_process"), eq(workspaceRuntimeServices.status, "running"), ownershipCondition));
|
|
6427
|
+
const results = await Promise.all(rows.map(async (row) => ({
|
|
6428
|
+
row,
|
|
6429
|
+
healthStatus: await isRuntimeServiceUrlHealthy(row.url, { ...row, db: input.db })
|
|
6430
|
+
? "healthy"
|
|
6431
|
+
: "unhealthy",
|
|
6432
|
+
})));
|
|
6433
|
+
await Promise.all(results.map(async ({ row, healthStatus }) => {
|
|
6434
|
+
const liveRecord = runtimeServicesById.get(row.id);
|
|
6435
|
+
if (liveRecord)
|
|
6436
|
+
liveRecord.healthStatus = healthStatus;
|
|
6437
|
+
if (row.healthStatus === healthStatus)
|
|
6438
|
+
return;
|
|
6439
|
+
await input.db.update(workspaceRuntimeServices).set({ healthStatus, updatedAt: new Date() }).where(and(eq(workspaceRuntimeServices.id, row.id), eq(workspaceRuntimeServices.companyId, input.companyId), eq(workspaceRuntimeServices.status, "running")));
|
|
6440
|
+
}));
|
|
6441
|
+
return {
|
|
6442
|
+
checked: results.length,
|
|
6443
|
+
healthy: results.filter((result) => result.healthStatus === "healthy").length,
|
|
6444
|
+
unhealthy: results.filter((result) => result.healthStatus === "unhealthy").length,
|
|
6445
|
+
};
|
|
6446
|
+
}
|
|
5574
6447
|
export async function reconcilePersistedRuntimeServicesOnStartup(db) {
|
|
5575
6448
|
const rows = await db
|
|
5576
6449
|
.select()
|
|
@@ -5724,9 +6597,28 @@ export async function reconcilePersistedRuntimeServicesOnStartup(db) {
|
|
|
5724
6597
|
}
|
|
5725
6598
|
if (adoptedRecord) {
|
|
5726
6599
|
const adoptedUrl = adoptedRecord.url ?? row.backendUrl ?? row.url ?? null;
|
|
6600
|
+
const adoptedHealthInput = {
|
|
6601
|
+
db,
|
|
6602
|
+
serviceName: row.serviceName,
|
|
6603
|
+
command: row.command,
|
|
6604
|
+
provider: "local_process",
|
|
6605
|
+
port: adoptedRecord.port ?? row.port,
|
|
6606
|
+
cwd: row.cwd,
|
|
6607
|
+
executionWorkspaceId: row.executionWorkspaceId ?? null,
|
|
6608
|
+
companyId: row.companyId,
|
|
6609
|
+
};
|
|
6610
|
+
// A surviving service can be slow to answer one probe when the host is
|
|
6611
|
+
// busy at startup. One timeout is not enough evidence to terminate it.
|
|
6612
|
+
// Confirm an unhealthy verdict with a second probe after a short bounded
|
|
6613
|
+
// delay, the same way the reuse path protects a shared runtime.
|
|
6614
|
+
let adoptedHealthy = await isRuntimeServiceUrlHealthy(adoptedUrl, adoptedHealthInput);
|
|
6615
|
+
if (!adoptedHealthy) {
|
|
6616
|
+
await delay(250);
|
|
6617
|
+
adoptedHealthy = await isRuntimeServiceUrlHealthy(adoptedUrl, adoptedHealthInput);
|
|
6618
|
+
}
|
|
5727
6619
|
if (backfillDecision.action === "reprovision"
|
|
5728
6620
|
|| !exposureHealthMatches
|
|
5729
|
-
|| !
|
|
6621
|
+
|| !adoptedHealthy) {
|
|
5730
6622
|
if (backfillDecision.action === "reprovision")
|
|
5731
6623
|
backfilled += 1;
|
|
5732
6624
|
await terminateLocalService(adoptedRecord);
|
|
@@ -5885,6 +6777,7 @@ export async function restartDesiredRuntimeServicesOnStartup(db) {
|
|
|
5885
6777
|
worktreePath: null,
|
|
5886
6778
|
warnings: [],
|
|
5887
6779
|
created: false,
|
|
6780
|
+
branchCreatedByRuntime: false,
|
|
5888
6781
|
},
|
|
5889
6782
|
config: {
|
|
5890
6783
|
workspaceRuntime: runtimeConfig.workspaceRuntime,
|
|
@@ -5937,6 +6830,7 @@ export async function restartDesiredRuntimeServicesOnStartup(db) {
|
|
|
5937
6830
|
worktreePath: row.strategyType === "git_worktree" ? row.cwd : null,
|
|
5938
6831
|
warnings: [],
|
|
5939
6832
|
created: false,
|
|
6833
|
+
branchCreatedByRuntime: isRuntimeOwnedGitBranch(row.metadata),
|
|
5940
6834
|
},
|
|
5941
6835
|
executionWorkspaceId: row.id,
|
|
5942
6836
|
config: {
|