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,127 @@
|
|
|
1
|
+
// packages/server/src/coordinator/session-lifecycle.integration.test.ts
|
|
2
|
+
//
|
|
3
|
+
// The rest of this package's coordinator tests use fakeDeps() for the git-worktree
|
|
4
|
+
// boundary (see session-lifecycle.test.ts, dispatcher.test.ts) — good for the DI seam,
|
|
5
|
+
// but never proves the real adapter (packages/server/src/adapters/git-worktree.ts)
|
|
6
|
+
// actually round-trips with the coordinator. This file exercises the REAL worktree
|
|
7
|
+
// adapter (addWorktree/removeWorktree/isWorktreeClean/mergeBase) against a real temp
|
|
8
|
+
// git repo. github + spawn stay faked: they need network / a real CLI the test
|
|
9
|
+
// environment may not have.
|
|
10
|
+
import { test, before } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { execFile } from "node:child_process";
|
|
13
|
+
import { promisify } from "node:util";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
const run = promisify(execFile);
|
|
18
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-lifecycle-integration-"));
|
|
19
|
+
const { migrate } = await import("../db/index.js");
|
|
20
|
+
const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
|
|
21
|
+
const { createIssue, getIssue } = await import("../repository/issues.js");
|
|
22
|
+
const { listWorkerSessionsForIssue } = await import("../repository/worker-sessions.js");
|
|
23
|
+
const { listOpenHumanActions } = await import("../repository/human-actions.js");
|
|
24
|
+
const { startIssueWorkflow, advanceIssue } = await import("./session-lifecycle.js");
|
|
25
|
+
const worktreeAdapter = await import("../adapters/git-worktree.js");
|
|
26
|
+
before(() => {
|
|
27
|
+
migrate();
|
|
28
|
+
});
|
|
29
|
+
async function git(cwd, args) {
|
|
30
|
+
const { stdout } = await run("git", args, { cwd });
|
|
31
|
+
return stdout.trim();
|
|
32
|
+
}
|
|
33
|
+
async function initRepo() {
|
|
34
|
+
const repo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-integration-repo-"));
|
|
35
|
+
await git(repo, ["init", "-b", "main"]);
|
|
36
|
+
await git(repo, ["config", "user.email", "test@example.com"]);
|
|
37
|
+
await git(repo, ["config", "user.name", "Test"]);
|
|
38
|
+
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
|
|
39
|
+
await git(repo, ["add", "README.md"]);
|
|
40
|
+
await git(repo, ["commit", "-m", "initial"]);
|
|
41
|
+
return repo;
|
|
42
|
+
}
|
|
43
|
+
function seedIssue(repo) {
|
|
44
|
+
return createIssue({
|
|
45
|
+
title: "Integration issue",
|
|
46
|
+
description: "test issue",
|
|
47
|
+
acceptanceCriteria: "works",
|
|
48
|
+
repo,
|
|
49
|
+
baseBranch: "main",
|
|
50
|
+
developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
51
|
+
reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
|
|
52
|
+
maxReviewRounds: 3,
|
|
53
|
+
source: "manual",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
test("a real git worktree carries a developer commit through to review under the real adapter", async () => {
|
|
57
|
+
const repo = await initRepo();
|
|
58
|
+
const issue = seedIssue(repo);
|
|
59
|
+
const branch = `issue-${issue.id}`;
|
|
60
|
+
// Filled in once the fake "developer" has actually committed inside the real worktree.
|
|
61
|
+
let developerHeadSha = "";
|
|
62
|
+
const deps = {
|
|
63
|
+
worktree: {
|
|
64
|
+
addWorktree: worktreeAdapter.addWorktree,
|
|
65
|
+
removeWorktree: worktreeAdapter.removeWorktree,
|
|
66
|
+
isWorktreeClean: worktreeAdapter.isWorktreeClean,
|
|
67
|
+
mergeBase: worktreeAdapter.mergeBase,
|
|
68
|
+
},
|
|
69
|
+
github: {
|
|
70
|
+
viewPr: async () => ({
|
|
71
|
+
number: 1,
|
|
72
|
+
url: "https://github.com/x/y/pull/1",
|
|
73
|
+
headRefOid: developerHeadSha,
|
|
74
|
+
baseRefName: "main",
|
|
75
|
+
headRefName: branch,
|
|
76
|
+
reviews: [],
|
|
77
|
+
}),
|
|
78
|
+
publishReview: async () => ({ ok: true, event: "APPROVE" }),
|
|
79
|
+
},
|
|
80
|
+
spawnDeveloper: async (session) => {
|
|
81
|
+
const worktreePath = session.worktreePath;
|
|
82
|
+
// Prove this is a REAL git worktree: write a file and commit, on disk.
|
|
83
|
+
fs.writeFileSync(path.join(worktreePath, "feature.txt"), "real work\n");
|
|
84
|
+
await git(worktreePath, ["add", "feature.txt"]);
|
|
85
|
+
await git(worktreePath, ["commit", "-m", "add feature"]);
|
|
86
|
+
developerHeadSha = await git(worktreePath, ["rev-parse", "HEAD"]);
|
|
87
|
+
return { exitCode: 0, transcript: "done", logPath: "/tmp/log" };
|
|
88
|
+
},
|
|
89
|
+
spawnReviewer: async () => ({
|
|
90
|
+
exitCode: 0,
|
|
91
|
+
transcript: "```json\n" +
|
|
92
|
+
JSON.stringify({
|
|
93
|
+
verdict: "approved",
|
|
94
|
+
baseSha: (await git(repo, ["rev-parse", "HEAD"])),
|
|
95
|
+
headSha: developerHeadSha,
|
|
96
|
+
acceptanceCriteriaAssessment: "met",
|
|
97
|
+
evidenceAssessment: "ok",
|
|
98
|
+
findings: [],
|
|
99
|
+
risks: [],
|
|
100
|
+
}) +
|
|
101
|
+
"\n```",
|
|
102
|
+
logPath: "/tmp/log",
|
|
103
|
+
}),
|
|
104
|
+
};
|
|
105
|
+
await startIssueWorkflow(issue.id, deps);
|
|
106
|
+
await advanceIssue(issue.id, deps); // developer: real worktree add -> real commit -> real merge-base
|
|
107
|
+
const afterDev = listWorkerSessionsForIssue(issue.id);
|
|
108
|
+
assert.equal(afterDev.length, 2);
|
|
109
|
+
assert.equal(afterDev[0].status, "done");
|
|
110
|
+
assert.equal(afterDev[1].role, "reviewer");
|
|
111
|
+
assert.equal(afterDev[1].inputSha, developerHeadSha);
|
|
112
|
+
assert.ok(developerHeadSha, "developer session must have produced a real commit sha");
|
|
113
|
+
const afterDevIssue = getIssue(issue.id);
|
|
114
|
+
assert.equal(afterDevIssue?.status, "reviewing");
|
|
115
|
+
assert.equal(afterDevIssue?.headSha, developerHeadSha);
|
|
116
|
+
// The developer's worktree must have been cleaned up by the real adapter, but the
|
|
117
|
+
// branch it created must survive in the bare repo for the reviewer worktree to check out.
|
|
118
|
+
const branches = await git(repo, ["branch", "--list", branch]);
|
|
119
|
+
assert.ok(branches.includes(branch));
|
|
120
|
+
await advanceIssue(issue.id, deps); // reviewer: real detached worktree checkout at the developer's real commit
|
|
121
|
+
const afterReview = listWorkerSessionsForIssue(issue.id);
|
|
122
|
+
assert.equal(afterReview.length, 2);
|
|
123
|
+
assert.equal(afterReview[1].status, "done");
|
|
124
|
+
assert.equal(getIssue(issue.id)?.status, "final_review");
|
|
125
|
+
assert.ok(listOpenHumanActions().some((a) => a.issueId === issue.id && a.actionType === "final_review"));
|
|
126
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
127
|
+
});
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// packages/server/src/coordinator/session-lifecycle.ts
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import { v4 as uuid } from "uuid";
|
|
6
|
+
import { getIssue, transitionIssue, incrementIssueRound } from "../repository/issues.js";
|
|
7
|
+
import { createWorkerSession, claimQueuedSession, completeSession, listWorkerSessionsForIssue, setSessionWorktreePath, } from "../repository/worker-sessions.js";
|
|
8
|
+
import { startWorkflowInstance, appendWorkflowEvent } from "../repository/workflow-events.js";
|
|
9
|
+
import { createHumanAction } from "../repository/human-actions.js";
|
|
10
|
+
import { reconcileFinding, listFindingsForIssue } from "../repository/findings.js";
|
|
11
|
+
import { getAgent } from "../repository/agents.js";
|
|
12
|
+
import { addWorktree, removeWorktree, isWorktreeClean, mergeBase } from "../adapters/git-worktree.js";
|
|
13
|
+
import { viewPr, publishReview } from "../adapters/github.js";
|
|
14
|
+
import { spawnDeveloperSession, spawnReviewerSession } from "./spawn.js";
|
|
15
|
+
import { buildDeveloperPrompt, buildReviewerPrompt } from "./prompts.js";
|
|
16
|
+
import { parseReviewerResult } from "./reviewer-result.js";
|
|
17
|
+
import { routeDeveloperOutcome, routeReviewerOutcome } from "./routing.js";
|
|
18
|
+
export const defaultCoordinatorDeps = {
|
|
19
|
+
worktree: { addWorktree, removeWorktree, isWorktreeClean, mergeBase },
|
|
20
|
+
github: { viewPr, publishReview },
|
|
21
|
+
spawnDeveloper: spawnDeveloperSession,
|
|
22
|
+
spawnReviewer: spawnReviewerSession,
|
|
23
|
+
};
|
|
24
|
+
function worktreePathFor(issue, role, round) {
|
|
25
|
+
return path.join(os.tmpdir(), "agent-dealer-worktrees", issue.id, `${role}-r${round}`);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Resolves the selected agent profile's effective runtime/model/deck at session-creation
|
|
29
|
+
* time so the recorded worker_session snapshots it (later profile edits don't rewrite
|
|
30
|
+
* history). Falls back to claude_code only when no profile is set at all. Exported so
|
|
31
|
+
* other call sites that create worker_sessions (e.g. the human-action resolve route's
|
|
32
|
+
* repair-round restart) resolve the real profile instead of re-hardcoding a runtime.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveProfile(agentId) {
|
|
35
|
+
const agent = agentId ? getAgent(agentId) : null;
|
|
36
|
+
return {
|
|
37
|
+
runtime: agent?.runtime ?? "claude_code",
|
|
38
|
+
model: agent?.defaultModel ?? agent?.defaultExecuteModel ?? null,
|
|
39
|
+
deckId: agent?.deckId ?? null,
|
|
40
|
+
playbookId: agent?.playbookId ?? null,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export async function startIssueWorkflow(issueId, _deps = defaultCoordinatorDeps) {
|
|
44
|
+
const issue = getIssue(issueId);
|
|
45
|
+
if (!issue)
|
|
46
|
+
throw new Error(`Issue not found: ${issueId}`);
|
|
47
|
+
const instance = startWorkflowInstance(issueId, "dev_reviewer_v1");
|
|
48
|
+
appendWorkflowEvent({ issueId, workflowInstanceId: instance.id, type: "workflow.started", actorType: "system", stage: "ready" });
|
|
49
|
+
// Deciding and recording the branch name here (not just on a successful handoff) means
|
|
50
|
+
// every developer round — including a round 1 that fails after creating the branch —
|
|
51
|
+
// agrees on the same name, so repair rounds always check out the right ref.
|
|
52
|
+
const branch = `issue-${issue.id}`;
|
|
53
|
+
const updated = transitionIssue(issueId, "developing", {
|
|
54
|
+
currentOwner: "developer",
|
|
55
|
+
currentIntent: "Developer implementing round 1",
|
|
56
|
+
branch,
|
|
57
|
+
});
|
|
58
|
+
const profile = resolveProfile(issue.developerAgentId);
|
|
59
|
+
const session = createWorkerSession({
|
|
60
|
+
issueId,
|
|
61
|
+
role: "developer",
|
|
62
|
+
round: 1,
|
|
63
|
+
agentId: issue.developerAgentId,
|
|
64
|
+
runtime: profile.runtime,
|
|
65
|
+
model: profile.model,
|
|
66
|
+
});
|
|
67
|
+
appendWorkflowEvent({
|
|
68
|
+
issueId,
|
|
69
|
+
workflowInstanceId: instance.id,
|
|
70
|
+
workerSessionId: session.id,
|
|
71
|
+
type: "worker.started",
|
|
72
|
+
actorType: "system",
|
|
73
|
+
stage: updated.status,
|
|
74
|
+
round: 1,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** Claims and runs exactly one queued session for this issue to completion, then routes the outcome. */
|
|
78
|
+
export async function advanceIssue(issueId, deps = defaultCoordinatorDeps) {
|
|
79
|
+
const issue = getIssue(issueId);
|
|
80
|
+
if (!issue)
|
|
81
|
+
throw new Error(`Issue not found: ${issueId}`);
|
|
82
|
+
const queued = listWorkerSessionsForIssue(issueId).filter((s) => s.status === "queued");
|
|
83
|
+
const session = queued[queued.length - 1];
|
|
84
|
+
if (!session)
|
|
85
|
+
return;
|
|
86
|
+
const claimed = claimQueuedSession(session.id);
|
|
87
|
+
if (!claimed)
|
|
88
|
+
return; // another dispatcher already took it
|
|
89
|
+
const worktreePath = worktreePathFor(issue, claimed.role === "reviewer" ? "reviewer" : "developer", claimed.round);
|
|
90
|
+
if (claimed.role === "developer") {
|
|
91
|
+
await runDeveloperSession(issue, claimed, worktreePath, deps);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
await runReviewerSession(issue, claimed, worktreePath, deps);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function runDeveloperSession(issue, session, worktreePath, deps) {
|
|
98
|
+
const branch = issue.branch ?? `issue-${issue.id}`;
|
|
99
|
+
if (session.round === 1) {
|
|
100
|
+
await deps.worktree.addWorktree({ repo: issue.repo, path: worktreePath, ref: issue.baseBranch, newBranch: branch });
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
// Repair round: the branch already exists from round 1 (git worktree add -b creates
|
|
104
|
+
// the branch in the repo, independent of that worktree's later removal) — check it
|
|
105
|
+
// out as-is so work continues from the prior head instead of restarting from base.
|
|
106
|
+
await deps.worktree.addWorktree({ repo: issue.repo, path: worktreePath, ref: branch });
|
|
107
|
+
}
|
|
108
|
+
const withPath = setSessionWorktreePath(session.id, worktreePath);
|
|
109
|
+
const priorFindings = listFindingsForIssue(issue.id).filter((f) => f.status === "open" || f.status === "recurring");
|
|
110
|
+
const profile = resolveProfile(issue.developerAgentId);
|
|
111
|
+
const prompt = buildDeveloperPrompt({
|
|
112
|
+
taskSnapshot: { title: issue.title, description: issue.description ?? "", acceptanceCriteria: issue.acceptanceCriteria ?? "", repo: issue.repo, baseBranch: issue.baseBranch },
|
|
113
|
+
round: session.round,
|
|
114
|
+
findings: priorFindings.length ? priorFindings : undefined,
|
|
115
|
+
worktreePath,
|
|
116
|
+
deckId: profile.deckId,
|
|
117
|
+
playbookId: profile.playbookId,
|
|
118
|
+
});
|
|
119
|
+
let result;
|
|
120
|
+
try {
|
|
121
|
+
result = await deps.spawnDeveloper(withPath, prompt);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
result = { exitCode: 1, transcript: "", logPath: "", timedOut: false };
|
|
125
|
+
}
|
|
126
|
+
const sessionStatus = result.exitCode === 0 && !result.timedOut ? "done" : "failed";
|
|
127
|
+
completeSession(session.id, { status: sessionStatus, exitCode: result.exitCode, logPath: result.logPath });
|
|
128
|
+
appendWorkflowEvent({ issueId: issue.id, workerSessionId: session.id, type: "worker.completed", actorType: "developer", stage: issue.status, round: session.round });
|
|
129
|
+
const outcome = await classifyDeveloperOutcome(issue, worktreePath, sessionStatus, deps);
|
|
130
|
+
const routed = routeDeveloperOutcome(outcome, { currentRound: issue.currentRound, maxReviewRounds: issue.maxReviewRounds });
|
|
131
|
+
if (outcome.kind === "clean_handoff") {
|
|
132
|
+
appendWorkflowEvent({ issueId: issue.id, workerSessionId: session.id, type: "pull_request.opened", actorType: "system", stage: issue.status, round: session.round, payload: { prNumber: outcome.prNumber, prUrl: outcome.prUrl, headSha: outcome.headSha } });
|
|
133
|
+
}
|
|
134
|
+
if (routed.next === "spawn_reviewer" && outcome.kind === "clean_handoff") {
|
|
135
|
+
transitionIssue(issue.id, "reviewing", {
|
|
136
|
+
currentOwner: "reviewer",
|
|
137
|
+
currentIntent: "Reviewer evaluating PR",
|
|
138
|
+
headSha: outcome.headSha,
|
|
139
|
+
baseSha: outcome.baseSha,
|
|
140
|
+
prNumber: outcome.prNumber,
|
|
141
|
+
prUrl: outcome.prUrl,
|
|
142
|
+
});
|
|
143
|
+
const reviewerProfile = resolveProfile(issue.reviewerAgentId);
|
|
144
|
+
createWorkerSession({ issueId: issue.id, role: "reviewer", round: session.round, agentId: issue.reviewerAgentId, runtime: reviewerProfile.runtime, model: reviewerProfile.model, inputSha: outcome.headSha });
|
|
145
|
+
}
|
|
146
|
+
else if (routed.next === "retry_developer") {
|
|
147
|
+
incrementIssueRound(issue.id);
|
|
148
|
+
const next = getIssue(issue.id);
|
|
149
|
+
const developerProfile = resolveProfile(issue.developerAgentId);
|
|
150
|
+
createWorkerSession({ issueId: issue.id, role: "developer", round: next.currentRound, agentId: issue.developerAgentId, runtime: developerProfile.runtime, model: developerProfile.model });
|
|
151
|
+
}
|
|
152
|
+
else if (routed.next === "human_action") {
|
|
153
|
+
transitionIssue(issue.id, "needs_human", { currentOwner: "human" });
|
|
154
|
+
createHumanAction({ issueId: issue.id, actionType: routed.actionType, reason: routed.reason, question: routed.reason });
|
|
155
|
+
}
|
|
156
|
+
if (outcome.kind !== "dirty_worktree") {
|
|
157
|
+
await deps.worktree.removeWorktree({ repo: issue.repo, path: worktreePath, force: false }).catch(() => undefined);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
async function classifyDeveloperOutcome(issue, worktreePath, sessionStatus, deps) {
|
|
161
|
+
if (sessionStatus === "failed")
|
|
162
|
+
return { kind: "session_failed" };
|
|
163
|
+
const clean = await deps.worktree.isWorktreeClean(worktreePath);
|
|
164
|
+
if (!clean)
|
|
165
|
+
return { kind: "dirty_worktree" };
|
|
166
|
+
try {
|
|
167
|
+
const view = await deps.github.viewPr({ cwd: worktreePath });
|
|
168
|
+
const baseSha = await deps.worktree.mergeBase({ repo: worktreePath, base: view.baseRefName, head: view.headRefName });
|
|
169
|
+
return { kind: "clean_handoff", headSha: view.headRefOid, baseSha, prNumber: view.number, prUrl: view.url };
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return { kind: "no_pr" };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function runReviewerSession(issue, session, worktreePath, deps) {
|
|
176
|
+
await deps.worktree.addWorktree({ repo: issue.repo, path: worktreePath, ref: session.inputSha ?? issue.headSha ?? issue.baseBranch, detach: true });
|
|
177
|
+
const withPath = setSessionWorktreePath(session.id, worktreePath);
|
|
178
|
+
const reviewerProfile = resolveProfile(issue.reviewerAgentId);
|
|
179
|
+
const prompt = buildReviewerPrompt({
|
|
180
|
+
taskSnapshot: { title: issue.title, description: issue.description ?? "", acceptanceCriteria: issue.acceptanceCriteria ?? "", repo: issue.repo, baseBranch: issue.baseBranch },
|
|
181
|
+
baseSha: issue.baseSha ?? "",
|
|
182
|
+
headSha: session.inputSha ?? issue.headSha ?? "",
|
|
183
|
+
worktreePath,
|
|
184
|
+
deckId: reviewerProfile.deckId,
|
|
185
|
+
playbookId: reviewerProfile.playbookId,
|
|
186
|
+
});
|
|
187
|
+
let result;
|
|
188
|
+
try {
|
|
189
|
+
result = await deps.spawnReviewer(withPath, prompt);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
result = { exitCode: 1, transcript: "", logPath: "", timedOut: false };
|
|
193
|
+
}
|
|
194
|
+
const sessionStatus = result.exitCode === 0 && !result.timedOut ? "done" : "failed";
|
|
195
|
+
completeSession(session.id, { status: sessionStatus, exitCode: result.exitCode, logPath: result.logPath });
|
|
196
|
+
appendWorkflowEvent({ issueId: issue.id, workerSessionId: session.id, type: "worker.completed", actorType: "reviewer", stage: issue.status, round: session.round });
|
|
197
|
+
const outcome = await classifyReviewerOutcome(issue, session, worktreePath, sessionStatus, result.transcript, deps);
|
|
198
|
+
const routed = routeReviewerOutcome(outcome, { currentRound: issue.currentRound, maxReviewRounds: issue.maxReviewRounds });
|
|
199
|
+
if (outcome.kind === "verdict") {
|
|
200
|
+
appendWorkflowEvent({ issueId: issue.id, workerSessionId: session.id, type: "review.submitted", actorType: "reviewer", stage: issue.status, round: session.round, payload: outcome.result });
|
|
201
|
+
for (const f of outcome.result.findings) {
|
|
202
|
+
reconcileFinding({ issueId: issue.id, fingerprint: f.fingerprint, severity: f.severity, title: f.title, rationale: f.rationale, file: f.file, line: f.line, round: session.round });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (routed.next === "final_review") {
|
|
206
|
+
transitionIssue(issue.id, "final_review", { currentOwner: "human" });
|
|
207
|
+
createHumanAction({ issueId: issue.id, actionType: "final_review", reason: "Reviewer approved the PR", question: "Accept this work?" });
|
|
208
|
+
}
|
|
209
|
+
else if (routed.next === "retry_developer_with_findings") {
|
|
210
|
+
transitionIssue(issue.id, "repairing", { currentOwner: "developer" });
|
|
211
|
+
incrementIssueRound(issue.id);
|
|
212
|
+
const next = getIssue(issue.id);
|
|
213
|
+
const developerProfile = resolveProfile(issue.developerAgentId);
|
|
214
|
+
createWorkerSession({ issueId: issue.id, role: "developer", round: next.currentRound, agentId: issue.developerAgentId, runtime: developerProfile.runtime, model: developerProfile.model });
|
|
215
|
+
appendWorkflowEvent({ issueId: issue.id, type: "repair.started", actorType: "system", stage: next.status, round: next.currentRound });
|
|
216
|
+
}
|
|
217
|
+
else if (routed.next === "retry_reviewer_at_new_head") {
|
|
218
|
+
// Record the freshly verified head (never re-queue against the stale one) — the issue
|
|
219
|
+
// stays in "reviewing" (self-loop) throughout, it never actually left that stage.
|
|
220
|
+
transitionIssue(issue.id, "reviewing", { headSha: routed.headSha });
|
|
221
|
+
createWorkerSession({ issueId: issue.id, role: "reviewer", round: session.round, agentId: issue.reviewerAgentId, runtime: reviewerProfile.runtime, model: reviewerProfile.model, inputSha: routed.headSha });
|
|
222
|
+
}
|
|
223
|
+
else if (routed.next === "human_action") {
|
|
224
|
+
transitionIssue(issue.id, "needs_human", { currentOwner: "human" });
|
|
225
|
+
createHumanAction({ issueId: issue.id, actionType: routed.actionType, reason: routed.reason, question: routed.reason });
|
|
226
|
+
}
|
|
227
|
+
await deps.worktree.removeWorktree({ repo: issue.repo, path: worktreePath, force: true }).catch(() => undefined);
|
|
228
|
+
}
|
|
229
|
+
/** Renders the coordinator's own normalized review body — never the agent's raw transcript. */
|
|
230
|
+
function renderReviewBody(result) {
|
|
231
|
+
const lines = [
|
|
232
|
+
`**Verdict:** ${result.verdict}`,
|
|
233
|
+
``,
|
|
234
|
+
`**Acceptance criteria:** ${result.acceptanceCriteriaAssessment}`,
|
|
235
|
+
`**Evidence:** ${result.evidenceAssessment}`,
|
|
236
|
+
];
|
|
237
|
+
if (result.findings.length) {
|
|
238
|
+
lines.push(``, `**Findings:**`);
|
|
239
|
+
for (const f of result.findings) {
|
|
240
|
+
const loc = f.file ? ` (${f.file}${f.line ? `:${f.line}` : ""})` : "";
|
|
241
|
+
lines.push(`- [${f.severity}] ${f.title}${loc}: ${f.rationale}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (result.risks.length) {
|
|
245
|
+
lines.push(``, `**Risks:**`);
|
|
246
|
+
for (const r of result.risks)
|
|
247
|
+
lines.push(`- ${r}`);
|
|
248
|
+
}
|
|
249
|
+
if (result.productScopeQuestion) {
|
|
250
|
+
lines.push(``, `**Product scope question:** ${result.productScopeQuestion}`);
|
|
251
|
+
}
|
|
252
|
+
return lines.join("\n");
|
|
253
|
+
}
|
|
254
|
+
async function classifyReviewerOutcome(issue, session, worktreePath, sessionStatus, transcript, deps) {
|
|
255
|
+
if (sessionStatus === "failed")
|
|
256
|
+
return { kind: "session_failed" };
|
|
257
|
+
const view = await deps.github.viewPr({ cwd: worktreePath }).catch(() => null);
|
|
258
|
+
if (view && session.inputSha && view.headRefOid !== session.inputSha) {
|
|
259
|
+
return { kind: "stale", currentHeadSha: view.headRefOid };
|
|
260
|
+
}
|
|
261
|
+
const parsed = parseReviewerResult(transcript);
|
|
262
|
+
if (!parsed)
|
|
263
|
+
return { kind: "session_failed" };
|
|
264
|
+
// The coordinator is the only component that publishes — the reviewer has no git/GitHub
|
|
265
|
+
// write access (see args.ts), so this is the sole place a review is ever submitted.
|
|
266
|
+
if (issue.prNumber) {
|
|
267
|
+
const event = parsed.verdict === "approved" ? "APPROVE" : parsed.verdict === "changes_requested" ? "REQUEST_CHANGES" : "COMMENT";
|
|
268
|
+
const bodyFile = path.join(os.tmpdir(), `review-${uuid()}.md`);
|
|
269
|
+
fs.writeFileSync(bodyFile, renderReviewBody(parsed));
|
|
270
|
+
try {
|
|
271
|
+
const published = await deps.github.publishReview({ cwd: worktreePath, prNumber: issue.prNumber, event, bodyFilePath: bodyFile });
|
|
272
|
+
if (!published.ok)
|
|
273
|
+
return { kind: "publish_failed" };
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
fs.unlink(bodyFile, () => undefined);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return { kind: "verdict", result: parsed };
|
|
280
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// packages/server/src/coordinator/session-lifecycle.test.ts
|
|
2
|
+
import { test, before } 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
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-lifecycle-"));
|
|
8
|
+
const { migrate } = await import("../db/index.js");
|
|
9
|
+
const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
|
|
10
|
+
const { createIssue, getIssue } = await import("../repository/issues.js");
|
|
11
|
+
const { listWorkerSessionsForIssue } = await import("../repository/worker-sessions.js");
|
|
12
|
+
const { listOpenHumanActions } = await import("../repository/human-actions.js");
|
|
13
|
+
const { listWorkflowEventsForIssue } = await import("../repository/workflow-events.js");
|
|
14
|
+
const { startIssueWorkflow, advanceIssue } = await import("./session-lifecycle.js");
|
|
15
|
+
const { ReviewerResult } = await import("./reviewer-result.js");
|
|
16
|
+
before(() => {
|
|
17
|
+
migrate();
|
|
18
|
+
});
|
|
19
|
+
function seedIssue(title) {
|
|
20
|
+
return createIssue({
|
|
21
|
+
title,
|
|
22
|
+
description: "test issue",
|
|
23
|
+
acceptanceCriteria: "works",
|
|
24
|
+
repo: "/tmp/fake-repo",
|
|
25
|
+
baseBranch: "main",
|
|
26
|
+
developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
27
|
+
reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
|
|
28
|
+
maxReviewRounds: 3,
|
|
29
|
+
source: "manual",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/** Fake deps: no real git/gh/spawn — deterministic canned responses per test. */
|
|
33
|
+
function fakeDeps(overrides = {}) {
|
|
34
|
+
return {
|
|
35
|
+
worktree: {
|
|
36
|
+
addWorktree: overrides.addWorktree ?? (async () => { }),
|
|
37
|
+
removeWorktree: async () => { },
|
|
38
|
+
isWorktreeClean: overrides.isWorktreeClean ?? (async () => true),
|
|
39
|
+
mergeBase: overrides.mergeBase ?? (async () => "base-sha-1"),
|
|
40
|
+
},
|
|
41
|
+
github: {
|
|
42
|
+
viewPr: overrides.viewPr ??
|
|
43
|
+
(async () => ({ number: 1, url: "https://github.com/x/y/pull/1", headRefOid: "head-sha-1", baseRefName: "main", headRefName: "issue-branch", reviews: [] })),
|
|
44
|
+
publishReview: overrides.publishReview ?? (async () => ({ ok: true, event: "APPROVE" })),
|
|
45
|
+
},
|
|
46
|
+
spawnDeveloper: overrides.spawnDeveloper ?? (async () => ({ exitCode: 0, transcript: "done", logPath: "/tmp/log" })),
|
|
47
|
+
spawnReviewer: overrides.spawnReviewer ?? (async () => ({ exitCode: 0, transcript: "reviewed", logPath: "/tmp/log" })),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
test("starting a workflow creates one instance and one queued developer session for round 1", async () => {
|
|
51
|
+
const issue = seedIssue("Start workflow");
|
|
52
|
+
await startIssueWorkflow(issue.id, fakeDeps());
|
|
53
|
+
const sessions = listWorkerSessionsForIssue(issue.id);
|
|
54
|
+
assert.equal(sessions.length, 1);
|
|
55
|
+
assert.equal(sessions[0].role, "developer");
|
|
56
|
+
assert.equal(sessions[0].round, 1);
|
|
57
|
+
assert.equal(getIssue(issue.id)?.status, "developing");
|
|
58
|
+
});
|
|
59
|
+
test("clean developer handoff advances to a queued reviewer session", async () => {
|
|
60
|
+
const issue = seedIssue("Clean handoff");
|
|
61
|
+
await startIssueWorkflow(issue.id, fakeDeps());
|
|
62
|
+
await advanceIssue(issue.id, fakeDeps());
|
|
63
|
+
const sessions = listWorkerSessionsForIssue(issue.id);
|
|
64
|
+
assert.equal(sessions.length, 2);
|
|
65
|
+
assert.equal(sessions[1].role, "reviewer");
|
|
66
|
+
assert.equal(sessions[1].inputSha, "head-sha-1");
|
|
67
|
+
assert.equal(getIssue(issue.id)?.status, "reviewing");
|
|
68
|
+
assert.equal(getIssue(issue.id)?.headSha, "head-sha-1");
|
|
69
|
+
});
|
|
70
|
+
test("approved review creates a final_review human action", async () => {
|
|
71
|
+
const issue = seedIssue("Approved review");
|
|
72
|
+
const approvedResult = { verdict: "approved", baseSha: "base-sha-1", headSha: "head-sha-1", acceptanceCriteriaAssessment: "met", evidenceAssessment: "ok", findings: [], risks: [] };
|
|
73
|
+
await startIssueWorkflow(issue.id, fakeDeps());
|
|
74
|
+
await advanceIssue(issue.id, fakeDeps()); // developer -> reviewer queued
|
|
75
|
+
await advanceIssue(issue.id, fakeDeps({
|
|
76
|
+
spawnReviewer: async () => ({ exitCode: 0, transcript: "```json\n" + JSON.stringify(approvedResult) + "\n```", logPath: "/tmp/log" }),
|
|
77
|
+
})); // reviewer runs -> approved
|
|
78
|
+
assert.equal(getIssue(issue.id)?.status, "final_review");
|
|
79
|
+
const actions = listOpenHumanActions();
|
|
80
|
+
assert.ok(actions.some((a) => a.issueId === issue.id && a.actionType === "final_review"));
|
|
81
|
+
});
|
|
82
|
+
test("changes_requested with rounds remaining queues a round-2 developer session with findings context", async () => {
|
|
83
|
+
const issue = seedIssue("Changes requested");
|
|
84
|
+
const crResult = {
|
|
85
|
+
verdict: "changes_requested",
|
|
86
|
+
baseSha: "base-sha-1",
|
|
87
|
+
headSha: "head-sha-1",
|
|
88
|
+
acceptanceCriteriaAssessment: "partial",
|
|
89
|
+
evidenceAssessment: "ok",
|
|
90
|
+
findings: [{ fingerprint: "fp1", severity: "blocking", title: "Missing check", rationale: "r" }],
|
|
91
|
+
risks: [],
|
|
92
|
+
};
|
|
93
|
+
await startIssueWorkflow(issue.id, fakeDeps());
|
|
94
|
+
await advanceIssue(issue.id, fakeDeps());
|
|
95
|
+
await advanceIssue(issue.id, fakeDeps({ spawnReviewer: async () => ({ exitCode: 0, transcript: "```json\n" + JSON.stringify(crResult) + "\n```", logPath: "/tmp/log" }) }));
|
|
96
|
+
const sessions = listWorkerSessionsForIssue(issue.id);
|
|
97
|
+
assert.equal(sessions.length, 3);
|
|
98
|
+
assert.equal(sessions[2].role, "developer");
|
|
99
|
+
assert.equal(sessions[2].round, 2);
|
|
100
|
+
assert.equal(getIssue(issue.id)?.status, "repairing");
|
|
101
|
+
assert.equal(getIssue(issue.id)?.currentRound, 2);
|
|
102
|
+
});
|
|
103
|
+
test("dirty worktree after developer session creates policy_escalation without consuming a round", async () => {
|
|
104
|
+
const issue = seedIssue("Dirty worktree");
|
|
105
|
+
await startIssueWorkflow(issue.id, fakeDeps());
|
|
106
|
+
await advanceIssue(issue.id, fakeDeps({ isWorktreeClean: async () => false }));
|
|
107
|
+
assert.equal(getIssue(issue.id)?.status, "needs_human");
|
|
108
|
+
assert.equal(getIssue(issue.id)?.currentRound, 1);
|
|
109
|
+
const actions = listOpenHumanActions();
|
|
110
|
+
assert.ok(actions.some((a) => a.issueId === issue.id && a.actionType === "policy_escalation"));
|
|
111
|
+
});
|
|
112
|
+
test("every completed round emits workflow_events with the issue and round attached", async () => {
|
|
113
|
+
const issue = seedIssue("Events issue");
|
|
114
|
+
await startIssueWorkflow(issue.id, fakeDeps());
|
|
115
|
+
await advanceIssue(issue.id, fakeDeps());
|
|
116
|
+
const events = listWorkflowEventsForIssue(issue.id);
|
|
117
|
+
assert.ok(events.some((e) => e.type === "workflow.started"));
|
|
118
|
+
assert.ok(events.some((e) => e.type === "worker.started"));
|
|
119
|
+
assert.ok(events.some((e) => e.type === "worker.completed"));
|
|
120
|
+
assert.ok(events.some((e) => e.type === "pull_request.opened"));
|
|
121
|
+
});
|