agent-dealer 0.2.0 → 1.0.0
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/bundle/server/dist/adapters/agent-deck-bind.js +443 -0
- package/bundle/server/dist/adapters/agent-deck-bind.test.js +454 -0
- package/bundle/server/dist/adapters/agent-deck.js +38 -50
- package/bundle/server/dist/adapters/agent-deck.test.js +63 -0
- package/bundle/server/dist/adapters/agent-health.js +230 -33
- package/bundle/server/dist/adapters/agent-health.test.js +237 -0
- package/bundle/server/dist/adapters/authority-lifecycle.js +289 -0
- package/bundle/server/dist/adapters/codex-scoped-config.js +103 -0
- package/bundle/server/dist/adapters/execution-authority.js +103 -0
- package/bundle/server/dist/adapters/execution-authority.test.js +166 -0
- package/bundle/server/dist/adapters/external.js +0 -1
- package/bundle/server/dist/adapters/git-worktree.js +514 -0
- package/bundle/server/dist/adapters/git-worktree.test.js +502 -0
- package/bundle/server/dist/adapters/github.js +170 -0
- package/bundle/server/dist/adapters/github.test.js +205 -0
- package/bundle/server/dist/adapters/linear-inbox.js +199 -30
- package/bundle/server/dist/adapters/linear-inbox.test.js +35 -0
- package/bundle/server/dist/adapters/linear-sync.js +3 -101
- package/bundle/server/dist/adapters/managed-repo.js +168 -0
- package/bundle/server/dist/adapters/managed-repo.test.js +63 -0
- package/bundle/server/dist/adapters/outbound-delivery.js +83 -0
- package/bundle/server/dist/adapters/outbound-delivery.test.js +53 -0
- package/bundle/server/dist/adapters/reflect-authority.js +43 -0
- package/bundle/server/dist/cli-env.js +26 -0
- package/bundle/server/dist/cli-env.test.js +14 -1
- package/bundle/server/dist/coordinator/abort.integration.test.js +74 -0
- package/bundle/server/dist/coordinator/admission.js +330 -0
- package/bundle/server/dist/coordinator/admission.test.js +338 -0
- package/bundle/server/dist/coordinator/args.js +128 -0
- package/bundle/server/dist/coordinator/args.test.js +132 -0
- package/bundle/server/dist/coordinator/authority-recovery.integration.test.js +661 -0
- package/bundle/server/dist/coordinator/auto-merge.integration.test.js +363 -0
- package/bundle/server/dist/coordinator/auto-merge.js +286 -0
- package/bundle/server/dist/coordinator/auto-merge.timeout.test.js +14 -0
- package/bundle/server/dist/coordinator/branch-progress.js +81 -0
- package/bundle/server/dist/coordinator/clock-jump.js +98 -0
- package/bundle/server/dist/coordinator/commands.js +1088 -0
- package/bundle/server/dist/coordinator/commands.test.js +554 -0
- package/bundle/server/dist/coordinator/deck-outage-config.js +32 -0
- package/bundle/server/dist/coordinator/deck-outage.integration.test.js +200 -0
- package/bundle/server/dist/coordinator/dependencies.js +242 -0
- package/bundle/server/dist/coordinator/dependency-readiness.integration.test.js +535 -0
- package/bundle/server/dist/coordinator/developer-effect.js +959 -0
- package/bundle/server/dist/coordinator/developer-effect.test.js +1143 -0
- package/bundle/server/dist/coordinator/dispatcher.js +58 -0
- package/bundle/server/dist/coordinator/dispatcher.test.js +81 -0
- package/bundle/server/dist/coordinator/effect-registry.js +17 -0
- package/bundle/server/dist/coordinator/execution-env.integration.test.js +122 -0
- package/bundle/server/dist/coordinator/failure-reason.js +273 -0
- package/bundle/server/dist/coordinator/failure-reason.test.js +234 -0
- package/bundle/server/dist/coordinator/guidance.js +33 -0
- package/bundle/server/dist/coordinator/guidance.test.js +110 -0
- package/bundle/server/dist/coordinator/human-resolution.js +88 -0
- package/bundle/server/dist/coordinator/human-resolution.test.js +72 -0
- package/bundle/server/dist/coordinator/intent-forecast.js +13 -0
- package/bundle/server/dist/coordinator/intent-forecast.test.js +34 -0
- package/bundle/server/dist/coordinator/latest-failure.js +100 -0
- package/bundle/server/dist/coordinator/latest-failure.test.js +125 -0
- package/bundle/server/dist/coordinator/metrics.js +35 -0
- package/bundle/server/dist/coordinator/metrics.test.js +52 -0
- package/bundle/server/dist/coordinator/permissions.js +127 -0
- package/bundle/server/dist/coordinator/permissions.test.js +184 -0
- package/bundle/server/dist/coordinator/presumed-dead-budget.integration.test.js +146 -0
- package/bundle/server/dist/coordinator/process-liveness.js +191 -0
- package/bundle/server/dist/coordinator/profile-snapshot.js +20 -0
- package/bundle/server/dist/coordinator/profile-snapshot.test.js +118 -0
- package/bundle/server/dist/coordinator/projection.js +147 -0
- package/bundle/server/dist/coordinator/projection.test.js +121 -0
- package/bundle/server/dist/coordinator/prompts.js +164 -0
- package/bundle/server/dist/coordinator/prompts.test.js +177 -0
- package/bundle/server/dist/coordinator/queue-by-default.integration.test.js +271 -0
- package/bundle/server/dist/coordinator/reclaim-republish.integration.test.js +372 -0
- package/bundle/server/dist/coordinator/recovery.js +466 -0
- package/bundle/server/dist/coordinator/recovery.test.js +164 -0
- package/bundle/server/dist/coordinator/reflect-trigger.js +218 -0
- package/bundle/server/dist/coordinator/reflect-trigger.test.js +324 -0
- package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +234 -0
- package/bundle/server/dist/coordinator/restart-recovery.integration.test.js +363 -0
- package/bundle/server/dist/coordinator/reviewer-effect.js +507 -0
- package/bundle/server/dist/coordinator/reviewer-effect.test.js +612 -0
- package/bundle/server/dist/coordinator/reviewer-result.js +38 -0
- package/bundle/server/dist/coordinator/reviewer-result.test.js +45 -0
- package/bundle/server/dist/coordinator/routing.js +160 -0
- package/bundle/server/dist/coordinator/routing.test.js +336 -0
- package/bundle/server/dist/coordinator/session-lifecycle.integration.test.js +127 -0
- package/bundle/server/dist/coordinator/session-lifecycle.js +280 -0
- package/bundle/server/dist/coordinator/session-lifecycle.test.js +121 -0
- package/bundle/server/dist/coordinator/session-progress.js +351 -0
- package/bundle/server/dist/coordinator/session-progress.test.js +190 -0
- package/bundle/server/dist/coordinator/session-timeouts.js +33 -0
- package/bundle/server/dist/coordinator/sleep-recovery.integration.test.js +233 -0
- package/bundle/server/dist/coordinator/spawn.js +49 -0
- package/bundle/server/dist/coordinator/sync-issue-base-branch.js +18 -0
- package/bundle/server/dist/coordinator/usage-cap-config.js +10 -0
- package/bundle/server/dist/coordinator/usage-cap-defer.js +189 -0
- package/bundle/server/dist/coordinator/usage-cap.integration.test.js +193 -0
- package/bundle/server/dist/coordinator/usage.js +40 -0
- package/bundle/server/dist/coordinator/usage.test.js +69 -0
- package/bundle/server/dist/coordinator/verification-receipt.js +333 -0
- package/bundle/server/dist/coordinator/verification-receipt.test.js +382 -0
- package/bundle/server/dist/coordinator/worker-loop.js +363 -0
- package/bundle/server/dist/coordinator/worker-loop.test.js +327 -0
- package/bundle/server/dist/coordinator/workflow-instances.test.js +45 -0
- package/bundle/server/dist/coordinator/worktree-owner-liveness.js +82 -0
- package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +199 -0
- package/bundle/server/dist/db/artifacts-issue-columns.test.js +18 -0
- package/bundle/server/dist/db/authority-attempts-stale-at-migration.test.js +65 -0
- package/bundle/server/dist/db/idempotency-index-migration.test.js +57 -0
- package/bundle/server/dist/db/index.js +359 -7
- package/bundle/server/dist/db/linear-inbox-unlock.test.js +67 -0
- package/bundle/server/dist/db/migrate-to-issues.js +824 -0
- package/bundle/server/dist/db/migrate-to-issues.test.js +842 -0
- package/bundle/server/dist/db/schema.sql +311 -1
- package/bundle/server/dist/db/schema.test.js +66 -0
- package/bundle/server/dist/db/worker-session-pid-migration.test.js +112 -0
- package/bundle/server/dist/dev-review-cli-happy-path.integration.test.js +338 -0
- package/bundle/server/dist/direct-start-liveness.integration.test.js +350 -0
- package/bundle/server/dist/index.js +58 -5
- package/bundle/server/dist/paths.js +40 -4
- package/bundle/server/dist/paths.test.js +42 -0
- package/bundle/server/dist/pid-marker-claim-child.js +18 -0
- package/bundle/server/dist/pid-marker-hold-arbiter-child.js +24 -0
- package/bundle/server/dist/pid-marker.js +141 -0
- package/bundle/server/dist/pid-marker.test.js +208 -0
- package/bundle/server/dist/queue/approve-deliver.js +128 -28
- package/bundle/server/dist/queue/reflect-schedule.js +25 -0
- package/bundle/server/dist/queue/result-qa.js +1 -1
- package/bundle/server/dist/queue/send-gate.test.js +172 -36
- package/bundle/server/dist/repository/agents.js +55 -18
- package/bundle/server/dist/repository/artifacts-for-issue.js +42 -0
- package/bundle/server/dist/repository/artifacts-for-issue.test.js +48 -0
- package/bundle/server/dist/repository/artifacts.js +47 -0
- package/bundle/server/dist/repository/artifacts.test.js +48 -0
- package/bundle/server/dist/repository/authority-attempts.js +189 -0
- package/bundle/server/dist/repository/findings.js +70 -0
- package/bundle/server/dist/repository/findings.test.js +68 -0
- package/bundle/server/dist/repository/human-actions.js +142 -0
- package/bundle/server/dist/repository/human-actions.test.js +123 -0
- package/bundle/server/dist/repository/intake-settings.js +5 -59
- package/bundle/server/dist/repository/issues.js +305 -0
- package/bundle/server/dist/repository/issues.test.js +69 -0
- package/bundle/server/dist/repository/outbound-drafts.js +28 -0
- package/bundle/server/dist/repository/queue-entries.js +162 -0
- package/bundle/server/dist/repository/review-publications.js +153 -0
- package/bundle/server/dist/repository/review-publications.test.js +78 -0
- package/bundle/server/dist/repository/runs-external-id.test.js +5 -3
- package/bundle/server/dist/repository/runs.js +4 -211
- package/bundle/server/dist/repository/runtime-availability.js +54 -0
- package/bundle/server/dist/repository/usage-events.js +60 -0
- package/bundle/server/dist/repository/usage-events.test.js +56 -0
- package/bundle/server/dist/repository/work-items.js +305 -0
- package/bundle/server/dist/repository/work-items.test.js +142 -0
- package/bundle/server/dist/repository/worker-sessions.js +219 -0
- package/bundle/server/dist/repository/worker-sessions.test.js +60 -0
- package/bundle/server/dist/repository/workflow-events.js +193 -0
- package/bundle/server/dist/repository/workflow-events.test.js +89 -0
- package/bundle/server/dist/resolve-tsx-bin.js +19 -0
- package/bundle/server/dist/routes/human-actions.js +68 -0
- package/bundle/server/dist/routes/human-actions.test.js +284 -0
- package/bundle/server/dist/routes/index.js +29 -496
- package/bundle/server/dist/routes/issues.js +240 -0
- package/bundle/server/dist/routes/issues.test.js +451 -0
- package/bundle/server/dist/routes/queue.js +41 -0
- package/bundle/server/dist/routes/queue.test.js +78 -0
- package/bundle/server/dist/runners/claude-args.js +18 -30
- package/bundle/server/dist/runners/claude.js +19 -163
- package/bundle/server/dist/runners/claude.test.js +15 -70
- package/bundle/server/dist/runners/codex-args.js +59 -0
- package/bundle/server/dist/runners/codex-args.test.js +72 -0
- package/bundle/server/dist/runners/codex-jsonl.js +150 -0
- package/bundle/server/dist/runners/codex-jsonl.test.js +36 -0
- package/bundle/server/dist/runners/codex-resume.test.js +19 -0
- package/bundle/server/dist/runners/codex.js +46 -0
- package/bundle/server/dist/runners/models-codex.test.js +14 -0
- package/bundle/server/dist/runners/models.js +53 -1
- package/bundle/server/dist/runners/persist.js +4 -1
- package/bundle/server/dist/runners/process-registry.js +11 -0
- package/bundle/server/dist/runners/prompts.js +13 -280
- package/bundle/server/dist/runners/prompts.test.js +22 -153
- package/bundle/server/dist/runners/qa.js +17 -1
- package/bundle/server/dist/runners/reflect.js +4 -16
- package/bundle/server/dist/runners/spawn-cli.js +197 -0
- package/bundle/server/dist/runners/spawn-cli.test.js +130 -0
- package/bundle/server/dist/runners/stream-json.js +0 -141
- package/bundle/server/dist/runners/usage-cap.js +242 -0
- package/bundle/server/dist/runners/usage-cap.test.js +97 -0
- package/bundle/server/dist/server-liveness.js +37 -0
- package/bundle/server/dist/server-liveness.test.js +82 -0
- package/bundle/server/dist/static-ui.test.js +44 -0
- package/bundle/server/package.json +4 -2
- package/bundle/server/static-ui/assets/index-BTtPGYpY.js +60 -0
- package/bundle/server/static-ui/assets/index-BxTx4b1d.css +1 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/agents.d.ts +262 -133
- package/bundle/shared/dist/agents.js +85 -19
- package/bundle/shared/dist/cursor-auth-health.d.ts +12 -0
- package/bundle/shared/dist/cursor-auth-health.js +36 -0
- package/bundle/shared/dist/cursor-auth-health.test.d.ts +1 -0
- package/bundle/shared/dist/cursor-auth-health.test.js +40 -0
- package/bundle/shared/dist/findings.d.ts +46 -0
- package/bundle/shared/dist/findings.js +17 -0
- package/bundle/shared/dist/findings.test.d.ts +1 -0
- package/bundle/shared/dist/findings.test.js +20 -0
- package/bundle/shared/dist/github-repo.d.ts +28 -0
- package/bundle/shared/dist/github-repo.js +98 -0
- package/bundle/shared/dist/github-repo.test.d.ts +1 -0
- package/bundle/shared/dist/github-repo.test.js +32 -0
- package/bundle/shared/dist/human-actions.d.ts +66 -0
- package/bundle/shared/dist/human-actions.js +51 -0
- package/bundle/shared/dist/human-actions.test.d.ts +1 -0
- package/bundle/shared/dist/human-actions.test.js +57 -0
- package/bundle/shared/dist/index.d.ts +567 -757
- package/bundle/shared/dist/index.js +22 -36
- package/bundle/shared/dist/issues.d.ts +385 -0
- package/bundle/shared/dist/issues.js +169 -0
- package/bundle/shared/dist/issues.test.d.ts +1 -0
- package/bundle/shared/dist/issues.test.js +72 -0
- package/bundle/shared/dist/outbound-draft.d.ts +6 -0
- package/bundle/shared/dist/outbound-draft.js +4 -0
- package/bundle/shared/dist/plan-triage.d.ts +6 -6
- package/bundle/shared/dist/profile-snapshot.d.ts +154 -0
- package/bundle/shared/dist/profile-snapshot.js +145 -0
- package/bundle/shared/dist/profile-snapshot.test.d.ts +1 -0
- package/bundle/shared/dist/profile-snapshot.test.js +67 -0
- package/bundle/shared/dist/queue-entries.d.ts +89 -0
- package/bundle/shared/dist/queue-entries.js +19 -0
- package/bundle/shared/dist/result-qa.d.ts +2 -2
- package/bundle/shared/dist/runtime-auth-health.d.ts +66 -0
- package/bundle/shared/dist/runtime-auth-health.js +186 -0
- package/bundle/shared/dist/runtime-auth-health.test.d.ts +1 -0
- package/bundle/shared/dist/runtime-auth-health.test.js +200 -0
- package/bundle/shared/dist/runtime-availability.d.ts +51 -0
- package/bundle/shared/dist/runtime-availability.js +13 -0
- package/bundle/shared/dist/runtime.d.ts +1 -1
- package/bundle/shared/dist/runtime.js +1 -1
- package/bundle/shared/dist/usage-events.d.ts +36 -0
- package/bundle/shared/dist/usage-events.js +13 -0
- package/bundle/shared/dist/worker-sessions.d.ts +140 -0
- package/bundle/shared/dist/worker-sessions.js +70 -0
- package/bundle/shared/dist/worker-sessions.test.d.ts +1 -0
- package/bundle/shared/dist/worker-sessions.test.js +39 -0
- package/bundle/shared/dist/workflow.d.ts +78 -0
- package/bundle/shared/dist/workflow.js +59 -0
- package/bundle/shared/dist/workflow.test.d.ts +1 -0
- package/bundle/shared/dist/workflow.test.js +61 -0
- package/bundle/shared/package.json +1 -1
- package/dist/action.d.ts +1 -0
- package/dist/action.js +50 -0
- package/dist/action.test.d.ts +1 -0
- package/dist/action.test.js +70 -0
- package/dist/agent.d.ts +1 -0
- package/dist/agent.js +17 -0
- package/dist/agent.test.d.ts +1 -0
- package/dist/agent.test.js +19 -0
- package/dist/cli-check.d.ts +7 -0
- package/dist/cli-check.js +40 -0
- package/dist/doctor.js +59 -1
- package/dist/http.d.ts +5 -0
- package/dist/http.js +19 -0
- package/dist/index.js +28 -0
- package/dist/issue.d.ts +35 -0
- package/dist/issue.js +143 -0
- package/dist/issue.test.d.ts +1 -0
- package/dist/issue.test.js +240 -0
- package/dist/lifecycle.contract.test.d.ts +1 -0
- package/dist/lifecycle.contract.test.js +135 -0
- package/dist/queue.d.ts +11 -0
- package/dist/queue.js +60 -0
- package/dist/queue.test.d.ts +1 -0
- package/dist/queue.test.js +46 -0
- package/dist/test-fetch-stub.d.ts +6 -0
- package/dist/test-fetch-stub.js +31 -0
- package/package.json +2 -1
- package/templates/prod.env.example +1 -1
- package/bundle/server/static-ui/assets/index-B_7S7xJb.js +0 -79
- package/bundle/server/static-ui/assets/index-QYv3zmE6.css +0 -1
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// packages/server/src/coordinator/usage-cap.integration.test.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-111 acceptance: cap detection → deferral without spending attempt/infra budgets.
|
|
4
|
+
import { test, before, beforeEach, after } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-cap-int-"));
|
|
11
|
+
process.env.MAX_COORDINATOR_CONCURRENCY = "1";
|
|
12
|
+
process.env.COORDINATOR_HEARTBEAT_MS = "20";
|
|
13
|
+
process.env.COORDINATOR_FAIL_BACKOFF_MS = "0";
|
|
14
|
+
process.env.COORDINATOR_POLL_INTERVAL_MS = "50";
|
|
15
|
+
process.env.USAGE_CAP_FALLBACK_COOLDOWN_MS = "60000";
|
|
16
|
+
const fixturePath = path.join(path.dirname(fileURLToPath(import.meta.url)), "../runners/fixtures/claude-rate-limit-rejected.ndjson");
|
|
17
|
+
const { migrate, getDb } = await import("../db/index.js");
|
|
18
|
+
const { createAgent } = await import("../repository/agents.js");
|
|
19
|
+
const { createIssue, getIssue } = await import("../repository/issues.js");
|
|
20
|
+
const { listWorkItemsForIssue } = await import("../repository/work-items.js");
|
|
21
|
+
const { listWorkflowEventsForIssue } = await import("../repository/workflow-events.js");
|
|
22
|
+
const { recordRuntimeAvailability } = await import("../repository/runtime-availability.js");
|
|
23
|
+
const { startWorkflow } = await import("./commands.js");
|
|
24
|
+
const { registerEffectHandler, resetEffectHandlers } = await import("./effect-registry.js");
|
|
25
|
+
const { runCoordinatorTick, drainCoordinator } = await import("./worker-loop.js");
|
|
26
|
+
const { recordUsageCapFromLog } = await import("../runners/usage-cap.js");
|
|
27
|
+
before(() => migrate());
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
getDb().exec(`
|
|
30
|
+
DELETE FROM review_publications;
|
|
31
|
+
DELETE FROM work_items;
|
|
32
|
+
DELETE FROM human_actions;
|
|
33
|
+
DELETE FROM workflow_events;
|
|
34
|
+
DELETE FROM findings;
|
|
35
|
+
DELETE FROM worker_sessions;
|
|
36
|
+
DELETE FROM artifacts;
|
|
37
|
+
DELETE FROM usage_events;
|
|
38
|
+
DELETE FROM workflow_instances;
|
|
39
|
+
DELETE FROM issues;
|
|
40
|
+
DELETE FROM runtime_availability;
|
|
41
|
+
`);
|
|
42
|
+
});
|
|
43
|
+
after(() => resetEffectHandlers());
|
|
44
|
+
async function pump(max = 10) {
|
|
45
|
+
for (let i = 0; i < max; i++) {
|
|
46
|
+
await runCoordinatorTick();
|
|
47
|
+
await drainCoordinator();
|
|
48
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
test("in-flight cap defers work item without incrementing attempt_count or infra_attempts", async () => {
|
|
52
|
+
const repo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-cap-repo-"));
|
|
53
|
+
const dev = createAgent({ name: "dev-cap", runtime: "claude_code", deckId: "00000000-0000-4000-a000-000000000099" });
|
|
54
|
+
const rev = createAgent({ name: "rev-cap", runtime: "claude_code", deckId: "00000000-0000-4000-a000-000000000099" });
|
|
55
|
+
const issueId = createIssue({
|
|
56
|
+
title: "Cap test",
|
|
57
|
+
description: "d",
|
|
58
|
+
acceptanceCriteria: "ac",
|
|
59
|
+
repo,
|
|
60
|
+
baseBranch: "main",
|
|
61
|
+
developerAgentId: dev.id,
|
|
62
|
+
reviewerAgentId: rev.id,
|
|
63
|
+
maxReviewRounds: 2,
|
|
64
|
+
maxInfraAttempts: 1,
|
|
65
|
+
source: "manual"
|
|
66
|
+
}).id;
|
|
67
|
+
registerEffectHandler("developer", async () => {
|
|
68
|
+
const until = new Date(Date.now() + 120_000).toISOString();
|
|
69
|
+
recordRuntimeAvailability({
|
|
70
|
+
runtime: "claude_code",
|
|
71
|
+
unavailableUntil: until,
|
|
72
|
+
reason: "claude_code usage capped — five_hour limit rejected",
|
|
73
|
+
evidence: { test: true }
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
kind: "usage_capped",
|
|
77
|
+
until,
|
|
78
|
+
reason: "claude_code usage capped — five_hour limit rejected"
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
const started = startWorkflow(issueId);
|
|
82
|
+
assert.equal(started.ok, true);
|
|
83
|
+
if (started.ok !== true)
|
|
84
|
+
return;
|
|
85
|
+
const before = listWorkItemsForIssue(issueId)[0];
|
|
86
|
+
assert.equal(before.attemptCount, 0);
|
|
87
|
+
await pump();
|
|
88
|
+
const issue = getIssue(issueId);
|
|
89
|
+
assert.equal(issue.infraAttempts, 0);
|
|
90
|
+
const items = listWorkItemsForIssue(issueId);
|
|
91
|
+
assert.equal(items.length, 1);
|
|
92
|
+
const item = items[0];
|
|
93
|
+
assert.equal(item.status, "pending");
|
|
94
|
+
assert.equal(item.attemptCount, 0);
|
|
95
|
+
assert.ok(Date.parse(item.availableAt) > Date.now());
|
|
96
|
+
const events = listWorkflowEventsForIssue(issueId);
|
|
97
|
+
assert.ok(events.some((e) => e.type === "worker.deferred"));
|
|
98
|
+
});
|
|
99
|
+
test("pending item on capped runtime is not spawned before until (pre-spawn deferral)", async () => {
|
|
100
|
+
const repo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-cap-repo2-"));
|
|
101
|
+
const dev = createAgent({ name: "dev-cap2", runtime: "claude_code", deckId: "00000000-0000-4000-a000-000000000099" });
|
|
102
|
+
const rev = createAgent({ name: "rev-cap2", runtime: "claude_code", deckId: "00000000-0000-4000-a000-000000000099" });
|
|
103
|
+
const until = new Date(Date.now() + 300_000).toISOString();
|
|
104
|
+
recordRuntimeAvailability({
|
|
105
|
+
runtime: "claude_code",
|
|
106
|
+
unavailableUntil: until,
|
|
107
|
+
reason: "claude_code usage capped — test"
|
|
108
|
+
});
|
|
109
|
+
let spawnCalls = 0;
|
|
110
|
+
registerEffectHandler("developer", async () => {
|
|
111
|
+
spawnCalls++;
|
|
112
|
+
return { kind: "no_pr" };
|
|
113
|
+
});
|
|
114
|
+
const issueId = createIssue({
|
|
115
|
+
title: "Pre-spawn cap",
|
|
116
|
+
description: "d",
|
|
117
|
+
acceptanceCriteria: "ac",
|
|
118
|
+
repo,
|
|
119
|
+
baseBranch: "main",
|
|
120
|
+
developerAgentId: dev.id,
|
|
121
|
+
reviewerAgentId: rev.id,
|
|
122
|
+
maxReviewRounds: 2,
|
|
123
|
+
maxInfraAttempts: 3,
|
|
124
|
+
source: "manual"
|
|
125
|
+
}).id;
|
|
126
|
+
startWorkflow(issueId);
|
|
127
|
+
await pump(3);
|
|
128
|
+
assert.equal(spawnCalls, 0);
|
|
129
|
+
const item = listWorkItemsForIssue(issueId)[0];
|
|
130
|
+
assert.equal(item.status, "pending");
|
|
131
|
+
assert.equal(item.attemptCount, 0);
|
|
132
|
+
assert.ok(Date.parse(item.availableAt) > Date.now());
|
|
133
|
+
assert.ok(listWorkflowEventsForIssue(issueId).some((e) => e.type === "worker.deferred"));
|
|
134
|
+
});
|
|
135
|
+
test("deferral past the ceiling escalates to policy_escalation instead of deferring forever", async () => {
|
|
136
|
+
const prevCeiling = process.env.USAGE_CAP_DEFERRAL_CEILING_MS;
|
|
137
|
+
process.env.USAGE_CAP_DEFERRAL_CEILING_MS = "50";
|
|
138
|
+
try {
|
|
139
|
+
const { listHumanActionsForIssue } = await import("../repository/human-actions.js");
|
|
140
|
+
const repo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-cap-ceiling-"));
|
|
141
|
+
const dev = createAgent({ name: "dev-ceiling", runtime: "claude_code", deckId: "00000000-0000-4000-a000-000000000099" });
|
|
142
|
+
const rev = createAgent({ name: "rev-ceiling", runtime: "claude_code", deckId: "00000000-0000-4000-a000-000000000099" });
|
|
143
|
+
const issueId = createIssue({
|
|
144
|
+
title: "Ceiling test",
|
|
145
|
+
description: "d",
|
|
146
|
+
acceptanceCriteria: "ac",
|
|
147
|
+
repo,
|
|
148
|
+
baseBranch: "main",
|
|
149
|
+
developerAgentId: dev.id,
|
|
150
|
+
reviewerAgentId: rev.id,
|
|
151
|
+
maxReviewRounds: 2,
|
|
152
|
+
maxInfraAttempts: 3,
|
|
153
|
+
source: "manual"
|
|
154
|
+
}).id;
|
|
155
|
+
// Every attempt reports the same hard cap — availableAt is a few ms out so the next
|
|
156
|
+
// pump tick re-attempts quickly, letting the 50ms ceiling elapse across two deferrals.
|
|
157
|
+
registerEffectHandler("developer", async () => {
|
|
158
|
+
const until = new Date(Date.now() + 5).toISOString();
|
|
159
|
+
return {
|
|
160
|
+
kind: "usage_capped",
|
|
161
|
+
until,
|
|
162
|
+
reason: "claude_code usage capped — plan limit rejected"
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
const started = startWorkflow(issueId);
|
|
166
|
+
assert.equal(started.ok, true);
|
|
167
|
+
await pump(1); // first cap observation — defers, starts the ceiling clock
|
|
168
|
+
assert.equal(getIssue(issueId).status, "developing");
|
|
169
|
+
await new Promise((r) => setTimeout(r, 60)); // elapse the 50ms deferral ceiling
|
|
170
|
+
await pump(); // second cap observation — ceiling exceeded, escalates
|
|
171
|
+
const issue = getIssue(issueId);
|
|
172
|
+
assert.equal(issue.status, "needs_human");
|
|
173
|
+
const escalation = listHumanActionsForIssue(issueId).find((a) => a.status === "open");
|
|
174
|
+
assert.equal(escalation?.actionType, "policy_escalation");
|
|
175
|
+
const items = listWorkItemsForIssue(issueId);
|
|
176
|
+
assert.equal(items.length, 1);
|
|
177
|
+
assert.equal(items[0].status, "done", "escalation finishes the work item rather than deferring it again");
|
|
178
|
+
const events = listWorkflowEventsForIssue(issueId);
|
|
179
|
+
assert.equal(events.filter((e) => e.type === "worker.deferred").length, 1, "exactly one deferral before escalation");
|
|
180
|
+
assert.ok(events.some((e) => e.type === "worker.failed"));
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
if (prevCeiling === undefined)
|
|
184
|
+
delete process.env.USAGE_CAP_DEFERRAL_CEILING_MS;
|
|
185
|
+
else
|
|
186
|
+
process.env.USAGE_CAP_DEFERRAL_CEILING_MS = prevCeiling;
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
test("fixture log records runtime_availability with resetsAt", () => {
|
|
190
|
+
const cap = recordUsageCapFromLog(fixturePath, "claude_code", Date.parse("2026-01-01T00:00:00.000Z"));
|
|
191
|
+
assert.ok(cap);
|
|
192
|
+
assert.equal(cap.unavailableUntil, new Date(1784283600 * 1000).toISOString());
|
|
193
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { extractResultText, parseNdjsonFile } from "../runners/stream-json.js";
|
|
2
|
+
import { normalizeCodexEvents, parseCodexJsonl } from "../runners/codex-jsonl.js";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
const EMPTY = { tokensIn: null, tokensOut: null, costUsd: null };
|
|
5
|
+
/** Best-effort: a missing/unparseable log never blocks recording the session's duration. */
|
|
6
|
+
export function extractSpawnUsage(logPath, runtime) {
|
|
7
|
+
if (!fs.existsSync(logPath))
|
|
8
|
+
return EMPTY;
|
|
9
|
+
const events = runtime === "codex_local"
|
|
10
|
+
? normalizeCodexEvents(parseCodexJsonl(fs.readFileSync(logPath, "utf8")))
|
|
11
|
+
: parseNdjsonFile(logPath);
|
|
12
|
+
const result = events.find((e) => e.type === "result");
|
|
13
|
+
const usage = result?.usage ?? {};
|
|
14
|
+
return {
|
|
15
|
+
tokensIn: usage.input_tokens ?? usage.inputTokens ?? null,
|
|
16
|
+
tokensOut: usage.output_tokens ?? usage.outputTokens ?? null,
|
|
17
|
+
costUsd: typeof result?.total_cost_usd === "number" ? result.total_cost_usd : null,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The final assistant message text out of a developer/reviewer session's raw ndjson log
|
|
22
|
+
* — never the raw multi-event stream `spawnCli` hands back as its in-memory `transcript`.
|
|
23
|
+
* That raw stream is not a single coherent document: with `--stream-partial-output`
|
|
24
|
+
* (cursor) the same growing answer appears as many overlapping partial fragments before
|
|
25
|
+
* its final, complete form, so any parser (e.g. reviewer-result.ts's fenced-JSON regex)
|
|
26
|
+
* run directly against it can match an early, truncated fragment instead of the finished
|
|
27
|
+
* output. Reuses the exact per-runtime `type: "result"` extraction `extractSpawnUsage`
|
|
28
|
+
* already applies to the same log, so both stay in sync with how each runtime's events
|
|
29
|
+
* are parsed/normalized. Falls back to the raw transcript when nothing parses (e.g. a
|
|
30
|
+
* crash before any structured event, or a non-stream-json output format) so callers never
|
|
31
|
+
* regress to less text than they had before this extraction existed.
|
|
32
|
+
*/
|
|
33
|
+
export function extractResultTranscript(logPath, runtime, fallback) {
|
|
34
|
+
if (!fs.existsSync(logPath))
|
|
35
|
+
return fallback;
|
|
36
|
+
const events = runtime === "codex_local"
|
|
37
|
+
? normalizeCodexEvents(parseCodexJsonl(fs.readFileSync(logPath, "utf8")))
|
|
38
|
+
: parseNdjsonFile(logPath);
|
|
39
|
+
return extractResultText(events) ?? fallback;
|
|
40
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// packages/server/src/coordinator/usage.test.ts
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { extractResultTranscript, extractSpawnUsage } from "./usage.js";
|
|
8
|
+
function writeLog(lines) {
|
|
9
|
+
const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dealer-usage-")), "session.ndjson");
|
|
10
|
+
fs.writeFileSync(file, lines.map((l) => JSON.stringify(l)).join("\n"));
|
|
11
|
+
return file;
|
|
12
|
+
}
|
|
13
|
+
test("extractSpawnUsage reads claude-shaped stream-json usage/cost", () => {
|
|
14
|
+
const logPath = writeLog([
|
|
15
|
+
{ type: "system", subtype: "init" },
|
|
16
|
+
{ type: "assistant", message: { content: [{ type: "text", text: "hi" }] } },
|
|
17
|
+
{ type: "result", total_cost_usd: 0.0421, usage: { input_tokens: 1000, output_tokens: 200 } },
|
|
18
|
+
]);
|
|
19
|
+
const usage = extractSpawnUsage(logPath, "claude_code");
|
|
20
|
+
assert.equal(usage.tokensIn, 1000);
|
|
21
|
+
assert.equal(usage.tokensOut, 200);
|
|
22
|
+
assert.equal(usage.costUsd, 0.0421);
|
|
23
|
+
});
|
|
24
|
+
test("extractSpawnUsage reads codex-shaped jsonl usage (no cost field)", () => {
|
|
25
|
+
const logPath = writeLog([
|
|
26
|
+
{ type: "turn.completed", usage: { input_tokens: 500, cached_input_tokens: 100, output_tokens: 50 } },
|
|
27
|
+
]);
|
|
28
|
+
const usage = extractSpawnUsage(logPath, "codex_local");
|
|
29
|
+
assert.equal(usage.tokensIn, 500);
|
|
30
|
+
assert.equal(usage.tokensOut, 50);
|
|
31
|
+
assert.equal(usage.costUsd, null);
|
|
32
|
+
});
|
|
33
|
+
test("extractSpawnUsage returns nulls for a missing log file", () => {
|
|
34
|
+
const usage = extractSpawnUsage("/does/not/exist.ndjson", "claude_code");
|
|
35
|
+
assert.deepEqual(usage, { tokensIn: null, tokensOut: null, costUsd: null });
|
|
36
|
+
});
|
|
37
|
+
test("extractSpawnUsage returns nulls when no result event is present", () => {
|
|
38
|
+
const logPath = writeLog([{ type: "assistant", message: { content: [] } }]);
|
|
39
|
+
const usage = extractSpawnUsage(logPath, "claude_code");
|
|
40
|
+
assert.deepEqual(usage, { tokensIn: null, tokensOut: null, costUsd: null });
|
|
41
|
+
});
|
|
42
|
+
// Regression for the bug this extractor was added to fix: with `--stream-partial-output`
|
|
43
|
+
// (cursor), the same growing answer streams as repeated partial fragments before its
|
|
44
|
+
// final, complete form — a truncated fence can appear in the raw stream well before the
|
|
45
|
+
// finished one. Parsing the raw stdout directly (the old behavior) risks matching that
|
|
46
|
+
// earlier, incomplete fragment; extracting the final `type: "result"` event's text first
|
|
47
|
+
// sidesteps that regardless of what came before it in the stream.
|
|
48
|
+
test("extractResultTranscript returns the final result text, not an earlier partial fragment", () => {
|
|
49
|
+
const logPath = writeLog([
|
|
50
|
+
{ type: "system", subtype: "init" },
|
|
51
|
+
// An earlier partial-output chunk containing what looks like an (incomplete) fence.
|
|
52
|
+
{ type: "assistant", message: { content: [{ type: "text", text: '```json\n{"verdict": "appro' }] } },
|
|
53
|
+
{ type: "result", result: '```json\n{"verdict":"approved"}\n```' },
|
|
54
|
+
]);
|
|
55
|
+
const text = extractResultTranscript(logPath, "cursor_local", "fallback");
|
|
56
|
+
assert.equal(text, '```json\n{"verdict":"approved"}\n```');
|
|
57
|
+
});
|
|
58
|
+
test("extractResultTranscript reads codex-shaped jsonl via the normalized result event", () => {
|
|
59
|
+
const logPath = writeLog([
|
|
60
|
+
{ type: "item.completed", item: { type: "agent_message", text: '```json\n{"verdict":"approved"}\n```' } },
|
|
61
|
+
]);
|
|
62
|
+
const text = extractResultTranscript(logPath, "codex_local", "fallback");
|
|
63
|
+
assert.equal(text, '```json\n{"verdict":"approved"}\n```');
|
|
64
|
+
});
|
|
65
|
+
test("extractResultTranscript falls back to the raw transcript when nothing parses", () => {
|
|
66
|
+
assert.equal(extractResultTranscript("/does/not/exist.ndjson", "claude_code", "raw fallback text"), "raw fallback text");
|
|
67
|
+
const logPath = writeLog([{ type: "assistant", message: { content: [] } }]);
|
|
68
|
+
assert.equal(extractResultTranscript(logPath, "claude_code", "raw fallback text"), "raw fallback text");
|
|
69
|
+
});
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// packages/server/src/coordinator/verification-receipt.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-130: mine verification commands from a developer session log and persist a
|
|
4
|
+
// SHA-scoped receipt so an infra retry can carry "already green at this tip" evidence
|
|
5
|
+
// without a runtime-level `--resume`. A receipt never instructs the agent to skip — it
|
|
6
|
+
// is evidence. HEAD move (new commits after the checks) invalidates it automatically.
|
|
7
|
+
import { parseNdjsonFile } from "../runners/stream-json.js";
|
|
8
|
+
export const VERIFICATION_RECEIPT_KIND = "verification_receipt";
|
|
9
|
+
/**
|
|
10
|
+
* A command segment is verification only when a runner invocation starts the segment
|
|
11
|
+
* (start of string / after && || ; |), not when a keyword appears mid-line in `rg typecheck`.
|
|
12
|
+
*/
|
|
13
|
+
const VERIFICATION_SEGMENT_RE = /^(?:cd\s+\S+\s*(?:&&|;)\s+)*(?:npx\s+|pnpm\s+(?:exec\s+|run\s+)?|yarn\s+(?:run\s+)?|uv\s+run\s+)?(?:npm\s+test\b|npm\s+run\s+(?:test\b[\w:-]*|typecheck\b|flow:verify\b|poc:integration\b)|pytest\b|vitest\b|jest\b|node\s+--test\b)/i;
|
|
14
|
+
/**
|
|
15
|
+
* Match real commit-shaped invocations, including `git -c … commit`, `git -C dir commit`,
|
|
16
|
+
* and `git-yubikey-commit`. False positives that clear the receipt are safer than missing
|
|
17
|
+
* a tip-changing command (which would pin a green suite to an unverified tip).
|
|
18
|
+
*/
|
|
19
|
+
const HEAD_CHANGING_RE = /\bgit(?:-[a-z0-9-]+)?\b[^;&|\n]*\b(?:commit|amend|rebase|reset|merge|cherry-pick|revert|pull|checkout|apply|am)\b/i;
|
|
20
|
+
const REV_PARSE_HEAD_RE = /\bgit(?:\s+-C\s+\S+)?\s+rev-parse\s+HEAD\b/i;
|
|
21
|
+
const FULL_SHA_RE = /\b([0-9a-f]{40})\b/i;
|
|
22
|
+
/** Prefer runner-shaped counts over a bare n/m (dates, coverage ratios, progress). */
|
|
23
|
+
const DETAIL_PATTERNS = [
|
|
24
|
+
{
|
|
25
|
+
re: /#\s*pass\s+(\d+)/i,
|
|
26
|
+
format: (m) => `${m[1]} passed`,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
re: /(\d+)\s+passed,\s*(\d+)\s+total/i,
|
|
30
|
+
format: (m) => `${m[1]}/${m[2]}`,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
re: /Tests?\s+(\d+)\s+passed/i,
|
|
34
|
+
format: (m) => `${m[1]} passed`,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
re: /(\d+)\s+passed(?:ing)?\b/i,
|
|
38
|
+
format: (m) => `${m[1]} passed`,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
re: /(\d+)\s*\/\s*(\d+)\s+tests?\s+passed/i,
|
|
42
|
+
format: (m) => `${m[1]}/${m[2]}`,
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
export function isVerificationCommand(command) {
|
|
46
|
+
return command
|
|
47
|
+
.split(/(?:&&|\|\||;|\|)/)
|
|
48
|
+
.some((seg) => VERIFICATION_SEGMENT_RE.test(seg.trim()));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Carry a prior receipt only for interrupted / crashed style retries — not when the job
|
|
52
|
+
* is to reproduce a CI/check failure at this same SHA (checks_failed).
|
|
53
|
+
*/
|
|
54
|
+
export function shouldCarryVerificationReceipt(retryReason) {
|
|
55
|
+
if (!retryReason?.trim())
|
|
56
|
+
return false;
|
|
57
|
+
if (/PR checks failed|checks failed/i.test(retryReason))
|
|
58
|
+
return false;
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
function normalizeCommand(command) {
|
|
62
|
+
return command.replace(/\s+/g, " ").trim();
|
|
63
|
+
}
|
|
64
|
+
function toolResultText(content) {
|
|
65
|
+
if (typeof content === "string")
|
|
66
|
+
return content;
|
|
67
|
+
if (!Array.isArray(content))
|
|
68
|
+
return "";
|
|
69
|
+
return content
|
|
70
|
+
.map((part) => {
|
|
71
|
+
if (typeof part === "string")
|
|
72
|
+
return part;
|
|
73
|
+
if (part && typeof part === "object" && typeof part.text === "string") {
|
|
74
|
+
return part.text;
|
|
75
|
+
}
|
|
76
|
+
return "";
|
|
77
|
+
})
|
|
78
|
+
.join("\n");
|
|
79
|
+
}
|
|
80
|
+
function outcomeFromClaudeResult(isError, text) {
|
|
81
|
+
if (isError === true)
|
|
82
|
+
return "failed";
|
|
83
|
+
if (/exit code[:\s]+[1-9]\d*/i.test(text))
|
|
84
|
+
return "failed";
|
|
85
|
+
if (isError === false)
|
|
86
|
+
return "passed";
|
|
87
|
+
if (/exit code[:\s]+0\b/i.test(text))
|
|
88
|
+
return "passed";
|
|
89
|
+
return "unknown";
|
|
90
|
+
}
|
|
91
|
+
function outcomeFromExitCode(exitCode) {
|
|
92
|
+
if (typeof exitCode !== "number")
|
|
93
|
+
return "unknown";
|
|
94
|
+
return exitCode === 0 ? "passed" : "failed";
|
|
95
|
+
}
|
|
96
|
+
function detailFromOutput(text) {
|
|
97
|
+
for (const { re, format } of DETAIL_PATTERNS) {
|
|
98
|
+
const m = text.match(re);
|
|
99
|
+
if (!m)
|
|
100
|
+
continue;
|
|
101
|
+
const detail = format(m);
|
|
102
|
+
if (detail)
|
|
103
|
+
return detail;
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
function cursorShellEntry(e) {
|
|
108
|
+
if (e.type !== "tool_call")
|
|
109
|
+
return null;
|
|
110
|
+
const nested = e.tool_call;
|
|
111
|
+
if (!nested || typeof nested !== "object" || Array.isArray(nested))
|
|
112
|
+
return null;
|
|
113
|
+
const shell = nested.shellToolCall;
|
|
114
|
+
if (!shell || typeof shell !== "object")
|
|
115
|
+
return null;
|
|
116
|
+
const args = shell.args;
|
|
117
|
+
const command = typeof args?.command === "string" ? args.command : "";
|
|
118
|
+
if (!command)
|
|
119
|
+
return null;
|
|
120
|
+
const subtype = typeof e.subtype === "string" ? e.subtype : "";
|
|
121
|
+
const result = shell.result;
|
|
122
|
+
let exitCode;
|
|
123
|
+
let output = "";
|
|
124
|
+
if (result && typeof result === "object") {
|
|
125
|
+
if (typeof result.exitCode === "number")
|
|
126
|
+
exitCode = result.exitCode;
|
|
127
|
+
else if (typeof result.exit_code === "number")
|
|
128
|
+
exitCode = result.exit_code;
|
|
129
|
+
const stdout = typeof result.stdout === "string" ? result.stdout : "";
|
|
130
|
+
const stderr = typeof result.stderr === "string" ? result.stderr : "";
|
|
131
|
+
output = [stdout, stderr].filter(Boolean).join("\n");
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
command,
|
|
135
|
+
exitCode,
|
|
136
|
+
output,
|
|
137
|
+
completed: subtype === "completed" || result != null,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/** Every Bash/Shell tool_use in an assistant event (parallel batches included). */
|
|
141
|
+
function claudeBashCommands(e) {
|
|
142
|
+
if (e.type !== "assistant")
|
|
143
|
+
return [];
|
|
144
|
+
const msg = e.message;
|
|
145
|
+
const out = [];
|
|
146
|
+
for (const c of msg?.content ?? []) {
|
|
147
|
+
if (c.type === "tool_use" && (c.name === "Bash" || c.name === "Shell") && c.id && c.input?.command) {
|
|
148
|
+
out.push({ id: c.id, command: c.input.command });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
function claudeToolResults(e) {
|
|
154
|
+
if (e.type !== "user")
|
|
155
|
+
return [];
|
|
156
|
+
const msg = e.message;
|
|
157
|
+
const out = [];
|
|
158
|
+
for (const c of msg?.content ?? []) {
|
|
159
|
+
if (c.type !== "tool_result" || typeof c.tool_use_id !== "string")
|
|
160
|
+
continue;
|
|
161
|
+
out.push({
|
|
162
|
+
toolUseId: c.tool_use_id,
|
|
163
|
+
isError: typeof c.is_error === "boolean" ? c.is_error : undefined,
|
|
164
|
+
text: toolResultText(c.content),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Walk a session NDJSON log and build a receipt when at least one verification command
|
|
171
|
+
* completed and we can pin it to a branch SHA that still matches the tip.
|
|
172
|
+
*
|
|
173
|
+
* `headShaHint` is the worktree's `git rev-parse HEAD` at persist time — used when the
|
|
174
|
+
* agent never printed a SHA, and as a gate that the tip has not moved past the checks.
|
|
175
|
+
*/
|
|
176
|
+
export function extractVerificationReceiptFromLog(logPath, opts) {
|
|
177
|
+
if (!logPath)
|
|
178
|
+
return null;
|
|
179
|
+
let events;
|
|
180
|
+
try {
|
|
181
|
+
events = parseNdjsonFile(logPath);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
if (!events.length)
|
|
187
|
+
return null;
|
|
188
|
+
let observedHead = null;
|
|
189
|
+
const pendingBash = new Map();
|
|
190
|
+
/** Last result per normalized command; cleared when HEAD-changing git runs. */
|
|
191
|
+
const byCommand = new Map();
|
|
192
|
+
const applyShellCompleted = (command, outcome, output) => {
|
|
193
|
+
const normalized = normalizeCommand(command);
|
|
194
|
+
if (!normalized)
|
|
195
|
+
return;
|
|
196
|
+
const changesHead = HEAD_CHANGING_RE.test(normalized);
|
|
197
|
+
const isVerify = isVerificationCommand(normalized);
|
|
198
|
+
// Compound `npm test && git commit` (or the reverse) is ambiguous — refuse to vouch.
|
|
199
|
+
if (changesHead && isVerify) {
|
|
200
|
+
byCommand.clear();
|
|
201
|
+
observedHead = null;
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (changesHead) {
|
|
205
|
+
// New tip — prior suite results no longer vouch for HEAD.
|
|
206
|
+
byCommand.clear();
|
|
207
|
+
observedHead = null;
|
|
208
|
+
}
|
|
209
|
+
if (REV_PARSE_HEAD_RE.test(normalized)) {
|
|
210
|
+
const m = output.trim().match(FULL_SHA_RE);
|
|
211
|
+
if (m)
|
|
212
|
+
observedHead = m[1].toLowerCase();
|
|
213
|
+
}
|
|
214
|
+
if (!isVerify)
|
|
215
|
+
return;
|
|
216
|
+
const detail = detailFromOutput(output);
|
|
217
|
+
byCommand.set(normalized, {
|
|
218
|
+
command: normalized.length > 240 ? `${normalized.slice(0, 237)}…` : normalized,
|
|
219
|
+
outcome,
|
|
220
|
+
...(detail ? { detail } : {}),
|
|
221
|
+
});
|
|
222
|
+
};
|
|
223
|
+
for (const e of events) {
|
|
224
|
+
for (const bash of claudeBashCommands(e)) {
|
|
225
|
+
pendingBash.set(bash.id, bash.command);
|
|
226
|
+
}
|
|
227
|
+
for (const result of claudeToolResults(e)) {
|
|
228
|
+
const command = pendingBash.get(result.toolUseId);
|
|
229
|
+
if (!command)
|
|
230
|
+
continue;
|
|
231
|
+
pendingBash.delete(result.toolUseId);
|
|
232
|
+
applyShellCompleted(command, outcomeFromClaudeResult(result.isError, result.text), result.text);
|
|
233
|
+
}
|
|
234
|
+
const shell = cursorShellEntry(e);
|
|
235
|
+
if (shell?.completed) {
|
|
236
|
+
applyShellCompleted(shell.command, outcomeFromExitCode(shell.exitCode), shell.output);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const commands = [...byCommand.values()];
|
|
240
|
+
if (!commands.length)
|
|
241
|
+
return null;
|
|
242
|
+
// Only persist when something actually passed — a failed suite is not evidence to skip.
|
|
243
|
+
if (!commands.some((c) => c.outcome === "passed"))
|
|
244
|
+
return null;
|
|
245
|
+
const hint = opts?.headShaHint?.trim().toLowerCase() || null;
|
|
246
|
+
const headSha = (observedHead ?? hint)?.toLowerCase() ?? null;
|
|
247
|
+
if (!headSha || !FULL_SHA_RE.test(headSha))
|
|
248
|
+
return null;
|
|
249
|
+
// Tip moved after the checks (or agent never re-verified after committing).
|
|
250
|
+
if (hint && observedHead && hint !== observedHead)
|
|
251
|
+
return null;
|
|
252
|
+
// When only headShaHint is available, it is safe iff no HEAD-changing command ran after
|
|
253
|
+
// the last verification (those clear byCommand, so we would not reach a receipt here).
|
|
254
|
+
return {
|
|
255
|
+
headSha,
|
|
256
|
+
commands,
|
|
257
|
+
recordedAt: (opts?.now ?? (() => new Date().toISOString()))(),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
export function parseVerificationReceipt(content) {
|
|
261
|
+
if (!content || typeof content !== "object")
|
|
262
|
+
return null;
|
|
263
|
+
const raw = content;
|
|
264
|
+
if (typeof raw.headSha !== "string" || !FULL_SHA_RE.test(raw.headSha))
|
|
265
|
+
return null;
|
|
266
|
+
if (!Array.isArray(raw.commands) || raw.commands.length === 0)
|
|
267
|
+
return null;
|
|
268
|
+
const commands = [];
|
|
269
|
+
for (const c of raw.commands) {
|
|
270
|
+
if (!c || typeof c !== "object")
|
|
271
|
+
continue;
|
|
272
|
+
const row = c;
|
|
273
|
+
if (typeof row.command !== "string" || !row.command.trim())
|
|
274
|
+
continue;
|
|
275
|
+
const outcome = row.outcome === "passed" || row.outcome === "failed" || row.outcome === "unknown"
|
|
276
|
+
? row.outcome
|
|
277
|
+
: "unknown";
|
|
278
|
+
commands.push({
|
|
279
|
+
command: row.command,
|
|
280
|
+
outcome,
|
|
281
|
+
...(typeof row.detail === "string" && row.detail.trim() ? { detail: row.detail.trim() } : {}),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
if (!commands.length)
|
|
285
|
+
return null;
|
|
286
|
+
return {
|
|
287
|
+
headSha: raw.headSha.toLowerCase(),
|
|
288
|
+
commands,
|
|
289
|
+
recordedAt: typeof raw.recordedAt === "string" ? raw.recordedAt : new Date(0).toISOString(),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/** Null when missing or when HEAD has moved since the receipt was recorded. */
|
|
293
|
+
export function receiptForCurrentHead(receipt, currentHeadSha) {
|
|
294
|
+
if (!receipt || !currentHeadSha)
|
|
295
|
+
return null;
|
|
296
|
+
if (receipt.headSha.toLowerCase() !== currentHeadSha.trim().toLowerCase())
|
|
297
|
+
return null;
|
|
298
|
+
return receipt;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* CI already rejected this tip — a later crash/timeout retry at the same SHA must not
|
|
302
|
+
* re-inject "do not re-run an unchanged green suite" (class: ci-rejected-local-green).
|
|
303
|
+
*/
|
|
304
|
+
export function receiptSupersededByFailedChecks(receipt, checksEvidence) {
|
|
305
|
+
if (!checksEvidence || checksEvidence.snapshot !== "failure")
|
|
306
|
+
return false;
|
|
307
|
+
if (typeof checksEvidence.headSha !== "string" || !checksEvidence.headSha.trim())
|
|
308
|
+
return false;
|
|
309
|
+
return checksEvidence.headSha.trim().toLowerCase() === receipt.headSha.toLowerCase();
|
|
310
|
+
}
|
|
311
|
+
function receiptIsUniformlyGreen(receipt) {
|
|
312
|
+
return receipt.commands.length > 0 && receipt.commands.every((c) => c.outcome === "passed");
|
|
313
|
+
}
|
|
314
|
+
/** Prompt lines under ## Previous attempt — evidence only. */
|
|
315
|
+
export function formatVerificationReceiptSection(receipt) {
|
|
316
|
+
const short = receipt.headSha.slice(0, 8);
|
|
317
|
+
const lines = [
|
|
318
|
+
`### Prior verification receipt`,
|
|
319
|
+
`At \`${short}\` (\`${receipt.headSha}\`), HEAD is unchanged. Prior session evidence:`,
|
|
320
|
+
];
|
|
321
|
+
for (const c of receipt.commands) {
|
|
322
|
+
const detail = c.detail ? ` (${c.detail})` : "";
|
|
323
|
+
lines.push(`- \`${c.command}\` ${c.outcome}${detail}`);
|
|
324
|
+
}
|
|
325
|
+
if (receiptIsUniformlyGreen(receipt)) {
|
|
326
|
+
lines.push(`This is evidence, not an instruction to skip — re-run anything you doubt. Do not re-run an unchanged green suite by default.`);
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
lines.push(`This is evidence, not an instruction to skip — re-run anything you doubt. Treat only commands marked passed as already green; re-check failed or unknown ones.`);
|
|
330
|
+
}
|
|
331
|
+
lines.push(``);
|
|
332
|
+
return lines;
|
|
333
|
+
}
|