devcontour-harness 0.0.0-stage → 0.1.0-alpha.6
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/LICENSE +21 -0
- package/README.md +183 -2
- package/docs/architecture.md +473 -0
- package/docs/compatibility-versions.md +56 -0
- package/docs/compatibility.md +192 -0
- package/docs/connectors.md +323 -0
- package/docs/contracts.md +603 -0
- package/docs/integrations/hindsight.md +148 -0
- package/docs/quickstart.md +182 -0
- package/docs/release-notes/0.1.0-alpha.6.md +238 -0
- package/docs/review-probes.md +120 -0
- package/docs/review-protocol.md +251 -0
- package/docs/toolchains.md +167 -0
- package/docs/turbo-starter.md +269 -0
- package/docs/upgrade.md +202 -0
- package/examples/README.md +27 -0
- package/examples/external-connector/README.md +19 -0
- package/examples/external-connector/connector.mjs +55 -0
- package/examples/files-only/README.md +28 -0
- package/examples/files-only/context/norms/rules.md +4 -0
- package/examples/files-only/graph.json +23 -0
- package/examples/files-only/policy.json +30 -0
- package/examples/one-vendor/README.md +19 -0
- package/examples/one-vendor/policy.json +27 -0
- package/examples/single-task/README.md +18 -0
- package/examples/single-task/graph.json +27 -0
- package/examples/single-task/policy-same-vendor.json +55 -0
- package/examples/single-task/policy.json +52 -0
- package/examples/single-task/src/.gitkeep +0 -0
- package/examples/single-task/test/run.js +19 -0
- package/examples/toolchains/README.md +32 -0
- package/examples/toolchains/go/bad/label/label.go +9 -0
- package/examples/toolchains/go/good/label/label.go +9 -0
- package/examples/toolchains/go/policy.json +79 -0
- package/examples/toolchains/go/repo/REQUIREMENTS.md +9 -0
- package/examples/toolchains/go/repo/_gitignore +1 -0
- package/examples/toolchains/go/repo/go.mod +5 -0
- package/examples/toolchains/go/repo/go.sum +2 -0
- package/examples/toolchains/go/repo/label/label.go +9 -0
- package/examples/toolchains/go/repo/label/label_test.go +27 -0
- package/examples/toolchains/go/repo/label/version.go +9 -0
- package/examples/toolchains/go/task.json +25 -0
- package/examples/toolchains/mixed-go/bad/backend/label/label.go +9 -0
- package/examples/toolchains/mixed-go/bad/frontend/src/label.js +4 -0
- package/examples/toolchains/mixed-go/good/backend/label/label.go +9 -0
- package/examples/toolchains/mixed-go/good/frontend/src/label.js +4 -0
- package/examples/toolchains/mixed-go/policy.json +126 -0
- package/examples/toolchains/mixed-go/repo/REQUIREMENTS.md +9 -0
- package/examples/toolchains/mixed-go/repo/_gitignore +6 -0
- package/examples/toolchains/mixed-go/repo/backend/go.mod +3 -0
- package/examples/toolchains/mixed-go/repo/backend/label/label.go +9 -0
- package/examples/toolchains/mixed-go/repo/backend/label/label_test.go +30 -0
- package/examples/toolchains/mixed-go/repo/contract/check.mjs +17 -0
- package/examples/toolchains/mixed-go/repo/contract/label.json +9 -0
- package/examples/toolchains/mixed-go/repo/frontend/package.json +15 -0
- package/examples/toolchains/mixed-go/repo/frontend/pnpm-lock.yaml +22 -0
- package/examples/toolchains/mixed-go/repo/frontend/src/label.js +4 -0
- package/examples/toolchains/mixed-go/repo/frontend/test/label.test.js +11 -0
- package/examples/toolchains/mixed-go/task.json +25 -0
- package/examples/toolchains/mixed-python/bad/backend/src/fx_backend/__init__.py +6 -0
- package/examples/toolchains/mixed-python/bad/frontend/src/label.js +4 -0
- package/examples/toolchains/mixed-python/good/backend/src/fx_backend/__init__.py +6 -0
- package/examples/toolchains/mixed-python/good/frontend/src/label.js +4 -0
- package/examples/toolchains/mixed-python/policy.json +121 -0
- package/examples/toolchains/mixed-python/repo/REQUIREMENTS.md +9 -0
- package/examples/toolchains/mixed-python/repo/_gitignore +6 -0
- package/examples/toolchains/mixed-python/repo/backend/.python-version +1 -0
- package/examples/toolchains/mixed-python/repo/backend/pyproject.toml +19 -0
- package/examples/toolchains/mixed-python/repo/backend/src/fx_backend/__init__.py +6 -0
- package/examples/toolchains/mixed-python/repo/backend/tests/test_label.py +11 -0
- package/examples/toolchains/mixed-python/repo/backend/uv.lock +108 -0
- package/examples/toolchains/mixed-python/repo/contract/check.mjs +17 -0
- package/examples/toolchains/mixed-python/repo/contract/label.json +9 -0
- package/examples/toolchains/mixed-python/repo/frontend/package.json +15 -0
- package/examples/toolchains/mixed-python/repo/frontend/pnpm-lock.yaml +22 -0
- package/examples/toolchains/mixed-python/repo/frontend/src/label.js +4 -0
- package/examples/toolchains/mixed-python/repo/frontend/test/label.test.js +11 -0
- package/examples/toolchains/mixed-python/task.json +25 -0
- package/examples/toolchains/npm/bad/src/label.js +4 -0
- package/examples/toolchains/npm/good/src/label.js +4 -0
- package/examples/toolchains/npm/policy.json +82 -0
- package/examples/toolchains/npm/repo/REQUIREMENTS.md +9 -0
- package/examples/toolchains/npm/repo/_gitignore +4 -0
- package/examples/toolchains/npm/repo/package-lock.json +22 -0
- package/examples/toolchains/npm/repo/package.json +13 -0
- package/examples/toolchains/npm/repo/src/label.js +4 -0
- package/examples/toolchains/npm/repo/test/label.test.js +13 -0
- package/examples/toolchains/npm/task.json +25 -0
- package/examples/toolchains/pnpm/bad/packages/label/src/label.js +4 -0
- package/examples/toolchains/pnpm/good/packages/label/src/label.js +4 -0
- package/examples/toolchains/pnpm/policy.json +85 -0
- package/examples/toolchains/pnpm/repo/REQUIREMENTS.md +9 -0
- package/examples/toolchains/pnpm/repo/_gitignore +4 -0
- package/examples/toolchains/pnpm/repo/package.json +10 -0
- package/examples/toolchains/pnpm/repo/packages/checker/check.mjs +5 -0
- package/examples/toolchains/pnpm/repo/packages/checker/package.json +9 -0
- package/examples/toolchains/pnpm/repo/packages/label/package.json +14 -0
- package/examples/toolchains/pnpm/repo/packages/label/src/label.js +4 -0
- package/examples/toolchains/pnpm/repo/packages/label/test/label.test.js +13 -0
- package/examples/toolchains/pnpm/repo/pnpm-lock.yaml +29 -0
- package/examples/toolchains/pnpm/repo/pnpm-workspace.yaml +2 -0
- package/examples/toolchains/pnpm/task.json +25 -0
- package/examples/toolchains/python/bad/src/fx_label/__init__.py +6 -0
- package/examples/toolchains/python/good/src/fx_label/__init__.py +6 -0
- package/examples/toolchains/python/policy.json +80 -0
- package/examples/toolchains/python/repo/.python-version +1 -0
- package/examples/toolchains/python/repo/REQUIREMENTS.md +9 -0
- package/examples/toolchains/python/repo/_gitignore +5 -0
- package/examples/toolchains/python/repo/pyproject.toml +19 -0
- package/examples/toolchains/python/repo/src/fx_label/__init__.py +6 -0
- package/examples/toolchains/python/repo/tests/test_label.py +13 -0
- package/examples/toolchains/python/repo/uv.lock +108 -0
- package/examples/toolchains/python/task.json +25 -0
- package/examples/toolchains/turbo/bad/packages/label/src/label.js +4 -0
- package/examples/toolchains/turbo/good/packages/label/src/label.js +4 -0
- package/examples/toolchains/turbo/policy.json +110 -0
- package/examples/toolchains/turbo/repo/REQUIREMENTS.md +9 -0
- package/examples/toolchains/turbo/repo/_gitignore +4 -0
- package/examples/toolchains/turbo/repo/package.json +13 -0
- package/examples/toolchains/turbo/repo/packages/checker/check.mjs +5 -0
- package/examples/toolchains/turbo/repo/packages/checker/package.json +9 -0
- package/examples/toolchains/turbo/repo/packages/label/package.json +14 -0
- package/examples/toolchains/turbo/repo/packages/label/src/label.js +4 -0
- package/examples/toolchains/turbo/repo/packages/label/test/label.test.js +13 -0
- package/examples/toolchains/turbo/repo/pnpm-lock.yaml +94 -0
- package/examples/toolchains/turbo/repo/pnpm-workspace.yaml +2 -0
- package/examples/toolchains/turbo/repo/turbo.json +7 -0
- package/examples/toolchains/turbo/task.json +25 -0
- package/examples/turbo-starter/README.md +16 -0
- package/examples/turbo-starter/changes/issued-at-consumer-only.patch +12 -0
- package/examples/turbo-starter/changes/issued-at.patch +130 -0
- package/examples/turbo-starter/cleanup.mjs +58 -0
- package/examples/turbo-starter/graph.json +32 -0
- package/examples/turbo-starter/harness/acceptance/db-isolation.test.mjs +97 -0
- package/examples/turbo-starter/harness/acceptance/env-probe.test.mjs +75 -0
- package/examples/turbo-starter/harness/acceptance/generation.test.mjs +54 -0
- package/examples/turbo-starter/harness/acceptance/issued-at.test.mjs +43 -0
- package/examples/turbo-starter/harness/acceptance/lifecycle-probe.test.mjs +47 -0
- package/examples/turbo-starter/harness/compose-bridge.mjs +593 -0
- package/examples/turbo-starter/harness/compose.acceptance.yml +23 -0
- package/examples/turbo-starter/harness/env/backend.env.template +7 -0
- package/examples/turbo-starter/harness/env/compose.env.template +6 -0
- package/examples/turbo-starter/policy.json +109 -0
- package/examples/turbo-starter/policy.lifecycle.json +114 -0
- package/examples/turbo-starter/setup.mjs +352 -0
- package/examples/two-vendors/policy.json +30 -0
- package/lib/agents/cli-adapter.d.ts +48 -0
- package/lib/agents/cli-adapter.js +386 -0
- package/lib/agents/inspect.d.ts +6 -0
- package/lib/agents/inspect.js +46 -0
- package/lib/agents/probe-server.d.ts +2 -0
- package/lib/agents/probe-server.js +13 -0
- package/lib/agents/review-probe.d.ts +295 -0
- package/lib/agents/review-probe.js +872 -0
- package/lib/agents/runtime-events.d.ts +11 -0
- package/lib/agents/runtime-events.js +21 -0
- package/lib/agents/schemas.d.ts +217 -0
- package/lib/agents/schemas.js +124 -0
- package/lib/agents/usage.d.ts +8 -0
- package/lib/agents/usage.js +83 -0
- package/lib/cli.d.ts +2 -0
- package/lib/cli.js +491 -0
- package/lib/connectors/files.d.ts +108 -0
- package/lib/connectors/files.js +133 -0
- package/lib/contracts/checks.d.ts +307 -0
- package/lib/contracts/checks.js +181 -0
- package/lib/contracts/cli.d.ts +3895 -0
- package/lib/contracts/cli.js +664 -0
- package/lib/contracts/context.d.ts +135 -0
- package/lib/contracts/context.js +129 -0
- package/lib/contracts/digest.d.ts +9 -0
- package/lib/contracts/digest.js +22 -0
- package/lib/contracts/errors.d.ts +81 -0
- package/lib/contracts/errors.js +70 -0
- package/lib/contracts/events.d.ts +827 -0
- package/lib/contracts/events.js +258 -0
- package/lib/contracts/execution.d.ts +3216 -0
- package/lib/contracts/execution.js +872 -0
- package/lib/contracts/failure.d.ts +67 -0
- package/lib/contracts/failure.js +127 -0
- package/lib/contracts/index.d.ts +18 -0
- package/lib/contracts/index.js +18 -0
- package/lib/contracts/policy.d.ts +1080 -0
- package/lib/contracts/policy.js +544 -0
- package/lib/contracts/ports.d.ts +417 -0
- package/lib/contracts/ports.js +1 -0
- package/lib/contracts/primitives.d.ts +63 -0
- package/lib/contracts/primitives.js +73 -0
- package/lib/contracts/profiles.d.ts +336 -0
- package/lib/contracts/profiles.js +109 -0
- package/lib/contracts/published.d.ts +3237 -0
- package/lib/contracts/published.js +34 -0
- package/lib/contracts/result.d.ts +1280 -0
- package/lib/contracts/result.js +311 -0
- package/lib/contracts/review.d.ts +441 -0
- package/lib/contracts/review.js +477 -0
- package/lib/contracts/toolchain.d.ts +96 -0
- package/lib/contracts/toolchain.js +89 -0
- package/lib/contracts/version.d.ts +17 -0
- package/lib/contracts/version.js +17 -0
- package/lib/contracts/work.d.ts +173 -0
- package/lib/contracts/work.js +178 -0
- package/lib/control/base-move.d.ts +44 -0
- package/lib/control/base-move.js +90 -0
- package/lib/control/doctor.d.ts +50 -0
- package/lib/control/doctor.js +113 -0
- package/lib/control/harness.d.ts +1125 -0
- package/lib/control/harness.js +741 -0
- package/lib/control/provenance.d.ts +17 -0
- package/lib/control/provenance.js +25 -0
- package/lib/control/recovery.d.ts +237 -0
- package/lib/control/recovery.js +236 -0
- package/lib/control/review.d.ts +62 -0
- package/lib/control/review.js +178 -0
- package/lib/control/workflow.d.ts +247 -0
- package/lib/control/workflow.js +259 -0
- package/lib/engine/budget.d.ts +345 -0
- package/lib/engine/budget.js +278 -0
- package/lib/engine/classify.d.ts +8 -0
- package/lib/engine/classify.js +32 -0
- package/lib/engine/engine.d.ts +1527 -0
- package/lib/engine/engine.js +2259 -0
- package/lib/engine/failure.d.ts +1 -0
- package/lib/engine/failure.js +3 -0
- package/lib/engine/joint-runner.d.ts +257 -0
- package/lib/engine/joint-runner.js +254 -0
- package/lib/engine/measured.d.ts +15 -0
- package/lib/engine/measured.js +82 -0
- package/lib/engine/prompt.d.ts +66 -0
- package/lib/engine/prompt.js +131 -0
- package/lib/engine/property-recovery.d.ts +50 -0
- package/lib/engine/property-recovery.js +171 -0
- package/lib/engine/repair.d.ts +33 -0
- package/lib/engine/repair.js +82 -0
- package/lib/engine/result.d.ts +9 -0
- package/lib/engine/result.js +218 -0
- package/lib/engine/reuse.d.ts +80 -0
- package/lib/engine/reuse.js +102 -0
- package/lib/engine/runner.d.ts +360 -0
- package/lib/engine/runner.js +1836 -0
- package/lib/engine/standalone.d.ts +6 -0
- package/lib/engine/standalone.js +48 -0
- package/lib/engine/usage.d.ts +87 -0
- package/lib/engine/usage.js +165 -0
- package/lib/environment/isolation.d.ts +110 -0
- package/lib/environment/isolation.js +238 -0
- package/lib/environment/lifecycle.d.ts +40 -0
- package/lib/environment/lifecycle.js +197 -0
- package/lib/environment/live-calls.d.ts +14 -0
- package/lib/environment/live-calls.js +73 -0
- package/lib/environment/os-tools.d.ts +16 -0
- package/lib/environment/os-tools.js +35 -0
- package/lib/environment/process.d.ts +32 -0
- package/lib/environment/process.js +369 -0
- package/lib/environment/redaction.d.ts +22 -0
- package/lib/environment/redaction.js +102 -0
- package/lib/environment/resources.d.ts +31 -0
- package/lib/environment/resources.js +120 -0
- package/lib/index.d.ts +65 -0
- package/lib/index.js +67 -0
- package/lib/integrations/connector/commands.d.ts +358 -0
- package/lib/integrations/connector/commands.js +428 -0
- package/lib/integrations/connector/config.d.ts +215 -0
- package/lib/integrations/connector/config.js +214 -0
- package/lib/integrations/connector/delivery.d.ts +107 -0
- package/lib/integrations/connector/delivery.js +591 -0
- package/lib/integrations/connector/fake.d.ts +291 -0
- package/lib/integrations/connector/fake.js +786 -0
- package/lib/integrations/connector/history.d.ts +124 -0
- package/lib/integrations/connector/history.js +497 -0
- package/lib/integrations/connector/index.d.ts +16 -0
- package/lib/integrations/connector/index.js +16 -0
- package/lib/integrations/connector/redact.d.ts +19 -0
- package/lib/integrations/connector/redact.js +91 -0
- package/lib/integrations/connector/registry.d.ts +15 -0
- package/lib/integrations/connector/registry.js +43 -0
- package/lib/integrations/connector/snapshot.d.ts +131 -0
- package/lib/integrations/connector/snapshot.js +422 -0
- package/lib/integrations/connector/source.d.ts +37 -0
- package/lib/integrations/connector/source.js +73 -0
- package/lib/integrations/connector/steps.d.ts +71 -0
- package/lib/integrations/connector/steps.js +188 -0
- package/lib/integrations/connector/store.d.ts +140 -0
- package/lib/integrations/connector/store.js +277 -0
- package/lib/integrations/connector/summary.d.ts +38 -0
- package/lib/integrations/connector/summary.js +59 -0
- package/lib/integrations/connector/testing-worker.d.ts +1 -0
- package/lib/integrations/connector/testing-worker.js +81 -0
- package/lib/integrations/connector/testing.d.ts +205 -0
- package/lib/integrations/connector/testing.js +1615 -0
- package/lib/integrations/connector/transport.d.ts +240 -0
- package/lib/integrations/connector/transport.js +28 -0
- package/lib/integrations/memory/context.d.ts +120 -0
- package/lib/integrations/memory/context.js +239 -0
- package/lib/integrations/memory/index.d.ts +8 -0
- package/lib/integrations/memory/index.js +8 -0
- package/lib/integrations/memory/provider.d.ts +79 -0
- package/lib/integrations/memory/provider.js +22 -0
- package/lib/integrations/memory/sink.d.ts +56 -0
- package/lib/integrations/memory/sink.js +116 -0
- package/lib/integrations/memory/testing.d.ts +44 -0
- package/lib/integrations/memory/testing.js +61 -0
- package/lib/integrations/relay-v1/index.d.ts +122 -0
- package/lib/integrations/relay-v1/index.js +860 -0
- package/lib/integrations/relay-v2/index.d.ts +145 -0
- package/lib/integrations/relay-v2/index.js +752 -0
- package/lib/integrations/relay-v2/process.d.ts +32 -0
- package/lib/integrations/relay-v2/process.js +112 -0
- package/lib/integrations/relay-v2/protocol.d.ts +172 -0
- package/lib/integrations/relay-v2/protocol.js +142 -0
- package/lib/package.d.ts +2 -0
- package/lib/package.js +3 -0
- package/lib/profiles/resolve.d.ts +190 -0
- package/lib/profiles/resolve.js +273 -0
- package/lib/storage/document.d.ts +1234 -0
- package/lib/storage/document.js +80 -0
- package/lib/storage/sqlite.d.ts +35 -0
- package/lib/storage/sqlite.js +260 -0
- package/lib/testing/index.d.ts +5 -0
- package/lib/testing/index.js +5 -0
- package/lib/testing/store-contract.d.ts +8 -0
- package/lib/testing/store-contract.js +167 -0
- package/lib/toolchains/index.d.ts +9 -0
- package/lib/toolchains/index.js +9 -0
- package/lib/toolchains/inspect.d.ts +105 -0
- package/lib/toolchains/inspect.js +831 -0
- package/lib/toolchains/manifests.d.ts +67 -0
- package/lib/toolchains/manifests.js +238 -0
- package/lib/toolchains/probe.d.ts +28 -0
- package/lib/toolchains/probe.js +54 -0
- package/lib/toolchains/recipes.d.ts +33 -0
- package/lib/toolchains/recipes.js +51 -0
- package/lib/validate.d.ts +36 -0
- package/lib/validate.js +175 -0
- package/lib/verification/acceptance.d.ts +284 -0
- package/lib/verification/acceptance.js +670 -0
- package/lib/verification/checks.d.ts +72 -0
- package/lib/verification/checks.js +190 -0
- package/lib/verification/confirm.d.ts +125 -0
- package/lib/verification/confirm.js +411 -0
- package/lib/verification/corpus.d.ts +1303 -0
- package/lib/verification/corpus.js +744 -0
- package/lib/verification/dependencies.d.ts +66 -0
- package/lib/verification/dependencies.js +144 -0
- package/lib/verification/gates.d.ts +160 -0
- package/lib/verification/gates.js +600 -0
- package/lib/verification/joint.d.ts +63 -0
- package/lib/verification/joint.js +121 -0
- package/lib/verification/proof.d.ts +54 -0
- package/lib/verification/proof.js +96 -0
- package/lib/verification/property.d.ts +86 -0
- package/lib/verification/property.js +214 -0
- package/lib/verification/requirements.d.ts +25 -0
- package/lib/verification/requirements.js +69 -0
- package/lib/verification/self-check.d.ts +29 -0
- package/lib/verification/self-check.js +99 -0
- package/lib/verification/spike.d.ts +79 -0
- package/lib/verification/spike.js +124 -0
- package/package.json +100 -4
- package/profiles/expo-mobile.json +15 -0
- package/profiles/go-api.json +15 -0
- package/profiles/go-gotestsum.json +21 -0
- package/profiles/mobile-maestro.json +29 -0
- package/profiles/nest-api.json +26 -0
- package/profiles/next-product.json +72 -0
- package/profiles/node-checks.json +77 -0
- package/profiles/npm-ci.json +22 -0
- package/profiles/pnpm-frozen.json +24 -0
- package/profiles/python-api.json +32 -0
- package/profiles/python-uv.json +26 -0
- package/profiles/react-native.json +50 -0
- package/profiles/react-vite-admin.json +71 -0
- package/schemas/context-snapshot.schema.json +231 -0
- package/schemas/contract-document.schema.json +385 -0
- package/schemas/execution-document.schema.json +3695 -0
- package/schemas/execution-policy.schema.json +2196 -0
- package/schemas/joint-verification.schema.json +617 -0
- package/schemas/result-envelope.schema.json +1652 -0
- package/schemas/review-record.schema.json +177 -0
- package/schemas/run-event.schema.json +2458 -0
- package/schemas/run-result.schema.json +1570 -0
- package/schemas/stack-profile.schema.json +535 -0
- package/schemas/work-graph.schema.json +381 -0
- package/schemas/work-order.schema.json +256 -0
- package/skills/harness/SKILL.md +64 -0
- package/templates/AGENTS.md +13 -0
- package/templates/controller-mode/AGENTS.controller.md +58 -0
- package/templates/controller-mode/README.md +44 -0
- package/templates/graph.json +22 -0
- package/templates/policy.json +28 -0
|
@@ -0,0 +1,1836 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
6
|
+
import { roleBinding, selectReviewer, mcpEnvironment, HarnessError, digestOf, contextDigest, implementationResultSchema, codeReviewHistory, codeReviewScope, checkCodeReview, logicalReview, DEFAULT_REVIEW_PROTOCOL_RETRIES, reviewResultSchema, sha256, } from "../contracts/index.js";
|
|
7
|
+
import { git } from "../environment/process.js";
|
|
8
|
+
import { executionEnvironment, runSteps, withEnvironment } from "../environment/lifecycle.js";
|
|
9
|
+
import { redactDeep } from "../environment/redaction.js";
|
|
10
|
+
import { implementationOutputSchema, reviewOutputSchema } from "../agents/schemas.js";
|
|
11
|
+
import { unobservedReview } from "../agents/inspect.js";
|
|
12
|
+
import { reviewerRunsChecks } from "../agents/cli-adapter.js";
|
|
13
|
+
import { gateProbePrompt, observeReproductions, probeCommand, probeGates, probeLimits, probeRecords, recoverProbeLog, probeTool, } from "../agents/review-probe.js";
|
|
14
|
+
import { runGate } from "../verification/gates.js";
|
|
15
|
+
import { assertDependencies, dependencyFreshness, removeSnapshots, snapshotDependencies, } from "../verification/dependencies.js";
|
|
16
|
+
import { CleanupFailure, cleanupEnvironment } from "../environment/lifecycle.js";
|
|
17
|
+
import { defaultResourceDatabase, withResources } from "../environment/resources.js";
|
|
18
|
+
import { REQUIREMENT_GATE, assertRequirements } from "../verification/requirements.js";
|
|
19
|
+
import { BlockedError, classifyFailure } from "./classify.js";
|
|
20
|
+
import { TaskFailure } from "./failure.js";
|
|
21
|
+
import { prompt } from "./prompt.js";
|
|
22
|
+
import { contractDocuments, orderCheckManifest } from "../verification/checks.js";
|
|
23
|
+
import { CorpusStore, localRoots } from "../verification/corpus.js";
|
|
24
|
+
import { submitCase } from "../verification/confirm.js";
|
|
25
|
+
import { runSpike } from "../verification/spike.js";
|
|
26
|
+
import { propertySeed } from "../verification/property.js";
|
|
27
|
+
import { selfCheckNote, writeSelfCheck } from "../verification/self-check.js";
|
|
28
|
+
import { measuredInvoke } from "./measured.js";
|
|
29
|
+
import { continuableDraft, implementationBasis } from "./reuse.js";
|
|
30
|
+
const ordered = (gates, ids) => {
|
|
31
|
+
const byId = new Map(gates.map((g) => [g.id, g]));
|
|
32
|
+
const out = [];
|
|
33
|
+
const seen = new Set();
|
|
34
|
+
const visit = (id) => {
|
|
35
|
+
// Псевдо-gate (актуальность требований) проверяется отдельно, а не командой.
|
|
36
|
+
if (seen.has(id) || !byId.has(id))
|
|
37
|
+
return;
|
|
38
|
+
seen.add(id);
|
|
39
|
+
for (const dep of byId.get(id)?.dependsOn ?? [])
|
|
40
|
+
if (ids.includes(dep))
|
|
41
|
+
visit(dep);
|
|
42
|
+
out.push(byId.get(id));
|
|
43
|
+
};
|
|
44
|
+
ids.forEach(visit);
|
|
45
|
+
return out;
|
|
46
|
+
};
|
|
47
|
+
const within = (path, prefixes) => prefixes.some((p) => p === '.' || path === p || path.startsWith(p.endsWith('/') ? p : p + '/'));
|
|
48
|
+
/**
|
|
49
|
+
* Исполнитель очереди. Один действующий controller; попытка проходит
|
|
50
|
+
* реализацию, проверку области, gates и независимое ревью кандидата, затем
|
|
51
|
+
* интеграцию с текущей принятой веткой, gates и ревью integration SHA и
|
|
52
|
+
* приёмку с CAS продвижения target ref внутри транзакции приёмки.
|
|
53
|
+
*/
|
|
54
|
+
export class Runner {
|
|
55
|
+
options;
|
|
56
|
+
/** Предел git update-ref приёмки внутри транзакции; конечный и короче lease. */
|
|
57
|
+
static refTimeoutMs = 15000;
|
|
58
|
+
owner;
|
|
59
|
+
engine;
|
|
60
|
+
policy;
|
|
61
|
+
jobs = new Map();
|
|
62
|
+
mergeTails = new Map();
|
|
63
|
+
/** Входы незавершённой очистки проверок потребителей, по попыткам. */
|
|
64
|
+
retained = new Map();
|
|
65
|
+
/** Самопроверка исполнителя попытки: каталог (читаем writer) и план. */
|
|
66
|
+
selfChecks = new Map();
|
|
67
|
+
/** Корпус подтверждённых случаев: принятие версий и закреплённые snapshot. */
|
|
68
|
+
corpus;
|
|
69
|
+
constructor(options) {
|
|
70
|
+
this.options = options;
|
|
71
|
+
this.engine = options.engine;
|
|
72
|
+
this.policy = options.engine.policy;
|
|
73
|
+
this.owner = options.owner ?? 'harness-' + randomUUID();
|
|
74
|
+
this.corpus = new CorpusStore(this.engine, localRoots([
|
|
75
|
+
options.stateDir,
|
|
76
|
+
options.baseDir,
|
|
77
|
+
...this.policy.repositories.map((r) => this.dirOf(r.id)),
|
|
78
|
+
...this.policy.repositories.map((r) => this.repoPath(r)),
|
|
79
|
+
]));
|
|
80
|
+
}
|
|
81
|
+
/** Каталог попыток репозитория. */
|
|
82
|
+
dirOf(repositoryId) {
|
|
83
|
+
return this.options.stateDirFor?.(repositoryId) ?? this.options.stateDir;
|
|
84
|
+
}
|
|
85
|
+
repoPath(repo) {
|
|
86
|
+
return isAbsolute(repo.path) ? repo.path : resolve(this.options.baseDir, repo.path);
|
|
87
|
+
}
|
|
88
|
+
repo(id) {
|
|
89
|
+
const repo = this.policy.repositories.find((r) => r.id === id);
|
|
90
|
+
if (!repo)
|
|
91
|
+
throw new Error('Репозиторий не объявлен: ' + id);
|
|
92
|
+
return repo;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Закрыто процессам попытки: состояние controller и исходные checkout;
|
|
96
|
+
* открыты их `.git` и закреплённые снимки зависимостей — на чтение.
|
|
97
|
+
*/
|
|
98
|
+
boundary(dependencies = []) {
|
|
99
|
+
const repos = this.policy.repositories.map((r) => this.repoPath(r));
|
|
100
|
+
return {
|
|
101
|
+
hidden: [
|
|
102
|
+
...new Set([
|
|
103
|
+
this.options.stateDir,
|
|
104
|
+
...this.policy.repositories.map((r) => this.dirOf(r.id)),
|
|
105
|
+
...repos,
|
|
106
|
+
]),
|
|
107
|
+
],
|
|
108
|
+
readable: [...repos.map((r) => join(r, '.git')), ...dependencies.map((d) => d.path)],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
repositories() {
|
|
112
|
+
return { repo: (id) => this.repo(id), path: (repo) => this.repoPath(repo) };
|
|
113
|
+
}
|
|
114
|
+
/** Подготовка репозиториев: целевая ветка существует и не открыта ни в одном worktree. */
|
|
115
|
+
async init() {
|
|
116
|
+
for (const repo of this.policy.repositories) {
|
|
117
|
+
const path = this.repoPath(repo);
|
|
118
|
+
try {
|
|
119
|
+
await git(path, 'rev-parse', '--verify', repo.targetRef);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
await git(path, 'update-ref', repo.targetRef, 'HEAD');
|
|
123
|
+
}
|
|
124
|
+
await this.assertTargetDetached(repo);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async assertTargetDetached(repo) {
|
|
128
|
+
const list = await git(this.repoPath(repo), 'worktree', 'list', '--porcelain');
|
|
129
|
+
if (list.split('\n').includes(`branch ${repo.targetRef}`))
|
|
130
|
+
throw new Error(`Целевая ветка ${repo.targetRef} открыта в worktree; переключите его`);
|
|
131
|
+
}
|
|
132
|
+
get active() {
|
|
133
|
+
return this.jobs.size;
|
|
134
|
+
}
|
|
135
|
+
/** Дождаться завершения выданных попыток (без новых выдач): хост ведёт свой цикл поверх `tick`. */
|
|
136
|
+
async settled() {
|
|
137
|
+
try {
|
|
138
|
+
await Promise.all([...this.jobs.values()].map((j) => j.promise));
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
// Продление роли нужно, пока идёт работа; следующий `tick` его возобновит.
|
|
142
|
+
this.stopLead();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Актуальность требований на фазе: источник на HEAD репозитория и на
|
|
147
|
+
* проверенном SHA совпадает с закреплённым; evidence псевдо-gate
|
|
148
|
+
* `requirement-source` записывается только при совпадении.
|
|
149
|
+
*/
|
|
150
|
+
requirements(attempt, work, repoPath, cwd, sha, phase) {
|
|
151
|
+
if (!work.order.requirements.length)
|
|
152
|
+
return;
|
|
153
|
+
assertRequirements(work.order, repoPath);
|
|
154
|
+
assertRequirements(work.order, cwd, sha);
|
|
155
|
+
this.engine.evidence(attempt.id, attempt.token, {
|
|
156
|
+
kind: 'test',
|
|
157
|
+
phase,
|
|
158
|
+
sha,
|
|
159
|
+
gate: REQUIREMENT_GATE,
|
|
160
|
+
passed: true,
|
|
161
|
+
command: ['harness', 'requirements-check'],
|
|
162
|
+
exitCode: 0,
|
|
163
|
+
log: '',
|
|
164
|
+
digest: sha256(JSON.stringify(work.order.requirements)),
|
|
165
|
+
summary: 'Закреплённые требования совпадают с источником на HEAD и на проверенном SHA',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
leaseTimer;
|
|
169
|
+
/**
|
|
170
|
+
* Роль controller продлевается, пока у процесса есть работа; утрата роли
|
|
171
|
+
* прерывает его попытки — новый controller не делит очередь с прежним.
|
|
172
|
+
*/
|
|
173
|
+
keepLead() {
|
|
174
|
+
if (this.leaseTimer)
|
|
175
|
+
return;
|
|
176
|
+
this.leaseTimer = setInterval(() => {
|
|
177
|
+
if (this.engine.lead(this.owner))
|
|
178
|
+
return;
|
|
179
|
+
for (const job of this.jobs.values())
|
|
180
|
+
job.controller.abort();
|
|
181
|
+
}, Math.max(500, this.policy.leaseMs / 3));
|
|
182
|
+
this.leaseTimer.unref();
|
|
183
|
+
}
|
|
184
|
+
stopLead() {
|
|
185
|
+
if (this.leaseTimer)
|
|
186
|
+
clearInterval(this.leaseTimer);
|
|
187
|
+
this.leaseTimer = undefined;
|
|
188
|
+
}
|
|
189
|
+
/** Освободить роль controller: процесс больше не ведёт очередь. */
|
|
190
|
+
release() {
|
|
191
|
+
this.stopLead();
|
|
192
|
+
this.engine.release(this.owner);
|
|
193
|
+
}
|
|
194
|
+
/** Один шаг очереди до конца: выдать свободные слоты и довести выданное, без новых выдач. */
|
|
195
|
+
async step() {
|
|
196
|
+
await this.tick();
|
|
197
|
+
try {
|
|
198
|
+
await Promise.all([...this.jobs.values()].map((j) => j.promise));
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
this.stopLead();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/** Один шаг: роль controller, recovery, выдача свободных слотов. */
|
|
205
|
+
async tick() {
|
|
206
|
+
if (!this.engine.lead(this.owner)) {
|
|
207
|
+
for (const job of this.jobs.values())
|
|
208
|
+
job.controller.abort();
|
|
209
|
+
// Без своих попыток вести нечего: занятость роли — явный отказ, а не пустой шаг.
|
|
210
|
+
if (!this.jobs.size) {
|
|
211
|
+
const lease = this.engine.read((v) => v.controller());
|
|
212
|
+
const other = lease && lease.engineVersion !== this.engine.version;
|
|
213
|
+
throw new HarnessError('CONTROLLER_BUSY', `Очередь ведёт другой controller${lease ? ` (${lease.owner} до ${new Date(lease.leaseUntil).toISOString()})` : ''}` +
|
|
214
|
+
(other
|
|
215
|
+
? `: версия движка ${lease.engineVersion ?? 'прежняя, без записи версии'}, у этого процесса ${this.engine.version}`
|
|
216
|
+
: ''));
|
|
217
|
+
}
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
this.keepLead();
|
|
221
|
+
await this.recover();
|
|
222
|
+
// Запрос операции применить политику: процесс с ожидаемой политикой
|
|
223
|
+
// применяет его, когда работ под прежней не осталось.
|
|
224
|
+
this.engine.serveReconfigure(this.owner);
|
|
225
|
+
// Отложенные принятия корпуса: поручение освободилось — принятие
|
|
226
|
+
// завершается до следующей выдачи.
|
|
227
|
+
this.corpus.resume();
|
|
228
|
+
for (const [id, job] of this.jobs)
|
|
229
|
+
if (this.engine.read((v) => v.attempt(id)?.status) !== 'active')
|
|
230
|
+
job.controller.abort();
|
|
231
|
+
while (this.jobs.size < this.policy.concurrency) {
|
|
232
|
+
const attempt = this.engine.claim(this.owner);
|
|
233
|
+
if (!attempt)
|
|
234
|
+
break;
|
|
235
|
+
const controller = new AbortController();
|
|
236
|
+
const promise = this.execute(attempt, controller).finally(() => this.jobs.delete(attempt.id));
|
|
237
|
+
this.jobs.set(attempt.id, { promise, controller });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/** Вести очередь, пока есть работа, которую можно выдать или довести. */
|
|
241
|
+
async drain() {
|
|
242
|
+
try {
|
|
243
|
+
while (true) {
|
|
244
|
+
await this.tick();
|
|
245
|
+
if (!this.jobs.size)
|
|
246
|
+
break;
|
|
247
|
+
await Promise.all([...this.jobs.values()].map((j) => j.promise));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
this.stopLead();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/** Штатная остановка: пауза `shutdown`, прерывание попыток, освобождение роли controller. */
|
|
255
|
+
async stop() {
|
|
256
|
+
this.engine.pause('shutdown');
|
|
257
|
+
for (const job of this.jobs.values())
|
|
258
|
+
job.controller.abort();
|
|
259
|
+
await Promise.all([...this.jobs.values()].map((j) => j.promise));
|
|
260
|
+
this.release();
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Recovery после падения: истёкшая попытка с сохранённым integration SHA,
|
|
264
|
+
* который уже в target ref, принимается заново проверкой всех evidence;
|
|
265
|
+
* иначе она истекает с отказом окружения. Повторный recovery ничего не
|
|
266
|
+
* дублирует: принятая попытка уже не активна.
|
|
267
|
+
*/
|
|
268
|
+
/** Когда recovery последний раз искал прерванные вызовы. */
|
|
269
|
+
abandonScanAt = 0;
|
|
270
|
+
async recover() {
|
|
271
|
+
// Поиск прерванных вызовов читает всю историю вызовов: при старте и затем
|
|
272
|
+
// не чаще раза в минуту, а не на каждом такте.
|
|
273
|
+
if (Date.now() - this.abandonScanAt >= 60_000) {
|
|
274
|
+
this.abandonScanAt = Date.now();
|
|
275
|
+
this.engine.abandonCalls();
|
|
276
|
+
this.engine.pinLegacyCalls();
|
|
277
|
+
}
|
|
278
|
+
const now = this.engine.clock();
|
|
279
|
+
const stale = this.engine.read((v) => v.attempts().filter((a) => a.status === 'active' && a.leaseUntil <= now));
|
|
280
|
+
for (const a of stale) {
|
|
281
|
+
if (!a.integrationSha)
|
|
282
|
+
continue;
|
|
283
|
+
const repo = this.repo(a.repositoryId);
|
|
284
|
+
try {
|
|
285
|
+
// Восстанавливается только попытка под текущей политикой и требованиями.
|
|
286
|
+
const work = this.engine.read((v) => v.work(a.workId));
|
|
287
|
+
const policy = this.engine.read((v) => this.engine.host.basis.policyDigest(work, v));
|
|
288
|
+
if (a.policyDigest !== policy)
|
|
289
|
+
throw new Error('Политика изменилась; попытка под прежней не принимается');
|
|
290
|
+
if (work.order.requirements.length)
|
|
291
|
+
assertRequirements(work.order, this.repoPath(repo));
|
|
292
|
+
await git(this.repoPath(repo), 'merge-base', '--is-ancestor', a.integrationSha, repo.targetRef);
|
|
293
|
+
const adopted = this.engine.adopt(a.id, this.owner);
|
|
294
|
+
this.engine.finish(a.id, adopted.token, a.integrationSha);
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
this.engine.rejectRecovery(a.id, error instanceof Error ? error.message : String(error));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
this.engine.expire();
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Окружение фазы: политика, репозиторий и — для вызова runtime — профиль
|
|
304
|
+
* инструментов его назначения. Недостающий секрет — отказ окружения до
|
|
305
|
+
* вызова, а не запуск без него.
|
|
306
|
+
*/
|
|
307
|
+
environment(attempt, phase, cwd, profile) {
|
|
308
|
+
const repo = this.repo(attempt.repositoryId);
|
|
309
|
+
const tools = profile ? this.policy.toolProfiles[profile] : undefined;
|
|
310
|
+
if (profile && !tools)
|
|
311
|
+
throw new BlockedError('Неизвестный профиль инструментов: ' + profile);
|
|
312
|
+
try {
|
|
313
|
+
return this.environmentOf(attempt, phase, cwd, [
|
|
314
|
+
this.policy.environment,
|
|
315
|
+
repo.environment,
|
|
316
|
+
tools?.environment,
|
|
317
|
+
// MCP-серверы профиля получают свои переменные по имени.
|
|
318
|
+
...(tools ? [mcpEnvironment(tools)] : []),
|
|
319
|
+
]);
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
throw new BlockedError(error instanceof Error ? error.message : String(error));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
environmentOf(attempt, phase, cwd, profiles, repositoryId = attempt.repositoryId, dependencies = attempt.dependencies ?? []) {
|
|
326
|
+
const repo = this.repo(repositoryId);
|
|
327
|
+
return executionEnvironment(profiles, {
|
|
328
|
+
DEVCONTOUR_REPOSITORY_ID: repo.id,
|
|
329
|
+
DEVCONTOUR_RUN_ID: attempt.id,
|
|
330
|
+
DEVCONTOUR_TASK_ID: attempt.workId,
|
|
331
|
+
DEVCONTOUR_PHASE: phase,
|
|
332
|
+
DEVCONTOUR_RESOURCES_JSON: JSON.stringify(attempt.resources ?? []),
|
|
333
|
+
DEVCONTOUR_DEPENDENCIES_JSON: JSON.stringify(dependencies),
|
|
334
|
+
// Компоненты попытки: закреплённые зависимости и своё рабочее дерево.
|
|
335
|
+
DEVCONTOUR_COMPONENTS_JSON: JSON.stringify({
|
|
336
|
+
...Object.fromEntries(dependencies.map((d) => [d.repositoryId, d.path])),
|
|
337
|
+
[repo.id]: cwd,
|
|
338
|
+
}),
|
|
339
|
+
}, this.options.environment ?? process.env);
|
|
340
|
+
}
|
|
341
|
+
async call(adapter, input, signal) {
|
|
342
|
+
const review = input.task !== 'implement';
|
|
343
|
+
const work = await this.workOf(input.attempt);
|
|
344
|
+
const role = roleBinding(this.policy, work.order.role, work.order.repositoryId);
|
|
345
|
+
// Профиль reviewer — того назначения, которое выбрано для этой попытки
|
|
346
|
+
// (основной или запасной reviewer правила).
|
|
347
|
+
const chosen = selectReviewer(this.policy, 'code', { runtime: input.attempt.runtime }, work.order.role, work.order.repositoryId);
|
|
348
|
+
const reviewerProfile = chosen.ok ? chosen.reviewer.toolProfile : undefined;
|
|
349
|
+
const binding = review
|
|
350
|
+
? {
|
|
351
|
+
runtime: input.attempt.reviewer,
|
|
352
|
+
...(input.attempt.reviewerModel ? { model: input.attempt.reviewerModel } : {}),
|
|
353
|
+
...(input.attempt.reviewerEffort
|
|
354
|
+
? { effort: input.attempt.reviewerEffort }
|
|
355
|
+
: {}),
|
|
356
|
+
...(reviewerProfile ? { toolProfile: reviewerProfile } : {}),
|
|
357
|
+
}
|
|
358
|
+
: role.writer;
|
|
359
|
+
// Окружение вызова — с профилем инструментов его назначения.
|
|
360
|
+
const execution = this.environment(input.attempt, input.phase, input.cwd, binding.toolProfile);
|
|
361
|
+
const boundary = this.boundary(input.attempt.dependencies);
|
|
362
|
+
// Вызов под учётом: бюджет проверяется до вызова, расход закрепляется за попыткой.
|
|
363
|
+
const response = await measuredInvoke(this.engine, adapter, {
|
|
364
|
+
callId: randomUUID(),
|
|
365
|
+
task: input.task,
|
|
366
|
+
subject: {
|
|
367
|
+
workId: input.attempt.workId,
|
|
368
|
+
attemptId: input.attempt.id,
|
|
369
|
+
phase: input.phase,
|
|
370
|
+
},
|
|
371
|
+
binding,
|
|
372
|
+
cwd: input.cwd,
|
|
373
|
+
prompt: input.prompt,
|
|
374
|
+
outputSchema: review ? reviewOutputSchema : implementationOutputSchema,
|
|
375
|
+
session: { kind: 'fresh' },
|
|
376
|
+
sandbox: {
|
|
377
|
+
mode: this.policy.isolation.mode,
|
|
378
|
+
writable: review ? [] : [input.cwd],
|
|
379
|
+
denied: boundary.hidden,
|
|
380
|
+
readable: [
|
|
381
|
+
input.cwd,
|
|
382
|
+
...boundary.readable,
|
|
383
|
+
// Самопроверка исполнителя: план и полный корпус вне worktree.
|
|
384
|
+
...(!review && this.selfChecks.has(input.attempt.id)
|
|
385
|
+
? [this.selfChecks.get(input.attempt.id).dir]
|
|
386
|
+
: []),
|
|
387
|
+
],
|
|
388
|
+
network: { domains: this.policy.isolation.domains },
|
|
389
|
+
},
|
|
390
|
+
timeoutMs: Math.min(this.policy.runTimeoutMs, input.timeoutMs ?? Infinity),
|
|
391
|
+
env: Object.fromEntries(Object.entries(execution.env).filter((e) => e[1] !== undefined)),
|
|
392
|
+
artifactDir: input.dir,
|
|
393
|
+
secrets: [...(execution.redact.secrets ?? [])],
|
|
394
|
+
...(review && input.probe ? { probe: input.probe } : {}),
|
|
395
|
+
...(review && input.findings ? { findings: input.findings } : {}),
|
|
396
|
+
}, signal, {
|
|
397
|
+
repositoryId: input.attempt.repositoryId,
|
|
398
|
+
runId: input.attempt.id,
|
|
399
|
+
stage: review ? input.phase + '-review' : 'implementation',
|
|
400
|
+
});
|
|
401
|
+
if (response.model && !review && response.model !== input.attempt.model)
|
|
402
|
+
this.engine.update(input.attempt.id, input.attempt.token, 'run.model', {
|
|
403
|
+
model: response.model,
|
|
404
|
+
});
|
|
405
|
+
if (response.status === 'unavailable')
|
|
406
|
+
throw new BlockedError(`${adapter.runtime}: runtime недоступен${response.diagnostics.reason ? ': ' + response.diagnostics.reason : ''}`);
|
|
407
|
+
if (response.status === 'timeout')
|
|
408
|
+
throw new TaskFailure('timeout', `${adapter.runtime}: исчерпан лимит времени прогона`);
|
|
409
|
+
if (response.status === 'cancelled')
|
|
410
|
+
throw new Error(`${adapter.runtime}: прогон прерван`);
|
|
411
|
+
// Runtime завершился штатно, но его структурированный ответ не прочитан
|
|
412
|
+
// (не JSON): у reviewer это ошибка формата ответа — протокол ревью, а не
|
|
413
|
+
// содержательный отказ и не отказ runtime (docs/review-protocol.md).
|
|
414
|
+
if (review &&
|
|
415
|
+
response.status === 'failed' &&
|
|
416
|
+
response.diagnostics.exitCode === 0 &&
|
|
417
|
+
response.diagnostics.reason?.startsWith('структурированный ответ не прочитан'))
|
|
418
|
+
return {
|
|
419
|
+
...response,
|
|
420
|
+
status: 'ok',
|
|
421
|
+
output: undefined,
|
|
422
|
+
unreadable: response.diagnostics.reason,
|
|
423
|
+
redact: execution.redact,
|
|
424
|
+
};
|
|
425
|
+
if (response.status === 'failed') {
|
|
426
|
+
const message = `${adapter.runtime}: runtime завершился отказом${response.diagnostics.reason ? ': ' + response.diagnostics.reason : ''}`;
|
|
427
|
+
throw new TaskFailure(classifyFailure(undefined, message) === 'unknown'
|
|
428
|
+
? review
|
|
429
|
+
? 'review'
|
|
430
|
+
: 'unknown'
|
|
431
|
+
: classifyFailure(undefined, message), message);
|
|
432
|
+
}
|
|
433
|
+
// Маска — полным окружением вызова, включая секреты его профиля.
|
|
434
|
+
return { ...response, redact: execution.redact };
|
|
435
|
+
}
|
|
436
|
+
workOf(attempt) {
|
|
437
|
+
return Promise.resolve(this.engine.read((v) => v.work(attempt.workId)));
|
|
438
|
+
}
|
|
439
|
+
history(attempt) {
|
|
440
|
+
return this.engine.read((v) => v.attempts(attempt.workId));
|
|
441
|
+
}
|
|
442
|
+
/** Поддержка протокола переходов находок адаптером reviewer, по runtime. */
|
|
443
|
+
protocols = new Map();
|
|
444
|
+
/**
|
|
445
|
+
* Адаптер передаёт reviewer протокол переходов находок
|
|
446
|
+
* (`RuntimeCapabilities.reviewProtocol`, docs/review-protocol.md). Читается
|
|
447
|
+
* асинхронно до вызова, вне транзакций хранилища; адаптер без поля (или с
|
|
448
|
+
* отказом capabilities) — прежняя семантика alpha.5.
|
|
449
|
+
*/
|
|
450
|
+
reviewProtocol(runtime) {
|
|
451
|
+
let known = this.protocols.get(runtime);
|
|
452
|
+
if (!known) {
|
|
453
|
+
const adapter = this.options.adapters[runtime];
|
|
454
|
+
known = adapter
|
|
455
|
+
? adapter.capabilities().then((c) => c.reviewProtocol === 1, () => false)
|
|
456
|
+
: Promise.resolve(false);
|
|
457
|
+
this.protocols.set(runtime, known);
|
|
458
|
+
}
|
|
459
|
+
return known;
|
|
460
|
+
}
|
|
461
|
+
adapter(runtime) {
|
|
462
|
+
const adapter = this.options.adapters[runtime];
|
|
463
|
+
if (!adapter)
|
|
464
|
+
throw new BlockedError(`Runtime ${runtime} не подключён`);
|
|
465
|
+
return adapter;
|
|
466
|
+
}
|
|
467
|
+
/** Область записи кандидата: защищённые пути, пустой результат, выход за область поручения и роли. */
|
|
468
|
+
async assertScope(work, repo, cwd, context, range) {
|
|
469
|
+
const changed = (await (range
|
|
470
|
+
? git(cwd, 'diff', '--no-renames', '--name-only', '-z', ...range)
|
|
471
|
+
: git(cwd, 'diff', '--cached', '--no-renames', '--name-only', '-z')))
|
|
472
|
+
.split('\0')
|
|
473
|
+
.filter(Boolean);
|
|
474
|
+
// Закреплённый контекст из этого репозитория — норма, а не результат работы:
|
|
475
|
+
// исполнитель, правящий его, подгонял бы свои же правила.
|
|
476
|
+
const pinnedContext = (context?.sources ?? [])
|
|
477
|
+
.filter((source) => source.repositoryId === repo.id)
|
|
478
|
+
.flatMap((source) => [...source.files, ...source.references].map((f) => f.path));
|
|
479
|
+
// Закреплённые checker/oracle/generator контрактов поручения: попытка, которая
|
|
480
|
+
// правит эталон, подгоняла бы проверку под себя.
|
|
481
|
+
const checkers = this.engine
|
|
482
|
+
.read((v) => contractDocuments(work.order, v))
|
|
483
|
+
.filter((c) => (c.repositoryId ?? work.order.repositoryId) === repo.id)
|
|
484
|
+
.flatMap((c) => (c.artifacts ?? []).map((a) => a.path));
|
|
485
|
+
const protectedList = [
|
|
486
|
+
...repo.protectedPaths,
|
|
487
|
+
...repo.generatedPaths,
|
|
488
|
+
...pinnedContext,
|
|
489
|
+
...checkers,
|
|
490
|
+
];
|
|
491
|
+
const forbidden = changed.filter((p) => within(p, protectedList));
|
|
492
|
+
if (forbidden.length)
|
|
493
|
+
throw new TaskFailure('scope-violation', `Изменены защищённые файлы: ${forbidden.join(', ')}`);
|
|
494
|
+
if (!changed.length)
|
|
495
|
+
throw new TaskFailure('empty-result', 'Изменений нет: поручение отчиталось о выполнении пустым коммитом');
|
|
496
|
+
const role = roleBinding(this.policy, work.order.role, work.order.repositoryId)?.writePaths;
|
|
497
|
+
const outside = changed.filter((p) => !within(p, work.order.writeScope) || (role && !within(p, role)));
|
|
498
|
+
if (outside.length)
|
|
499
|
+
throw new TaskFailure('scope-violation', 'Изменены файлы вне области поручения/роли: ' + outside.join(', '));
|
|
500
|
+
}
|
|
501
|
+
/** Стадия попытки с замером: начало, конец и исход записываются под fencing. */
|
|
502
|
+
async timed(attempt, stage, fn) {
|
|
503
|
+
const id = this.engine.timingStarted(attempt.id, attempt.token, stage);
|
|
504
|
+
try {
|
|
505
|
+
const value = await fn();
|
|
506
|
+
this.engine.timingFinished(attempt.id, attempt.token, id, 'passed');
|
|
507
|
+
return value;
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
try {
|
|
511
|
+
this.engine.timingFinished(attempt.id, attempt.token, id, 'failed');
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
/* Утраченное владение не публикует поздний замер. */
|
|
515
|
+
}
|
|
516
|
+
throw error;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Кандидат предыдущей попытки вместо новой реализации. Движок уже сверил
|
|
521
|
+
* основания; здесь проверяется сам коммит: ровно один поверх той же базы,
|
|
522
|
+
* рабочее дерево чистое, область прежняя. Проверки и ревью затем идут
|
|
523
|
+
* заново — переиспользуется только написанный код.
|
|
524
|
+
*/
|
|
525
|
+
async reuseCandidate(attempt, work, repo, cwd, base, context) {
|
|
526
|
+
const sha = await git(cwd, 'rev-parse', 'HEAD');
|
|
527
|
+
const parents = (await git(cwd, 'rev-list', '--parents', '-n', '1', sha)).split(' ').slice(1);
|
|
528
|
+
if (sha !== attempt.reusedFrom.candidateSha || parents.length !== 1 || parents[0] !== base)
|
|
529
|
+
throw new TaskFailure('unknown', `Кандидат ${attempt.reusedFrom.candidateSha} не является одним коммитом поверх ${base}`);
|
|
530
|
+
if (await git(cwd, 'status', '--porcelain'))
|
|
531
|
+
throw new TaskFailure('unknown', 'Рабочее дерево взятого кандидата не чистое');
|
|
532
|
+
await this.assertScope(work, repo, cwd, context, [base, sha]);
|
|
533
|
+
return sha;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Время реализации кончилось раньше работы, или она оборвалась. Изменения
|
|
537
|
+
* в области поручения сохраняются патчем к базе: следующая попытка начнёт с
|
|
538
|
+
* них, а не с пустого дерева. Не удалось сохранить — попытка просто
|
|
539
|
+
* начнётся заново.
|
|
540
|
+
*/
|
|
541
|
+
async keepDraft(attempt, work, artifacts) {
|
|
542
|
+
if (attempt.candidateSha || !attempt.worktree || !attempt.baseSha || !work.approvedDigest)
|
|
543
|
+
return;
|
|
544
|
+
try {
|
|
545
|
+
const dir = join(artifacts, 'implementation');
|
|
546
|
+
await mkdir(dir, { recursive: true });
|
|
547
|
+
const patch = join(dir, 'partial.patch');
|
|
548
|
+
// Только в области записи поручения: в дереве бывают и посторонние
|
|
549
|
+
// неотслеживаемые каталоги — кеш менеджера пакетов, отчёты.
|
|
550
|
+
const scope = ['--', ...work.order.writeScope];
|
|
551
|
+
await git(attempt.worktree, 'add', '-A', ...scope);
|
|
552
|
+
await git(attempt.worktree, 'diff', '--cached', '--binary', `--output=${patch}`, attempt.baseSha, ...scope);
|
|
553
|
+
const text = await readFile(patch, 'utf8');
|
|
554
|
+
if (!text.trim() || text.length > 5_000_000)
|
|
555
|
+
return;
|
|
556
|
+
const files = (await git(attempt.worktree, 'diff', '--cached', '--name-only', attempt.baseSha, ...scope))
|
|
557
|
+
.split('\n')
|
|
558
|
+
.filter(Boolean);
|
|
559
|
+
this.engine.recordPartial(attempt.id, attempt.token, {
|
|
560
|
+
patch,
|
|
561
|
+
digest: sha256(text),
|
|
562
|
+
files,
|
|
563
|
+
spec: work.approvedDigest,
|
|
564
|
+
contracts: Object.fromEntries(work.order.contracts.map((c) => [c.id, c.digest])),
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
/* Черновик — удобство, а не условие: без него попытка начнётся с нуля. */
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
/** Накладывает черновик предыдущей попытки, если он есть и годится. */
|
|
572
|
+
async continueDraft(attempt, work, repoPath, cwd) {
|
|
573
|
+
const draft = this.engine.read((v) => continuableDraft(v, work, attempt.id));
|
|
574
|
+
if (!draft)
|
|
575
|
+
return attempt;
|
|
576
|
+
const dir = join(this.dirOf(attempt.repositoryId), 'artifacts', attempt.id, 'implementation');
|
|
577
|
+
await mkdir(dir, { recursive: true });
|
|
578
|
+
const scope = ['--', ...work.order.writeScope];
|
|
579
|
+
let applied = false;
|
|
580
|
+
let files = [];
|
|
581
|
+
try {
|
|
582
|
+
let patch;
|
|
583
|
+
if (draft.kind === 'unfinished') {
|
|
584
|
+
patch = draft.patch;
|
|
585
|
+
files = draft.files;
|
|
586
|
+
// Черновик подменённый или повреждённый не накладывается.
|
|
587
|
+
if (sha256(await readFile(patch, 'utf8')) !== draft.digest)
|
|
588
|
+
throw new Error('digest');
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
// Кандидат лежит коммитом в репозитории; его изменения относительно
|
|
592
|
+
// своей базы переносятся на текущую.
|
|
593
|
+
patch = join(dir, 'rejected-candidate.patch');
|
|
594
|
+
await git(repoPath, 'diff', '--binary', `--output=${patch}`, draft.baseSha, draft.candidateSha, ...scope);
|
|
595
|
+
if (!(await readFile(patch, 'utf8')).trim())
|
|
596
|
+
throw new Error('empty');
|
|
597
|
+
files = (await git(repoPath, 'diff', '--name-only', draft.baseSha, draft.candidateSha, ...scope))
|
|
598
|
+
.split('\n')
|
|
599
|
+
.filter(Boolean);
|
|
600
|
+
}
|
|
601
|
+
await git(cwd, 'apply', '--3way', '--whitespace=nowarn', patch);
|
|
602
|
+
// --3way кладёт изменения в индекс; исполнитель видит их как свою
|
|
603
|
+
// незакоммиченную работу.
|
|
604
|
+
await git(cwd, 'reset', '-q');
|
|
605
|
+
applied = true;
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
await git(cwd, 'reset', '-q', '--hard').catch(() => undefined);
|
|
609
|
+
await git(cwd, 'clean', '-fdq').catch(() => undefined);
|
|
610
|
+
}
|
|
611
|
+
const continuedFrom = {
|
|
612
|
+
runId: draft.runId,
|
|
613
|
+
kind: draft.kind,
|
|
614
|
+
reason: draft.reason,
|
|
615
|
+
...(draft.respecified ? { respecified: true } : {}),
|
|
616
|
+
applied,
|
|
617
|
+
files,
|
|
618
|
+
};
|
|
619
|
+
await writeFile(join(dir, 'continuation.json'), JSON.stringify(continuedFrom, null, 2));
|
|
620
|
+
this.engine.continuation(attempt.id, attempt.token, continuedFrom);
|
|
621
|
+
return { ...attempt, continuedFrom };
|
|
622
|
+
}
|
|
623
|
+
async execute(claimed, controller) {
|
|
624
|
+
let attempt = claimed;
|
|
625
|
+
const repo = this.repo(attempt.repositoryId);
|
|
626
|
+
const repoPath = this.repoPath(repo);
|
|
627
|
+
let timeout;
|
|
628
|
+
const rearm = () => {
|
|
629
|
+
clearTimeout(timeout);
|
|
630
|
+
timeout = setTimeout(() => controller.abort(new DOMException('Исчерпан лимит времени прогона', 'TimeoutError')), this.policy.runTimeoutMs);
|
|
631
|
+
};
|
|
632
|
+
rearm();
|
|
633
|
+
let heartbeatCause;
|
|
634
|
+
const heartbeat = setInterval(() => {
|
|
635
|
+
try {
|
|
636
|
+
this.engine.heartbeat(attempt.id, attempt.token);
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
640
|
+
heartbeatCause ??= reason;
|
|
641
|
+
try {
|
|
642
|
+
this.engine.heartbeatFailed(attempt.id, attempt.token, reason);
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
/* Хранилище недоступно: причина останется в отказе. */
|
|
646
|
+
}
|
|
647
|
+
controller.abort();
|
|
648
|
+
}
|
|
649
|
+
}, Math.max(1000, this.policy.leaseMs / 3));
|
|
650
|
+
const cwd = join(this.dirOf(repo.id), 'worktrees', attempt.id);
|
|
651
|
+
const integrationCwd = cwd + '-integration';
|
|
652
|
+
const artifacts = join(this.dirOf(repo.id), 'artifacts', attempt.id);
|
|
653
|
+
const worktrees = [];
|
|
654
|
+
const snapshots = [];
|
|
655
|
+
let preserve = false;
|
|
656
|
+
try {
|
|
657
|
+
// Ресурсы поручения, gates его репозитория и выбранных проверок
|
|
658
|
+
// потребителей — одним резервированием на всю попытку.
|
|
659
|
+
const reserve = [
|
|
660
|
+
...(claimed.workId ? ((await this.workOf(attempt)).order.resources ?? []) : []),
|
|
661
|
+
...repo.gates.flatMap((g) => g.resources ?? []),
|
|
662
|
+
...(claimed.consumers ?? []).flatMap((c) => this.repo(c.repositoryId)
|
|
663
|
+
.gates.filter((g) => c.gates.includes(g.id))
|
|
664
|
+
.flatMap((g) => g.resources ?? [])),
|
|
665
|
+
];
|
|
666
|
+
await withResources({
|
|
667
|
+
resources: this.policy.resources,
|
|
668
|
+
leaseMs: this.policy.leaseMs,
|
|
669
|
+
resourceDatabase: this.options.resourceDatabase ?? defaultResourceDatabase(),
|
|
670
|
+
}, [...new Set(reserve)], `${this.options.stateDir}:${attempt.id}`, controller.signal, async (signal, resources) => {
|
|
671
|
+
if (resources.length)
|
|
672
|
+
this.engine.update(attempt.id, attempt.token, 'run.context', { resources });
|
|
673
|
+
attempt = { ...attempt, resources };
|
|
674
|
+
const work = await this.workOf(attempt);
|
|
675
|
+
const context = await this.options.context?.(work);
|
|
676
|
+
if (context)
|
|
677
|
+
// Попытка закрепляет снимок контекста: writer и оба reviewer видят одни и те же байты.
|
|
678
|
+
this.engine.update(attempt.id, attempt.token, 'run.context', {
|
|
679
|
+
contextSnapshot: contextDigest(context),
|
|
680
|
+
context: context.sources.map((s) => ({
|
|
681
|
+
id: s.id,
|
|
682
|
+
version: s.version,
|
|
683
|
+
repositoryId: s.repositoryId ?? work.order.repositoryId,
|
|
684
|
+
revision: s.revision ?? s.digest,
|
|
685
|
+
digest: s.hostDigest ?? s.digest,
|
|
686
|
+
})),
|
|
687
|
+
});
|
|
688
|
+
// Обязательный контекст поручения должен быть закреплён до работы:
|
|
689
|
+
// без него исполнение не начинается (отказ окружения).
|
|
690
|
+
const missing = work.order.context.filter((id) => !context?.sources.some((s) => s.id === id));
|
|
691
|
+
if (missing.length)
|
|
692
|
+
throw new BlockedError('Обязательный контекст не закреплён: ' + missing.join(', '));
|
|
693
|
+
// Постановка по устаревшему источнику требований не исполняется.
|
|
694
|
+
if (work.order.requirements.length)
|
|
695
|
+
assertRequirements(work.order, repoPath);
|
|
696
|
+
// Снимок зависимостей репозитория — до реализации: writer, gates и
|
|
697
|
+
// reviewer видят одни и те же закреплённые деревья и artifacts.
|
|
698
|
+
if (repo.dependsOn.length) {
|
|
699
|
+
const dependencies = await snapshotDependencies({
|
|
700
|
+
repos: this.repositories(),
|
|
701
|
+
repositoryId: repo.id,
|
|
702
|
+
dir: join(this.dirOf(repo.id), 'dependencies', attempt.id),
|
|
703
|
+
artifacts,
|
|
704
|
+
runId: attempt.id,
|
|
705
|
+
policy: this.policy,
|
|
706
|
+
environment: this.options.environment ?? process.env,
|
|
707
|
+
signal,
|
|
708
|
+
predecessors: this.engine.read((v) => work.order.dependsOn.flatMap((id) => {
|
|
709
|
+
const w = v.work(id);
|
|
710
|
+
return w?.resultSha
|
|
711
|
+
? [{ repositoryId: w.order.repositoryId, resultSha: w.resultSha }]
|
|
712
|
+
: [];
|
|
713
|
+
})),
|
|
714
|
+
});
|
|
715
|
+
snapshots.push(...dependencies);
|
|
716
|
+
this.engine.dependencies(attempt.id, attempt.token, dependencies);
|
|
717
|
+
attempt = { ...attempt, dependencies };
|
|
718
|
+
}
|
|
719
|
+
const base = await git(repoPath, 'rev-parse', repo.targetRef);
|
|
720
|
+
await this.options.hooks?.prepared?.({ work, attempt, baseSha: base });
|
|
721
|
+
// Основания записываются до реализации; в той же транзакции решается,
|
|
722
|
+
// берётся ли кандидат предыдущей попытки вместо нового кода.
|
|
723
|
+
const reuse = this.engine.implementationBasis(attempt.id, attempt.token, implementationBasis(work, attempt, base, this.engine.version));
|
|
724
|
+
attempt = { ...attempt, ...(reuse.reusedFrom ? { reusedFrom: reuse.reusedFrom } : {}) };
|
|
725
|
+
await mkdir(join(this.dirOf(repo.id), 'worktrees'), { recursive: true });
|
|
726
|
+
await git(repoPath, 'worktree', 'add', '-b', `${this.options.branchPrefix ?? 'harness/run-'}${attempt.id}`, cwd, reuse.reusedFrom?.candidateSha ?? base);
|
|
727
|
+
worktrees.push(cwd);
|
|
728
|
+
this.engine.phase(attempt.id, attempt.token, 'running', { baseSha: base, worktree: cwd });
|
|
729
|
+
attempt = { ...attempt, baseSha: base, worktree: cwd };
|
|
730
|
+
const gates = ordered(repo.gates, attempt.requiredGates ?? repo.gates.map((g) => g.id));
|
|
731
|
+
const execution = this.environment(attempt, 'candidate', cwd);
|
|
732
|
+
const candidate = await withEnvironment(repo.lifecycle ?? this.policy.lifecycle, cwd, join(artifacts, 'candidate', 'environment'), execution, signal, async () => {
|
|
733
|
+
await runSteps(repo.prepare, cwd, join(artifacts, 'candidate', 'prepare'), execution, signal);
|
|
734
|
+
const sha = attempt.reusedFrom
|
|
735
|
+
? await this.timed(attempt, 'implementation-reuse', () => this.reuseCandidate(attempt, work, repo, cwd, base, context))
|
|
736
|
+
: await this.timed(attempt, 'implementation', async () => {
|
|
737
|
+
attempt = await this.continueDraft(attempt, work, repoPath, cwd);
|
|
738
|
+
const dir = join(artifacts, 'implementation');
|
|
739
|
+
await mkdir(dir, { recursive: true });
|
|
740
|
+
await writeFile(join(dir, 'work.json'), JSON.stringify(work.order, null, 2));
|
|
741
|
+
// Закреплённый снимок контекста — тем же байтам, что видят writer и reviewer.
|
|
742
|
+
if (context)
|
|
743
|
+
await writeFile(join(dir, 'context.json'), JSON.stringify(context, null, 2));
|
|
744
|
+
await this.prepareSelfCheck(attempt, repo, join(artifacts, 'self-check'));
|
|
745
|
+
const response = await this.call(this.adapter(attempt.runtime), {
|
|
746
|
+
task: 'implement',
|
|
747
|
+
attempt,
|
|
748
|
+
cwd,
|
|
749
|
+
dir,
|
|
750
|
+
phase: 'candidate',
|
|
751
|
+
prompt: prompt({
|
|
752
|
+
work,
|
|
753
|
+
attempt,
|
|
754
|
+
history: this.history(attempt),
|
|
755
|
+
context,
|
|
756
|
+
gates: gates.map((g) => ({ id: g.id, command: g.command })),
|
|
757
|
+
notes: [
|
|
758
|
+
...this.notes(attempt, false),
|
|
759
|
+
...(await this.hostNotes(work, attempt, false)),
|
|
760
|
+
],
|
|
761
|
+
// Открытые находки ревью с выданными ID — из истории
|
|
762
|
+
// поручения; только когда reviewer ведёт протокол
|
|
763
|
+
// переходов (иначе их никто не закроет).
|
|
764
|
+
...((await this.reviewProtocol(attempt.reviewer))
|
|
765
|
+
? {
|
|
766
|
+
findings: this.engine.read((v) => codeReviewHistory(v.attempts(attempt.workId), codeReviewScope(attempt.repositoryId, attempt.workId))),
|
|
767
|
+
}
|
|
768
|
+
: {}),
|
|
769
|
+
}),
|
|
770
|
+
}, signal);
|
|
771
|
+
const result = implementationResultSchema.safeParse(response.output);
|
|
772
|
+
if (!result.success)
|
|
773
|
+
throw new Error('Ответ исполнителя вне схемы: ' + result.error.issues[0]?.message);
|
|
774
|
+
if (!result.data.completed)
|
|
775
|
+
throw new Error('Исполнитель сообщил о незавершённой работе');
|
|
776
|
+
this.engine.heartbeat(attempt.id, attempt.token);
|
|
777
|
+
signal.throwIfAborted();
|
|
778
|
+
if ((await git(cwd, 'rev-parse', 'HEAD')) !== base)
|
|
779
|
+
throw new TaskFailure('scope-violation', 'Исполнитель изменил HEAD; интеграцией владеет harness');
|
|
780
|
+
await git(cwd, 'add', '-A');
|
|
781
|
+
await this.assertScope(work, repo, cwd, context);
|
|
782
|
+
await git(cwd, '-c', 'core.hooksPath=/dev/null', 'commit', '--no-gpg-sign', '-q', '-m', `${work.id}: ${work.order.title}`);
|
|
783
|
+
const committed = await git(cwd, 'rev-parse', 'HEAD');
|
|
784
|
+
await this.options.hooks?.implemented?.({
|
|
785
|
+
attempt,
|
|
786
|
+
output: result.data,
|
|
787
|
+
sha: committed,
|
|
788
|
+
});
|
|
789
|
+
return committed;
|
|
790
|
+
});
|
|
791
|
+
rearm();
|
|
792
|
+
this.engine.phase(attempt.id, attempt.token, 'verifying', { candidateSha: sha });
|
|
793
|
+
for (const gate of gates)
|
|
794
|
+
await this.gate(attempt, cwd, sha, 'candidate', gate, artifacts, signal, execution);
|
|
795
|
+
this.requirements(attempt, work, repoPath, cwd, sha, 'candidate');
|
|
796
|
+
rearm();
|
|
797
|
+
this.engine.phase(attempt.id, attempt.token, 'reviewing');
|
|
798
|
+
await this.review(attempt, work, cwd, base, sha, 'candidate', artifacts, signal, context);
|
|
799
|
+
return sha;
|
|
800
|
+
}, (stage, action) => this.timed(attempt, 'candidate-environment:' + stage, action));
|
|
801
|
+
await assertDependencies(attempt.dependencies ?? []);
|
|
802
|
+
attempt = { ...attempt, candidateSha: candidate };
|
|
803
|
+
const previous = this.mergeTails.get(repo.id) ?? Promise.resolve();
|
|
804
|
+
let unlock;
|
|
805
|
+
this.mergeTails.set(repo.id, new Promise((r) => (unlock = r)));
|
|
806
|
+
await this.timed(attempt, 'integration-wait', () => previous);
|
|
807
|
+
try {
|
|
808
|
+
rearm();
|
|
809
|
+
await this.integrate(attempt, work, repo, integrationCwd, worktrees, artifacts, signal, context, rearm);
|
|
810
|
+
}
|
|
811
|
+
finally {
|
|
812
|
+
unlock();
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
catch (error) {
|
|
817
|
+
// Незавершённая очистка окружения: рабочие деревья и снимки — входы её
|
|
818
|
+
// повторения (`environment.json` ссылается на них), их нельзя убирать.
|
|
819
|
+
preserve = error instanceof CleanupFailure;
|
|
820
|
+
// Прерывание по лимиту времени называется лимитом времени, что бы ни выбросила прерванная операция.
|
|
821
|
+
const expired = controller.signal.aborted &&
|
|
822
|
+
controller.signal.reason?.name === 'TimeoutError';
|
|
823
|
+
const message = expired && !(error instanceof TaskFailure)
|
|
824
|
+
? `Исчерпан лимит времени попытки (${Math.round(this.policy.runTimeoutMs / 1000)} с)`
|
|
825
|
+
: error instanceof Error
|
|
826
|
+
? error.message
|
|
827
|
+
: String(error);
|
|
828
|
+
try {
|
|
829
|
+
const kind = expired && !(error instanceof TaskFailure) ? 'timeout' : classifyFailure(error, message);
|
|
830
|
+
// Незакоммиченная работа сохраняется при любом отказе до кандидата:
|
|
831
|
+
// по времени, «не закончил», обрыв. Иначе повтор начинал с пустого
|
|
832
|
+
// дерева и тратил попытку на сделанное.
|
|
833
|
+
if (kind !== 'provider-auth')
|
|
834
|
+
await this.keepDraft(attempt, await this.workOf(attempt), artifacts);
|
|
835
|
+
// Пример reviewer — гипотеза; эталон решает, станет ли он обязательным:
|
|
836
|
+
// подтверждение идёт до записи отказа, и принятие новой версии корпуса
|
|
837
|
+
// ждёт окончания этой попытки. В operator mode подтверждение — ручное.
|
|
838
|
+
if (kind === 'review' && this.policy.approvalMode === 'agent')
|
|
839
|
+
await this.confirmReproductions(attempt).catch(() => undefined);
|
|
840
|
+
this.engine.fail(attempt.id, attempt.token, message, {
|
|
841
|
+
...(error instanceof TaskFailure && error.properties
|
|
842
|
+
? { properties: error.properties }
|
|
843
|
+
: {}),
|
|
844
|
+
kind,
|
|
845
|
+
blocked: (error instanceof BlockedError || kind === 'provider-auth') && !attempt.candidateSha,
|
|
846
|
+
...(heartbeatCause ? { heartbeatCause } : {}),
|
|
847
|
+
});
|
|
848
|
+
await this.options.hooks?.failed?.({ attempt, kind, message });
|
|
849
|
+
}
|
|
850
|
+
catch {
|
|
851
|
+
// Отменённая или потерявшая владение попытка не публикует поздний отказ.
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
finally {
|
|
855
|
+
clearTimeout(timeout);
|
|
856
|
+
clearInterval(heartbeat);
|
|
857
|
+
this.selfChecks.delete(attempt.id);
|
|
858
|
+
// Рабочие деревья попытки убираются; ветка попытки и объекты остаются —
|
|
859
|
+
// по ним recovery и следующая попытка видят кандидата. Снимки
|
|
860
|
+
// зависимостей: SHA, tree и artifacts записаны в попытке. Что убрать не
|
|
861
|
+
// удалось или нельзя (незавершённая очистка), записывается для
|
|
862
|
+
// `finishCleanup` — ошибки удаления не теряются.
|
|
863
|
+
const retained = this.retained.get(attempt.id) ?? { worktrees: [], snapshots: [] };
|
|
864
|
+
this.retained.delete(attempt.id);
|
|
865
|
+
const pending = {
|
|
866
|
+
worktrees: [
|
|
867
|
+
...worktrees.map((dir) => ({ checkout: repoPath, dir })),
|
|
868
|
+
...retained.worktrees,
|
|
869
|
+
],
|
|
870
|
+
snapshots: [...snapshots, ...retained.snapshots],
|
|
871
|
+
};
|
|
872
|
+
await this.removeTrees(artifacts, pending, preserve).catch(() => undefined);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Убрать рабочие деревья и снимки попытки. При незавершённой очистке
|
|
877
|
+
* (`keep`) ничего не убирается; оставшееся записывается в
|
|
878
|
+
* `cleanup-pending.json` артефактов попытки.
|
|
879
|
+
*/
|
|
880
|
+
async removeTrees(artifacts, pending, keep) {
|
|
881
|
+
const left = keep ? pending : { worktrees: [], snapshots: [] };
|
|
882
|
+
const errors = [];
|
|
883
|
+
if (!keep) {
|
|
884
|
+
for (const tree of pending.worktrees)
|
|
885
|
+
await git(tree.checkout, 'worktree', 'remove', '--force', tree.dir).catch((e) => {
|
|
886
|
+
if (!existsSync(tree.dir))
|
|
887
|
+
return;
|
|
888
|
+
left.worktrees.push(tree);
|
|
889
|
+
errors.push(`${tree.dir}: ${e instanceof Error ? e.message : String(e)}`);
|
|
890
|
+
});
|
|
891
|
+
errors.push(...(await removeSnapshots(this.repositories(), pending.snapshots)));
|
|
892
|
+
left.snapshots.push(...pending.snapshots.filter((d) => existsSync(d.path)));
|
|
893
|
+
}
|
|
894
|
+
const file = join(artifacts, 'cleanup-pending.json');
|
|
895
|
+
if (!left.worktrees.length && !left.snapshots.length)
|
|
896
|
+
return;
|
|
897
|
+
await mkdir(artifacts, { recursive: true });
|
|
898
|
+
await writeFile(file, JSON.stringify({ reason: keep ? 'cleanup-failed' : 'remove-failed', errors, ...left }, null, 2));
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Довести очистку завершённой попытки: повторить незавершённый teardown по
|
|
902
|
+
* каждому сохранённому `environment.json` (входы на месте), затем убрать
|
|
903
|
+
* рабочие деревья и снимки. Идемпотентно: без записи — `nothing-pending`;
|
|
904
|
+
* отказ повторной очистки оставляет запись и входы.
|
|
905
|
+
*/
|
|
906
|
+
async finishCleanup(attemptId) {
|
|
907
|
+
let attempt = this.engine.read((v) => v.attempt(attemptId));
|
|
908
|
+
if (!attempt)
|
|
909
|
+
throw new HarnessError('NOT_FOUND', 'Попытка не найдена: ' + attemptId);
|
|
910
|
+
// Попытка упавшего процесса: срок владения истёк, а recovery ещё не
|
|
911
|
+
// проходил. Истечение — то же, что сделал бы recovery; живую попытку
|
|
912
|
+
// (свой job или непросроченная аренда) очистка не трогает.
|
|
913
|
+
if (attempt.status === 'active' &&
|
|
914
|
+
!this.jobs.has(attemptId) &&
|
|
915
|
+
attempt.leaseUntil <= this.engine.clock()) {
|
|
916
|
+
this.engine.expire();
|
|
917
|
+
attempt = this.engine.read((v) => v.attempt(attemptId));
|
|
918
|
+
}
|
|
919
|
+
if (attempt.status === 'active' || this.jobs.has(attemptId))
|
|
920
|
+
throw new HarnessError('NOT_READY', 'Попытка ещё идёт: ' + attemptId);
|
|
921
|
+
const artifacts = join(this.dirOf(attempt.repositoryId), 'artifacts', attemptId);
|
|
922
|
+
const file = join(artifacts, 'cleanup-pending.json');
|
|
923
|
+
// Процесс, упавший при активном окружении, не успел записать
|
|
924
|
+
// cleanup-pending.json: его receipt остался `active`. Такое окружение
|
|
925
|
+
// очищается так же; cleanupEnvironment проверит, что владельца уже нет.
|
|
926
|
+
const interrupted = [];
|
|
927
|
+
for (const receipt of await receipts(artifacts)) {
|
|
928
|
+
const state = JSON.parse(await readFile(receipt, 'utf8'));
|
|
929
|
+
if (state.status === 'active')
|
|
930
|
+
interrupted.push({
|
|
931
|
+
receipt,
|
|
932
|
+
cwd: state.cwd,
|
|
933
|
+
repositoryId: state.devcontourEnv?.DEVCONTOUR_REPOSITORY_ID,
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
if (!existsSync(file) && !interrupted.length)
|
|
937
|
+
return { status: 'nothing-pending' };
|
|
938
|
+
const pending = existsSync(file)
|
|
939
|
+
? JSON.parse(await readFile(file, 'utf8'))
|
|
940
|
+
: {
|
|
941
|
+
reason: 'interrupted',
|
|
942
|
+
// Рабочие деревья прерванной попытки: каталоги её окружений и
|
|
943
|
+
// её собственные деревья candidate/integration, если остались.
|
|
944
|
+
worktrees: [
|
|
945
|
+
...new Map([
|
|
946
|
+
...interrupted.map((i) => ({
|
|
947
|
+
checkout: this.repoPath(this.repo(i.repositoryId ?? attempt.repositoryId)),
|
|
948
|
+
dir: i.cwd,
|
|
949
|
+
})),
|
|
950
|
+
...(attempt.worktree
|
|
951
|
+
? [attempt.worktree, attempt.worktree + '-integration']
|
|
952
|
+
: []).map((dir) => ({
|
|
953
|
+
checkout: this.repoPath(this.repo(attempt.repositoryId)),
|
|
954
|
+
dir,
|
|
955
|
+
})),
|
|
956
|
+
]
|
|
957
|
+
.filter((t) => existsSync(t.dir))
|
|
958
|
+
.map((t) => [t.dir, t])).values(),
|
|
959
|
+
],
|
|
960
|
+
snapshots: [],
|
|
961
|
+
};
|
|
962
|
+
const roots = [
|
|
963
|
+
...new Set([
|
|
964
|
+
await realpath(this.options.stateDir),
|
|
965
|
+
await realpath(this.dirOf(attempt.repositoryId)),
|
|
966
|
+
]),
|
|
967
|
+
];
|
|
968
|
+
const cleaned = [];
|
|
969
|
+
for (const receipt of await receipts(artifacts)) {
|
|
970
|
+
const state = JSON.parse(await readFile(receipt, 'utf8'));
|
|
971
|
+
if (state.status !== 'cleanup-failed' && state.status !== 'active')
|
|
972
|
+
continue;
|
|
973
|
+
// Владелец окружения — этот процесс, а попытка уже закончена.
|
|
974
|
+
await cleanupEnvironment(receipt, this.policy, roots, { owner: process.pid });
|
|
975
|
+
cleaned.push(receipt);
|
|
976
|
+
}
|
|
977
|
+
await rm(file, { force: true });
|
|
978
|
+
await this.removeTrees(artifacts, pending, false);
|
|
979
|
+
if (existsSync(file))
|
|
980
|
+
throw new HarnessError('INTERNAL', 'Не удалось убрать рабочие деревья попытки: ' +
|
|
981
|
+
JSON.parse(await readFile(file, 'utf8')).errors.join('; '));
|
|
982
|
+
return { status: 'cleaned', receipts: cleaned };
|
|
983
|
+
}
|
|
984
|
+
async gate(attempt, cwd, sha, phase, gate, artifacts, signal, execution, consumer) {
|
|
985
|
+
// Время проверок потребителя — отдельная стадия: в метриках области оно
|
|
986
|
+
// не смешивается с собственными gates поручения.
|
|
987
|
+
const stage = consumer
|
|
988
|
+
? `${phase}-consumer:${consumer.repositoryId}:${gate.id}`
|
|
989
|
+
: `${phase}-test:${gate.id}`;
|
|
990
|
+
await this.timed(attempt, stage, () => this.runOneGate(attempt, cwd, sha, phase, gate, artifacts, signal, execution, consumer));
|
|
991
|
+
}
|
|
992
|
+
async runOneGate(attempt, cwd, sha, phase, gate, artifacts, signal, execution, consumer) {
|
|
993
|
+
await runGate({
|
|
994
|
+
policy: this.policy,
|
|
995
|
+
record: (e) => this.engine.evidence(attempt.id, attempt.token, e),
|
|
996
|
+
cwd,
|
|
997
|
+
sha,
|
|
998
|
+
phase,
|
|
999
|
+
gate,
|
|
1000
|
+
artifactDir: consumer
|
|
1001
|
+
? join(artifacts, phase, 'consumer', consumer.repositoryId)
|
|
1002
|
+
: join(artifacts, phase),
|
|
1003
|
+
signal,
|
|
1004
|
+
execution,
|
|
1005
|
+
boundary: this.boundary(consumer?.dependencies ?? attempt.dependencies),
|
|
1006
|
+
...(consumer ? { consumer: consumer.repositoryId } : {}),
|
|
1007
|
+
...(gate.property ? { property: this.propertyRun(attempt, gate, consumer) } : {}),
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Ожидаемая проверка генеративного gate. Проверка потребителя исполняет
|
|
1012
|
+
* свою объявленную область целиком: договор свойств поручения к
|
|
1013
|
+
* одноимённому gate другого компонента не относится и не сужает его.
|
|
1014
|
+
*/
|
|
1015
|
+
propertyRun(attempt, gate, consumer) {
|
|
1016
|
+
return this.engine.read((v) => {
|
|
1017
|
+
const work = v.work(attempt.workId);
|
|
1018
|
+
const contracts = contractDocuments(work.order, v);
|
|
1019
|
+
const corpus = !consumer && attempt.checks?.corpus
|
|
1020
|
+
? this.corpus.snapshot(v, attempt.checks.corpus)
|
|
1021
|
+
: undefined;
|
|
1022
|
+
const expected = consumer
|
|
1023
|
+
? undefined
|
|
1024
|
+
: orderCheckManifest(work.order, contracts, gate.id, corpus);
|
|
1025
|
+
return {
|
|
1026
|
+
runId: attempt.id,
|
|
1027
|
+
...(expected ? { expected } : {}),
|
|
1028
|
+
...(corpus ? { corpus: { digest: corpus.digest, content: corpus.content } } : {}),
|
|
1029
|
+
contracts: Object.fromEntries(work.order.contracts.map((c) => [c.id, c.digest])),
|
|
1030
|
+
artifacts: contracts.flatMap((c) => (c.artifacts ?? []).map((a) => ({ path: a.path, blob: a.blob }))),
|
|
1031
|
+
};
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Проверки потребителей поручения — на integration, в рабочем дереве
|
|
1036
|
+
* каждого объявленного потребителя на голове его целевой ветки. Компонент
|
|
1037
|
+
* поручения подставлен рабочим деревом integration (его интегрированным
|
|
1038
|
+
* результатом), прочие зависимости потребителя — снимок их целевых веток,
|
|
1039
|
+
* собранный уже против подстановки. Договор окружения потребителя — как у
|
|
1040
|
+
* собственной integration: lifecycle, prepare, gates, cleanup. Что
|
|
1041
|
+
* проверено, записывается; рабочие деревья убираются и при отказе, а отказ
|
|
1042
|
+
* очистки не скрывается.
|
|
1043
|
+
*/
|
|
1044
|
+
async consumers(attempt, cwd, sha, artifacts, signal) {
|
|
1045
|
+
const provider = attempt.repositoryId;
|
|
1046
|
+
for (const consumer of attempt.consumers ?? []) {
|
|
1047
|
+
signal.throwIfAborted();
|
|
1048
|
+
const repo = this.repo(consumer.repositoryId);
|
|
1049
|
+
const checkout = this.repoPath(repo);
|
|
1050
|
+
const id = `${attempt.id}-consumer-${repo.id}-${randomUUID().slice(0, 8)}`;
|
|
1051
|
+
const dir = join(this.dirOf(repo.id), 'consumers', id);
|
|
1052
|
+
const own = join(artifacts, 'integration', 'consumer', repo.id);
|
|
1053
|
+
await mkdir(join(this.dirOf(repo.id), 'consumers'), { recursive: true });
|
|
1054
|
+
await git(checkout, 'worktree', 'add', '--detach', dir, repo.targetRef);
|
|
1055
|
+
let dependencies = [];
|
|
1056
|
+
let failure;
|
|
1057
|
+
try {
|
|
1058
|
+
const consumerSha = await git(dir, 'rev-parse', 'HEAD');
|
|
1059
|
+
// Проверяется закреплённая голова потребителя: подготовка, lifecycle и
|
|
1060
|
+
// gates не подменяют её коммитом или правкой отслеживаемых файлов.
|
|
1061
|
+
const pinned = async (step) => {
|
|
1062
|
+
if ((await git(dir, 'rev-parse', 'HEAD')) !== consumerSha)
|
|
1063
|
+
throw new Error(`Потребитель ${repo.id}: ${step} изменила HEAD`);
|
|
1064
|
+
if (await git(dir, 'status', '--porcelain', '--untracked-files=no'))
|
|
1065
|
+
throw new Error(`Потребитель ${repo.id}: ${step} изменила отслеживаемые файлы`);
|
|
1066
|
+
};
|
|
1067
|
+
dependencies = await snapshotDependencies({
|
|
1068
|
+
repos: this.repositories(),
|
|
1069
|
+
repositoryId: repo.id,
|
|
1070
|
+
dir: join(this.dirOf(repo.id), 'dependencies', id),
|
|
1071
|
+
artifacts: own,
|
|
1072
|
+
runId: attempt.id,
|
|
1073
|
+
policy: this.policy,
|
|
1074
|
+
environment: this.options.environment ?? process.env,
|
|
1075
|
+
signal,
|
|
1076
|
+
overrides: new Map([[provider, { path: cwd, sha }]]),
|
|
1077
|
+
});
|
|
1078
|
+
const execution = this.environmentOf(attempt, 'integration', dir, [this.policy.environment, repo.environment], repo.id, dependencies);
|
|
1079
|
+
await withEnvironment(repo.lifecycle ?? this.policy.lifecycle, dir, join(own, 'environment'), execution, signal, async () => {
|
|
1080
|
+
await runSteps(repo.prepare, dir, join(own, 'prepare'), execution, signal);
|
|
1081
|
+
await pinned('подготовка');
|
|
1082
|
+
for (const gate of ordered(repo.gates, consumer.gates))
|
|
1083
|
+
await this.gate(attempt, dir, sha, 'integration', gate, artifacts, signal, execution, {
|
|
1084
|
+
repositoryId: repo.id,
|
|
1085
|
+
dependencies,
|
|
1086
|
+
});
|
|
1087
|
+
}, (stage, action) => this.timed(attempt, `integration-consumer-environment:${repo.id}:${stage}`, action));
|
|
1088
|
+
await assertDependencies(dependencies);
|
|
1089
|
+
await pinned('проверка');
|
|
1090
|
+
// Свежесть фактических входов потребителя — прочих зависимостей и его
|
|
1091
|
+
// самого — на момент окончания проверки.
|
|
1092
|
+
const head = await git(checkout, 'rev-parse', repo.targetRef);
|
|
1093
|
+
const freshness = [
|
|
1094
|
+
...(await dependencyFreshness(this.repositories(), dependencies.filter((d) => d.repositoryId !== provider))),
|
|
1095
|
+
{ repositoryId: repo.id, snapshot: consumerSha, head, fresh: head === consumerSha },
|
|
1096
|
+
];
|
|
1097
|
+
this.engine.consumerChecked(attempt.id, attempt.token, repo.id, {
|
|
1098
|
+
freshness,
|
|
1099
|
+
observedAt: new Date().toISOString(),
|
|
1100
|
+
sha: consumerSha,
|
|
1101
|
+
tree: await git(dir, 'rev-parse', `${consumerSha}^{tree}`),
|
|
1102
|
+
dependencies: dependencies.map((d) => ({
|
|
1103
|
+
repositoryId: d.repositoryId,
|
|
1104
|
+
sha: d.sha,
|
|
1105
|
+
tree: d.tree,
|
|
1106
|
+
artifacts: d.artifacts,
|
|
1107
|
+
})),
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
catch (error) {
|
|
1111
|
+
failure = error instanceof Error ? error : new Error(String(error));
|
|
1112
|
+
}
|
|
1113
|
+
// Незавершённая очистка окружения потребителя: его рабочие деревья —
|
|
1114
|
+
// контекст повторной очистки по environment.json, их нельзя убирать.
|
|
1115
|
+
if (failure instanceof CleanupFailure) {
|
|
1116
|
+
const retained = this.retained.get(attempt.id) ?? { worktrees: [], snapshots: [] };
|
|
1117
|
+
retained.worktrees.push({ checkout, dir });
|
|
1118
|
+
retained.snapshots.push(...dependencies.filter((d) => d.repositoryId !== provider));
|
|
1119
|
+
this.retained.set(attempt.id, retained);
|
|
1120
|
+
throw new CleanupFailure(`${failure.message}; рабочие деревья проверки потребителя ${repo.id} сохранены для повторной очистки (${join(own, 'environment', 'environment.json')})`);
|
|
1121
|
+
}
|
|
1122
|
+
// Очистка — обязательная часть проверки: её отказ не скрывается и не
|
|
1123
|
+
// заслоняется отказом самой проверки.
|
|
1124
|
+
const leftovers = await removeSnapshots(this.repositories(), dependencies.filter((d) => d.repositoryId !== provider));
|
|
1125
|
+
await git(checkout, 'worktree', 'remove', '--force', dir).catch((e) => leftovers.push(`${repo.id}: ${e instanceof Error ? e.message : String(e)}`));
|
|
1126
|
+
const cleanup = leftovers.length
|
|
1127
|
+
? `не удалось убрать рабочие деревья проверки потребителя: ${leftovers.join('; ')}`
|
|
1128
|
+
: undefined;
|
|
1129
|
+
if (failure && cleanup)
|
|
1130
|
+
throw failure instanceof TaskFailure
|
|
1131
|
+
? new TaskFailure(failure.kind, `${failure.message}; ${cleanup}`)
|
|
1132
|
+
: new Error(`${failure.message}; ${cleanup}`, { cause: failure });
|
|
1133
|
+
if (failure)
|
|
1134
|
+
throw failure;
|
|
1135
|
+
if (cleanup)
|
|
1136
|
+
throw new TaskFailure('environment', cleanup[0].toUpperCase() + cleanup.slice(1));
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Независимое ревью точного SHA. Отказ — отсутствие одобрения, блокирующая
|
|
1141
|
+
* находка, ответ вне схемы, изменение дерева или HEAD reviewer-ом. Ответ
|
|
1142
|
+
* reviewer маскируется целиком перед записью.
|
|
1143
|
+
*/
|
|
1144
|
+
/**
|
|
1145
|
+
* Инструмент проверок reviewer с пределами controller — только тому, кто и
|
|
1146
|
+
* так может запускать команды: права reviewer он не расширяет, а
|
|
1147
|
+
* ограничивает срок. Спецификация лежит в каталоге ревью; значения
|
|
1148
|
+
* переменных окружения CLI передаёт серверу по имени, на диск они не пишутся.
|
|
1149
|
+
*/
|
|
1150
|
+
async reviewProbe(attempt, work, cwd, dir, phase, sha, gates,
|
|
1151
|
+
/**
|
|
1152
|
+
* Повтор reviewer того же логического ревью: срок и остаток бюджета
|
|
1153
|
+
* проверок переходят от первого вызова, а не выдаются заново.
|
|
1154
|
+
*/
|
|
1155
|
+
shared) {
|
|
1156
|
+
const chosen = selectReviewer(this.policy, 'code', { runtime: attempt.runtime }, work.order.role, work.order.repositoryId);
|
|
1157
|
+
const name = chosen.ok ? chosen.reviewer.toolProfile : undefined;
|
|
1158
|
+
const profile = name ? this.policy.toolProfiles[name] : undefined;
|
|
1159
|
+
// Fixture runtime команд не запускает: инструмента ему не нужно.
|
|
1160
|
+
if (attempt.reviewer === 'fixture' || !reviewerRunsChecks(attempt.reviewer, profile))
|
|
1161
|
+
return undefined;
|
|
1162
|
+
const limits = probeLimits(this.policy);
|
|
1163
|
+
const budgetMs = limits.budgetMs - (shared?.spentMs ?? 0);
|
|
1164
|
+
const deadline = shared?.deadline ?? Date.now() + this.policy.runTimeoutMs - limits.reserveMs;
|
|
1165
|
+
// Общий бюджет проверок логического ревью исчерпан: повтору инструмента не дают.
|
|
1166
|
+
if (budgetMs < 1000 || deadline - Date.now() < 1000)
|
|
1167
|
+
return undefined;
|
|
1168
|
+
const boundary = this.boundary(attempt.dependencies);
|
|
1169
|
+
const execution = this.environment(attempt, phase, cwd, name);
|
|
1170
|
+
const secrets = new Set(execution.redact.secrets ?? []);
|
|
1171
|
+
const spec = {
|
|
1172
|
+
cwd,
|
|
1173
|
+
controller: boundary.hidden,
|
|
1174
|
+
readable: [cwd, ...boundary.readable],
|
|
1175
|
+
isolation: this.policy.isolation,
|
|
1176
|
+
commandTimeoutMs: limits.commandTimeoutMs,
|
|
1177
|
+
budgetMs,
|
|
1178
|
+
deadline,
|
|
1179
|
+
env: Object.keys(execution.env).filter((k) => execution.env[k] !== undefined),
|
|
1180
|
+
secrets: Object.entries(execution.env)
|
|
1181
|
+
.filter(([, value]) => value !== undefined && secrets.has(value))
|
|
1182
|
+
.map(([key]) => key),
|
|
1183
|
+
settingsDir: join(dir, 'probe-settings'),
|
|
1184
|
+
log: join(dir, 'probes.jsonl'),
|
|
1185
|
+
// Объявленные gates — по ID: argv, cwd и путь отчёта выбирает controller.
|
|
1186
|
+
gates: probeGates(gates),
|
|
1187
|
+
phase,
|
|
1188
|
+
sha,
|
|
1189
|
+
artifacts: join(dir, 'probe-runs'),
|
|
1190
|
+
};
|
|
1191
|
+
const specPath = join(dir, 'probe.json');
|
|
1192
|
+
await writeFile(specPath, JSON.stringify(spec, null, 2));
|
|
1193
|
+
const seconds = (ms) => Math.round(ms / 1000);
|
|
1194
|
+
return {
|
|
1195
|
+
log: spec.log,
|
|
1196
|
+
deadline,
|
|
1197
|
+
server: {
|
|
1198
|
+
...probeCommand(specPath),
|
|
1199
|
+
env: spec.env,
|
|
1200
|
+
commandTimeoutMs: limits.commandTimeoutMs,
|
|
1201
|
+
},
|
|
1202
|
+
prompt: `Run checks with the ${probeTool} tool (argv, no shell; sources are read-only, temporary files go to TMPDIR). The controller stops each command after ${seconds(limits.commandTimeoutMs)} s and all checks share ${seconds(budgetMs)} s; ${seconds(limits.reserveMs)} s stay reserved for your verdict. A timeout is an observation about that command on that input, not proof of a defect: name the input and the limit if you report it, and give your verdict with what you have when the budget runs out. When a finding rests on a run, put its runId into reproduction.run.${spec.gates?.length ? ' ' + gateProbePrompt(spec.gates) : ''}`,
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
async review(attempt, work, cwd, base, sha, phase, artifacts, signal, context) {
|
|
1206
|
+
await this.timed(attempt, phase + '-review', () => this.reviewOnce(attempt, work, cwd, base, sha, phase, artifacts, signal, context));
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Логическое ревью точного SHA (docs/review-protocol.md). Ответ reviewer
|
|
1210
|
+
* проверяется целиком против истории находок поручения до любой записи;
|
|
1211
|
+
* валидный раунд записывается одной транзакцией под fencing. Ошибка
|
|
1212
|
+
* протокола — ограниченный повтор только reviewer на том же SHA, diff,
|
|
1213
|
+
* контексте, назначении и наборе открытых находок; writer не вызывается.
|
|
1214
|
+
* Изменение дерева/HEAD, отмена, утрата владения и отказ runtime в повтор
|
|
1215
|
+
* не превращаются.
|
|
1216
|
+
*/
|
|
1217
|
+
/**
|
|
1218
|
+
* Время проверок reviewer, уже израсходованное вызовами логического ревью
|
|
1219
|
+
* (журналы probe). Запуск без строки исхода — controller погиб посреди
|
|
1220
|
+
* команды — засчитывается всем резервом, записанным до запуска, а его
|
|
1221
|
+
* пережившие процессы останавливаются (R3-F01).
|
|
1222
|
+
*/
|
|
1223
|
+
async probeSpent(runs, repositoryId, phase) {
|
|
1224
|
+
let spent = 0;
|
|
1225
|
+
for (const runId of runs) {
|
|
1226
|
+
const dir = join(this.dirOf(repositoryId), 'artifacts', runId, phase, 'review');
|
|
1227
|
+
const logs = [
|
|
1228
|
+
join(dir, 'probes.jsonl'),
|
|
1229
|
+
...(await readdir(dir).catch(() => []))
|
|
1230
|
+
.filter((name) => name.startsWith('call-'))
|
|
1231
|
+
.map((name) => join(dir, name, 'probes.jsonl')),
|
|
1232
|
+
];
|
|
1233
|
+
for (const log of logs)
|
|
1234
|
+
spent += (await recoverProbeLog(log)).spentMs;
|
|
1235
|
+
}
|
|
1236
|
+
return spent;
|
|
1237
|
+
}
|
|
1238
|
+
async reviewOnce(attempt, work, cwd, base, sha, phase, artifacts, signal, context) {
|
|
1239
|
+
const dir = join(artifacts, phase, 'review');
|
|
1240
|
+
await mkdir(dir, { recursive: true });
|
|
1241
|
+
const diff = await git(cwd, 'diff', '--no-ext-diff', '--no-textconv', base, sha);
|
|
1242
|
+
if (diff.length > 1_000_000)
|
|
1243
|
+
throw new TaskFailure('review', 'Diff превышает лимит независимого ревью; разделите поручение');
|
|
1244
|
+
const status = await git(cwd, 'status', '--porcelain', '--untracked-files=all');
|
|
1245
|
+
const repo = this.repo(attempt.repositoryId);
|
|
1246
|
+
const gates = ordered(repo.gates, attempt.requiredGates ?? repo.gates.map((g) => g.id));
|
|
1247
|
+
const scope = codeReviewScope(attempt.repositoryId, attempt.workId);
|
|
1248
|
+
const calls = 1 + (this.policy.reviewProtocolRetries ?? DEFAULT_REVIEW_PROTOCOL_RETRIES);
|
|
1249
|
+
// Адаптер без протокола переходов: переходы не требуются (alpha.5), ошибка
|
|
1250
|
+
// формата ответа и противоречивый approved — по-прежнему ошибка протокола.
|
|
1251
|
+
const legacy = !(await this.reviewProtocol(attempt.reviewer));
|
|
1252
|
+
// История находок и счёт вызовов — по записанному состоянию всех попыток
|
|
1253
|
+
// поручения: новая попытка, фаза и рестарт их не обнуляют.
|
|
1254
|
+
const { history, lastIssues, spec } = this.engine.read((v) => {
|
|
1255
|
+
const attempts = v.attempts(attempt.workId);
|
|
1256
|
+
const last = attempts
|
|
1257
|
+
.flatMap((a) => a.evidence)
|
|
1258
|
+
.findLast((e) => e.kind === 'review' &&
|
|
1259
|
+
e.phase === phase &&
|
|
1260
|
+
e.sha === sha &&
|
|
1261
|
+
e.reviewRound?.scope === scope &&
|
|
1262
|
+
e.reviewRound.protocol === 'invalid');
|
|
1263
|
+
return {
|
|
1264
|
+
history: codeReviewHistory(attempts, scope),
|
|
1265
|
+
lastIssues: last?.reviewRound?.issues ?? [],
|
|
1266
|
+
spec: v.work(attempt.workId)?.approvedDigest,
|
|
1267
|
+
};
|
|
1268
|
+
});
|
|
1269
|
+
const expected = legacy
|
|
1270
|
+
? []
|
|
1271
|
+
: history.findings.filter((f) => f.status === 'open').map((f) => f.id);
|
|
1272
|
+
const round = history.round + 1;
|
|
1273
|
+
// Запрос одинаков для всех вызовов логического ревью: меняется только
|
|
1274
|
+
// диагностика ошибки протокола.
|
|
1275
|
+
const notes = [
|
|
1276
|
+
...this.notes(attempt, true, sha),
|
|
1277
|
+
...(await this.hostNotes(work, attempt, true)),
|
|
1278
|
+
];
|
|
1279
|
+
let issues = lastIssues;
|
|
1280
|
+
const stage = phase + '-review';
|
|
1281
|
+
const reserveMs = probeLimits(this.policy).reserveMs;
|
|
1282
|
+
let started = 0;
|
|
1283
|
+
for (;;) {
|
|
1284
|
+
// Счёт вызовов, срок и расход логического ревью — по записанному
|
|
1285
|
+
// состоянию: запись учёта вызова создаётся до внешнего invoke, поэтому
|
|
1286
|
+
// вызов, прерванный падением controller, засчитан и после рестарта.
|
|
1287
|
+
const logical = this.engine.read((v) => logicalReview(v.attempts(attempt.workId), scope, phase, sha, (runId) => startedCalls(v, attempt.repositoryId, runId, stage)));
|
|
1288
|
+
started = logical.used;
|
|
1289
|
+
const call = logical.used + 1;
|
|
1290
|
+
if (call > calls)
|
|
1291
|
+
break;
|
|
1292
|
+
// Срок логического ревью — от первого начатого вызова, в том числе
|
|
1293
|
+
// прежнего процесса: повтор и рестарт нового срока не получают.
|
|
1294
|
+
const deadline = (logical.firstStartMs ?? Date.now()) + this.policy.runTimeoutMs;
|
|
1295
|
+
const remaining = deadline - Date.now();
|
|
1296
|
+
// Срок исчерпан (в том числе прежним процессом): явная остановка без
|
|
1297
|
+
// вызова. Класс `review-protocol` — без автоматического повтора: иначе
|
|
1298
|
+
// обычный повтор по времени выдал бы тому же SHA новый срок.
|
|
1299
|
+
if (remaining < 1000)
|
|
1300
|
+
throw new TaskFailure('review-protocol', `REVIEW_PROTOCOL_INVALID: исчерпан срок логического ревью ${phase} ${sha} (${Math.round(this.policy.runTimeoutMs / 1000)} с от первого вызова; начато вызовов reviewer ${logical.used} из ${calls}, попытка ${attempt.id}); нового вызова нет`);
|
|
1301
|
+
// Бюджет проверок — общий на логическое ревью: расход прежних вызовов
|
|
1302
|
+
// (в том числе прерванных) — по их журналам проверок на диске.
|
|
1303
|
+
const shared = logical.firstStartMs === undefined
|
|
1304
|
+
? undefined
|
|
1305
|
+
: {
|
|
1306
|
+
deadline: deadline - reserveMs,
|
|
1307
|
+
spentMs: await this.probeSpent(logical.runs, attempt.repositoryId, phase),
|
|
1308
|
+
};
|
|
1309
|
+
if (call > 1) {
|
|
1310
|
+
// Повтор — только пока попытка своя, не отменена и основание прежнее.
|
|
1311
|
+
this.engine.heartbeat(attempt.id, attempt.token);
|
|
1312
|
+
signal.throwIfAborted();
|
|
1313
|
+
const current = this.engine.read((v) => v.work(attempt.workId)?.approvedDigest);
|
|
1314
|
+
if (current !== spec)
|
|
1315
|
+
throw new HarnessError('APPROVAL_STALE', 'Постановка поручения изменилась во время ревью; повтор reviewer не выполняется');
|
|
1316
|
+
}
|
|
1317
|
+
const callDir = call === 1 ? dir : join(dir, `call-${call}`);
|
|
1318
|
+
await mkdir(callDir, { recursive: true });
|
|
1319
|
+
const probe = await this.reviewProbe(attempt, work, cwd, callDir, phase, sha, gates, shared);
|
|
1320
|
+
const response = await this.call(this.adapter(attempt.reviewer), {
|
|
1321
|
+
task: 'review-code',
|
|
1322
|
+
attempt,
|
|
1323
|
+
cwd,
|
|
1324
|
+
dir: callDir,
|
|
1325
|
+
phase,
|
|
1326
|
+
prompt: prompt({
|
|
1327
|
+
work,
|
|
1328
|
+
attempt: { ...attempt, baseSha: base },
|
|
1329
|
+
history: this.history(attempt),
|
|
1330
|
+
context,
|
|
1331
|
+
gates: gates.map((g) => ({ id: g.id, command: g.command })),
|
|
1332
|
+
review: { sha, phase, diff },
|
|
1333
|
+
notes,
|
|
1334
|
+
...(legacy ? {} : { findings: history }),
|
|
1335
|
+
...(call > 1 ? { retry: { call, calls, issues } } : {}),
|
|
1336
|
+
}) + (probe ? '\n\n' + probe.prompt : ''),
|
|
1337
|
+
...(probe ? { probe: probe.server } : {}),
|
|
1338
|
+
...(legacy ? {} : { findings: requestFindings(history) }),
|
|
1339
|
+
timeoutMs: remaining,
|
|
1340
|
+
}, signal);
|
|
1341
|
+
// Reviewer не меняет закреплённые зависимости, как и своё дерево.
|
|
1342
|
+
await assertDependencies(attempt.dependencies ?? []);
|
|
1343
|
+
const redact = response.redact;
|
|
1344
|
+
const clean = (await git(cwd, 'status', '--porcelain', '--untracked-files=all')) === status &&
|
|
1345
|
+
!(await git(cwd, 'status', '--porcelain', '--untracked-files=no')) &&
|
|
1346
|
+
(await git(cwd, 'rev-parse', 'HEAD')) === sha;
|
|
1347
|
+
const verdict = response.unreadable
|
|
1348
|
+
? {
|
|
1349
|
+
valid: false,
|
|
1350
|
+
issues: [
|
|
1351
|
+
{
|
|
1352
|
+
path: '(response)',
|
|
1353
|
+
reason: 'schema',
|
|
1354
|
+
message: response.unreadable.slice(0, 1000),
|
|
1355
|
+
},
|
|
1356
|
+
],
|
|
1357
|
+
}
|
|
1358
|
+
: checkCodeReview(response.output, history, { runId: attempt.id, phase, sha }, { legacy });
|
|
1359
|
+
const result = verdict.result;
|
|
1360
|
+
const inspection = response.inspection ?? unobservedReview();
|
|
1361
|
+
// Проверки reviewer — по журналу controller, а не по самоотчёту модели.
|
|
1362
|
+
const probes = probe ? await probeRecords(probe.log) : undefined;
|
|
1363
|
+
const protocol = !clean
|
|
1364
|
+
? 'unsafe'
|
|
1365
|
+
: verdict.valid
|
|
1366
|
+
? 'valid'
|
|
1367
|
+
: 'invalid';
|
|
1368
|
+
const callIssues = verdict.valid ? [] : verdict.issues;
|
|
1369
|
+
const record = redactDeep({
|
|
1370
|
+
...(result
|
|
1371
|
+
? { ...result, findings: observeReproductions(result.findings, probes, redact) }
|
|
1372
|
+
: { approved: false, summary: 'ответ вне схемы', findings: [] }),
|
|
1373
|
+
inspection,
|
|
1374
|
+
issues: callIssues.map((i) => `${i.path}: ${i.reason}: ${i.message}`),
|
|
1375
|
+
protocol: {
|
|
1376
|
+
scope,
|
|
1377
|
+
round,
|
|
1378
|
+
call,
|
|
1379
|
+
calls,
|
|
1380
|
+
status: protocol,
|
|
1381
|
+
expected,
|
|
1382
|
+
issues: callIssues,
|
|
1383
|
+
...(verdict.valid
|
|
1384
|
+
? { ids: verdict.findings.map((f) => f.id), transitions: verdict.transitions }
|
|
1385
|
+
: {}),
|
|
1386
|
+
},
|
|
1387
|
+
// Ответ вне схемы сохраняется как есть (замаскированным) для разбора.
|
|
1388
|
+
...(result ? {} : { raw: response.output ?? null }),
|
|
1389
|
+
...(probes ? { probes } : {}),
|
|
1390
|
+
}, redact);
|
|
1391
|
+
// Служебные значения записи — перечисления контура, а не текст reviewer:
|
|
1392
|
+
// короткий секрет, совпавший с их частью, их не маскирует.
|
|
1393
|
+
record.inspection = {
|
|
1394
|
+
...record.inspection,
|
|
1395
|
+
mode: inspection.mode,
|
|
1396
|
+
source: inspection.source,
|
|
1397
|
+
};
|
|
1398
|
+
if (result)
|
|
1399
|
+
record.findings = record.findings.map((f, i) => ({
|
|
1400
|
+
...f,
|
|
1401
|
+
severity: result.findings[i]?.severity ?? f.severity,
|
|
1402
|
+
}));
|
|
1403
|
+
// Каждый вызов — свой каталог и свой review.json: байты прежнего вызова
|
|
1404
|
+
// не перезаписываются, digest evidence указывает на них.
|
|
1405
|
+
const log = join(callDir, 'review.json');
|
|
1406
|
+
await writeFile(log, JSON.stringify(record, null, 2), { flag: 'wx' });
|
|
1407
|
+
const digest = sha256(JSON.stringify(record));
|
|
1408
|
+
const reviewRound = {
|
|
1409
|
+
version: 1,
|
|
1410
|
+
scope,
|
|
1411
|
+
round,
|
|
1412
|
+
call,
|
|
1413
|
+
calls,
|
|
1414
|
+
protocol,
|
|
1415
|
+
expected,
|
|
1416
|
+
...(spec ? { spec } : {}),
|
|
1417
|
+
...(legacy ? { mode: 'legacy' } : {}),
|
|
1418
|
+
};
|
|
1419
|
+
if (!clean) {
|
|
1420
|
+
// Отказ безопасности: вердикт не применяется, протокольного повтора нет.
|
|
1421
|
+
this.engine.evidence(attempt.id, attempt.token, {
|
|
1422
|
+
kind: 'review',
|
|
1423
|
+
inspection: record.inspection,
|
|
1424
|
+
phase,
|
|
1425
|
+
sha,
|
|
1426
|
+
gate: 'independent-review',
|
|
1427
|
+
passed: false,
|
|
1428
|
+
command: [attempt.reviewer, 'review-code'],
|
|
1429
|
+
exitCode: 1,
|
|
1430
|
+
log,
|
|
1431
|
+
digest,
|
|
1432
|
+
summary: redact(`[${inspection.mode}] reviewer изменил дерево или HEAD`).slice(0, 4000),
|
|
1433
|
+
findings: (record.findings ?? []).map((f) => ({
|
|
1434
|
+
severity: f.severity,
|
|
1435
|
+
message: f.message,
|
|
1436
|
+
path: f.path ?? null,
|
|
1437
|
+
line: f.line ?? null,
|
|
1438
|
+
})),
|
|
1439
|
+
reviewRound,
|
|
1440
|
+
});
|
|
1441
|
+
throw new TaskFailure('review', 'Независимое ревью отклонило результат: reviewer изменил дерево или HEAD');
|
|
1442
|
+
}
|
|
1443
|
+
if (!verdict.valid) {
|
|
1444
|
+
issues = verdict.issues.map((i) => ({ ...i, message: redact(i.message) }));
|
|
1445
|
+
// Ошибка протокола — диагностика: история не меняется, находок нет.
|
|
1446
|
+
this.engine.evidence(attempt.id, attempt.token, {
|
|
1447
|
+
kind: 'review',
|
|
1448
|
+
inspection: record.inspection,
|
|
1449
|
+
phase,
|
|
1450
|
+
sha,
|
|
1451
|
+
gate: 'independent-review',
|
|
1452
|
+
passed: false,
|
|
1453
|
+
command: [attempt.reviewer, 'review-code'],
|
|
1454
|
+
exitCode: 1,
|
|
1455
|
+
log,
|
|
1456
|
+
digest,
|
|
1457
|
+
summary: redact(`[${inspection.mode}] REVIEW_PROTOCOL_INVALID (вызов ${call} из ${calls}): ` +
|
|
1458
|
+
protocolText(issues)).slice(0, 4000),
|
|
1459
|
+
findings: [],
|
|
1460
|
+
reviewRound: { ...reviewRound, issues },
|
|
1461
|
+
});
|
|
1462
|
+
continue;
|
|
1463
|
+
}
|
|
1464
|
+
// Ответ валиден целиком: находки с воспроизводимым входом — в реестр
|
|
1465
|
+
// поручения, уже замаскированными; наблюдение — только по журналу
|
|
1466
|
+
// controller. Команда из текста находки не исполняется.
|
|
1467
|
+
const reproductions = (record.findings ?? []).flatMap((f) => {
|
|
1468
|
+
// Наблюдение проставляет `observeReproductions` по журналу controller.
|
|
1469
|
+
const r = f.reproduction;
|
|
1470
|
+
if (!r)
|
|
1471
|
+
return [];
|
|
1472
|
+
return [
|
|
1473
|
+
{
|
|
1474
|
+
property: r.property,
|
|
1475
|
+
input: r.input,
|
|
1476
|
+
expected: r.expected,
|
|
1477
|
+
actual: r.actual,
|
|
1478
|
+
command: r.command,
|
|
1479
|
+
message: f.message.slice(0, 4000),
|
|
1480
|
+
claimed: r.executed,
|
|
1481
|
+
observed: r.observed === true,
|
|
1482
|
+
run: r.observed === true ? (r.run ?? null) : null,
|
|
1483
|
+
},
|
|
1484
|
+
];
|
|
1485
|
+
});
|
|
1486
|
+
if (reproductions.length)
|
|
1487
|
+
this.engine.recordReproductions(attempt.id, attempt.token, sha, reproductions);
|
|
1488
|
+
const passed = verdict.result.approved;
|
|
1489
|
+
// Вердикт, новые находки с выданными ID и переходы прежних — одна запись
|
|
1490
|
+
// под fencing: попытка, утратившая владение, их не сохраняет.
|
|
1491
|
+
this.engine.evidence(attempt.id, attempt.token, {
|
|
1492
|
+
kind: 'review',
|
|
1493
|
+
inspection: record.inspection,
|
|
1494
|
+
phase,
|
|
1495
|
+
sha,
|
|
1496
|
+
gate: 'independent-review',
|
|
1497
|
+
passed,
|
|
1498
|
+
command: [attempt.reviewer, 'review-code'],
|
|
1499
|
+
exitCode: passed ? 0 : 1,
|
|
1500
|
+
log,
|
|
1501
|
+
digest,
|
|
1502
|
+
summary: redact(`[${inspection.mode}] ${record.summary}`).slice(0, 4000),
|
|
1503
|
+
findings: (record.findings ?? []).map((f, i) => ({
|
|
1504
|
+
severity: f.severity,
|
|
1505
|
+
message: f.message,
|
|
1506
|
+
path: f.path ?? null,
|
|
1507
|
+
line: f.line ?? null,
|
|
1508
|
+
id: verdict.findings[i].id,
|
|
1509
|
+
status: 'open',
|
|
1510
|
+
})),
|
|
1511
|
+
reviewRound: {
|
|
1512
|
+
...reviewRound,
|
|
1513
|
+
round: verdict.round,
|
|
1514
|
+
transitions: verdict.transitions.map((t) => ({
|
|
1515
|
+
...t,
|
|
1516
|
+
basis: redact(t.basis).slice(0, 4000),
|
|
1517
|
+
})),
|
|
1518
|
+
},
|
|
1519
|
+
});
|
|
1520
|
+
if (!passed) {
|
|
1521
|
+
// Свойство находки — только объявленное поручением; свободная строка
|
|
1522
|
+
// reviewer сохраняется для разбора, но эпизод не открывает.
|
|
1523
|
+
const named = verdict.result.findings
|
|
1524
|
+
.map((f) => f.reproduction?.property)
|
|
1525
|
+
.filter((p) => !!p);
|
|
1526
|
+
const declared = new Set(work.order.properties ?? []);
|
|
1527
|
+
throw new TaskFailure('review', 'Независимое ревью отклонило результат: ' + redact(record.summary), named.length
|
|
1528
|
+
? {
|
|
1529
|
+
bound: [...new Set(named.filter((p) => declared.has(p)))],
|
|
1530
|
+
unbound: [...new Set(named.filter((p) => !declared.has(p)))],
|
|
1531
|
+
}
|
|
1532
|
+
: undefined);
|
|
1533
|
+
}
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
// Вызовы исчерпаны (включая прерванные рестартом): остановка с причиной,
|
|
1537
|
+
// без нового вызова и без новой реализации.
|
|
1538
|
+
throw new TaskFailure('review-protocol', `REVIEW_PROTOCOL_INVALID: ответ reviewer нарушил протокол ревью (${phase} ${sha}, начато вызовов ${started} из ${calls}, попытка ${attempt.id}): ` +
|
|
1539
|
+
(issues.length
|
|
1540
|
+
? protocolText(issues)
|
|
1541
|
+
: 'последний вызов прерван до ответа (рестарт controller); вердикта нет'));
|
|
1542
|
+
}
|
|
1543
|
+
/**
|
|
1544
|
+
* Обязательный корпус попытки (первые 20 случаев) и — исполнителю —
|
|
1545
|
+
* решение разбора ведущего, под которым выдан повтор.
|
|
1546
|
+
*/
|
|
1547
|
+
/**
|
|
1548
|
+
* Самопроверка исполнителя для поручения со свойствами: закреплённый checker,
|
|
1549
|
+
* полный корпус версии попытки и seed. Диагностика: gates от неё не зависят.
|
|
1550
|
+
*/
|
|
1551
|
+
async prepareSelfCheck(attempt, repo, dir) {
|
|
1552
|
+
const input = this.engine.read((v) => {
|
|
1553
|
+
const work = v.work(attempt.workId);
|
|
1554
|
+
const corpus = attempt.checks?.corpus
|
|
1555
|
+
? this.corpus.snapshot(v, attempt.checks.corpus)
|
|
1556
|
+
: undefined;
|
|
1557
|
+
return {
|
|
1558
|
+
order: work.order,
|
|
1559
|
+
contracts: contractDocuments(work.order, v),
|
|
1560
|
+
...(corpus ? { corpus: { content: corpus.content, cases: corpus.cases.length } } : {}),
|
|
1561
|
+
};
|
|
1562
|
+
});
|
|
1563
|
+
const plan = await writeSelfCheck({
|
|
1564
|
+
...input,
|
|
1565
|
+
gates: repo.gates,
|
|
1566
|
+
dir,
|
|
1567
|
+
seed: propertySeed(attempt.id, 'self', attempt.workId),
|
|
1568
|
+
});
|
|
1569
|
+
if (plan)
|
|
1570
|
+
this.selfChecks.set(attempt.id, { dir, plan });
|
|
1571
|
+
}
|
|
1572
|
+
/** Сведения хоста в запросе: данные, а не правила приёмки. */
|
|
1573
|
+
async hostNotes(work, attempt, review) {
|
|
1574
|
+
return (await this.options.hooks?.notes?.({ work, attempt, review })) ?? [];
|
|
1575
|
+
}
|
|
1576
|
+
notes(attempt, review, sha) {
|
|
1577
|
+
const reproductions = this.engine.reproductions(attempt.workId);
|
|
1578
|
+
return this.engine.read((v) => {
|
|
1579
|
+
const out = [];
|
|
1580
|
+
// Воспроизведения прошлых ревью — входы, на которых поручение уже
|
|
1581
|
+
// ломалось. Диагностика: gate они не расширяют.
|
|
1582
|
+
if (reproductions.length)
|
|
1583
|
+
out.push('Reproductions recorded by earlier reviews of this work order (diagnostic, not a gate; "observed" means the controller saw the reviewer run the command). Check the change against each input: ' +
|
|
1584
|
+
JSON.stringify(reproductions
|
|
1585
|
+
.slice(-20)
|
|
1586
|
+
.map(({ property, input, expected, actual, command, observed, runs }) => ({
|
|
1587
|
+
property,
|
|
1588
|
+
input,
|
|
1589
|
+
expected,
|
|
1590
|
+
actual,
|
|
1591
|
+
command,
|
|
1592
|
+
observed,
|
|
1593
|
+
seen: runs.length,
|
|
1594
|
+
}))));
|
|
1595
|
+
// Согласованные контракты поручения — их текстом, по закреплённому digest.
|
|
1596
|
+
const contracts = contractDocuments(v.work(attempt.workId).order, v);
|
|
1597
|
+
if (contracts.length)
|
|
1598
|
+
out.push('Approved contracts: ' +
|
|
1599
|
+
JSON.stringify(contracts.map((c) => ({ id: c.id, title: c.title, content: c.content }))));
|
|
1600
|
+
if (review)
|
|
1601
|
+
out.push('When a finding fails on a concrete input, fill reproduction: property (the testId or acceptance criterion it violates), input (the exact input as JSON text), expected and actual, and command (argv) that shows it. If you ran it through the controller check tool, set run to the runId it returned and executed=true; the controller confirms the run from its own log and records the command that actually ran. Otherwise run=null and it is a hypothesis. Use null for findings without such an input.');
|
|
1602
|
+
// Reviewer видит, что controller сам записал о проверяемом SHA: gates и
|
|
1603
|
+
// их итоги, а не пересказ исполнителя.
|
|
1604
|
+
if (review && sha) {
|
|
1605
|
+
const evidence = (v.attempt(attempt.id)?.evidence ?? []).filter((e) => e.sha === sha && e.kind !== 'review');
|
|
1606
|
+
out.push('Evidence the controller recorded for the review commit (gate, phase, passed, summary): ' +
|
|
1607
|
+
JSON.stringify(evidence.map((e) => ({
|
|
1608
|
+
gate: e.gate,
|
|
1609
|
+
phase: e.phase,
|
|
1610
|
+
passed: e.passed,
|
|
1611
|
+
summary: e.summary,
|
|
1612
|
+
...(e.consumer ? { consumer: e.consumer } : {}),
|
|
1613
|
+
}))), 'Where a requirement names a testId, open that test. Reject it when the test does not exercise the stated scenario, or when it would still pass if the behaviour it claims to prove were broken. A matching name is not proof.');
|
|
1614
|
+
}
|
|
1615
|
+
const r = v.work(attempt.workId)?.recovery;
|
|
1616
|
+
if (!review && r?.decision && r.status === 'retrying' && r.retryRun === attempt.id) {
|
|
1617
|
+
const d = r.decision;
|
|
1618
|
+
const source = `${d.source.kind}${d.source.runtime ? ' (' + d.source.runtime + ')' : ''}, recovery ${r.id}`;
|
|
1619
|
+
out.push(d.action === 'new-approach'
|
|
1620
|
+
? `Lead decision after two failures on property ${r.property} (source: ${source}): change the implementation approach. Hypothesis: ${d.hypothesis}. Verification plan: ${d.plan}. Reason: ${d.reason}`
|
|
1621
|
+
: `Lead decision after two failures on property ${r.property} (source: ${source}): confirmed cases were added to the mandatory corpus. Reason: ${d.reason}. Fix the class of defect, not only these inputs.`);
|
|
1622
|
+
}
|
|
1623
|
+
const self = this.selfChecks.get(attempt.id);
|
|
1624
|
+
if (!review && self)
|
|
1625
|
+
out.push(selfCheckNote(self.plan));
|
|
1626
|
+
const binding = attempt.checks?.corpus;
|
|
1627
|
+
if (binding) {
|
|
1628
|
+
const suite = this.corpus.get(v, binding.suite);
|
|
1629
|
+
const version = suite.versions.find((x) => x.digest === binding.digest);
|
|
1630
|
+
out.push(`Mandatory corpus ${suite.id} version ${version.number} (sha256 ${version.digest}): ${version.cases.length} cases confirmed by the pinned oracle on defective commits. They are part of the gate and run before review on both phases.${version.cases.length > 20 ? ' First 20 shown.' : ''} Cases: ` +
|
|
1631
|
+
JSON.stringify(version.cases.slice(0, 20).map((id) => {
|
|
1632
|
+
const c = suite.cases[id];
|
|
1633
|
+
return { id, property: c.property, input: c.input, expected: c.expected };
|
|
1634
|
+
})));
|
|
1635
|
+
}
|
|
1636
|
+
return out;
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
/**
|
|
1640
|
+
* Ограниченный эксперимент выполнимости тяжёлого обязательства: команда на
|
|
1641
|
+
* закреплённом SHA под изоляцией gates, с пределом времени на размер.
|
|
1642
|
+
*/
|
|
1643
|
+
spike(raw) {
|
|
1644
|
+
return runSpike({
|
|
1645
|
+
engine: this.engine,
|
|
1646
|
+
policy: this.policy,
|
|
1647
|
+
stateDir: this.options.stateDir,
|
|
1648
|
+
repoPath: (repo) => this.repoPath(repo),
|
|
1649
|
+
boundary: () => this.boundary(),
|
|
1650
|
+
...(this.options.environment ? { environment: this.options.environment } : {}),
|
|
1651
|
+
}, raw);
|
|
1652
|
+
}
|
|
1653
|
+
/**
|
|
1654
|
+
* Подтвердить пример закреплённым эталоном (быстрый маршрут корпуса). Ни
|
|
1655
|
+
* команда воспроизведения, ни данные входа не исполняются.
|
|
1656
|
+
*/
|
|
1657
|
+
submitCase(raw) {
|
|
1658
|
+
return submitCase({
|
|
1659
|
+
corpus: this.corpus,
|
|
1660
|
+
policy: this.policy,
|
|
1661
|
+
stateDir: this.options.stateDir,
|
|
1662
|
+
repoPath: (repo) => this.repoPath(repo),
|
|
1663
|
+
boundary: () => this.boundary(),
|
|
1664
|
+
...(this.options.environment ? { environment: this.options.environment } : {}),
|
|
1665
|
+
}, raw);
|
|
1666
|
+
}
|
|
1667
|
+
/**
|
|
1668
|
+
* Воспроизведения из отклонившего ревью попытки по объявленным свойствам —
|
|
1669
|
+
* на подтверждение эталоном. Не больше пяти; отказ одного не мешает другим.
|
|
1670
|
+
*/
|
|
1671
|
+
async confirmReproductions(attempt) {
|
|
1672
|
+
const { work, reviews } = this.engine.read((v) => ({
|
|
1673
|
+
work: v.work(attempt.workId),
|
|
1674
|
+
// Только применённые вердикты: ответ с ошибкой протокола — диагностика.
|
|
1675
|
+
reviews: v
|
|
1676
|
+
.attempt(attempt.id)
|
|
1677
|
+
.evidence.filter((e) => e.kind === 'review' &&
|
|
1678
|
+
!e.passed &&
|
|
1679
|
+
(!e.reviewRound || e.reviewRound.protocol === 'valid')),
|
|
1680
|
+
}));
|
|
1681
|
+
const declared = new Set(work.order.properties ?? []);
|
|
1682
|
+
if (!declared.size)
|
|
1683
|
+
return;
|
|
1684
|
+
const items = [];
|
|
1685
|
+
for (const e of reviews) {
|
|
1686
|
+
const saved = reviewResultSchema.safeParse(JSON.parse(await readFile(e.log, 'utf8').catch(() => 'null')));
|
|
1687
|
+
if (!saved.success)
|
|
1688
|
+
continue;
|
|
1689
|
+
for (const f of saved.data.findings)
|
|
1690
|
+
if (f.reproduction?.property && declared.has(f.reproduction.property))
|
|
1691
|
+
items.push({ sha: e.sha, reproduction: f.reproduction });
|
|
1692
|
+
}
|
|
1693
|
+
for (const item of items.slice(0, 5))
|
|
1694
|
+
try {
|
|
1695
|
+
await this.submitCase({
|
|
1696
|
+
workId: work.id,
|
|
1697
|
+
property: item.reproduction.property,
|
|
1698
|
+
input: item.reproduction.input,
|
|
1699
|
+
expected: item.reproduction.expected,
|
|
1700
|
+
actual: item.reproduction.actual,
|
|
1701
|
+
sourceRunId: attempt.id,
|
|
1702
|
+
sourceSha: item.sha,
|
|
1703
|
+
// Ключ — по предмету примера: тот же пример из следующей попытки
|
|
1704
|
+
// возвращает прежнее решение, а не второе подтверждение.
|
|
1705
|
+
key: `rep-${digestOf([work.id, item.reproduction.property, item.reproduction.input]).slice(0, 40)}`,
|
|
1706
|
+
origin: 'review',
|
|
1707
|
+
runtime: attempt.reviewer,
|
|
1708
|
+
});
|
|
1709
|
+
}
|
|
1710
|
+
catch {
|
|
1711
|
+
// Пример остаётся в артефакте ревью; эталон решит при следующем отказе.
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
async integrate(attempt, work, repo, cwd, worktrees, artifacts, signal, context,
|
|
1715
|
+
/** Новый предел фазы: ревью интеграции не получает остаток времени её проверок. */
|
|
1716
|
+
rearm = () => { }) {
|
|
1717
|
+
this.engine.heartbeat(attempt.id, attempt.token);
|
|
1718
|
+
signal.throwIfAborted();
|
|
1719
|
+
const repoPath = this.repoPath(repo);
|
|
1720
|
+
await this.assertTargetDetached(repo);
|
|
1721
|
+
const base = await git(repoPath, 'rev-parse', repo.targetRef);
|
|
1722
|
+
await git(repoPath, 'worktree', 'add', '--detach', cwd, base);
|
|
1723
|
+
worktrees.push(cwd);
|
|
1724
|
+
try {
|
|
1725
|
+
await git(cwd, '-c', 'core.hooksPath=/dev/null', 'merge', '--no-ff', '--no-edit', '--no-gpg-sign', attempt.candidateSha);
|
|
1726
|
+
}
|
|
1727
|
+
catch (error) {
|
|
1728
|
+
throw new TaskFailure('merge', error instanceof Error ? error.message : String(error));
|
|
1729
|
+
}
|
|
1730
|
+
const sha = await git(cwd, 'rev-parse', 'HEAD');
|
|
1731
|
+
this.engine.phase(attempt.id, attempt.token, 'integrating', { integrationSha: sha });
|
|
1732
|
+
const gates = ordered(repo.gates, attempt.requiredGates ?? repo.gates.map((g) => g.id));
|
|
1733
|
+
const execution = this.environment(attempt, 'integration', cwd);
|
|
1734
|
+
await withEnvironment(repo.lifecycle ?? this.policy.lifecycle, cwd, join(artifacts, 'integration', 'environment'), execution, signal, async () => {
|
|
1735
|
+
await runSteps(repo.prepare, cwd, join(artifacts, 'integration', 'prepare'), execution, signal);
|
|
1736
|
+
for (const gate of gates)
|
|
1737
|
+
await this.gate(attempt, cwd, sha, 'integration', gate, artifacts, signal, execution);
|
|
1738
|
+
await this.consumers(attempt, cwd, sha, artifacts, signal);
|
|
1739
|
+
this.requirements(attempt, work, repoPath, cwd, sha, 'integration');
|
|
1740
|
+
rearm();
|
|
1741
|
+
await this.review(attempt, work, cwd, attempt.baseSha, sha, 'integration', artifacts, signal, context);
|
|
1742
|
+
}, (stage, action) => this.timed(attempt, 'integration-environment:' + stage, action));
|
|
1743
|
+
if ((await git(cwd, 'rev-parse', 'HEAD')) !== sha ||
|
|
1744
|
+
(await git(cwd, 'status', '--porcelain', '--untracked-files=no')))
|
|
1745
|
+
throw new Error('Очистка окружения изменила проверенный код');
|
|
1746
|
+
await assertDependencies(attempt.dependencies ?? []);
|
|
1747
|
+
// Свежесть снимка зависимостей — фиксируется, не отклоняет: параллельная
|
|
1748
|
+
// работа законно двигает ветки зависимостей.
|
|
1749
|
+
if (attempt.dependencies?.length)
|
|
1750
|
+
this.engine.dependencyFreshness(attempt.id, attempt.token, await dependencyFreshness(this.repositories(), attempt.dependencies));
|
|
1751
|
+
signal.throwIfAborted();
|
|
1752
|
+
await this.assertTargetDetached(repo);
|
|
1753
|
+
// Fencing, CAS продвижения и приёмка — одна транзакция. Если процесс упадёт
|
|
1754
|
+
// после update-ref, recovery сверит сохранённые evidence до приёмки.
|
|
1755
|
+
try {
|
|
1756
|
+
this.engine.finish(attempt.id, attempt.token, sha, () => {
|
|
1757
|
+
// Источник требований мог измениться, пока шли проверки и ревью:
|
|
1758
|
+
// актуальность — непосредственно перед продвижением target.
|
|
1759
|
+
if (work.order.requirements.length)
|
|
1760
|
+
assertRequirements(work.order, repoPath);
|
|
1761
|
+
// Внутри транзакции: без hooks репозитория (они исполнили бы чужой код
|
|
1762
|
+
// под блокировкой записи) и с конечным пределом, короче lease.
|
|
1763
|
+
execFileSync('git', ['-c', 'core.hooksPath=/dev/null', 'update-ref', repo.targetRef, sha, base], { cwd: repoPath, stdio: 'pipe', timeout: Runner.refTimeoutMs });
|
|
1764
|
+
});
|
|
1765
|
+
}
|
|
1766
|
+
catch (error) {
|
|
1767
|
+
const text = error instanceof Error
|
|
1768
|
+
? error.message + String(error.stderr ?? '')
|
|
1769
|
+
: String(error);
|
|
1770
|
+
if (/cannot lock ref|is at .* but expected|reference already exists/i.test(text))
|
|
1771
|
+
throw new TaskFailure('stale-base', `Целевая ветка ${repo.targetRef} продвинулась после интеграции; повтор начнётся от актуальной базы`);
|
|
1772
|
+
throw error;
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
/** Сохранённые receipt окружений попытки (`environment.json`) в её артефактах. */
|
|
1777
|
+
async function receipts(dir) {
|
|
1778
|
+
const out = [];
|
|
1779
|
+
for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
|
1780
|
+
const path = join(dir, entry.name);
|
|
1781
|
+
if (entry.isDirectory())
|
|
1782
|
+
out.push(...(await receipts(path)));
|
|
1783
|
+
else if (entry.name === 'environment.json')
|
|
1784
|
+
out.push(path);
|
|
1785
|
+
}
|
|
1786
|
+
return out;
|
|
1787
|
+
}
|
|
1788
|
+
/** Причины ошибки протокола для сообщения: путь, класс, пояснение. */
|
|
1789
|
+
function protocolText(issues) {
|
|
1790
|
+
return (issues
|
|
1791
|
+
.slice(0, 10)
|
|
1792
|
+
.map((i) => `${i.path} ${i.reason}: ${i.message}`)
|
|
1793
|
+
.join('; ') || 'причина не записана').slice(0, 3000);
|
|
1794
|
+
}
|
|
1795
|
+
/** Структурированная история находок для запроса reviewer (`RuntimeRequest.findings`). */
|
|
1796
|
+
function requestFindings(history) {
|
|
1797
|
+
const item = (f) => ({
|
|
1798
|
+
id: f.id,
|
|
1799
|
+
severity: f.severity,
|
|
1800
|
+
message: f.message,
|
|
1801
|
+
path: f.path,
|
|
1802
|
+
line: f.line,
|
|
1803
|
+
status: f.status,
|
|
1804
|
+
origin: {
|
|
1805
|
+
runId: f.origin.runId,
|
|
1806
|
+
phase: f.origin.phase,
|
|
1807
|
+
sha: f.origin.sha,
|
|
1808
|
+
round: f.origin.round,
|
|
1809
|
+
},
|
|
1810
|
+
...(f.basis ? { basis: f.basis } : {}),
|
|
1811
|
+
});
|
|
1812
|
+
return {
|
|
1813
|
+
scope: history.scope,
|
|
1814
|
+
open: history.findings.filter((f) => f.status === 'open').map(item),
|
|
1815
|
+
closed: history.findings.filter((f) => f.status !== 'open').map(item),
|
|
1816
|
+
};
|
|
1817
|
+
}
|
|
1818
|
+
/**
|
|
1819
|
+
* Начатые вызовы reviewer попытки на стадии ревью фазы — по записям учёта
|
|
1820
|
+
* вызовов (`usage`), которые `measuredInvoke` создаёт до внешнего вызова.
|
|
1821
|
+
*/
|
|
1822
|
+
function startedCalls(view, repositoryId, runId, stage) {
|
|
1823
|
+
const records = view
|
|
1824
|
+
.locals('usage', repositoryId)
|
|
1825
|
+
.map((x) => x.value)
|
|
1826
|
+
.filter((r) => r.runId === runId && r.stage === stage);
|
|
1827
|
+
const starts = records.map((r) => Date.parse(r.startedAt)).filter((t) => !Number.isNaN(t));
|
|
1828
|
+
return {
|
|
1829
|
+
count: records.length,
|
|
1830
|
+
...(starts.length ? { firstStartMs: Math.min(...starts) } : {}),
|
|
1831
|
+
spentMs: records.reduce((n, r) => n +
|
|
1832
|
+
(r.durationMs ??
|
|
1833
|
+
r.elapsedMs ??
|
|
1834
|
+
(r.outcome === 'active' ? Math.max(0, Date.now() - Date.parse(r.startedAt)) : 0)), 0),
|
|
1835
|
+
};
|
|
1836
|
+
}
|