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,74 @@
|
|
|
1
|
+
// packages/server/src/coordinator/abort.integration.test.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-83 acceptance scenario that needs the real worker loop wired up: abort while a
|
|
4
|
+
// developer effect handler is actually in flight (leased, session running), then let that
|
|
5
|
+
// zombie attempt's late completion arrive — it must be fenced (a no-op) and must never
|
|
6
|
+
// reopen or mutate the now-closed issue. Mirrors execution-env.integration.test.ts's
|
|
7
|
+
// fake-effect-handler + runCoordinatorTick/drainCoordinator pump pattern.
|
|
8
|
+
import { test, before, beforeEach, afterEach } from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-abort-"));
|
|
14
|
+
const { migrate, getDb } = await import("../db/index.js");
|
|
15
|
+
const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
|
|
16
|
+
const { createIssue, getIssue } = await import("../repository/issues.js");
|
|
17
|
+
const { getWorkItem, listWorkItemsForIssue } = await import("../repository/work-items.js");
|
|
18
|
+
const { getWorkerSession } = await import("../repository/worker-sessions.js");
|
|
19
|
+
const { startWorkflow, abortIssue } = await import("./commands.js");
|
|
20
|
+
const { registerEffectHandler, resetEffectHandlers } = await import("./effect-registry.js");
|
|
21
|
+
const { runCoordinatorTick, drainCoordinator } = await import("./worker-loop.js");
|
|
22
|
+
before(() => migrate());
|
|
23
|
+
beforeEach(() => getDb().exec("DELETE FROM work_items"));
|
|
24
|
+
afterEach(() => resetEffectHandlers());
|
|
25
|
+
function newIssue() {
|
|
26
|
+
return createIssue({
|
|
27
|
+
title: "Abort mid-flight",
|
|
28
|
+
description: "d",
|
|
29
|
+
acceptanceCriteria: "It works",
|
|
30
|
+
repo: "acme/app",
|
|
31
|
+
developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
32
|
+
reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
|
|
33
|
+
baseBranch: "main",
|
|
34
|
+
maxReviewRounds: 3,
|
|
35
|
+
maxInfraAttempts: 3,
|
|
36
|
+
source: "manual"
|
|
37
|
+
}).id;
|
|
38
|
+
}
|
|
39
|
+
test("abortIssue fences a developer effect that completes after the abort already committed", async () => {
|
|
40
|
+
const issueId = newIssue();
|
|
41
|
+
startWorkflow(issueId);
|
|
42
|
+
// A deferred the test controls: the fake developer effect blocks here until the test
|
|
43
|
+
// lets it resolve, simulating a session still genuinely running when the abort lands.
|
|
44
|
+
let releaseEffect;
|
|
45
|
+
const blocked = new Promise((resolve) => {
|
|
46
|
+
releaseEffect = resolve;
|
|
47
|
+
});
|
|
48
|
+
registerEffectHandler("developer", async () => {
|
|
49
|
+
await blocked;
|
|
50
|
+
return { kind: "clean_handoff", branch: "issue-x", headSha: "deadbeef", baseSha: "base1", prNumber: 1, prUrl: "https://gh/pr/1" };
|
|
51
|
+
});
|
|
52
|
+
// Synchronous up to the handler's own first await (session create/bind/start all run
|
|
53
|
+
// inside one non-async transaction before the handler is ever invoked) — by the time
|
|
54
|
+
// this resolves, the work item is already `leased` and its session `running`.
|
|
55
|
+
const started = await runCoordinatorTick({ leaseOwner: "test" });
|
|
56
|
+
assert.equal(started, 1);
|
|
57
|
+
const item = listWorkItemsForIssue(issueId)[0];
|
|
58
|
+
assert.equal(item.status, "leased");
|
|
59
|
+
const sessionId = item.workerSessionId;
|
|
60
|
+
assert.ok(sessionId, "expected the session to already be bound before the handler blocks");
|
|
61
|
+
assert.equal(getWorkerSession(sessionId).status, "running");
|
|
62
|
+
const killed = [];
|
|
63
|
+
const result = abortIssue(issueId, "tester", { killProcess: (id) => (killed.push(id), true) });
|
|
64
|
+
assert.deepEqual(result, { ok: true, issueStatus: "closed", alreadyClosed: false });
|
|
65
|
+
assert.deepEqual(killed, [sessionId], "the running session's registered child process must be terminated");
|
|
66
|
+
assert.equal(getWorkItem(item.id).status, "cancelled");
|
|
67
|
+
assert.equal(getWorkerSession(sessionId).status, "cancelled");
|
|
68
|
+
// The zombie attempt now finishes and tries to hand off its (otherwise valid) outcome.
|
|
69
|
+
releaseEffect();
|
|
70
|
+
await drainCoordinator();
|
|
71
|
+
assert.equal(getWorkItem(item.id).status, "cancelled", "a late completion must never re-advance a cancelled work item");
|
|
72
|
+
assert.equal(getIssue(issueId).status, "closed", "a late completion must never reopen or mutate a closed issue");
|
|
73
|
+
assert.equal(getIssue(issueId).prNumber, null, "the late clean_handoff must never be projected onto the issue");
|
|
74
|
+
});
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// packages/server/src/coordinator/admission.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-103: when to call startWorkflowCore on which queued issue.
|
|
4
|
+
// Order (queue_entries) + eligibility rules + CapacityPolicy — not a second execution engine.
|
|
5
|
+
//
|
|
6
|
+
// NOT-118: this is also the *only* entry point a route may use to start an issue
|
|
7
|
+
// (`startIssueViaQueue`). Manual Start moves the entry to the front and admits it when a
|
|
8
|
+
// slot is free — it never bypasses the queue. The single ungated exception is a human
|
|
9
|
+
// action resolving into startWorkflowCore (commands.ts).
|
|
10
|
+
import { checkAgentDeckHealth } from "../adapters/agent-deck.js";
|
|
11
|
+
import { healthForAgent } from "../adapters/agent-health.js";
|
|
12
|
+
import { getDb } from "../db/index.js";
|
|
13
|
+
import { getAgent } from "../repository/agents.js";
|
|
14
|
+
import { getIssue, listIssues } from "../repository/issues.js";
|
|
15
|
+
import { enqueueIssue, getQueuedEntryForIssue, listQueuedEntries, markQueueEntryAdmitted, markQueueEntryRemoved, moveQueueEntryToTop, setQueueWaitReason, } from "../repository/queue-entries.js";
|
|
16
|
+
import { runtimeAvailability } from "../repository/runtime-availability.js";
|
|
17
|
+
import { getActiveWorkflowInstance } from "../repository/workflow-events.js";
|
|
18
|
+
import { listWorkItemsForIssue } from "../repository/work-items.js";
|
|
19
|
+
import { activeWorkflowConflictMessage, checkIssueReadiness, startWorkflowCore, StartPreconditionError, } from "./commands.js";
|
|
20
|
+
import { blockerSnapshotFor, DEPENDENCY_STATE_UNAVAILABLE, isDependencyTracked, unsatisfiedBlockerReason, } from "./dependencies.js";
|
|
21
|
+
/** Statuses that occupy an admission slot (Decision 2). */
|
|
22
|
+
export const occupyingStatuses = new Set(["developing", "reviewing", "repairing"]);
|
|
23
|
+
/**
|
|
24
|
+
* Statuses admission can actually start: `ready`, or `needs_human` parked back for a human
|
|
25
|
+
* to unblock. Everything else — mid-flight (`developing`/`reviewing`/`repairing`), awaiting
|
|
26
|
+
* a human's merge call (`final_review`), or terminal — is not startable.
|
|
27
|
+
*/
|
|
28
|
+
export const startableStatuses = new Set(["ready", "needs_human"]);
|
|
29
|
+
/**
|
|
30
|
+
* The single "may this issue enter the queue" predicate, shared by the eligibility rule,
|
|
31
|
+
* Manual Start and the create route's idempotent re-enqueue. Enqueueing on a looser
|
|
32
|
+
* predicate creates a row admission can never accept: a migrated `final_review` issue has
|
|
33
|
+
* no *active* instance but a completed one, so "nonterminal + no active workflow" would
|
|
34
|
+
* queue it forever with `status final_review — not startable`.
|
|
35
|
+
*/
|
|
36
|
+
export function isStartable(issue) {
|
|
37
|
+
return startableStatuses.has(issue.status) && !getActiveWorkflowInstance(issue.id);
|
|
38
|
+
}
|
|
39
|
+
export const sequentialCapacityPolicy = (activeIssues) => {
|
|
40
|
+
const occupying = activeIssues.filter((i) => occupyingStatuses.has(i.status)).length;
|
|
41
|
+
return Math.max(0, 1 - occupying);
|
|
42
|
+
};
|
|
43
|
+
let capacityPolicy = sequentialCapacityPolicy;
|
|
44
|
+
/** Test / future parallel wiring — swap the single CapacityPolicy function. */
|
|
45
|
+
export function setCapacityPolicyForTests(policy) {
|
|
46
|
+
capacityPolicy = policy;
|
|
47
|
+
}
|
|
48
|
+
export function resetCapacityPolicyForTests() {
|
|
49
|
+
capacityPolicy = sequentialCapacityPolicy;
|
|
50
|
+
}
|
|
51
|
+
export function countOccupyingIssues() {
|
|
52
|
+
return listOccupyingIssues().length;
|
|
53
|
+
}
|
|
54
|
+
function listOccupyingIssues() {
|
|
55
|
+
return listIssues([...occupyingStatuses]).map((i) => ({ id: i.id, status: i.status }));
|
|
56
|
+
}
|
|
57
|
+
let healthChecker = null;
|
|
58
|
+
/** Tests inject a pure checker so admission does not hit real CLIs. */
|
|
59
|
+
export function setAdmissionHealthCheckerForTests(checker) {
|
|
60
|
+
healthChecker = checker;
|
|
61
|
+
}
|
|
62
|
+
async function defaultAgentHealth(agent, deckOnline) {
|
|
63
|
+
const health = await healthForAgent(agent, deckOnline);
|
|
64
|
+
// usage_capped is owned by runtimeAvailable — keep agentsHealthy for CLI/workspace/deck.
|
|
65
|
+
const nonCap = health.issues.filter((i) => i.code !== "usage_capped");
|
|
66
|
+
if (nonCap.length > 0) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
reason: `agent unhealthy: ${agent.name} — ${nonCap.map((i) => i.message).join("; ")}`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return { ok: true };
|
|
73
|
+
}
|
|
74
|
+
async function checkAgentsHealthy(issue, ctx) {
|
|
75
|
+
const check = healthChecker ?? ((agent) => defaultAgentHealth(agent, ctx.deckOnline));
|
|
76
|
+
for (const role of ["developer", "reviewer"]) {
|
|
77
|
+
const agentId = role === "developer" ? issue.developerAgentId : issue.reviewerAgentId;
|
|
78
|
+
if (!agentId)
|
|
79
|
+
return { ok: false, reason: `missing ${role} agent` };
|
|
80
|
+
const agent = getAgent(agentId);
|
|
81
|
+
if (!agent)
|
|
82
|
+
return { ok: false, reason: `${role} agent not found` };
|
|
83
|
+
const result = await check(agent);
|
|
84
|
+
if (!result.ok)
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
return { ok: true };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Status must be startable (`ready`, or `needs_human` with no active workflow) and
|
|
91
|
+
* checkIssueReadiness must pass — never open product_scope_decision.
|
|
92
|
+
*/
|
|
93
|
+
function issueReadinessRule(issue) {
|
|
94
|
+
if (!startableStatuses.has(issue.status)) {
|
|
95
|
+
return { ok: false, reason: `issue status is ${issue.status} — not startable` };
|
|
96
|
+
}
|
|
97
|
+
if (getActiveWorkflowInstance(issue.id)) {
|
|
98
|
+
return { ok: false, reason: "issue already has an active workflow" };
|
|
99
|
+
}
|
|
100
|
+
const readiness = checkIssueReadiness(issue);
|
|
101
|
+
if (!readiness.ok) {
|
|
102
|
+
if (readiness.missing.includes("acceptance criteria") && readiness.missing.length === 1) {
|
|
103
|
+
return { ok: false, reason: "missing acceptance criteria" };
|
|
104
|
+
}
|
|
105
|
+
return { ok: false, reason: `missing ${readiness.missing.join(", ")}` };
|
|
106
|
+
}
|
|
107
|
+
return { ok: true };
|
|
108
|
+
}
|
|
109
|
+
function runtimeAvailableRule(issue) {
|
|
110
|
+
for (const role of ["developer", "reviewer"]) {
|
|
111
|
+
const agentId = role === "developer" ? issue.developerAgentId : issue.reviewerAgentId;
|
|
112
|
+
if (!agentId)
|
|
113
|
+
continue;
|
|
114
|
+
const agent = getAgent(agentId);
|
|
115
|
+
if (!agent)
|
|
116
|
+
continue;
|
|
117
|
+
const avail = runtimeAvailability(agent.runtime);
|
|
118
|
+
if (!avail.available) {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
reason: `runtime capped: ${agent.runtime} until ${avail.until} (${avail.reason})`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { ok: true };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* NOT-104: park a Linear-sourced issue while its *declared* blockers are unsatisfied, so a
|
|
129
|
+
* dependent never branches from a base that is missing its upstream work.
|
|
130
|
+
*
|
|
131
|
+
* Manual (`source != 'linear'`) issues have no declared dependency data, so this is a
|
|
132
|
+
* permanent no-op for them — including while Linear is unreachable, which is what keeps a
|
|
133
|
+
* Linear outage from pausing the whole queue.
|
|
134
|
+
*
|
|
135
|
+
* There is no bypass flag: the escape hatch is editing Linear (drop the relation, or cancel
|
|
136
|
+
* an abandoned blocker), which fixes the cause for every future dependent too. Cycles are
|
|
137
|
+
* not detected — both entries simply park pointing at each other, which is a stalled pair
|
|
138
|
+
* the operator can see and fix, never a wrong run.
|
|
139
|
+
*/
|
|
140
|
+
function blockedByDependency(issue, ctx) {
|
|
141
|
+
if (!isDependencyTracked(issue))
|
|
142
|
+
return { ok: true };
|
|
143
|
+
const blockers = ctx.blockers.get(issue.externalId);
|
|
144
|
+
// Absent means unknown, not unblocked (see BlockerSnapshot) — park until the next fetch.
|
|
145
|
+
if (!blockers)
|
|
146
|
+
return { ok: false, reason: DEPENDENCY_STATE_UNAVAILABLE };
|
|
147
|
+
const reason = unsatisfiedBlockerReason(blockers);
|
|
148
|
+
return reason ? { ok: false, reason } : { ok: true };
|
|
149
|
+
}
|
|
150
|
+
/** Initial eligibility rules — NOT-104 adds blockedByDependency here, not new machinery. */
|
|
151
|
+
export const defaultEligibilityRules = [
|
|
152
|
+
issueReadinessRule,
|
|
153
|
+
checkAgentsHealthy,
|
|
154
|
+
runtimeAvailableRule,
|
|
155
|
+
blockedByDependency,
|
|
156
|
+
];
|
|
157
|
+
let eligibilityRules = defaultEligibilityRules;
|
|
158
|
+
export function setEligibilityRulesForTests(rules) {
|
|
159
|
+
eligibilityRules = rules;
|
|
160
|
+
}
|
|
161
|
+
export function resetEligibilityRulesForTests() {
|
|
162
|
+
eligibilityRules = defaultEligibilityRules;
|
|
163
|
+
}
|
|
164
|
+
async function evaluateEligibility(issue, ctx) {
|
|
165
|
+
for (const rule of eligibilityRules) {
|
|
166
|
+
const result = await rule(issue, ctx);
|
|
167
|
+
if (!result.ok)
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
return { ok: true };
|
|
171
|
+
}
|
|
172
|
+
export async function admitNext() {
|
|
173
|
+
const freeSlots = capacityPolicy(listOccupyingIssues());
|
|
174
|
+
const entries = listQueuedEntries();
|
|
175
|
+
// Housekeeping pass — independent of free slots.
|
|
176
|
+
for (const entry of entries) {
|
|
177
|
+
const issue = getIssue(entry.issueId);
|
|
178
|
+
if (!issue || issue.status === "done" || issue.status === "closed") {
|
|
179
|
+
markQueueEntryRemoved(entry.issueId);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (getActiveWorkflowInstance(entry.issueId)) {
|
|
183
|
+
markQueueEntryAdmitted(entry.issueId);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (freeSlots <= 0)
|
|
187
|
+
return null;
|
|
188
|
+
// Re-list after housekeeping so admitted/removed rows are gone.
|
|
189
|
+
const remaining = listQueuedEntries();
|
|
190
|
+
if (remaining.length === 0)
|
|
191
|
+
return null;
|
|
192
|
+
// One deck-health check per tick, shared across every queued entry and role. Each
|
|
193
|
+
// agent's health check otherwise re-hits agent-deck's uncached /health endpoint per
|
|
194
|
+
// entry per role; with several issues queued and agent-deck slow/unreachable that
|
|
195
|
+
// serially stalls this loop, which worker-loop runs before any real work dispatch.
|
|
196
|
+
//
|
|
197
|
+
// Same shape for NOT-104's blocker snapshot: one batched, timeout-bounded Linear query
|
|
198
|
+
// for the whole remaining queue, and only on a tick that actually has a slot to fill —
|
|
199
|
+
// the early return above means a busy system makes zero Linear calls.
|
|
200
|
+
const queuedIssues = remaining
|
|
201
|
+
.map((entry) => getIssue(entry.issueId))
|
|
202
|
+
.filter((issue) => issue !== null);
|
|
203
|
+
const [deckOnline, blockers] = await Promise.all([
|
|
204
|
+
checkAgentDeckHealth(),
|
|
205
|
+
blockerSnapshotFor(queuedIssues),
|
|
206
|
+
]);
|
|
207
|
+
const ctx = { deckOnline, blockers };
|
|
208
|
+
for (const entry of remaining) {
|
|
209
|
+
const issue = getIssue(entry.issueId);
|
|
210
|
+
if (!issue) {
|
|
211
|
+
markQueueEntryRemoved(entry.issueId);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const eligibility = await evaluateEligibility(issue, ctx);
|
|
215
|
+
if (!eligibility.ok) {
|
|
216
|
+
setQueueWaitReason(entry.id, eligibility.reason);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
const started = getDb().transaction(() => {
|
|
221
|
+
// Dequeue race: operator may have removed the entry during async eligibility checks.
|
|
222
|
+
if (!getQueuedEntryForIssue(entry.issueId)) {
|
|
223
|
+
throw new StartPreconditionError(409, "queue entry no longer queued");
|
|
224
|
+
}
|
|
225
|
+
// Re-check capacity inside the txn so a concurrent Manual Start cannot double-admit.
|
|
226
|
+
if (capacityPolicy(listOccupyingIssues()) <= 0) {
|
|
227
|
+
throw new StartPreconditionError(409, "no free admission slots");
|
|
228
|
+
}
|
|
229
|
+
// Force-admit lives inside startWorkflowCore — single owner for start-path-queue-sync.
|
|
230
|
+
return startWorkflowCore(entry.issueId);
|
|
231
|
+
})();
|
|
232
|
+
return { issueId: entry.issueId, ...started };
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
const message = err instanceof StartPreconditionError
|
|
236
|
+
? err.message
|
|
237
|
+
: err instanceof Error
|
|
238
|
+
? err.message
|
|
239
|
+
: String(err);
|
|
240
|
+
// Entry was dequeued — nothing to record; try the next entry.
|
|
241
|
+
if (message.includes("no longer queued"))
|
|
242
|
+
continue;
|
|
243
|
+
// Capacity exhausted mid-walk (concurrent Manual Start) — stop; do not write wait reasons.
|
|
244
|
+
if (message.includes("no free admission slots"))
|
|
245
|
+
return null;
|
|
246
|
+
setQueueWaitReason(entry.id, message);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* NOT-118 read-time wait reason. `admitNext` returns before evaluating anything when no
|
|
253
|
+
* slot is free, so a full system would otherwise leave every queued entry with no reason
|
|
254
|
+
* (or a stale one from an earlier tick). Derived on read instead of written every tick.
|
|
255
|
+
*/
|
|
256
|
+
export function slotWaitReason() {
|
|
257
|
+
const running = listIssues([...occupyingStatuses]);
|
|
258
|
+
if (capacityPolicy(running.map((i) => ({ id: i.id, status: i.status }))) > 0)
|
|
259
|
+
return null;
|
|
260
|
+
const titles = running.map((i) => i.title).join(", ");
|
|
261
|
+
return titles ? `waiting for slot — running: ${titles}` : "waiting for slot";
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* The queue as operators read it: 1-based positions (rank, not the raw stored column, which
|
|
265
|
+
* legacy rows may have left sparse) and the current blocker per entry. While capacity is
|
|
266
|
+
* full that blocker *is* the missing slot, so the derived reason wins over whatever an
|
|
267
|
+
* earlier tick persisted; once a slot frees, the entry's own reason (missing acceptance
|
|
268
|
+
* criteria, unhealthy agent, capped runtime) surfaces again on the next tick.
|
|
269
|
+
*/
|
|
270
|
+
export function listQueuedEntriesForRead() {
|
|
271
|
+
const slotReason = slotWaitReason();
|
|
272
|
+
return listQueuedEntries().map((entry, index) => ({
|
|
273
|
+
...entry,
|
|
274
|
+
position: index + 1,
|
|
275
|
+
waitReason: slotReason ?? entry.waitReason,
|
|
276
|
+
}));
|
|
277
|
+
}
|
|
278
|
+
export function queueStatusForIssue(issueId) {
|
|
279
|
+
const entry = listQueuedEntriesForRead().find((e) => e.issueId === issueId);
|
|
280
|
+
return entry ? { position: entry.position, waitReason: entry.waitReason } : null;
|
|
281
|
+
}
|
|
282
|
+
/** The round-1 developer item an admitted start enqueued, for a start that raced a tick. */
|
|
283
|
+
function developerWorkItemFor(issueId, instanceId) {
|
|
284
|
+
return (listWorkItemsForIssue(issueId).find((w) => w.workflowInstanceId === instanceId && w.kind === "developer") ?? null);
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* NOT-118 Start: make sure the issue is queued, move its entry to position 1, then admit
|
|
288
|
+
* synchronously so an idle system still starts immediately. There is no "start now, skip
|
|
289
|
+
* the queue" escape hatch — when no slot is free (or the issue isn't eligible yet) it waits
|
|
290
|
+
* at the top of the queue with a reason, and is the next one admitted.
|
|
291
|
+
*
|
|
292
|
+
* Every route/CLI/agent start path goes through here. The one ungated exception is a human
|
|
293
|
+
* action resuming a workflow (resolveHumanActionAndAdvance → startWorkflowCore), which may
|
|
294
|
+
* briefly exceed capacity by design (NOT-103 decision 5).
|
|
295
|
+
*/
|
|
296
|
+
export async function startIssueViaQueue(issueId) {
|
|
297
|
+
const issue = getIssue(issueId);
|
|
298
|
+
if (!issue)
|
|
299
|
+
return { state: "error", code: 404, error: "Issue not found" };
|
|
300
|
+
if (getActiveWorkflowInstance(issueId)) {
|
|
301
|
+
return { state: "error", code: 409, error: activeWorkflowConflictMessage(issueId) };
|
|
302
|
+
}
|
|
303
|
+
if (!startableStatuses.has(issue.status)) {
|
|
304
|
+
return { state: "error", code: 409, error: `Issue is ${issue.status} — not startable` };
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
enqueueIssue(issueId); // idempotent when it is already queued
|
|
308
|
+
}
|
|
309
|
+
catch (err) {
|
|
310
|
+
// Only reachable if the issue turned terminal / started between the checks above and
|
|
311
|
+
// here; enqueueIssue tags those with an HTTP code.
|
|
312
|
+
const code = err.code ?? 409;
|
|
313
|
+
return { state: "error", code, error: err instanceof Error ? err.message : String(err) };
|
|
314
|
+
}
|
|
315
|
+
moveQueueEntryToTop(issueId);
|
|
316
|
+
const admitted = await admitNext();
|
|
317
|
+
if (admitted?.issueId === issueId) {
|
|
318
|
+
return { state: "admitted", instance: admitted.instance, workItem: admitted.workItem };
|
|
319
|
+
}
|
|
320
|
+
const queued = queueStatusForIssue(issueId);
|
|
321
|
+
if (queued)
|
|
322
|
+
return { state: "queued", ...queued };
|
|
323
|
+
// No longer queued and not admitted by this call — a concurrent coordinator tick admitted
|
|
324
|
+
// it between the enqueue and the walk, or an operator dequeued it mid-flight.
|
|
325
|
+
const instance = getActiveWorkflowInstance(issueId);
|
|
326
|
+
const workItem = instance ? developerWorkItemFor(issueId, instance.id) : null;
|
|
327
|
+
if (instance && workItem)
|
|
328
|
+
return { state: "admitted", instance, workItem };
|
|
329
|
+
return { state: "error", code: 409, error: "Issue left the queue before it could start" };
|
|
330
|
+
}
|