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,153 @@
|
|
|
1
|
+
// packages/server/src/repository/review-publications.ts
|
|
2
|
+
//
|
|
3
|
+
// A one-row-per-work-item durable claim + result (NOT-62 review rounds 2-4), inserted
|
|
4
|
+
// atomically BEFORE the reviewer effect calls `gh pr review`. A `gh` review submission
|
|
5
|
+
// isn't naturally idempotent the way a `git push` is, and a read-only "does a review
|
|
6
|
+
// already exist" lookup alone is a check-then-publish race — two overlapping attempts on
|
|
7
|
+
// the same work item could both observe "not found" before either has published.
|
|
8
|
+
//
|
|
9
|
+
// The claim is gated on the SAME lease token the work-item kernel already uses (round 4):
|
|
10
|
+
// every write here requires the caller's `leaseToken` to still equal the live
|
|
11
|
+
// `work_items.lease_token`, with `status = 'leased'` AND `lease_expires_at` still in the
|
|
12
|
+
// future (round 5 — `status` alone lags reality: a work item sits at `status = 'leased'`
|
|
13
|
+
// with its old token for the whole window between the lease actually expiring and
|
|
14
|
+
// recovery's next sweep reclaiming it, and an attempt resuming inside that window is not
|
|
15
|
+
// "live" just because no one has swept it yet), checked in the same statement as the
|
|
16
|
+
// write. A zombie whose lease has already expired or been reclaimed can therefore never
|
|
17
|
+
// win — or reclaim — this claim, no matter how the two attempts' wall-clock timing
|
|
18
|
+
// happens to fall; only whichever attempt currently holds the work item's one *unexpired*
|
|
19
|
+
// lease token ever can. Without this, an earlier design let a slower-but-still-"leased"
|
|
20
|
+
// zombie win the claim ahead of the actual current lease holder, which would then time
|
|
21
|
+
// out waiting and apply `publish_failed` as the workflow's terminal decision while the
|
|
22
|
+
// zombie went on to actually publish moments later — the exact "GitHub and workflow
|
|
23
|
+
// state disagree" failure mode this table exists to prevent. Once the winner records its
|
|
24
|
+
// actual result, a loser reports exactly that (never its own independently-produced
|
|
25
|
+
// verdict, which two separate reviewer sessions really can disagree on).
|
|
26
|
+
//
|
|
27
|
+
// Round 6: checking the lease is only "live" at the instant of claiming isn't enough —
|
|
28
|
+
// nothing stopped the underlying lease from expiring, and recovery reclaiming it to a
|
|
29
|
+
// new attempt, WHILE the `gh pr review` call granted by this claim is still in flight.
|
|
30
|
+
// So granting a claim now ALSO atomically extends the work item's lease
|
|
31
|
+
// (`PUBLICATION_LEASE_EXTENSION_MS`) in the same transaction as the claim itself —
|
|
32
|
+
// recovery's own reclaim query (`work_items.lease_expires_at < now`) is a single
|
|
33
|
+
// statement too, so the two can never interleave: either recovery's reclaim commits
|
|
34
|
+
// first (and this claim's own live-lease check then correctly fails) or this claim's
|
|
35
|
+
// extension commits first (and recovery's reclaim then correctly does not match). The
|
|
36
|
+
// ambient per-tick heartbeat (`worker-loop.ts`) would ordinarily keep re-extending the
|
|
37
|
+
// lease throughout anyway on a healthy process, but that protection is incidental to
|
|
38
|
+
// this table; the extension here makes holding the claim its own explicit, structural
|
|
39
|
+
// guarantee for the whole publish operation, not something borrowed from unrelated
|
|
40
|
+
// machinery timing.
|
|
41
|
+
import { getDb } from "../db/index.js";
|
|
42
|
+
const num = (name, dflt) => Number(process.env[name] ?? dflt);
|
|
43
|
+
/**
|
|
44
|
+
* How long a granted claim guarantees the underlying lease stays valid for — must
|
|
45
|
+
* comfortably exceed how long a real `gh pr review` call can take. Deliberately much
|
|
46
|
+
* longer than the work-item kernel's own default per-heartbeat `leaseMs` (60s): this is
|
|
47
|
+
* a one-shot grant at claim time, not refreshed on a timer the way the heartbeat is.
|
|
48
|
+
*/
|
|
49
|
+
function publicationLeaseExtensionMs() {
|
|
50
|
+
return num("REVIEWER_PUBLISH_LEASE_EXTENSION_MS", 5 * 60_000);
|
|
51
|
+
}
|
|
52
|
+
function rowFrom(raw) {
|
|
53
|
+
return {
|
|
54
|
+
workItemId: raw.work_item_id,
|
|
55
|
+
state: raw.state,
|
|
56
|
+
resultJson: raw.result_json,
|
|
57
|
+
event: raw.event,
|
|
58
|
+
usedCommentFallback: raw.used_comment_fallback === 1,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* True only while `leaseToken` is still the work item's current, ACTUALLY-active lease
|
|
63
|
+
* — `status = 'leased'` on its own is not enough (it lags an expiry that recovery hasn't
|
|
64
|
+
* swept yet); `lease_expires_at` must still be in the future too.
|
|
65
|
+
*/
|
|
66
|
+
const STILL_LEASED = `EXISTS (
|
|
67
|
+
SELECT 1 FROM work_items
|
|
68
|
+
WHERE id = @work_item_id AND lease_token = @lease_token AND status = 'leased' AND lease_expires_at > @now
|
|
69
|
+
)`;
|
|
70
|
+
/**
|
|
71
|
+
* Extends `workItemId`'s lease so it cannot expire (and so be reclaimed by recovery)
|
|
72
|
+
* before `publicationLeaseExtensionMs()` has elapsed. Re-checks the live-lease condition
|
|
73
|
+
* itself — a caller only extends a lease it is actually the current, live holder of.
|
|
74
|
+
* Must be called from inside the same transaction as the claim/reclaim grant it protects.
|
|
75
|
+
*/
|
|
76
|
+
function extendLeaseForPublication(workItemId, leaseToken, now) {
|
|
77
|
+
const expires = new Date(Date.parse(now) + publicationLeaseExtensionMs()).toISOString();
|
|
78
|
+
getDb()
|
|
79
|
+
.prepare("UPDATE work_items SET lease_expires_at = @expires, updated_at = @now WHERE id = @work_item_id AND lease_token = @lease_token AND status = 'leased'")
|
|
80
|
+
.run({ work_item_id: workItemId, lease_token: leaseToken, expires, now });
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Returns true if this call won the claim and may proceed to publish. False either
|
|
84
|
+
* because another attempt already holds/held it, or because `leaseToken` is no longer
|
|
85
|
+
* the work item's current lease (this attempt is itself the zombie — it must not
|
|
86
|
+
* publish regardless of who else is racing it). Winning atomically extends the
|
|
87
|
+
* underlying work-item lease (see module doc) so recovery cannot reclaim it out from
|
|
88
|
+
* under the `gh` call this grant is about to allow.
|
|
89
|
+
*/
|
|
90
|
+
export function claimReviewPublication(workItemId, leaseToken) {
|
|
91
|
+
const now = new Date().toISOString();
|
|
92
|
+
return getDb().transaction(() => {
|
|
93
|
+
const result = getDb()
|
|
94
|
+
.prepare(`
|
|
95
|
+
INSERT INTO review_publications (work_item_id, state, claimed_at, updated_at)
|
|
96
|
+
SELECT @work_item_id, 'claimed', @now, @now
|
|
97
|
+
WHERE NOT EXISTS (SELECT 1 FROM review_publications WHERE work_item_id = @work_item_id)
|
|
98
|
+
AND ${STILL_LEASED}
|
|
99
|
+
`)
|
|
100
|
+
.run({ work_item_id: workItemId, lease_token: leaseToken, now });
|
|
101
|
+
if (result.changes !== 1)
|
|
102
|
+
return false;
|
|
103
|
+
extendLeaseForPublication(workItemId, leaseToken, now);
|
|
104
|
+
return true;
|
|
105
|
+
})();
|
|
106
|
+
}
|
|
107
|
+
export function getReviewPublication(workItemId) {
|
|
108
|
+
const row = getDb().prepare("SELECT * FROM review_publications WHERE work_item_id = ?").get(workItemId);
|
|
109
|
+
return row ? rowFrom(row) : null;
|
|
110
|
+
}
|
|
111
|
+
/** Only the current claimant (state = 'claimed') may record a result — a stale writer can never overwrite one. */
|
|
112
|
+
export function recordReviewPublished(workItemId, input) {
|
|
113
|
+
getDb()
|
|
114
|
+
.prepare("UPDATE review_publications SET state = 'published', result_json = ?, event = ?, used_comment_fallback = ?, updated_at = ? WHERE work_item_id = ? AND state = 'claimed'")
|
|
115
|
+
.run(input.resultJson, input.event, input.usedCommentFallback ? 1 : 0, new Date().toISOString(), workItemId);
|
|
116
|
+
}
|
|
117
|
+
export function recordReviewPublishFailed(workItemId) {
|
|
118
|
+
getDb()
|
|
119
|
+
.prepare("UPDATE review_publications SET state = 'failed', updated_at = ? WHERE work_item_id = ? AND state = 'claimed'")
|
|
120
|
+
.run(new Date().toISOString(), workItemId);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Lets a new attempt retry publishing after a prior claimant recorded `failed` (it ran,
|
|
124
|
+
* but its own `gh` call never succeeded) — gated on the same live lease check as
|
|
125
|
+
* `claimReviewPublication`, so only the work item's current holder can ever reclaim.
|
|
126
|
+
*
|
|
127
|
+
* A stale `claimed` row (the claimant crashed between `gh` succeeding and this table
|
|
128
|
+
* being updated) is deliberately NOT reclaimable at all, by anyone, ever — surfacing
|
|
129
|
+
* instead as a human-visible `publish_failed` escalation. This is a known, accepted gap
|
|
130
|
+
* (a review already posted to GitHub could in that narrow case go unrecorded here): it
|
|
131
|
+
* fails toward a human double-checking an already-safe state rather than toward an
|
|
132
|
+
* automatic decision this code cannot actually verify is safe, matching this codebase's
|
|
133
|
+
* general policy of escalating, not guessing, when a prior attempt's true fate is
|
|
134
|
+
* unknown (see `profile-snapshot.ts`'s `PermissionPolicy` doc comment for the same
|
|
135
|
+
* reasoning applied elsewhere).
|
|
136
|
+
*/
|
|
137
|
+
export function reclaimFailedReviewPublication(workItemId, leaseToken) {
|
|
138
|
+
const now = new Date().toISOString();
|
|
139
|
+
return getDb().transaction(() => {
|
|
140
|
+
const result = getDb()
|
|
141
|
+
.prepare(`
|
|
142
|
+
UPDATE review_publications
|
|
143
|
+
SET state = 'claimed', claimed_at = @now, updated_at = @now
|
|
144
|
+
WHERE work_item_id = @work_item_id AND state = 'failed'
|
|
145
|
+
AND ${STILL_LEASED}
|
|
146
|
+
`)
|
|
147
|
+
.run({ work_item_id: workItemId, lease_token: leaseToken, now });
|
|
148
|
+
if (result.changes !== 1)
|
|
149
|
+
return false;
|
|
150
|
+
extendLeaseForPublication(workItemId, leaseToken, now);
|
|
151
|
+
return true;
|
|
152
|
+
})();
|
|
153
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// packages/server/src/repository/review-publications.test.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-62 review round 6: a claim grant must not just check the lease is live at that
|
|
4
|
+
// instant — it must also extend it, atomically, so recovery's own reclaim query cannot
|
|
5
|
+
// later match this work item while the `gh` call the claim protects is still in flight.
|
|
6
|
+
// These tests exercise that directly against `listExpiredLeases`, the exact function
|
|
7
|
+
// `coordinator/recovery.ts` uses to find reclaimable leases — not just a raw timestamp
|
|
8
|
+
// field — so a regression that broke the actual guarantee (not merely the column value)
|
|
9
|
+
// would be caught here.
|
|
10
|
+
import { test, before, beforeEach } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-reviewpub-"));
|
|
16
|
+
process.env.REVIEWER_PUBLISH_LEASE_EXTENSION_MS = "300000";
|
|
17
|
+
const { migrate, getDb } = await import("../db/index.js");
|
|
18
|
+
const { createAgent } = await import("../repository/agents.js");
|
|
19
|
+
const { createIssue } = await import("./issues.js");
|
|
20
|
+
const { startWorkflowInstance } = await import("./workflow-events.js");
|
|
21
|
+
const { enqueueWorkItem, claimWorkItem, listExpiredLeases } = await import("./work-items.js");
|
|
22
|
+
const { claimReviewPublication, recordReviewPublishFailed, reclaimFailedReviewPublication } = await import("./review-publications.js");
|
|
23
|
+
before(() => migrate());
|
|
24
|
+
beforeEach(() => getDb().exec("DELETE FROM review_publications; DELETE FROM work_items;"));
|
|
25
|
+
function freshLeasedWorkItem() {
|
|
26
|
+
const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: "00000000-0000-4000-a000-000000000099" });
|
|
27
|
+
const rev = createAgent({ name: `rev-${Math.random()}`, runtime: "claude_code", deckId: "00000000-0000-4000-a000-000000000099" });
|
|
28
|
+
const issue = createIssue({
|
|
29
|
+
title: "T",
|
|
30
|
+
repo: "acme/app",
|
|
31
|
+
developerAgentId: dev.id,
|
|
32
|
+
reviewerAgentId: rev.id,
|
|
33
|
+
baseBranch: "main",
|
|
34
|
+
maxReviewRounds: 3,
|
|
35
|
+
maxInfraAttempts: 3,
|
|
36
|
+
source: "manual"
|
|
37
|
+
});
|
|
38
|
+
const instance = startWorkflowInstance(issue.id, "dev_reviewer_v1");
|
|
39
|
+
enqueueWorkItem({ issueId: issue.id, workflowInstanceId: instance.id, kind: "reviewer", round: 1 });
|
|
40
|
+
// A very short leaseMs — about to expire on its own, matching the round-6 scenario.
|
|
41
|
+
const claimed = claimWorkItem("test-owner", { leaseMs: 20 });
|
|
42
|
+
return { id: claimed.id, leaseToken: claimed.leaseToken };
|
|
43
|
+
}
|
|
44
|
+
test("claiming publication extends the lease so recovery's own reclaim query no longer matches, even though the original lease was about to expire", async () => {
|
|
45
|
+
// leaseMs: 20 in freshLeasedWorkItem — the ORIGINAL lease is about to expire on its
|
|
46
|
+
// own; claiming happens immediately, while it's still (barely) live.
|
|
47
|
+
const { id, leaseToken } = freshLeasedWorkItem();
|
|
48
|
+
assert.equal(claimReviewPublication(id, leaseToken), true);
|
|
49
|
+
// Wait past when the ORIGINAL 20ms lease would have expired.
|
|
50
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
51
|
+
// Recovery's exact reclaim query must not see this item as expired, now or for the
|
|
52
|
+
// whole extension window — not just at the instant it was claimed.
|
|
53
|
+
assert.equal(listExpiredLeases(Date.now()).some((i) => i.id === id), false);
|
|
54
|
+
assert.equal(listExpiredLeases(Date.now() + 60_000).some((i) => i.id === id), false, "must stay unreclaimable well past the original lease's own duration");
|
|
55
|
+
});
|
|
56
|
+
test("reclaiming a failed publication also extends the lease", async () => {
|
|
57
|
+
const { id, leaseToken } = freshLeasedWorkItem();
|
|
58
|
+
assert.equal(claimReviewPublication(id, leaseToken), true);
|
|
59
|
+
recordReviewPublishFailed(id);
|
|
60
|
+
// The first claim's own extension is still ~5 minutes out — reset it back down to
|
|
61
|
+
// something about to expire again, so reclaiming below (called while it's still
|
|
62
|
+
// barely valid) is what has to be the thing keeping it alive past that point, not
|
|
63
|
+
// leftover slack from the first claim's extension.
|
|
64
|
+
getDb().prepare("UPDATE work_items SET lease_expires_at = ? WHERE id = ?").run(new Date(Date.now() + 20).toISOString(), id);
|
|
65
|
+
assert.equal(reclaimFailedReviewPublication(id, leaseToken), true);
|
|
66
|
+
await new Promise((resolve) => setTimeout(resolve, 40)); // past that reset 20ms window
|
|
67
|
+
assert.equal(listExpiredLeases(Date.now()).some((i) => i.id === id), false, "reclaiming must extend the lease too, not just a fresh claim");
|
|
68
|
+
});
|
|
69
|
+
test("a claim attempt whose lease is already gone (recovery reclaimed first) never extends anything", async () => {
|
|
70
|
+
const { id, leaseToken } = freshLeasedWorkItem();
|
|
71
|
+
// Recovery reclaims first: a fresh claimWorkItem-style rotation onto a new token would
|
|
72
|
+
// normally do this; simplest faithful simulation is directly rotating the token/lease.
|
|
73
|
+
getDb().prepare("UPDATE work_items SET lease_token = ? WHERE id = ?").run("someone-elses-token", id);
|
|
74
|
+
assert.equal(claimReviewPublication(id, leaseToken), false, "a stale token must never win the claim");
|
|
75
|
+
// And it must not have extended the (now someone-else's) lease either.
|
|
76
|
+
const row = getDb().prepare("SELECT lease_expires_at FROM work_items WHERE id = ?").get(id);
|
|
77
|
+
assert.ok(new Date(row.lease_expires_at).getTime() < Date.now() + 60_000, "a losing claim attempt must not extend the lease it doesn't hold");
|
|
78
|
+
});
|
|
@@ -7,11 +7,9 @@ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-ex
|
|
|
7
7
|
process.env.MAX_CONCURRENT_RUNS = "0";
|
|
8
8
|
const { migrate } = await import("../db/index.js");
|
|
9
9
|
const { BUILTIN_AGENT_CLAUDE_ID } = await import("@agent-dealer/shared");
|
|
10
|
-
const { updateAgent } = await import("./agents.js");
|
|
11
10
|
const { createRun } = await import("./runs.js");
|
|
12
11
|
before(() => {
|
|
13
12
|
migrate();
|
|
14
|
-
updateAgent(BUILTIN_AGENT_CLAUDE_ID, { workspaceRoot: process.env.AGENT_DEALER_HOME });
|
|
15
13
|
});
|
|
16
14
|
test("manual create mints external_id = run.id and leaves external_label null", () => {
|
|
17
15
|
const run = createRun({
|
|
@@ -19,6 +17,7 @@ test("manual create mints external_id = run.id and leaves external_label null",
|
|
|
19
17
|
taskCategory: "other",
|
|
20
18
|
status: "plan_pending",
|
|
21
19
|
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
20
|
+
repo: "acme/app"
|
|
22
21
|
});
|
|
23
22
|
assert.equal(run.externalId, run.id);
|
|
24
23
|
assert.equal(run.externalLabel, null);
|
|
@@ -30,17 +29,19 @@ test("manual retry copies parent external_id", () => {
|
|
|
30
29
|
taskCategory: "other",
|
|
31
30
|
status: "plan_pending",
|
|
32
31
|
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
32
|
+
repo: "acme/app"
|
|
33
33
|
});
|
|
34
34
|
const child = createRun({
|
|
35
35
|
title: parent.title,
|
|
36
36
|
taskCategory: parent.taskCategory,
|
|
37
37
|
status: "plan_approved",
|
|
38
38
|
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
39
|
+
repo: "acme/app"
|
|
39
40
|
}, {
|
|
40
41
|
source: parent.source,
|
|
41
42
|
externalId: parent.externalId ?? undefined,
|
|
42
43
|
externalLabel: parent.externalLabel ?? undefined,
|
|
43
|
-
lineageId: parent.lineageId ?? parent.id
|
|
44
|
+
lineageId: parent.lineageId ?? parent.id
|
|
44
45
|
});
|
|
45
46
|
assert.equal(child.externalId, parent.externalId);
|
|
46
47
|
assert.equal(child.externalId, parent.id);
|
|
@@ -54,6 +55,7 @@ test("explicit linear externalId is not replaced by run.id", () => {
|
|
|
54
55
|
taskCategory: "code",
|
|
55
56
|
status: "plan_pending",
|
|
56
57
|
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
58
|
+
repo: "acme/app"
|
|
57
59
|
}, { source: "linear", externalId: issueId, externalLabel: "LIN-1" });
|
|
58
60
|
assert.equal(run.externalId, issueId);
|
|
59
61
|
assert.equal(run.externalLabel, "LIN-1");
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CURSOR_DEFAULT_MODEL } from "@agent-dealer/shared";
|
|
2
|
-
import {
|
|
2
|
+
import { parsePhaseBudget, parseRunBudget, resolvePhaseBudget, serializeRunBudget, } from "@agent-dealer/shared";
|
|
3
3
|
import { canTransition } from "@agent-dealer/shared";
|
|
4
4
|
import { v4 as uuid } from "uuid";
|
|
5
5
|
import { getDb } from "../db/index.js";
|
|
@@ -43,9 +43,9 @@ export function createRun(input, opts) {
|
|
|
43
43
|
agentProfile?.defaultPlanModel ??
|
|
44
44
|
(isCursor ? CURSOR_DEFAULT_MODEL : null);
|
|
45
45
|
const executeModel = input.executeModel ?? input.planModel ?? agentProfile?.defaultExecuteModel ?? planModel;
|
|
46
|
-
const repo = input.repo ??
|
|
46
|
+
const repo = input.repo ?? null;
|
|
47
47
|
if (!repo) {
|
|
48
|
-
throw new Error("
|
|
48
|
+
throw new Error("Repo is required — provide a repository path/URL on the task (agent workspace is no longer used)");
|
|
49
49
|
}
|
|
50
50
|
const source = opts?.source ?? "manual";
|
|
51
51
|
const externalId = opts?.externalId ?? (source === "manual" ? id : null);
|
|
@@ -63,7 +63,7 @@ export function createRun(input, opts) {
|
|
|
63
63
|
agent_name: agent.agentName,
|
|
64
64
|
deck_id: agent.deckId ?? null,
|
|
65
65
|
deck_name: agent.deckName ?? opts?.deckName ?? null,
|
|
66
|
-
playbook_id:
|
|
66
|
+
playbook_id: null,
|
|
67
67
|
runtime: agent.runtime,
|
|
68
68
|
plan_model: planModel,
|
|
69
69
|
execute_model: executeModel,
|
|
@@ -103,47 +103,6 @@ export function getRun(id) {
|
|
|
103
103
|
const row = getDb().prepare("SELECT * FROM runs WHERE id = ?").get(id);
|
|
104
104
|
return row ? rowToRun(row) : null;
|
|
105
105
|
}
|
|
106
|
-
export function listRuns(status) {
|
|
107
|
-
const db = getDb();
|
|
108
|
-
if (!status) {
|
|
109
|
-
const rows = db.prepare("SELECT * FROM runs ORDER BY updated_at DESC").all();
|
|
110
|
-
return rows.map(rowToRun);
|
|
111
|
-
}
|
|
112
|
-
const statuses = Array.isArray(status) ? status : [status];
|
|
113
|
-
const placeholders = statuses.map(() => "?").join(",");
|
|
114
|
-
const rows = db
|
|
115
|
-
.prepare(`SELECT * FROM runs WHERE status IN (${placeholders}) ORDER BY updated_at DESC`)
|
|
116
|
-
.all(...statuses);
|
|
117
|
-
return rows.map(rowToRun);
|
|
118
|
-
}
|
|
119
|
-
export function countByStatus(status) {
|
|
120
|
-
const row = getDb()
|
|
121
|
-
.prepare("SELECT COUNT(*) as c FROM runs WHERE status = ?")
|
|
122
|
-
.get(status);
|
|
123
|
-
return row.c;
|
|
124
|
-
}
|
|
125
|
-
export function findByExternalId(source, externalId) {
|
|
126
|
-
const row = getDb()
|
|
127
|
-
.prepare("SELECT * FROM runs WHERE source = ? AND external_id = ?")
|
|
128
|
-
.get(source, externalId);
|
|
129
|
-
return row ? rowToRun(row) : null;
|
|
130
|
-
}
|
|
131
|
-
export function findActiveByExternalId(source, externalId) {
|
|
132
|
-
const row = getDb()
|
|
133
|
-
.prepare(`SELECT * FROM runs WHERE source = ? AND external_id = ? AND status NOT IN ('done', 'cancelled')`)
|
|
134
|
-
.get(source, externalId);
|
|
135
|
-
return row ? rowToRun(row) : null;
|
|
136
|
-
}
|
|
137
|
-
/** plan_pending runs where the agent (or human) has written a plan — ready for review. */
|
|
138
|
-
export function listRunsReadyForPlanReview() {
|
|
139
|
-
const rows = getDb()
|
|
140
|
-
.prepare(`SELECT DISTINCT r.* FROM runs r
|
|
141
|
-
INNER JOIN artifacts a ON a.run_id = r.id AND a.kind = 'draft_plan'
|
|
142
|
-
WHERE r.status = 'plan_pending'
|
|
143
|
-
ORDER BY r.updated_at ASC`)
|
|
144
|
-
.all();
|
|
145
|
-
return rows.map(rowToRun);
|
|
146
|
-
}
|
|
147
106
|
export function resolveBudgetForPhase(run, phase) {
|
|
148
107
|
const agent = run.agentId ? getAgent(run.agentId) : null;
|
|
149
108
|
return resolvePhaseBudget({
|
|
@@ -154,38 +113,6 @@ export function resolveBudgetForPhase(run, phase) {
|
|
|
154
113
|
testMode: process.env.AGENT_DEALER_TEST_BUDGET === "1",
|
|
155
114
|
});
|
|
156
115
|
}
|
|
157
|
-
export function updateRunBudget(id, patch) {
|
|
158
|
-
const run = getRun(id);
|
|
159
|
-
if (!run)
|
|
160
|
-
throw new Error(`Run not found: ${id}`);
|
|
161
|
-
const merged = mergeRunBudget(parseRunBudget(run.budgetJson), patch);
|
|
162
|
-
const budgetJson = serializeRunBudget(merged);
|
|
163
|
-
const now = new Date().toISOString();
|
|
164
|
-
getDb().prepare("UPDATE runs SET budget_json = ?, updated_at = ? WHERE id = ?").run(budgetJson, now, id);
|
|
165
|
-
const updated = getRun(id);
|
|
166
|
-
if (!updated)
|
|
167
|
-
throw new Error(`Run vanished: ${id}`);
|
|
168
|
-
return updated;
|
|
169
|
-
}
|
|
170
|
-
export function patchRunPhaseBudget(id, phase, budget) {
|
|
171
|
-
if (budget === undefined)
|
|
172
|
-
return getRun(id);
|
|
173
|
-
// null = clear run-level override for this phase (revert to agent defaults)
|
|
174
|
-
return updateRunBudget(id, { [phase]: budget });
|
|
175
|
-
}
|
|
176
|
-
export function resolveModelForPhase(run, phase) {
|
|
177
|
-
const agent = run.agentId ? getAgent(run.agentId) : null;
|
|
178
|
-
const isCursor = run.runtime === "cursor_local";
|
|
179
|
-
const raw = phase === "plan"
|
|
180
|
-
? (run.planModel ?? agent?.defaultPlanModel ?? null)
|
|
181
|
-
: (run.executeModel ?? agent?.defaultExecuteModel ?? run.planModel ?? agent?.defaultPlanModel ?? null);
|
|
182
|
-
const trimmed = raw?.trim();
|
|
183
|
-
if (trimmed)
|
|
184
|
-
return trimmed;
|
|
185
|
-
if (isCursor)
|
|
186
|
-
return CURSOR_DEFAULT_MODEL;
|
|
187
|
-
return undefined;
|
|
188
|
-
}
|
|
189
116
|
function applyRunFieldPatch(current, patch) {
|
|
190
117
|
return {
|
|
191
118
|
...current,
|
|
@@ -316,64 +243,6 @@ export function appendEvent(runId, type, payload) {
|
|
|
316
243
|
.run(id, runId, type, payloadJson, ts);
|
|
317
244
|
return { id, runId, type, payloadJson, ts };
|
|
318
245
|
}
|
|
319
|
-
export function listEvents(runId) {
|
|
320
|
-
const rows = getDb()
|
|
321
|
-
.prepare("SELECT * FROM events WHERE run_id = ? ORDER BY ts ASC")
|
|
322
|
-
.all(runId);
|
|
323
|
-
return rows.map((r) => ({
|
|
324
|
-
id: r.id,
|
|
325
|
-
runId: r.run_id,
|
|
326
|
-
type: r.type,
|
|
327
|
-
payloadJson: r.payload_json,
|
|
328
|
-
ts: r.ts,
|
|
329
|
-
}));
|
|
330
|
-
}
|
|
331
|
-
export function updateArtifactContent(artifactId, content) {
|
|
332
|
-
const contentJson = JSON.stringify(content);
|
|
333
|
-
getDb()
|
|
334
|
-
.prepare("UPDATE artifacts SET content_json = ? WHERE id = ?")
|
|
335
|
-
.run(contentJson, artifactId);
|
|
336
|
-
const row = getDb()
|
|
337
|
-
.prepare("SELECT * FROM artifacts WHERE id = ?")
|
|
338
|
-
.get(artifactId);
|
|
339
|
-
return {
|
|
340
|
-
id: row.id,
|
|
341
|
-
runId: row.run_id,
|
|
342
|
-
kind: row.kind,
|
|
343
|
-
contentJson: row.content_json,
|
|
344
|
-
blobPath: row.blob_path,
|
|
345
|
-
author: row.author,
|
|
346
|
-
createdAt: row.created_at,
|
|
347
|
-
};
|
|
348
|
-
}
|
|
349
|
-
/** Most recent run in the same lineage before `run` (retry parent). */
|
|
350
|
-
export function getLineageParentRun(run) {
|
|
351
|
-
const lineageId = run.lineageId;
|
|
352
|
-
if (!lineageId)
|
|
353
|
-
return null;
|
|
354
|
-
const row = getDb()
|
|
355
|
-
.prepare(`
|
|
356
|
-
SELECT * FROM runs
|
|
357
|
-
WHERE (lineage_id = @lineage_id OR id = @lineage_id)
|
|
358
|
-
AND id != @run_id
|
|
359
|
-
ORDER BY created_at DESC
|
|
360
|
-
LIMIT 1
|
|
361
|
-
`)
|
|
362
|
-
.get({ lineage_id: lineageId, run_id: run.id });
|
|
363
|
-
return row ? rowToRun(row) : null;
|
|
364
|
-
}
|
|
365
|
-
/** All runs in a lineage (root first), including cancelled retries. */
|
|
366
|
-
export function listLineageRuns(run) {
|
|
367
|
-
const lineageId = run.lineageId ?? run.id;
|
|
368
|
-
const rows = getDb()
|
|
369
|
-
.prepare(`
|
|
370
|
-
SELECT * FROM runs
|
|
371
|
-
WHERE lineage_id = @lineage_id OR id = @lineage_id
|
|
372
|
-
ORDER BY created_at ASC
|
|
373
|
-
`)
|
|
374
|
-
.all({ lineage_id: lineageId });
|
|
375
|
-
return rows.map(rowToRun);
|
|
376
|
-
}
|
|
377
246
|
export function getLatestArtifact(runId, kind) {
|
|
378
247
|
const row = getDb()
|
|
379
248
|
.prepare("SELECT * FROM artifacts WHERE run_id = ? AND kind = ? ORDER BY created_at DESC LIMIT 1")
|
|
@@ -390,79 +259,3 @@ export function getLatestArtifact(runId, kind) {
|
|
|
390
259
|
createdAt: row.created_at,
|
|
391
260
|
};
|
|
392
261
|
}
|
|
393
|
-
/** Latest plan_triage stops auto-approving / accepting answers once a human takes over the plan. */
|
|
394
|
-
export function markPlanTriageConsumed(runId) {
|
|
395
|
-
const art = getLatestArtifact(runId, "plan_triage");
|
|
396
|
-
if (!art?.contentJson)
|
|
397
|
-
return;
|
|
398
|
-
try {
|
|
399
|
-
const content = JSON.parse(art.contentJson);
|
|
400
|
-
if (content.consumed === true)
|
|
401
|
-
return;
|
|
402
|
-
updateArtifactContent(art.id, { ...content, consumed: true });
|
|
403
|
-
}
|
|
404
|
-
catch {
|
|
405
|
-
/* leave malformed triage untouched */
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
const OPS_BOARD_STATUSES = [
|
|
409
|
-
"queued",
|
|
410
|
-
"plan_pending",
|
|
411
|
-
"plan_approved",
|
|
412
|
-
"running",
|
|
413
|
-
"review",
|
|
414
|
-
"failed",
|
|
415
|
-
];
|
|
416
|
-
function inferOpsStatusFromArtifacts(runId) {
|
|
417
|
-
const exec = getLatestArtifact(runId, "execution_result");
|
|
418
|
-
if (exec?.contentJson) {
|
|
419
|
-
try {
|
|
420
|
-
const content = JSON.parse(exec.contentJson);
|
|
421
|
-
if (content.isError || (content.exitCode !== undefined && content.exitCode !== 0)) {
|
|
422
|
-
return "failed";
|
|
423
|
-
}
|
|
424
|
-
return "review";
|
|
425
|
-
}
|
|
426
|
-
catch {
|
|
427
|
-
return "review";
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
if (getLatestArtifact(runId, "approved_plan"))
|
|
431
|
-
return "plan_approved";
|
|
432
|
-
return "plan_pending";
|
|
433
|
-
}
|
|
434
|
-
export function statusBeforeCancel(runId) {
|
|
435
|
-
const events = listEvents(runId);
|
|
436
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
437
|
-
const e = events[i];
|
|
438
|
-
if (e.type !== "run.status_changed" || !e.payloadJson)
|
|
439
|
-
continue;
|
|
440
|
-
try {
|
|
441
|
-
const payload = JSON.parse(e.payloadJson);
|
|
442
|
-
if (payload.to === "cancelled" && payload.from && OPS_BOARD_STATUSES.includes(payload.from)) {
|
|
443
|
-
return payload.from;
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
catch {
|
|
447
|
-
// skip malformed event
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
return inferOpsStatusFromArtifacts(runId);
|
|
451
|
-
}
|
|
452
|
-
/** Return a given-up run to the Operations board at its prior stage. */
|
|
453
|
-
export function restoreRun(id) {
|
|
454
|
-
const run = getRun(id);
|
|
455
|
-
if (!run)
|
|
456
|
-
throw new Error(`Run not found: ${id}`);
|
|
457
|
-
if (run.status !== "cancelled") {
|
|
458
|
-
throw new Error(`Run is not given up (status: ${run.status})`);
|
|
459
|
-
}
|
|
460
|
-
const to = statusBeforeCancel(id);
|
|
461
|
-
const now = new Date().toISOString();
|
|
462
|
-
getDb().prepare("UPDATE runs SET status = ?, updated_at = ? WHERE id = ?").run(to, now, id);
|
|
463
|
-
appendEvent(id, "run.restored", { to });
|
|
464
|
-
const updated = getRun(id);
|
|
465
|
-
if (!updated)
|
|
466
|
-
throw new Error(`Run vanished: ${id}`);
|
|
467
|
-
return updated;
|
|
468
|
-
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// packages/server/src/repository/runtime-availability.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-111: durable per-runtime (account-level) usage-cap state. Runners write when they
|
|
4
|
+
// observe a hard cap; coordinator and queue admission read via runtimeAvailability().
|
|
5
|
+
import { getDb } from "../db/index.js";
|
|
6
|
+
/** Upsert the latest cap observation for a runtime (one row per runtime). */
|
|
7
|
+
export function recordRuntimeAvailability(input) {
|
|
8
|
+
const now = new Date().toISOString();
|
|
9
|
+
getDb()
|
|
10
|
+
.prepare(`
|
|
11
|
+
INSERT INTO runtime_availability (runtime, unavailable_until, reason, evidence_json, observed_at)
|
|
12
|
+
VALUES (@runtime, @unavailable_until, @reason, @evidence_json, @observed_at)
|
|
13
|
+
ON CONFLICT(runtime) DO UPDATE SET
|
|
14
|
+
unavailable_until = excluded.unavailable_until,
|
|
15
|
+
reason = excluded.reason,
|
|
16
|
+
evidence_json = excluded.evidence_json,
|
|
17
|
+
observed_at = excluded.observed_at
|
|
18
|
+
`)
|
|
19
|
+
.run({
|
|
20
|
+
runtime: input.runtime,
|
|
21
|
+
unavailable_until: input.unavailableUntil,
|
|
22
|
+
reason: input.reason,
|
|
23
|
+
evidence_json: input.evidence !== undefined ? JSON.stringify(input.evidence) : null,
|
|
24
|
+
observed_at: now,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/** Clear a runtime row once its unavailable_until has passed (lazy cleanup on read). */
|
|
28
|
+
function clearExpired(runtime, nowMs = Date.now()) {
|
|
29
|
+
getDb()
|
|
30
|
+
.prepare("DELETE FROM runtime_availability WHERE runtime = ? AND unavailable_until <= ?")
|
|
31
|
+
.run(runtime, new Date(nowMs).toISOString());
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Single read function for coordinator deferral and NOT-103 queue admission.
|
|
35
|
+
* Returns `{ available: true }` when no active cap is recorded.
|
|
36
|
+
*/
|
|
37
|
+
export function runtimeAvailability(runtime, nowMs = Date.now()) {
|
|
38
|
+
clearExpired(runtime, nowMs);
|
|
39
|
+
const row = getDb()
|
|
40
|
+
.prepare("SELECT * FROM runtime_availability WHERE runtime = ?")
|
|
41
|
+
.get(runtime);
|
|
42
|
+
if (!row)
|
|
43
|
+
return { available: true };
|
|
44
|
+
const untilMs = Date.parse(row.unavailable_until);
|
|
45
|
+
if (!Number.isFinite(untilMs) || untilMs <= nowMs) {
|
|
46
|
+
getDb().prepare("DELETE FROM runtime_availability WHERE runtime = ?").run(runtime);
|
|
47
|
+
return { available: true };
|
|
48
|
+
}
|
|
49
|
+
return { available: false, until: row.unavailable_until, reason: row.reason };
|
|
50
|
+
}
|
|
51
|
+
/** Test helper — wipe all cap rows. */
|
|
52
|
+
export function clearAllRuntimeAvailability() {
|
|
53
|
+
getDb().exec("DELETE FROM runtime_availability");
|
|
54
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { v4 as uuid } from "uuid";
|
|
2
|
+
import { getDb } from "../db/index.js";
|
|
3
|
+
function rowToUsageEvent(row) {
|
|
4
|
+
return {
|
|
5
|
+
id: row.id,
|
|
6
|
+
issueId: row.issue_id,
|
|
7
|
+
workerSessionId: row.worker_session_id,
|
|
8
|
+
role: row.role,
|
|
9
|
+
runtime: row.runtime,
|
|
10
|
+
tokensIn: row.tokens_in,
|
|
11
|
+
tokensOut: row.tokens_out,
|
|
12
|
+
costUsd: row.cost_usd,
|
|
13
|
+
durationMs: row.duration_ms,
|
|
14
|
+
ts: row.ts,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function recordUsageEvent(input) {
|
|
18
|
+
const row = {
|
|
19
|
+
id: uuid(),
|
|
20
|
+
issue_id: input.issueId,
|
|
21
|
+
worker_session_id: input.workerSessionId,
|
|
22
|
+
role: input.role,
|
|
23
|
+
runtime: input.runtime ?? null,
|
|
24
|
+
tokens_in: input.tokensIn ?? null,
|
|
25
|
+
tokens_out: input.tokensOut ?? null,
|
|
26
|
+
cost_usd: input.costUsd ?? null,
|
|
27
|
+
duration_ms: input.durationMs ?? null,
|
|
28
|
+
ts: new Date().toISOString(),
|
|
29
|
+
};
|
|
30
|
+
getDb().prepare(`
|
|
31
|
+
INSERT INTO usage_events (
|
|
32
|
+
id, issue_id, worker_session_id, role, runtime, tokens_in, tokens_out, cost_usd, duration_ms, ts
|
|
33
|
+
) VALUES (
|
|
34
|
+
@id, @issue_id, @worker_session_id, @role, @runtime, @tokens_in, @tokens_out, @cost_usd, @duration_ms, @ts
|
|
35
|
+
)
|
|
36
|
+
`).run(row);
|
|
37
|
+
return rowToUsageEvent(row);
|
|
38
|
+
}
|
|
39
|
+
export function summarizeIssueUsage(issueId) {
|
|
40
|
+
const row = getDb()
|
|
41
|
+
.prepare(`SELECT
|
|
42
|
+
COALESCE(SUM(cost_usd), 0) as total_cost,
|
|
43
|
+
COALESCE(SUM(duration_ms), 0) as total_duration,
|
|
44
|
+
COALESCE(SUM(tokens_in), 0) as total_in,
|
|
45
|
+
COALESCE(SUM(tokens_out), 0) as total_out
|
|
46
|
+
FROM usage_events WHERE issue_id = ?`)
|
|
47
|
+
.get(issueId);
|
|
48
|
+
return {
|
|
49
|
+
totalCostUsd: row.total_cost,
|
|
50
|
+
totalDurationMs: row.total_duration,
|
|
51
|
+
totalTokensIn: row.total_in,
|
|
52
|
+
totalTokensOut: row.total_out,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export function listUsageEventsForIssue(issueId) {
|
|
56
|
+
const rows = getDb()
|
|
57
|
+
.prepare("SELECT * FROM usage_events WHERE issue_id = ? ORDER BY ts ASC")
|
|
58
|
+
.all(issueId);
|
|
59
|
+
return rows.map(rowToUsageEvent);
|
|
60
|
+
}
|