carthagent 0.0.0-stage → 1.0.7
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 +219 -2
- package/bin/ctg.mjs +736 -0
- package/docs/budgets.md +72 -0
- package/docs/compaction.md +34 -0
- package/docs/context-efficiency-plan.md +24 -0
- package/docs/context-efficiency-review.md +27 -0
- package/docs/context-efficiency.md +49 -0
- package/docs/deepseek-jev-benchmark-plan.md +70 -0
- package/docs/deepseek-jev-benchmark-repair-plan.md +20 -0
- package/docs/deepseek-jev-benchmark-restart.md +9 -0
- package/docs/deepseek-jev-benchmark-results.md +77 -0
- package/docs/deepseek-jev-benchmark-running.md +60 -0
- package/docs/deepseek-jev-benchmark-v3-results.md +41 -0
- package/docs/deepseek-jev-benchmark-v4-protocol.md +15 -0
- package/docs/deepseek-jev-benchmark-v4-results.md +119 -0
- package/docs/deepseek-jev-http400-diagnosis.md +45 -0
- package/docs/delivery-review-evidence.md +53 -0
- package/docs/delivery-timeout-repair-plan.md +51 -0
- package/docs/evaluation-readiness.md +31 -0
- package/docs/evaluation.md +70 -0
- package/docs/extending.md +354 -0
- package/docs/glm-pilot-execution.md +9 -0
- package/docs/glm-pilot-plan.md +15 -0
- package/docs/glm-pilot-results.md +71 -0
- package/docs/graph-context-optimization.md +15 -0
- package/docs/jev-repository-pilot-plan.md +25 -0
- package/docs/jev-repository-pilot-protocol.md +39 -0
- package/docs/jev-repository-pilot-restart.md +7 -0
- package/docs/jev-repository-pilot-results.md +79 -0
- package/docs/jev.md +32 -0
- package/docs/model-registry.md +25 -0
- package/docs/native-compatibility.md +117 -0
- package/docs/opencode-archive.md +9 -0
- package/docs/opencode-migration.md +115 -0
- package/docs/opencode-runtime.md +94 -0
- package/docs/opencode-workspace.md +31 -0
- package/docs/owned-runtime-workflow-registration.md +17 -0
- package/docs/plan-advisor-evaluation.md +38 -0
- package/docs/planning-startup-fix-plan.md +23 -0
- package/docs/planning-startup-fix-review.md +24 -0
- package/docs/planning.md +118 -0
- package/docs/production-merge-review.md +23 -0
- package/docs/quality.md +35 -0
- package/docs/repository-benchmark-design.md +111 -0
- package/docs/review-assurance.md +9 -0
- package/docs/review-experiment-results.md +59 -0
- package/docs/tested-planning-roadmap.md +58 -0
- package/docs/tested-planning.md +58 -0
- package/docs/tui-pickers.md +9 -0
- package/docs/webdriver.md +83 -0
- package/docs/workflow-modes.md +26 -0
- package/docs/workflow.d2 +17 -0
- package/docs/workflow.svg +149 -0
- package/docs/workspace-coordination.md +9 -0
- package/evaluate.mjs +249 -0
- package/extensions/codegraph.ts +36 -0
- package/extensions/delivery.ts +819 -0
- package/extensions/experiential.ts +21 -0
- package/extensions/subscriptions.ts +21 -0
- package/guidance/profiles/authenticated-web.json +24 -0
- package/guidance/profiles/data-visualization.json +25 -0
- package/guidance/profiles/database-change.json +24 -0
- package/guidance/profiles/external-api.json +24 -0
- package/guidance/profiles/transactional-data.json +25 -0
- package/guidance/profiles/web-application.json +26 -0
- package/lib/acceptance-review.mjs +4 -0
- package/lib/browser-bin.mjs +160 -0
- package/lib/browser-errors.mjs +54 -0
- package/lib/browser.mjs +219 -0
- package/lib/budget.mjs +37 -0
- package/lib/cdp.mjs +221 -0
- package/lib/cloud/client.mjs +275 -0
- package/lib/cloud/control-plane.mjs +350 -0
- package/lib/cloud/gateway.mjs +380 -0
- package/lib/cloud/http.mjs +135 -0
- package/lib/cloud/ledger.mjs +215 -0
- package/lib/codegraph-query.mjs +60 -0
- package/lib/codegraph.mjs +378 -0
- package/lib/context-archive.mjs +18 -0
- package/lib/delivery-context.mjs +132 -0
- package/lib/delivery-guidance.mjs +9 -0
- package/lib/delivery-review.mjs +94 -0
- package/lib/delivery.mjs +610 -0
- package/lib/engine.mjs +137 -0
- package/lib/guidance.mjs +71 -0
- package/lib/jev-advisor.mjs +47 -0
- package/lib/opencode/archive.mjs +122 -0
- package/lib/opencode/compatibility.mjs +19 -0
- package/lib/opencode/credentials.mjs +205 -0
- package/lib/opencode/evaluation-budget-plugin/index.ts +147 -0
- package/lib/opencode/evaluation.mjs +242 -0
- package/lib/opencode/file-lock.mjs +86 -0
- package/lib/opencode/headless.mjs +200 -0
- package/lib/opencode/review.mjs +213 -0
- package/lib/opencode/runtime.mjs +246 -0
- package/lib/opencode/service.mjs +249 -0
- package/lib/opencode/session-options.mjs +60 -0
- package/lib/opencode/workspace.mjs +133 -0
- package/lib/openrouter-jev.mjs +37 -0
- package/lib/outcome-contract.mjs +21 -0
- package/lib/plan-advisor.mjs +38 -0
- package/lib/planning-access.mjs +7 -0
- package/lib/planning-advice.mjs +28 -0
- package/lib/planning-discovery.mjs +14 -0
- package/lib/planning-guide.mjs +34 -0
- package/lib/planning-view.mjs +57 -0
- package/lib/planning.mjs +155 -0
- package/lib/probe-diagnostics.mjs +13 -0
- package/lib/providers/experiential.mjs +292 -0
- package/lib/providers/model-catalog.json +700 -0
- package/lib/providers/model-catalog.mjs +7 -0
- package/lib/providers/names.mjs +13 -0
- package/lib/providers/subscription-models.mjs +483 -0
- package/lib/reports.mjs +80 -0
- package/lib/review-state.mjs +49 -0
- package/lib/review.mjs +114 -0
- package/lib/scenarios.mjs +99 -0
- package/lib/ship-auth.mjs +44 -0
- package/lib/ship-jev.mjs +59 -0
- package/lib/ship-telemetry.mjs +183 -0
- package/lib/tui/ansi.mjs +255 -0
- package/lib/tui/app.mjs +2445 -0
- package/lib/tui/auth.mjs +249 -0
- package/lib/tui/carthage.mjs +109 -0
- package/lib/tui/clipboard.mjs +122 -0
- package/lib/tui/d2.mjs +431 -0
- package/lib/tui/feed.mjs +467 -0
- package/lib/tui/framing.mjs +80 -0
- package/lib/tui/glyphs.mjs +172 -0
- package/lib/tui/hash-worker.mjs +11 -0
- package/lib/tui/mock-engine.mjs +222 -0
- package/lib/tui/models.mjs +73 -0
- package/lib/tui/picker.mjs +18 -0
- package/lib/tui/theme.mjs +663 -0
- package/lib/tui/title.mjs +7 -0
- package/lib/update-channel.mjs +62 -0
- package/lib/update.mjs +230 -0
- package/lib/webdriver.mjs +82 -0
- package/lib/workflow.mjs +88 -0
- package/lib/workspace.mjs +39 -0
- package/metadata.json +8 -0
- package/package.json +94 -4
- package/plugins/carthagent/index.ts +3 -0
- package/plugins/carthagent/package.json +28 -0
- package/plugins/carthagent/src/archive-rpc.ts +46 -0
- package/plugins/carthagent/src/bootstrap.ts +38 -0
- package/plugins/carthagent/src/index.ts +62 -0
- package/plugins/carthagent/src/pull-request-rpc.ts +54 -0
- package/plugins/carthagent/src/rpc.ts +76 -0
- package/plugins/carthagent/src/server/archive-rpc.ts +327 -0
- package/plugins/carthagent/src/server/budgets.ts +21 -0
- package/plugins/carthagent/src/server/codegraph.ts +46 -0
- package/plugins/carthagent/src/server/delivery.ts +488 -0
- package/plugins/carthagent/src/server/device-auth.ts +91 -0
- package/plugins/carthagent/src/server/native-delivery.ts +26 -0
- package/plugins/carthagent/src/server/orchestration.ts +56 -0
- package/plugins/carthagent/src/server/policy.ts +24 -0
- package/plugins/carthagent/src/server/providers.ts +424 -0
- package/plugins/carthagent/src/server/pull-request-rpc.ts +387 -0
- package/plugins/carthagent/src/server/session-controls.ts +82 -0
- package/plugins/carthagent/src/server/telemetry.ts +92 -0
- package/plugins/carthagent/src/server/workspace-rpc.ts +349 -0
- package/plugins/carthagent/src/session-rpc.ts +31 -0
- package/plugins/carthagent/src/testing.ts +85 -0
- package/plugins/carthagent/src/workspace-rpc.ts +133 -0
- package/plugins/carthagent/test-plugin/index.ts +4 -0
- package/plugins/carthagent/test-plugin/package.json +1 -0
- package/plugins/carthagent/tsconfig.json +19 -0
- package/plugins/carthagent/tui.ts +1 -0
- package/prompts/delivery.md +7 -0
- package/public/index.html +983 -0
- package/scenarios/cli/grade_cli.py +69 -0
- package/scenarios/cli/scenario.json +21 -0
- package/scenarios/game/baseline/minecraft.py +193 -0
- package/scenarios/game/context.md +1 -0
- package/scenarios/game/grade_minecraft_clone.py +603 -0
- package/scenarios/game/requirements.txt +2 -0
- package/scenarios/game/scenario.json +33 -0
- package/server.mjs +341 -0
- package/skills/game-development/SKILL.md +36 -0
- package/skills/game-development/assets/ursina_starter.py +98 -0
- package/skills/game-development/scripts/verify_ursina.py +80 -0
- package/skills/software-delivery/SKILL.md +50 -0
- package/skills/typescript-delivery/SKILL.md +69 -0
- package/tests/acceptance-review.test.mjs +13 -0
- package/tests/auth.test.mjs +234 -0
- package/tests/bounded-summary.test.mjs +68 -0
- package/tests/browser-bin.test.mjs +91 -0
- package/tests/browser.test.mjs +96 -0
- package/tests/budget-engine.test.mjs +110 -0
- package/tests/budget.test.mjs +23 -0
- package/tests/cli.test.mjs +245 -0
- package/tests/clipboard.test.mjs +79 -0
- package/tests/cloud-client.test.mjs +129 -0
- package/tests/cloud-control-plane.test.mjs +144 -0
- package/tests/cloud-gateway.test.mjs +147 -0
- package/tests/cloud-http.test.mjs +102 -0
- package/tests/cloud-ledger.test.mjs +75 -0
- package/tests/codegraph-query.test.mjs +66 -0
- package/tests/codegraph.test.mjs +142 -0
- package/tests/delivery-context.test.mjs +66 -0
- package/tests/delivery-review.test.mjs +139 -0
- package/tests/delivery.test.mjs +343 -0
- package/tests/engine-api.mjs +24 -0
- package/tests/experiential-extension.test.mjs +37 -0
- package/tests/experiential.test.mjs +300 -0
- package/tests/extension.test.mjs +921 -0
- package/tests/fixtures/archive-plugin/index.ts +10 -0
- package/tests/guidance.test.mjs +78 -0
- package/tests/model-registry.test.mjs +68 -0
- package/tests/models.test.mjs +70 -0
- package/tests/openai-idempotency.test.mjs +99 -0
- package/tests/opencode-archive-rpc.test.mjs +82 -0
- package/tests/opencode-delivery.test.mjs +231 -0
- package/tests/opencode-providers.test.mjs +414 -0
- package/tests/opencode-runtime.test.mjs +455 -0
- package/tests/opencode-session-controls.test.mjs +115 -0
- package/tests/opencode-session-options.test.mjs +26 -0
- package/tests/opencode-workspace.test.mjs +339 -0
- package/tests/outcome-contract.test.mjs +17 -0
- package/tests/picker.test.mjs +34 -0
- package/tests/plan-advisor.test.mjs +88 -0
- package/tests/plan-review-loop.test.mjs +53 -0
- package/tests/planning-advice.test.mjs +27 -0
- package/tests/planning-contract.test.mjs +65 -0
- package/tests/planning-startup.test.mjs +38 -0
- package/tests/planning-view.test.mjs +33 -0
- package/tests/planning.test.mjs +223 -0
- package/tests/probe-diagnostics.test.mjs +21 -0
- package/tests/pull-request-rpc.test.mjs +366 -0
- package/tests/review-state.test.mjs +32 -0
- package/tests/review.test.mjs +127 -0
- package/tests/server.test.mjs +252 -0
- package/tests/ship-jev.test.mjs +77 -0
- package/tests/ship-telemetry.test.mjs +333 -0
- package/tests/standard-workflow.test.mjs +28 -0
- package/tests/subscription-models.test.mjs +416 -0
- package/tests/subscriptions-extension.test.mjs +27 -0
- package/tests/terminal-title.test.mjs +13 -0
- package/tests/test_grade_minecraft_clone.py +199 -0
- package/tests/test_starter.py +78 -0
- package/tests/theme.test.mjs +175 -0
- package/tests/tui-copy-paste.test.mjs +54 -0
- package/tests/tui.test.mjs +808 -0
- package/tests/update-channels.test.mjs +129 -0
- package/tests/update.test.mjs +175 -0
- package/tests/webdriver.test.mjs +31 -0
- package/tests/workflow.test.mjs +47 -0
- package/tests/workspace-rpc.test.mjs +236 -0
- package/tests/workspace.test.mjs +24 -0
- package/tools/benchmark/advisor.test.mjs +7 -0
- package/tools/benchmark/advisor.ts +27 -0
- package/tools/benchmark/campaign.mjs +45 -0
- package/tools/benchmark/diagnose-request.mjs +40 -0
- package/tools/benchmark/eval-common.mjs +32 -0
- package/tools/benchmark/eval-ledger.mjs +22 -0
- package/tools/benchmark/eval-shop.mjs +26 -0
- package/tools/benchmark/eval-voxel.mjs +20 -0
- package/tools/benchmark/evaluate.mjs +24 -0
- package/tools/benchmark/evaluators.test.mjs +13 -0
- package/tools/benchmark/fixtures/shop/server.mjs +18 -0
- package/tools/benchmark/fixtures/shop/ui.html +8 -0
- package/tools/benchmark/freeze.mjs +30 -0
- package/tools/benchmark/gateway.mjs +91 -0
- package/tools/benchmark/gateway.test.mjs +38 -0
- package/tools/benchmark/generator.mjs +11 -0
- package/tools/benchmark/generator.test.mjs +26 -0
- package/tools/benchmark/guard.test.mjs +133 -0
- package/tools/benchmark/guard.ts +136 -0
- package/tools/benchmark/native-plugin/index.ts +24 -0
- package/tools/benchmark/planning-startup.test.mjs +47 -0
- package/tools/benchmark/preflight-live.mjs +31 -0
- package/tools/benchmark/preflight.mjs +22 -0
- package/tools/benchmark/replay-diagnostic.mjs +23 -0
- package/tools/benchmark/runner.mjs +141 -0
- package/tools/benchmark/runner.test.mjs +93 -0
- package/tools/benchmark/runtime.mjs +23 -0
- package/tools/benchmark/selftest.mjs +47 -0
- package/tools/benchmark/smoke-jev.mjs +5 -0
- package/tools/benchmark/tasks.mjs +20 -0
- package/tools/benchmark/transport.mjs +65 -0
- package/tools/benchmark/transport.test.mjs +44 -0
- package/tools/benchmark/upstream-error.mjs +18 -0
- package/tools/benchmark/upstream-error.test.mjs +10 -0
- package/tools/browser-check.mjs +109 -0
- package/tools/check-syntax.mjs +27 -0
- package/tools/evaluate-plan-advisor.mjs +20 -0
- package/tools/native-fixture.mjs +194 -0
- package/tools/native-pty-smoke.py +108 -0
- package/tools/native-report-plugin/index.ts +50 -0
- package/tools/native-report-plugin/package.json +7 -0
- package/tools/patch-compaction.mjs +24 -0
- package/tools/patch-model-catalog.mjs +12 -0
- package/tools/patch-print-shutdown.mjs +39 -0
- package/tools/repo-pilot/evaluator.cjs +32 -0
- package/tools/repo-pilot/reference.mjs +43 -0
- package/tools/repo-pilot/runner.mjs +85 -0
- package/tools/repo-pilot/sandbox-entry.mjs +12 -0
- package/tools/repo-pilot/tasks.mjs +20 -0
- package/tools/repo-pilot/type-evaluator.cjs +6 -0
- package/tools/test-native-controls.mjs +84 -0
- package/tools/test-native-delivery-host.mjs +307 -0
- package/tools/test-native-evaluation.mjs +46 -0
- package/tools/test-native-host.mjs +385 -0
- package/tools/test-native-outcomes.mjs +73 -0
- package/tools/test-native-package.mjs +70 -0
- package/tools/test-native-pty.mjs +20 -0
- package/tools/test-native-report.mjs +88 -0
- package/tools/test-native-swe-host.mjs +132 -0
- package/tools/test-native-swe.mjs +122 -0
- package/tools/test-picker-pty.py +45 -0
- package/tools/test-python.mjs +29 -0
- package/tools/test-webdriver.mjs +54 -0
- package/tools/verify-release-version.mjs +37 -0
- package/tools/webdriver-check.mjs +52 -0
- package/vendor/agent/bounded-summary.mjs +88 -0
- package/vendor/agent/chunks/anthropic-messages-VWZZOSJQ.js +6 -0
- package/vendor/agent/chunks/anthropic.js +75 -0
- package/vendor/agent/chunks/azure-openai-responses-BJLAM6CD.js +2 -0
- package/vendor/agent/chunks/bedrock-converse-stream.js +74 -0
- package/vendor/agent/chunks/chord-context.js +96 -0
- package/vendor/agent/chunks/chunk-4L3WN2XY.js +2 -0
- package/vendor/agent/chunks/chunk-6CYCI3EJ.js +12 -0
- package/vendor/agent/chunks/chunk-6R7UI4IY.js +2 -0
- package/vendor/agent/chunks/chunk-7ZC4UXSL.js +2 -0
- package/vendor/agent/chunks/chunk-AXIIZGTV.js +2 -0
- package/vendor/agent/chunks/chunk-CLNPYIDP.js +42 -0
- package/vendor/agent/chunks/chunk-DRQDKNXA.js +2 -0
- package/vendor/agent/chunks/chunk-GMWCTUPB.js +2 -0
- package/vendor/agent/chunks/chunk-IDDQWTHI.js +2 -0
- package/vendor/agent/chunks/chunk-JVUZSMYM.js +1548 -0
- package/vendor/agent/chunks/chunk-L2ZPNLYD.js +2 -0
- package/vendor/agent/chunks/chunk-NUHFSC37.js +25 -0
- package/vendor/agent/chunks/chunk-OUXLLA64.js +11 -0
- package/vendor/agent/chunks/chunk-PGXDEOVV.js +2 -0
- package/vendor/agent/chunks/chunk-PJACZCIR.js +2 -0
- package/vendor/agent/chunks/chunk-PRRNXB7S.js +2 -0
- package/vendor/agent/chunks/chunk-TEPMHNKQ.js +2 -0
- package/vendor/agent/chunks/chunk-TRDNDS6A.js +4 -0
- package/vendor/agent/chunks/chunk-U6ZMQKGD.js +50 -0
- package/vendor/agent/chunks/chunk-UAQELI3K.js +2 -0
- package/vendor/agent/chunks/chunk-USJZMAIR.js +2 -0
- package/vendor/agent/chunks/chunk-XDUWOHPL.js +6 -0
- package/vendor/agent/chunks/chunk-XNGRGP62.js +2 -0
- package/vendor/agent/chunks/github-copilot.js +2 -0
- package/vendor/agent/chunks/google-generative-ai-Q2K7FBDI.js +2 -0
- package/vendor/agent/chunks/google-vertex-7UJBF4S7.js +2 -0
- package/vendor/agent/chunks/https-proxy-agent-2VXB7436.js +13 -0
- package/vendor/agent/chunks/image-resize-worker.js +2 -0
- package/vendor/agent/chunks/kimi-coding.js +2 -0
- package/vendor/agent/chunks/lib-HGQMNYVT.js +9 -0
- package/vendor/agent/chunks/mistral-conversations-4M6BZBX6.js +5 -0
- package/vendor/agent/chunks/multipart-parser-SQ6GDJL7.js +3 -0
- package/vendor/agent/chunks/node-KG362ECQ.js +16 -0
- package/vendor/agent/chunks/node-TWVVAGPO.js +2 -0
- package/vendor/agent/chunks/openai-codex-responses-R6VYZTVY.js +10 -0
- package/vendor/agent/chunks/openai-codex.js +75 -0
- package/vendor/agent/chunks/openai-completions-EKZT2IH2.js +7 -0
- package/vendor/agent/chunks/openai-responses-TFDINO6W.js +2 -0
- package/vendor/agent/chunks/openrouter-images-N4XVVFSD.js +2 -0
- package/vendor/agent/chunks/openrouter.js +75 -0
- package/vendor/agent/chunks/pi-messages-PML4SPVJ.js +8 -0
- package/vendor/agent/chunks/radius.js +75 -0
- package/vendor/agent/chunks/src-UK77SYZE.js +4 -0
- package/vendor/agent/chunks/xai.js +2 -0
- package/vendor/agent/cli.js +3 -0
- package/vendor/agent/dist/modes/interactive/theme/dark.json +91 -0
- package/vendor/agent/dist/modes/interactive/theme/light.json +90 -0
- package/vendor/agent/dist/modes/interactive/theme/theme-schema.json +356 -0
- package/vendor/agent/index.js +2 -0
- package/vendor/agent/modes/interactive/theme/dark.json +91 -0
- package/vendor/agent/modes/interactive/theme/light.json +90 -0
- package/vendor/agent/modes/interactive/theme/theme-schema.json +356 -0
- package/vendor/agent/package.json +14 -0
- package/vendor/agent/rpc-entry.js +3 -0
- package/vendor/agent/theme/dark.json +91 -0
- package/vendor/agent/theme/light.json +90 -0
- package/vendor/agent/theme/theme-schema.json +356 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { Plugin } from '@opencode/plugin';
|
|
2
|
+
|
|
3
|
+
type Turn = { id: string; finish: string; productive: boolean };
|
|
4
|
+
const ledgerKey = (sessionID: string) => `evaluation/${sessionID}/turns`;
|
|
5
|
+
const budgetKey = 'ctg.evaluation.budget';
|
|
6
|
+
const retryKey = (sessionID: string) => `evaluation/${sessionID}/retries`;
|
|
7
|
+
|
|
8
|
+
/** Durable per-session admission boundary for productive OpenCode model requests. */
|
|
9
|
+
export default Plugin.define({
|
|
10
|
+
id: 'ctg.evaluation-budget',
|
|
11
|
+
async setup(ctx) {
|
|
12
|
+
const maxTurns = Number(process.env.CTG_EVALUATION_MAX_TURNS);
|
|
13
|
+
const maxCost = Number(process.env.CTG_EVALUATION_MAX_COST);
|
|
14
|
+
const maxRetries = Number(process.env.CTG_EVALUATION_MAX_RETRIES ?? 8);
|
|
15
|
+
if (!Number.isInteger(maxTurns) || maxTurns < 1 || maxTurns > 1000) throw Error('Invalid evaluation turn limit');
|
|
16
|
+
if (!Number.isFinite(maxCost) || maxCost <= 0) throw Error('Invalid evaluation cost limit');
|
|
17
|
+
if (!Number.isInteger(maxRetries) || maxRetries < 0 || maxRetries > 100) throw Error('Invalid evaluation retry limit');
|
|
18
|
+
|
|
19
|
+
// Durable, ID-keyed session events populate the persistent ledger. The
|
|
20
|
+
// storage ledger survives compaction and service restarts; message IDs make
|
|
21
|
+
// repeated delivery idempotent and prevent double-counting completed turns.
|
|
22
|
+
const turnsBySession = new Map<string, Map<string, Turn>>();
|
|
23
|
+
const eventAbort = new AbortController();
|
|
24
|
+
const pendingWrites = new Map<string, Promise<void>>();
|
|
25
|
+
const retriesBySession = new Map<string, number>();
|
|
26
|
+
const eventTask = (async () => {
|
|
27
|
+
for await (const event of ctx.event.subscribe({ signal: eventAbort.signal })) {
|
|
28
|
+
if (!event.type.startsWith('session.') || !('sessionID' in event.data)) continue;
|
|
29
|
+
const sessionID = event.data.sessionID;
|
|
30
|
+
if (event.type === 'session.step.ended' || event.type === 'session.step.failed') {
|
|
31
|
+
const messageID = event.data.assistantMessageID;
|
|
32
|
+
const turns = await loadTurns(sessionID);
|
|
33
|
+
const failed = event.type === 'session.step.failed' || event.data.finish === 'error';
|
|
34
|
+
turns.set(messageID, { id: messageID, finish: failed ? 'error' : event.data.finish ?? 'error', productive: !failed });
|
|
35
|
+
await saveTurns(sessionID, turns);
|
|
36
|
+
} else if (event.type === 'session.execution.failed') {
|
|
37
|
+
// Retry exhaustion can terminate an execution without a final step event.
|
|
38
|
+
// It does not count as productive work, but remains separately bounded.
|
|
39
|
+
await ctx.storage.set(retryKey(sessionID), retriesBySession.get(sessionID) ?? 0);
|
|
40
|
+
} else if (event.type === 'session.compaction.ended') {
|
|
41
|
+
const transcript = await ctx.session.context({ sessionID });
|
|
42
|
+
const turns = await loadTurns(sessionID);
|
|
43
|
+
for (const message of transcript) {
|
|
44
|
+
if (message.type !== 'assistant' || !message.finish) continue;
|
|
45
|
+
if (message.finish === 'error') turns.set(message.id, { id: message.id, finish: 'error', productive: false });
|
|
46
|
+
else turns.set(message.id, { id: message.id, finish: message.finish, productive: true });
|
|
47
|
+
}
|
|
48
|
+
await saveTurns(sessionID, turns);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
})();
|
|
52
|
+
// Async iteration normally exits when cleanup aborts its subscription. Keep a
|
|
53
|
+
// terminal rejection observed so it cannot become an unhandled plugin error.
|
|
54
|
+
void eventTask.catch(() => undefined);
|
|
55
|
+
|
|
56
|
+
async function loadTurns(sessionID: string) {
|
|
57
|
+
await pendingWrites.get(sessionID);
|
|
58
|
+
const turns = new Map<string, Turn>();
|
|
59
|
+
const persisted = await ctx.storage.get(ledgerKey(sessionID));
|
|
60
|
+
if (Array.isArray(persisted)) {
|
|
61
|
+
for (const item of persisted) {
|
|
62
|
+
if (item && typeof item === 'object' && 'id' in item && 'finish' in item && 'productive' in item
|
|
63
|
+
&& typeof item.id === 'string' && typeof item.finish === 'string' && typeof item.productive === 'boolean') turns.set(item.id, item as Turn);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const [id, turn] of turnsBySession.get(sessionID) ?? []) turns.set(id, turn);
|
|
67
|
+
turnsBySession.set(sessionID, turns);
|
|
68
|
+
return turns;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function saveTurns(sessionID: string, turns: Map<string, Turn>) {
|
|
72
|
+
const before = pendingWrites.get(sessionID) ?? Promise.resolve();
|
|
73
|
+
const write = before.catch(() => undefined).then(() => ctx.storage.set(ledgerKey(sessionID), [...turns.values()]));
|
|
74
|
+
pendingWrites.set(sessionID, write);
|
|
75
|
+
try { await write; }
|
|
76
|
+
finally { if (pendingWrites.get(sessionID) === write) pendingWrites.delete(sessionID); }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let modelRequest;
|
|
80
|
+
let retry;
|
|
81
|
+
try {
|
|
82
|
+
modelRequest = await ctx.session.hook('model.request', async event => {
|
|
83
|
+
if (event.kind !== 'primary') return;
|
|
84
|
+
await pendingWrites.get(event.sessionID);
|
|
85
|
+
const session = await ctx.session.get({ sessionID: event.sessionID });
|
|
86
|
+
const configured = session.metadata?.[budgetKey];
|
|
87
|
+
if (!configured || typeof configured !== 'object'
|
|
88
|
+
|| configured.maxTurns !== maxTurns || configured.maxCost !== maxCost) {
|
|
89
|
+
throw Error('Evaluation session budget handshake is missing or differs from the active admission guard');
|
|
90
|
+
}
|
|
91
|
+
const turns = await loadTurns(event.sessionID);
|
|
92
|
+
// Reconcile the host's durable session projection before every admission.
|
|
93
|
+
// The storage ledger retains pre-compaction IDs; context contributes any
|
|
94
|
+
// completed assistant turns written before a service/plugin restart.
|
|
95
|
+
const transcript = await ctx.session.context({ sessionID:event.sessionID });
|
|
96
|
+
for (const message of transcript) {
|
|
97
|
+
if (message.type !== 'assistant' || !message.finish) continue;
|
|
98
|
+
// A retained compacted summary is a new assistant message, but never
|
|
99
|
+
// an admitted model request; only IDs seen in durable step events count.
|
|
100
|
+
if (message.metadata?.synthetic === true || message.content?.some(part => part.type === 'compaction')) continue;
|
|
101
|
+
turns.set(message.id, { id:message.id, finish:message.finish, productive:message.finish !== 'error' });
|
|
102
|
+
}
|
|
103
|
+
await saveTurns(event.sessionID, turns);
|
|
104
|
+
const usedTurns = [...turns.values()].filter(turn => turn.productive).length;
|
|
105
|
+
if (usedTurns >= maxTurns) throw Error('Evaluation productive model-turn budget exhausted before request admission');
|
|
106
|
+
|
|
107
|
+
const info = session;
|
|
108
|
+
const model = (await ctx.model.list({ location: { directory: ctx.location.directory } })).data
|
|
109
|
+
.find(candidate => candidate.providerID === event.model.providerID && candidate.id === event.model.id);
|
|
110
|
+
const rates = model?.cost;
|
|
111
|
+
if (!Number.isFinite(info.cost) || !rates?.length || !Number.isFinite(model?.limit?.context) || !Number.isFinite(model?.limit?.output)) {
|
|
112
|
+
throw Error('Evaluation usage or model pricing limits unavailable; refusing request admission');
|
|
113
|
+
}
|
|
114
|
+
const maxInputRate = Math.max(...rates.map(rate => rate.input), ...rates.map(rate => rate.cache.read));
|
|
115
|
+
const maxOutputRate = Math.max(...rates.map(rate => rate.output), ...rates.map(rate => rate.cache.write));
|
|
116
|
+
const worstCaseCost = (model.limit.context * maxInputRate + model.limit.output * maxOutputRate) / 1_000_000;
|
|
117
|
+
if (!Number.isFinite(worstCaseCost) || info.cost + worstCaseCost > maxCost) throw Error('Evaluation cost budget exhausted before request admission');
|
|
118
|
+
});
|
|
119
|
+
retry = await ctx.session.hook('retry', async event => {
|
|
120
|
+
if (event.attempt >= maxRetries) {
|
|
121
|
+
event.decision = { retry: false };
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const persisted = await ctx.storage.get(retryKey(event.sessionID));
|
|
125
|
+
const total = Math.max(retriesBySession.get(event.sessionID) ?? 0, typeof persisted === 'number' ? persisted : 0) + 1;
|
|
126
|
+
retriesBySession.set(event.sessionID, total);
|
|
127
|
+
await ctx.storage.set(retryKey(event.sessionID), total);
|
|
128
|
+
if (total > maxRetries) event.decision = { retry: false };
|
|
129
|
+
});
|
|
130
|
+
} catch (error) {
|
|
131
|
+
eventAbort.abort();
|
|
132
|
+
await retry?.dispose();
|
|
133
|
+
await modelRequest?.dispose();
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
return async () => {
|
|
137
|
+
try { await retry.dispose(); }
|
|
138
|
+
finally {
|
|
139
|
+
try { await modelRequest.dispose(); }
|
|
140
|
+
finally {
|
|
141
|
+
eventAbort.abort();
|
|
142
|
+
await eventTask.catch(() => undefined);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
});
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { OpenCode } from '@opencode/client';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
export const NATIVE_EVALUATION_RUNTIME = 'opencode-2';
|
|
5
|
+
export const EVALUATION_BUDGET_PLUGIN = 'ctg.evaluation-budget';
|
|
6
|
+
export const EVALUATION_BUDGET_METADATA = 'ctg.evaluation.budget';
|
|
7
|
+
export const hashFile = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
8
|
+
|
|
9
|
+
function positive(value, label, maximum = Number.MAX_SAFE_INTEGER) {
|
|
10
|
+
if (!Number.isFinite(value) || value <= 0 || value > maximum) throw Error(`Invalid ${label}`);
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
const assistantMessages = messages => messages.filter(message => message.type === 'assistant');
|
|
14
|
+
|
|
15
|
+
async function allMessages(api, sessionID, options = {}) {
|
|
16
|
+
const messages = [];
|
|
17
|
+
let cursor;
|
|
18
|
+
do {
|
|
19
|
+
const page = await api.message.list({ sessionID, limit: 200, ...(cursor ? { cursor } : { order:'asc' }) }, options);
|
|
20
|
+
messages.push(...page.data);
|
|
21
|
+
cursor = page.cursor.next;
|
|
22
|
+
} while (cursor);
|
|
23
|
+
return messages;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function bounded(promise, milliseconds, fallback) {
|
|
27
|
+
let timer;
|
|
28
|
+
try {
|
|
29
|
+
return await Promise.race([
|
|
30
|
+
Promise.resolve(promise),
|
|
31
|
+
new Promise(resolve => { timer = setTimeout(() => resolve(fallback), milliseconds); }),
|
|
32
|
+
]);
|
|
33
|
+
} finally {
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Run one native OpenCode session with fail-closed accounting and cancellation. */
|
|
39
|
+
export async function runNativeEvaluation({ directory, prompt, systemPrompt = '', model, provider, timeoutSeconds = 600, maxTurns = 100, maxCost = 3,
|
|
40
|
+
signal, onEvent = () => {}, client, baseUrl = process.env.CTG_OPENCODE_URL, headers = {}, version = '2.0.32', outcomeTimeoutSeconds = 10 }) {
|
|
41
|
+
positive(timeoutSeconds, 'timeoutSeconds', 3600);
|
|
42
|
+
positive(maxTurns, 'maxTurns', 1000);
|
|
43
|
+
positive(maxCost, 'maxCost');
|
|
44
|
+
positive(outcomeTimeoutSeconds, 'outcomeTimeoutSeconds', 60);
|
|
45
|
+
if (!directory || !prompt || !model || !provider) throw Error('directory, prompt, provider, and model are required');
|
|
46
|
+
if (!client && !baseUrl) throw Error('CTG_OPENCODE_URL must identify the isolated native OpenCode service; shared service discovery is disabled.');
|
|
47
|
+
if (!client && !Object.keys(headers).length && process.env.CTG_OPENCODE_AUTHORIZATION) headers = { authorization: process.env.CTG_OPENCODE_AUTHORIZATION };
|
|
48
|
+
const api = client || OpenCode.make({ baseUrl, headers });
|
|
49
|
+
const controller = new AbortController();
|
|
50
|
+
const eventController = new AbortController();
|
|
51
|
+
const abort = () => controller.abort(signal?.reason || new Error('Evaluation stopped'));
|
|
52
|
+
if (signal?.aborted) abort();
|
|
53
|
+
else signal?.addEventListener('abort', abort, { once: true });
|
|
54
|
+
controller.signal.addEventListener('abort', () => eventController.abort(), { once: true });
|
|
55
|
+
const timer = setTimeout(() => controller.abort(new Error('Evaluation wall-time limit')), timeoutSeconds * 1000);
|
|
56
|
+
const started = Date.now();
|
|
57
|
+
const state = { turns: 0, productiveTurns: 0, providerErrors: 0, cost: null, tokens: 0, tools: {}, errors: [], lastStop: null, budgetReason: null, deliveryStatus: null };
|
|
58
|
+
let session, streamTask, streamError, outcome;
|
|
59
|
+
let interruptionStatus = 'not-needed';
|
|
60
|
+
let finished = false;
|
|
61
|
+
let stopAfterIdle = false;
|
|
62
|
+
const toolByCallID = new Map();
|
|
63
|
+
|
|
64
|
+
const onEventStream = async () => {
|
|
65
|
+
try {
|
|
66
|
+
for await (const event of api.event.subscribe({ signal: eventController.signal })) {
|
|
67
|
+
if (finished) break;
|
|
68
|
+
const data = event.data;
|
|
69
|
+
// Native session events are scoped only by their typed data.sessionID.
|
|
70
|
+
// Ignore missing IDs as well as neighbors; never infer scope from V1 fields.
|
|
71
|
+
if (event.type.startsWith('session.') && data?.sessionID !== session.id) continue;
|
|
72
|
+
if (event.type === 'session.step.ended') {
|
|
73
|
+
state.turns++;
|
|
74
|
+
if (data.finish === 'error') state.providerErrors++;
|
|
75
|
+
else state.productiveTurns++;
|
|
76
|
+
state.lastStop = data.finish;
|
|
77
|
+
} else if (event.type === 'session.execution.failed') {
|
|
78
|
+
state.providerErrors++;
|
|
79
|
+
state.errors.push(data.error?.message || 'OpenCode execution failed');
|
|
80
|
+
} else if (event.type === 'session.tool.called') {
|
|
81
|
+
toolByCallID.set(data.id, data);
|
|
82
|
+
const name = data.tool || data.name;
|
|
83
|
+
if (name) state.tools[name] = (state.tools[name] || 0) + 1;
|
|
84
|
+
} else if (event.type === 'session.tool.success') {
|
|
85
|
+
const call = toolByCallID.get(data.id);
|
|
86
|
+
if (call?.tool === 'delivery_finish' || call?.name === 'delivery_finish') {
|
|
87
|
+
const text = (data.content || []).filter(item => item.type === 'text').map(item => item.text).join('\n');
|
|
88
|
+
try { state.deliveryStatus = JSON.parse(text).status || null; } catch {}
|
|
89
|
+
}
|
|
90
|
+
} else if (event.type === 'session.tool.failed') {
|
|
91
|
+
const call = toolByCallID.get(data.id);
|
|
92
|
+
if (call?.tool === 'delivery_finish' || call?.name === 'delivery_finish') state.deliveryStatus = null;
|
|
93
|
+
}
|
|
94
|
+
onEvent(event);
|
|
95
|
+
if (event.type === 'session.idle' && data.sessionID === session.id) {
|
|
96
|
+
if (stopAfterIdle) break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (!eventController.signal.aborted) streamError = error;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const pollOutcome = async () => {
|
|
105
|
+
const deadline=Date.now()+outcomeTimeoutSeconds*1000;
|
|
106
|
+
do {
|
|
107
|
+
const info=await api.session.get({sessionID:session.id},{signal:AbortSignal.timeout(5000)});
|
|
108
|
+
if(info.outcome) return info;
|
|
109
|
+
if(Date.now()>=deadline) throw Error('OpenCode session terminal outcome did not become available');
|
|
110
|
+
await new Promise(resolve=>setTimeout(resolve,25));
|
|
111
|
+
} while(true);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
if (controller.signal.aborted) throw controller.signal.reason;
|
|
116
|
+
const info = await api.server.info({ signal: controller.signal });
|
|
117
|
+
if (info.version && !info.version.startsWith('2.')) throw Error(`OpenCode 2 required, received ${info.version}`);
|
|
118
|
+
const plugins = await api.plugin.list({ location: { directory } }, { signal: controller.signal });
|
|
119
|
+
const guard = plugins.data.find(plugin => plugin.id === EVALUATION_BUDGET_PLUGIN);
|
|
120
|
+
if (guard?.state.status !== 'active') throw Error(`Evaluation admission guard ${EVALUATION_BUDGET_PLUGIN} is not active`);
|
|
121
|
+
session = await api.session.create({
|
|
122
|
+
model: { providerID: provider, id: model },
|
|
123
|
+
location: { directory },
|
|
124
|
+
title: `CTG evaluation ${NATIVE_EVALUATION_RUNTIME}`,
|
|
125
|
+
metadata: { [EVALUATION_BUDGET_METADATA]: { maxTurns, maxCost } },
|
|
126
|
+
}, { signal: controller.signal });
|
|
127
|
+
streamTask = onEventStream();
|
|
128
|
+
// Subscription has no replay; allow its connection to establish before admission.
|
|
129
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
130
|
+
if (controller.signal.aborted) throw controller.signal.reason;
|
|
131
|
+
await api.session.prompt({ sessionID: session.id, text: `${systemPrompt ? `${systemPrompt}\n\n` : ''}${prompt}` }, { signal: controller.signal });
|
|
132
|
+
await api.session.wait({ sessionID: session.id }, { signal: controller.signal });
|
|
133
|
+
stopAfterIdle = true;
|
|
134
|
+
if (streamTask) await Promise.race([streamTask, new Promise(resolve => setTimeout(resolve, 200))]);
|
|
135
|
+
finished = true;
|
|
136
|
+
eventController.abort(); // wait() is authoritative; snapshot below reconciles a missed idle.
|
|
137
|
+
await bounded(streamTask, 250, undefined);
|
|
138
|
+
|
|
139
|
+
// wait() may beat durable projection updates. Poll this exact session until its
|
|
140
|
+
// authoritative terminal outcome is visible; absence is never success.
|
|
141
|
+
const sessionInfo = await pollOutcome();
|
|
142
|
+
outcome = sessionInfo.outcome;
|
|
143
|
+
if (outcome !== 'succeeded') state.errors.push(`OpenCode session ended with authoritative outcome: ${outcome}`);
|
|
144
|
+
|
|
145
|
+
// Fetch only this session's assistant transcript; /session/stats is project-wide.
|
|
146
|
+
const transcript = await allMessages(api, session.id, { signal: AbortSignal.timeout(15000) });
|
|
147
|
+
const assistant = assistantMessages(transcript);
|
|
148
|
+
state.turns = assistant.filter(message => message.finish !== undefined).length;
|
|
149
|
+
state.productiveTurns = assistant.filter(message => message.finish !== undefined && message.finish !== 'error').length;
|
|
150
|
+
state.providerErrors = Math.max(state.providerErrors, assistant.filter(message => message.finish === 'error').length);
|
|
151
|
+
state.lastStop = assistant.at(-1)?.finish || null;
|
|
152
|
+
state.tools = {};
|
|
153
|
+
for (const message of assistant) for (const part of message.content || []) {
|
|
154
|
+
if (part.type === 'tool') state.tools[part.name] = (state.tools[part.name] || 0) + 1;
|
|
155
|
+
if (part.type === 'tool' && part.name === 'delivery_finish' && part.state?.status === 'completed') {
|
|
156
|
+
const text=(part.state.content || []).filter(item=>item.type==='text').map(item=>item.text).join('\n');
|
|
157
|
+
try { state.deliveryStatus=JSON.parse(text).status || null; } catch {}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const tokenUsage = sessionInfo.tokens;
|
|
161
|
+
if (!Number.isFinite(sessionInfo.cost) || sessionInfo.cost < 0 || !tokenUsage || !['input','output','reasoning'].every(key => Number.isFinite(tokenUsage[key]) && tokenUsage[key] >= 0) || !tokenUsage.cache || !['read','write'].every(key => Number.isFinite(tokenUsage.cache[key]) && tokenUsage.cache[key] >= 0)) {
|
|
162
|
+
throw Error('OpenCode session aggregate usage is unavailable; refusing evaluation without enforceable max-cost');
|
|
163
|
+
}
|
|
164
|
+
state.cost = sessionInfo.cost;
|
|
165
|
+
state.tokens = tokenUsage.input + tokenUsage.output + tokenUsage.reasoning + tokenUsage.cache.read + tokenUsage.cache.write;
|
|
166
|
+
if (state.cost >= maxCost) state.budgetReason = 'reported cost limit';
|
|
167
|
+
if (state.productiveTurns > maxTurns) state.budgetReason = 'productive model turn limit exceeded';
|
|
168
|
+
if (outcome !== 'succeeded' && !state.budgetReason) {
|
|
169
|
+
state.budgetReason = `session ${outcome}`;
|
|
170
|
+
}
|
|
171
|
+
if (!state.budgetReason && (state.turns !== state.productiveTurns + state.providerErrors)) {
|
|
172
|
+
state.budgetReason = 'incomplete turn accounting';
|
|
173
|
+
state.errors.push('Transcript did not reconcile all completed assistant turns.');
|
|
174
|
+
}
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (!state.budgetReason) {
|
|
177
|
+
if (controller.signal.aborted && Date.now() - started >= timeoutSeconds * 1000) state.budgetReason = 'wall-time limit';
|
|
178
|
+
else if (controller.signal.aborted && signal?.aborted && /wall.?time/i.test(String(signal.reason?.message || signal.reason || ''))) state.budgetReason = 'wall-time limit';
|
|
179
|
+
else if (controller.signal.aborted && signal?.aborted) state.budgetReason = 'cancelled';
|
|
180
|
+
else state.budgetReason = 'native OpenCode request failed';
|
|
181
|
+
}
|
|
182
|
+
state.errors.push(error instanceof Error ? error.message : String(error));
|
|
183
|
+
if (session && !outcome) {
|
|
184
|
+
try {
|
|
185
|
+
if (!controller.signal.aborted) {
|
|
186
|
+
await api.session.wait({ sessionID:session.id }, { signal:AbortSignal.timeout(outcomeTimeoutSeconds * 1000) });
|
|
187
|
+
const info=await pollOutcome();
|
|
188
|
+
outcome=info.outcome;
|
|
189
|
+
}
|
|
190
|
+
} catch {}
|
|
191
|
+
}
|
|
192
|
+
if (session) {
|
|
193
|
+
try {
|
|
194
|
+
const info = await api.session.get({ sessionID:session.id }, { signal:AbortSignal.timeout(3000) });
|
|
195
|
+
if (Number.isFinite(info.cost) && info.cost >= 0) state.cost = info.cost;
|
|
196
|
+
const usage = info.tokens;
|
|
197
|
+
if (usage && ['input','output','reasoning'].every(key=>Number.isFinite(usage[key])) && usage.cache && Number.isFinite(usage.cache.read) && Number.isFinite(usage.cache.write)) state.tokens = usage.input+usage.output+usage.reasoning+usage.cache.read+usage.cache.write;
|
|
198
|
+
} catch {}
|
|
199
|
+
}
|
|
200
|
+
} finally {
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
signal?.removeEventListener('abort', abort);
|
|
203
|
+
finished = true;
|
|
204
|
+
eventController.abort();
|
|
205
|
+
if (session && !outcome) {
|
|
206
|
+
const cleanupMs = 1000;
|
|
207
|
+
const interruptResult = await bounded(
|
|
208
|
+
Promise.resolve().then(() => api.session.interrupt(
|
|
209
|
+
{ sessionID: session.id, resume: false },
|
|
210
|
+
{ signal: AbortSignal.timeout(cleanupMs) },
|
|
211
|
+
)).then(() => true, () => false),
|
|
212
|
+
cleanupMs,
|
|
213
|
+
false,
|
|
214
|
+
);
|
|
215
|
+
interruptionStatus = interruptResult ? 'requested' : 'unknown';
|
|
216
|
+
if (!outcome) {
|
|
217
|
+
const terminal = await bounded(
|
|
218
|
+
Promise.resolve().then(() => api.session.get(
|
|
219
|
+
{ sessionID: session.id },
|
|
220
|
+
{ signal: AbortSignal.timeout(cleanupMs) },
|
|
221
|
+
)).catch(() => null),
|
|
222
|
+
cleanupMs,
|
|
223
|
+
null,
|
|
224
|
+
);
|
|
225
|
+
if (terminal?.outcome) {
|
|
226
|
+
outcome = terminal.outcome;
|
|
227
|
+
interruptionStatus = outcome === 'interrupted' ? 'confirmed' : 'unknown';
|
|
228
|
+
} else {
|
|
229
|
+
interruptionStatus = 'unknown';
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
interruptionStatus = outcome === 'interrupted' ? 'confirmed' : 'unknown';
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (streamTask) await bounded(streamTask.catch(() => {}), 250, undefined);
|
|
236
|
+
}
|
|
237
|
+
if (!outcome && !state.budgetReason) {
|
|
238
|
+
state.budgetReason = 'missing terminal session outcome';
|
|
239
|
+
state.errors.push('OpenCode session has no authoritative terminal outcome.');
|
|
240
|
+
}
|
|
241
|
+
return { ...state, outcome: outcome || null, interruptionStatus, exitCode: state.budgetReason || streamError ? 1 : 0, timedOut: state.budgetReason === 'wall-time limit', durationMs: Date.now() - started, sessionID: session?.id || null, runtime: NATIVE_EVALUATION_RUNTIME, opencodeVersion: version };
|
|
242
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, open, readFile, rm, stat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const alive = pid => {
|
|
6
|
+
try { process.kill(pid, 0); return true; }
|
|
7
|
+
catch (error) { return error.code === 'EPERM'; }
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function timeoutError(file, owner) {
|
|
11
|
+
return new Error(`Timed out waiting for CTG credential lock ${file}${owner?.pid ? ` held by process ${owner.pid}` : ''}. If recovery is stuck, inspect the lock and recovery files before retrying.`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Acquire a cross-process lock with bounded wait and serialized dead-lock recovery. */
|
|
15
|
+
export async function acquireFileLock(file, options = {}) {
|
|
16
|
+
const timeout = options.timeout ?? 15_000;
|
|
17
|
+
const deadline = Date.now() + timeout;
|
|
18
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
19
|
+
let handle;
|
|
20
|
+
const token = randomUUID();
|
|
21
|
+
while (!handle) {
|
|
22
|
+
if (Date.now() >= deadline) throw timeoutError(file);
|
|
23
|
+
try {
|
|
24
|
+
handle = await open(file, 'wx', 0o600);
|
|
25
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, token, startedAt: Date.now() }));
|
|
26
|
+
await handle.sync();
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error.code !== 'EEXIST') {
|
|
29
|
+
await handle?.close().catch(() => {});
|
|
30
|
+
handle = undefined;
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
let owner;
|
|
34
|
+
let observed;
|
|
35
|
+
try { observed = await readFile(file, 'utf8'); owner = JSON.parse(observed); } catch {}
|
|
36
|
+
const abandoned = owner?.pid
|
|
37
|
+
? !alive(owner.pid)
|
|
38
|
+
: await stat(file).then(details => Date.now() - details.mtimeMs >= (options.staleLockAge ?? 5000)).catch(() => false);
|
|
39
|
+
if (abandoned) {
|
|
40
|
+
let recovery;
|
|
41
|
+
try { recovery = await open(`${file}.recovery`, 'wx', 0o600); }
|
|
42
|
+
catch (lockError) {
|
|
43
|
+
if (lockError.code !== 'EEXIST') throw lockError;
|
|
44
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(options.pollInterval ?? 40, Math.max(1, deadline - Date.now()))));
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const recoveryToken = JSON.stringify({ pid: process.pid, token: randomUUID() });
|
|
48
|
+
try {
|
|
49
|
+
await recovery.writeFile(recoveryToken);
|
|
50
|
+
await recovery.sync();
|
|
51
|
+
const latest = await readFile(file, 'utf8').catch(() => '');
|
|
52
|
+
let latestOwner;
|
|
53
|
+
try { latestOwner = JSON.parse(latest); } catch {}
|
|
54
|
+
if (latest === observed) {
|
|
55
|
+
const stillAbandoned = latestOwner?.pid
|
|
56
|
+
? !alive(latestOwner.pid)
|
|
57
|
+
: await stat(file).then(details => Date.now() - details.mtimeMs >= (options.staleLockAge ?? 5000)).catch(() => false);
|
|
58
|
+
if (stillAbandoned) await rm(file, { force: true });
|
|
59
|
+
}
|
|
60
|
+
} finally {
|
|
61
|
+
const currentRecovery = await readFile(`${file}.recovery`, 'utf8').catch(() => '');
|
|
62
|
+
try { if (currentRecovery === recoveryToken) await rm(`${file}.recovery`, { force: true }); } catch {}
|
|
63
|
+
await recovery.close();
|
|
64
|
+
}
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (Date.now() >= deadline) throw timeoutError(file, owner);
|
|
68
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(options.pollInterval ?? 40, Math.max(1, deadline - Date.now()))));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
let released = false;
|
|
72
|
+
return async () => {
|
|
73
|
+
if (released) return;
|
|
74
|
+
released = true;
|
|
75
|
+
const owner = await readFile(file, 'utf8').catch(() => '');
|
|
76
|
+
try { if (JSON.parse(owner).token === token) await rm(file, { force: true }); } catch {}
|
|
77
|
+
await handle.close();
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Cross-process critical section; lock ownership is always released in finally. */
|
|
82
|
+
export async function withFileLock(file, action, options = {}) {
|
|
83
|
+
const release = await acquireFileLock(file, options);
|
|
84
|
+
try { return await action(); }
|
|
85
|
+
finally { await release(); }
|
|
86
|
+
}
|