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,141 @@
|
|
|
1
|
+
// packages/server/src/pid-marker.ts
|
|
2
|
+
//
|
|
3
|
+
// A generic, path-parameterized exclusive pid marker: a JSON file naming the pid that
|
|
4
|
+
// currently "owns" whatever it's guarding, claimed via an atomic exclusive create and
|
|
5
|
+
// released only by its own owner. Two independent callers build on this:
|
|
6
|
+
//
|
|
7
|
+
// - server-liveness.ts: the server's own entrypoint claims a marker at
|
|
8
|
+
// AGENT_DEALER_HOME/server.pid for its whole running lifetime.
|
|
9
|
+
// - db/migrate-to-issues.ts: the cutover script claims that SAME file for the duration
|
|
10
|
+
// of the migration, so a server trying to start concurrently sees the migration's own
|
|
11
|
+
// (live) pid as the current owner and refuses to start, and the migration's claim
|
|
12
|
+
// itself fails if a server is already running — the same primitive closes the race
|
|
13
|
+
// from both directions instead of one side merely observing the other's state.
|
|
14
|
+
//
|
|
15
|
+
// Kept path-parameterized (not bound to AGENT_DEALER_HOME/getDataDir()) so the migration
|
|
16
|
+
// script — which operates on an arbitrary caller-supplied database path, not necessarily
|
|
17
|
+
// this process's own AGENT_DEALER_HOME — can guard the directory the target database
|
|
18
|
+
// actually lives in.
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import Database from "better-sqlite3";
|
|
21
|
+
function isPidAlive(pid) {
|
|
22
|
+
if (!Number.isFinite(pid) || pid <= 0)
|
|
23
|
+
return false;
|
|
24
|
+
try {
|
|
25
|
+
process.kill(pid, 0);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function readRaw(filePath) {
|
|
33
|
+
if (!fs.existsSync(filePath))
|
|
34
|
+
return null;
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Whoever currently owns this marker, or null if the file is absent or unreadable.
|
|
43
|
+
* `alive` reflects a live process.kill(pid, 0) probe against the recorded pid — a
|
|
44
|
+
* present-but-stale marker (dead pid) still returns a result, with `alive: false`, so a
|
|
45
|
+
* caller can distinguish "nothing here" from "something here, but it's stale." */
|
|
46
|
+
export function readPidMarkerOwner(filePath) {
|
|
47
|
+
const raw = readRaw(filePath);
|
|
48
|
+
if (!raw)
|
|
49
|
+
return null;
|
|
50
|
+
const pid = raw.pid;
|
|
51
|
+
if (typeof pid !== "number" || !Number.isFinite(pid) || pid <= 0)
|
|
52
|
+
return null;
|
|
53
|
+
return { pid, alive: isPidAlive(pid), raw };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Runs `fn` under a real OS-level advisory lock scoped to this marker, via a tiny
|
|
57
|
+
* dedicated SQLite file (`<filePath>.arbiter`) and a `BEGIN EXCLUSIVE` transaction —
|
|
58
|
+
* not a hand-rolled lock *file*, which would face the exact same "check it, decide it's
|
|
59
|
+
* stale, delete it, recreate it" race this exists to close, just one level further down
|
|
60
|
+
* (a prior version of this code tried exactly that with a `.reclaim-lock` file, and it
|
|
61
|
+
* had the identical bug: two processes could both observe the lock file as abandoned and
|
|
62
|
+
* both "steal" it, one destroying the other's fresh claim).
|
|
63
|
+
*
|
|
64
|
+
* SQLite's `BEGIN EXCLUSIVE` acquires a real filesystem advisory lock (fcntl/flock under
|
|
65
|
+
* the hood in rollback-journal mode) that a second connection's own `BEGIN EXCLUSIVE`
|
|
66
|
+
* genuinely blocks on — `busy_timeout` only bounds how long a live contender waits its
|
|
67
|
+
* turn. Critically, this sidesteps the entire "how do I tell a stale lock from a held one"
|
|
68
|
+
* problem: if the holding process dies for any reason (including a crash), the OS
|
|
69
|
+
* releases the underlying advisory lock immediately, so there is no lease/timeout
|
|
70
|
+
* heuristic to get wrong here at all.
|
|
71
|
+
*/
|
|
72
|
+
function withArbiterLock(filePath, fn) {
|
|
73
|
+
const db = new Database(`${filePath}.arbiter`);
|
|
74
|
+
try {
|
|
75
|
+
db.pragma("busy_timeout = 3000");
|
|
76
|
+
return db.transaction(fn).exclusive();
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
db.close();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Claims the marker for this process (process.pid), refusing only when a *different*,
|
|
84
|
+
* still-alive pid already holds it. A stale marker (dead pid, or unreadable content) is
|
|
85
|
+
* reclaimed rather than treated as a blocker — a crashed prior owner must never
|
|
86
|
+
* permanently wedge this lock. `extra` fields (e.g. `port`, `role`) are merged into the
|
|
87
|
+
* written state alongside `pid`/`startedAt`.
|
|
88
|
+
*
|
|
89
|
+
* The fast path (nobody home) is a single atomic exclusive create (`wx`), so two processes
|
|
90
|
+
* racing an *absent* marker cannot both believe they claimed it. Reclaiming a *stale*
|
|
91
|
+
* marker is different: "read it, decide it's dead, replace it" is not one atomic step on
|
|
92
|
+
* its own, so the decide-and-replace sequence runs under withArbiterLock — a real OS-level
|
|
93
|
+
* advisory lock, not a second hand-rolled lock file with the same race one level down.
|
|
94
|
+
*/
|
|
95
|
+
export function claimPidMarker(filePath, extra = {}) {
|
|
96
|
+
const state = { pid: process.pid, startedAt: new Date().toISOString(), ...extra };
|
|
97
|
+
const payload = JSON.stringify(state, null, 2);
|
|
98
|
+
try {
|
|
99
|
+
fs.writeFileSync(filePath, payload, { flag: "wx" });
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
if (err.code !== "EEXIST")
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
const owner = readPidMarkerOwner(filePath);
|
|
107
|
+
if (owner && owner.pid !== process.pid && owner.alive) {
|
|
108
|
+
return false; // a different, live owner holds this marker — do not touch it
|
|
109
|
+
}
|
|
110
|
+
if (owner && owner.pid === process.pid) {
|
|
111
|
+
return true; // already ours (e.g. a re-entrant claim) — nothing to replace
|
|
112
|
+
}
|
|
113
|
+
// Stale (dead pid) or unreadable — the destructive replace happens under the arbiter
|
|
114
|
+
// lock, not on the strength of the read above alone.
|
|
115
|
+
return withArbiterLock(filePath, () => {
|
|
116
|
+
// Re-check under the lock: the marker may have been legitimately reclaimed by whoever
|
|
117
|
+
// held this lock just before us.
|
|
118
|
+
const ownerUnderLock = readPidMarkerOwner(filePath);
|
|
119
|
+
if (ownerUnderLock && ownerUnderLock.pid !== process.pid && ownerUnderLock.alive) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
// No `wx` here: holding the arbiter lock is what makes this replace exclusive, not the
|
|
123
|
+
// create flag — the destination may legitimately still exist (the stale file itself).
|
|
124
|
+
fs.writeFileSync(filePath, payload);
|
|
125
|
+
return true;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
/** Releases the marker only if it currently names *this* process — never a different
|
|
129
|
+
* (possibly still-alive) owner's marker, including one this process never actually claimed
|
|
130
|
+
* because claimPidMarker() returned false for it. */
|
|
131
|
+
export function releasePidMarker(filePath) {
|
|
132
|
+
const owner = readPidMarkerOwner(filePath);
|
|
133
|
+
if (!owner || owner.pid !== process.pid)
|
|
134
|
+
return;
|
|
135
|
+
try {
|
|
136
|
+
fs.unlinkSync(filePath);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// already gone — fine on a second release call
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// packages/server/src/pid-marker.test.ts
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { claimPidMarker, releasePidMarker, readPidMarkerOwner } from "./pid-marker.js";
|
|
10
|
+
import { resolveTsxBin } from "./resolve-tsx-bin.js";
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const childScript = path.join(__dirname, "pid-marker-claim-child.ts");
|
|
13
|
+
const holdArbiterScript = path.join(__dirname, "pid-marker-hold-arbiter-child.ts");
|
|
14
|
+
const tsxBin = resolveTsxBin(path.resolve(__dirname, "..", "..", ".."));
|
|
15
|
+
/** Resolves once the child has actually exited — killing it alone isn't enough to keep
|
|
16
|
+
* the test process from hanging afterward: an unresolved 'exit' event leaves the child's
|
|
17
|
+
* handle open, and node:test won't let the process exit while one is outstanding. */
|
|
18
|
+
function waitForExit(child) {
|
|
19
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
20
|
+
return Promise.resolve();
|
|
21
|
+
return new Promise((resolve) => child.once("exit", () => resolve()));
|
|
22
|
+
}
|
|
23
|
+
function tempMarkerPath() {
|
|
24
|
+
return path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dealer-pid-marker-")), "marker.json");
|
|
25
|
+
}
|
|
26
|
+
/** A genuinely alive, same-user child pid — safe to signal, unlike e.g. pid 1 (which is
|
|
27
|
+
* alive but throws EPERM rather than ESRCH for an unprivileged process.kill probe). */
|
|
28
|
+
function spawnLiveChild() {
|
|
29
|
+
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
|
|
30
|
+
return { pid: child.pid, kill: () => child.kill("SIGKILL") };
|
|
31
|
+
}
|
|
32
|
+
test("claimPidMarker claims an absent marker", () => {
|
|
33
|
+
const filePath = tempMarkerPath();
|
|
34
|
+
assert.equal(claimPidMarker(filePath), true);
|
|
35
|
+
const owner = readPidMarkerOwner(filePath);
|
|
36
|
+
assert.equal(owner?.pid, process.pid);
|
|
37
|
+
assert.equal(owner?.alive, true);
|
|
38
|
+
});
|
|
39
|
+
test("claimPidMarker stores extra fields alongside pid/startedAt", () => {
|
|
40
|
+
const filePath = tempMarkerPath();
|
|
41
|
+
claimPidMarker(filePath, { role: "migration", port: 1234 });
|
|
42
|
+
const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
43
|
+
assert.equal(raw.role, "migration");
|
|
44
|
+
assert.equal(raw.port, 1234);
|
|
45
|
+
assert.equal(typeof raw.startedAt, "string");
|
|
46
|
+
});
|
|
47
|
+
test("claimPidMarker refuses a marker owned by a different, live pid — and does not touch it", () => {
|
|
48
|
+
const filePath = tempMarkerPath();
|
|
49
|
+
const other = spawnLiveChild();
|
|
50
|
+
try {
|
|
51
|
+
fs.writeFileSync(filePath, JSON.stringify({ pid: other.pid, startedAt: "x" }));
|
|
52
|
+
assert.equal(claimPidMarker(filePath), false);
|
|
53
|
+
const owner = readPidMarkerOwner(filePath);
|
|
54
|
+
assert.equal(owner?.pid, other.pid);
|
|
55
|
+
assert.equal(owner?.alive, true);
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
other.kill();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
test("claimPidMarker reclaims a stale marker (dead pid)", () => {
|
|
62
|
+
const filePath = tempMarkerPath();
|
|
63
|
+
fs.writeFileSync(filePath, JSON.stringify({ pid: 999999, startedAt: "x" }));
|
|
64
|
+
assert.equal(claimPidMarker(filePath), true);
|
|
65
|
+
assert.equal(readPidMarkerOwner(filePath)?.pid, process.pid);
|
|
66
|
+
});
|
|
67
|
+
test("claimPidMarker reclaims unreadable/corrupt content", () => {
|
|
68
|
+
const filePath = tempMarkerPath();
|
|
69
|
+
fs.writeFileSync(filePath, "not json");
|
|
70
|
+
assert.equal(claimPidMarker(filePath), true);
|
|
71
|
+
assert.equal(readPidMarkerOwner(filePath)?.pid, process.pid);
|
|
72
|
+
});
|
|
73
|
+
test("releasePidMarker removes a marker this process owns", () => {
|
|
74
|
+
const filePath = tempMarkerPath();
|
|
75
|
+
claimPidMarker(filePath);
|
|
76
|
+
releasePidMarker(filePath);
|
|
77
|
+
assert.equal(fs.existsSync(filePath), false);
|
|
78
|
+
});
|
|
79
|
+
test("releasePidMarker never removes a marker owned by a different pid", () => {
|
|
80
|
+
const filePath = tempMarkerPath();
|
|
81
|
+
const other = spawnLiveChild();
|
|
82
|
+
try {
|
|
83
|
+
fs.writeFileSync(filePath, JSON.stringify({ pid: other.pid, startedAt: "x" }));
|
|
84
|
+
releasePidMarker(filePath);
|
|
85
|
+
assert.ok(fs.existsSync(filePath));
|
|
86
|
+
assert.equal(readPidMarkerOwner(filePath)?.pid, other.pid);
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
other.kill();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
test("releasePidMarker on an absent file is a no-op", () => {
|
|
93
|
+
const filePath = tempMarkerPath();
|
|
94
|
+
assert.doesNotThrow(() => releasePidMarker(filePath));
|
|
95
|
+
});
|
|
96
|
+
test("readPidMarkerOwner distinguishes absent from stale", () => {
|
|
97
|
+
const filePath = tempMarkerPath();
|
|
98
|
+
assert.equal(readPidMarkerOwner(filePath), null);
|
|
99
|
+
fs.writeFileSync(filePath, JSON.stringify({ pid: 999999, startedAt: "x" }));
|
|
100
|
+
const owner = readPidMarkerOwner(filePath);
|
|
101
|
+
assert.equal(owner?.pid, 999999);
|
|
102
|
+
assert.equal(owner?.alive, false);
|
|
103
|
+
});
|
|
104
|
+
test("concurrent reclaim of the same stale marker: exactly one real process wins, never a corrupted mix of both", { timeout: 20000 }, () => {
|
|
105
|
+
// Real child processes, not an in-process simulation — a fake interleaving inside one
|
|
106
|
+
// JS event loop can't actually exercise the OS-level race between two independent
|
|
107
|
+
// processes both reading "stale", both deciding to reclaim, and both replacing the
|
|
108
|
+
// marker. Before the reclaim-lock fix, this reliably produced runs where more than one
|
|
109
|
+
// child reported claimed:true for the same marker.
|
|
110
|
+
const filePath = tempMarkerPath();
|
|
111
|
+
fs.writeFileSync(filePath, JSON.stringify({ pid: 999999, startedAt: "x" })); // stale
|
|
112
|
+
const N = 8;
|
|
113
|
+
const children = Array.from({ length: N }, () => spawn(tsxBin, [childScript, filePath], { stdio: ["ignore", "pipe", "inherit"] }));
|
|
114
|
+
// The winner(s) stay alive on purpose (see the helper script) — so wait for each
|
|
115
|
+
// child's single result line, not for the process to exit.
|
|
116
|
+
const firstLines = children.map((child) => new Promise((resolve) => {
|
|
117
|
+
let buf = "";
|
|
118
|
+
child.stdout.on("data", (d) => {
|
|
119
|
+
buf += d.toString();
|
|
120
|
+
const nl = buf.indexOf("\n");
|
|
121
|
+
if (nl !== -1)
|
|
122
|
+
resolve(buf.slice(0, nl));
|
|
123
|
+
});
|
|
124
|
+
}));
|
|
125
|
+
return Promise.all(firstLines)
|
|
126
|
+
.then((lines) => {
|
|
127
|
+
const results = lines.map((l) => JSON.parse(l));
|
|
128
|
+
const winners = results.filter((r) => r.claimed);
|
|
129
|
+
assert.equal(winners.length, 1, `expected exactly one winner, got: ${JSON.stringify(results)}`);
|
|
130
|
+
const finalOwner = readPidMarkerOwner(filePath);
|
|
131
|
+
assert.equal(finalOwner?.pid, winners[0].pid, "the file must name the one process that actually won");
|
|
132
|
+
})
|
|
133
|
+
.finally(() => {
|
|
134
|
+
// SIGTERM, not SIGKILL: the tsx launcher forwards SIGTERM to the real node process
|
|
135
|
+
// it forks to actually run the script before exiting; SIGKILL only kills the
|
|
136
|
+
// launcher itself (an unblockable signal it never gets to react to), orphaning
|
|
137
|
+
// that grandchild — which is exactly the live process holding server.pid's
|
|
138
|
+
// real-world equivalent open, so it (and this whole test process) would hang.
|
|
139
|
+
for (const child of children)
|
|
140
|
+
child.kill("SIGTERM");
|
|
141
|
+
return Promise.all(children.map(waitForExit));
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
test("the arbiter lock does not linger after a normal claim — a second claim proceeds immediately", () => {
|
|
145
|
+
const filePath = tempMarkerPath();
|
|
146
|
+
fs.writeFileSync(filePath, JSON.stringify({ pid: 999999, startedAt: "x" }));
|
|
147
|
+
claimPidMarker(filePath);
|
|
148
|
+
releasePidMarker(filePath);
|
|
149
|
+
fs.writeFileSync(filePath, JSON.stringify({ pid: 999998, startedAt: "x" }));
|
|
150
|
+
const start = Date.now();
|
|
151
|
+
assert.equal(claimPidMarker(filePath), true);
|
|
152
|
+
assert.ok(Date.now() - start < 500, "a second claim must not be blocked by a stale arbiter lock");
|
|
153
|
+
});
|
|
154
|
+
test("a process that crashes while holding the arbiter lock does not wedge it — the OS releases it, no lease timeout needed", { timeout: 20000 }, async () => {
|
|
155
|
+
// This is the regression the reviewer asked for: instead of a hand-rolled
|
|
156
|
+
// ".reclaim-lock" file with its own "is it stale" heuristic (which had the exact same
|
|
157
|
+
// check-then-delete race the main marker's reclaim did), the arbiter lock is a real
|
|
158
|
+
// OS-level advisory lock (via SQLite's BEGIN EXCLUSIVE) that the kernel releases the
|
|
159
|
+
// instant its holder dies, for any reason — including SIGKILL, simulating a crash
|
|
160
|
+
// mid-decision. Proves this empirically rather than by argument: hold the lock in a
|
|
161
|
+
// real child process, kill it, and confirm a subsequent claim succeeds promptly
|
|
162
|
+
// instead of waiting out the full busy_timeout (or hanging forever).
|
|
163
|
+
const filePath = tempMarkerPath();
|
|
164
|
+
fs.writeFileSync(filePath, JSON.stringify({ pid: 999999, startedAt: "x" })); // stale main marker
|
|
165
|
+
const holder = spawn(tsxBin, [holdArbiterScript, filePath], { stdio: ["ignore", "pipe", "inherit"] });
|
|
166
|
+
const realHolderPid = await new Promise((resolve) => {
|
|
167
|
+
let buf = "";
|
|
168
|
+
holder.stdout.on("data", (d) => {
|
|
169
|
+
buf += d.toString();
|
|
170
|
+
const nl = buf.indexOf("\n");
|
|
171
|
+
if (nl === -1)
|
|
172
|
+
return;
|
|
173
|
+
const msg = JSON.parse(buf.slice(0, nl));
|
|
174
|
+
resolve(msg.pid);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
// Kill the *actual* process holding the lock, not the tsx launcher — see the helper
|
|
178
|
+
// script's comment for why those are different pids and why SIGKILL specifically
|
|
179
|
+
// (an unblockable signal the launcher cannot forward) has to target the real one.
|
|
180
|
+
process.kill(realHolderPid, "SIGKILL");
|
|
181
|
+
holder.kill("SIGTERM"); // clean up the launcher wrapper too
|
|
182
|
+
await waitForExit(holder);
|
|
183
|
+
const start = Date.now();
|
|
184
|
+
const claimed = claimPidMarker(filePath);
|
|
185
|
+
const elapsedMs = Date.now() - start;
|
|
186
|
+
assert.equal(claimed, true);
|
|
187
|
+
assert.equal(readPidMarkerOwner(filePath)?.pid, process.pid);
|
|
188
|
+
assert.ok(elapsedMs < 1000, `claim should succeed promptly once the OS releases the dead holder's lock, took ${elapsedMs}ms`);
|
|
189
|
+
});
|
|
190
|
+
test("sanity: the child helper script actually claims when run alone", async () => {
|
|
191
|
+
const filePath = tempMarkerPath();
|
|
192
|
+
const child = spawn(tsxBin, [childScript, filePath], { stdio: ["ignore", "pipe", "inherit"] });
|
|
193
|
+
await new Promise((resolve) => {
|
|
194
|
+
let buf = "";
|
|
195
|
+
child.stdout.on("data", (d) => {
|
|
196
|
+
buf += d.toString();
|
|
197
|
+
const nl = buf.indexOf("\n");
|
|
198
|
+
if (nl === -1)
|
|
199
|
+
return;
|
|
200
|
+
const parsed = JSON.parse(buf.slice(0, nl));
|
|
201
|
+
assert.equal(parsed.claimed, true);
|
|
202
|
+
assert.equal(readPidMarkerOwner(filePath)?.pid, parsed.pid);
|
|
203
|
+
resolve();
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
child.kill("SIGTERM"); // see the concurrent-reclaim test for why SIGTERM, not SIGKILL
|
|
207
|
+
await waitForExit(child);
|
|
208
|
+
});
|
|
@@ -1,9 +1,62 @@
|
|
|
1
|
-
import { deliverOutboundDraft } from "../adapters/
|
|
1
|
+
import { deliverOutboundDraft } from "../adapters/outbound-delivery.js";
|
|
2
2
|
import { syncLinearForRun } from "../adapters/linear-sync.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { scheduleReflect } from "./
|
|
3
|
+
import { addArtifact, appendEvent, getRun, transitionRun } from "../repository/runs.js";
|
|
4
|
+
import { deliverInFlight, getPendingOutboundDraft, markOutboundDraftSent, patchPendingOutboundBody, rejectPendingOutboundDrafts, revertOutboundDraftToPending, } from "../repository/outbound-drafts.js";
|
|
5
|
+
import { createHumanAction, findOpenHumanActionForRun, getHumanAction, resolveHumanAction, } from "../repository/human-actions.js";
|
|
6
|
+
import { scheduleReflect } from "./reflect-schedule.js";
|
|
7
|
+
/** Who/what resolved a parked `outbound_delivery_interaction_required` action when
|
|
8
|
+
* `finalizeRunWithoutDelivery` auto-closes it — `resolveOutboundDeliveryAction`'s
|
|
9
|
+
* `retry_send` passes the real operator + "retry_send" through; every other caller (a plain
|
|
10
|
+
* caller with no operator identity) gets this generic marker. */
|
|
11
|
+
const DEFAULT_ACTION_RESOLUTION = { resolvedBy: "system", choice: "resolved_via_approve" };
|
|
12
|
+
/**
|
|
13
|
+
* Resolves a run's open `outbound_delivery_interaction_required` action, if any — a no-op
|
|
14
|
+
* otherwise. Any code path that terminalizes a run (finalize-on-success below, but also
|
|
15
|
+
* `resolveOutboundDeliveryAction`'s `reject`) must call this, or a
|
|
16
|
+
* parked action is left open forever: once the run leaves `review`, `resolveOutboundDeliveryAction`'s
|
|
17
|
+
* `retry_send` 400s on "Run must be in review" and there is no other way to close the item.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveOpenDeliveryParkForRun(runId, actionResolution) {
|
|
20
|
+
const openAction = findOpenHumanActionForRun(runId, "outbound_delivery_interaction_required");
|
|
21
|
+
if (openAction) {
|
|
22
|
+
resolveHumanAction(openAction.id, actionResolution.resolvedBy, { choice: actionResolution.choice });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Finalizes a run that has no more delivery work to do (no pending draft, or the pending
|
|
27
|
+
* draft was just sent). Also auto-resolves any still-open `outbound_delivery_interaction_required`
|
|
28
|
+
* action for this run: an operator can clear a park either by resolving it directly
|
|
29
|
+
* (`retry_send`/`reject`) or by an approve path that finds nothing left to deliver — either
|
|
30
|
+
* must close the queue item, not just the run.
|
|
31
|
+
*/
|
|
32
|
+
function finalizeRunWithoutDelivery(runId, actionResolution = DEFAULT_ACTION_RESOLUTION) {
|
|
33
|
+
resolveOpenDeliveryParkForRun(runId, actionResolution);
|
|
34
|
+
const updated = transitionRun(runId, "done");
|
|
35
|
+
syncLinearForRun(updated, "done").catch((e) => console.error("[linear-sync] done:", e));
|
|
36
|
+
scheduleReflect(updated, { trigger: "approve" });
|
|
37
|
+
return updated;
|
|
38
|
+
}
|
|
39
|
+
/** Raises the one operator action this run's ambiguous-delivery blockage calls for — at most
|
|
40
|
+
* one open `outbound_delivery_interaction_required` action per run at a time (same idempotent
|
|
41
|
+
* re-raise convention as `product_scope_decision`'s `findOpenHumanAction`). Action type kept
|
|
42
|
+
* for existing parks even though the trigger is now ambiguous-only (NOT-106). */
|
|
43
|
+
function parkOnAmbiguousDelivery(runId, draftArtifactId, toolCall, reason) {
|
|
44
|
+
const existing = findOpenHumanActionForRun(runId, "outbound_delivery_interaction_required");
|
|
45
|
+
if (existing)
|
|
46
|
+
return;
|
|
47
|
+
createHumanAction({
|
|
48
|
+
runId,
|
|
49
|
+
actionType: "outbound_delivery_interaction_required",
|
|
50
|
+
reason,
|
|
51
|
+
question: "Outbound delivery may have already reached the provider. Retry the send or reject the draft?",
|
|
52
|
+
evidence: { draftArtifactId, serviceId: toolCall.serviceName, toolName: toolCall.toolName },
|
|
53
|
+
responseOptions: [
|
|
54
|
+
{ choice: "retry_send", label: "Retry send" },
|
|
55
|
+
{ choice: "reject", label: "Reject draft" },
|
|
56
|
+
],
|
|
57
|
+
requestId: null,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
7
60
|
export async function approveRunWithDeliver(runId, deps) {
|
|
8
61
|
const run = getRun(runId);
|
|
9
62
|
if (!run)
|
|
@@ -29,48 +82,95 @@ export async function approveRunWithDeliver(runId, deps) {
|
|
|
29
82
|
}
|
|
30
83
|
}
|
|
31
84
|
if (!pending) {
|
|
32
|
-
|
|
33
|
-
syncLinearForRun(updated, "done").catch((e) => console.error("[linear-sync] done:", e));
|
|
34
|
-
scheduleReflect(updated, { trigger: "approve" });
|
|
35
|
-
return { ok: true, run: updated, delivered: false };
|
|
85
|
+
return { ok: true, run: finalizeRunWithoutDelivery(runId, deps?.actionResolution), delivered: false };
|
|
36
86
|
}
|
|
37
87
|
if (!run.deckId) {
|
|
38
88
|
return { ok: false, code: 502, error: "No deck bound — cannot deliver outbound draft" };
|
|
39
89
|
}
|
|
40
90
|
const deliver = deps?.deliver ?? deliverOutboundDraft;
|
|
91
|
+
const draftArtifactId = pending.artifact.id;
|
|
92
|
+
const toolCall = pending.content.draft.toolCall;
|
|
93
|
+
const deckId = run.deckId;
|
|
41
94
|
deliverInFlight.add(runId);
|
|
42
95
|
try {
|
|
43
|
-
const claimed = markOutboundDraftSent(
|
|
96
|
+
const claimed = markOutboundDraftSent(draftArtifactId);
|
|
44
97
|
if (!claimed) {
|
|
45
98
|
return { ok: false, code: 409, error: "Draft already sent or rejected" };
|
|
46
99
|
}
|
|
47
|
-
let
|
|
48
|
-
let permalink;
|
|
100
|
+
let result;
|
|
49
101
|
try {
|
|
50
|
-
|
|
51
|
-
workspaceRoot: workspaceForRun(run),
|
|
52
|
-
});
|
|
53
|
-
toolResult = result.toolResult;
|
|
54
|
-
permalink = result.permalink;
|
|
102
|
+
result = await deliver(deckId, toolCall);
|
|
55
103
|
}
|
|
56
104
|
catch (err) {
|
|
57
|
-
revertOutboundDraftToPending(
|
|
58
|
-
const
|
|
59
|
-
appendEvent(runId, "deliver_failed", { error:
|
|
60
|
-
return { ok: false, code: 502, error:
|
|
105
|
+
revertOutboundDraftToPending(draftArtifactId);
|
|
106
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
107
|
+
appendEvent(runId, "deliver_failed", { error: reason, errorCode: "DELIVERY_FAILED" });
|
|
108
|
+
return { ok: false, code: 502, error: reason, errorCode: "DELIVERY_FAILED" };
|
|
109
|
+
}
|
|
110
|
+
if (!result.ok) {
|
|
111
|
+
revertOutboundDraftToPending(draftArtifactId);
|
|
112
|
+
const errorCode = result.kind === "ambiguous" ? "AMBIGUOUS_RESULT" : "DELIVERY_FAILED";
|
|
113
|
+
appendEvent(runId, "deliver_failed", { error: result.reason, errorCode });
|
|
114
|
+
// Ambiguous timeout is never safe to silently bounded-retry (NOT-91) — it needs an
|
|
115
|
+
// explicit "retry send or reject" human decision.
|
|
116
|
+
if (result.kind === "ambiguous") {
|
|
117
|
+
parkOnAmbiguousDelivery(runId, draftArtifactId, toolCall, result.reason);
|
|
118
|
+
}
|
|
119
|
+
return { ok: false, code: 502, error: result.reason, errorCode };
|
|
61
120
|
}
|
|
62
121
|
addArtifact(runId, "send_receipt", {
|
|
63
|
-
draftArtifactId
|
|
122
|
+
draftArtifactId,
|
|
64
123
|
sentAt: new Date().toISOString(),
|
|
65
|
-
toolResult: toolResult,
|
|
66
|
-
permalink,
|
|
124
|
+
toolResult: result.toolResult,
|
|
125
|
+
permalink: result.permalink,
|
|
67
126
|
}, "system");
|
|
68
|
-
|
|
69
|
-
syncLinearForRun(updated, "done").catch((e) => console.error("[linear-sync] done:", e));
|
|
70
|
-
scheduleReflect(updated, { trigger: "approve" });
|
|
71
|
-
return { ok: true, run: updated, delivered: true };
|
|
127
|
+
return { ok: true, run: finalizeRunWithoutDelivery(runId, deps?.actionResolution), delivered: true };
|
|
72
128
|
}
|
|
73
129
|
finally {
|
|
74
130
|
deliverInFlight.delete(runId);
|
|
75
131
|
}
|
|
76
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Resolves an open `outbound_delivery_interaction_required` action — Run-scoped, so this
|
|
135
|
+
* (like NOT-94's `resolveReflectionInteractionAction`) never goes through
|
|
136
|
+
* `resolveHumanActionAndAdvance`, which assumes an Issue + workflow_instance.
|
|
137
|
+
*
|
|
138
|
+
* `reject` is always terminal: no provider call, action resolved immediately, run finalized
|
|
139
|
+
* without delivering.
|
|
140
|
+
*
|
|
141
|
+
* `retry_send` re-attempts delivery of the still-pending draft. The action is resolved only
|
|
142
|
+
* once that attempt actually *succeeds* — on any failure (ordinary infra, or another
|
|
143
|
+
* ambiguous result) the action is left open rather than resolved out from under the
|
|
144
|
+
* operator. `approveRunWithDeliver`'s own park dedup (`findOpenHumanActionForRun`) finds
|
|
145
|
+
* this same still-open action on a repeat ambiguous result and does not raise a second one.
|
|
146
|
+
*/
|
|
147
|
+
export async function resolveOutboundDeliveryAction(actionId, resolvedBy, choice, deps) {
|
|
148
|
+
const action = getHumanAction(actionId);
|
|
149
|
+
if (!action)
|
|
150
|
+
return { ok: false, code: 404, error: "Human action not found" };
|
|
151
|
+
if (action.actionType !== "outbound_delivery_interaction_required") {
|
|
152
|
+
return { ok: false, code: 400, error: `Action ${actionId} is not an outbound_delivery_interaction_required action` };
|
|
153
|
+
}
|
|
154
|
+
if (!action.runId)
|
|
155
|
+
return { ok: false, code: 500, error: "Human action has no run" };
|
|
156
|
+
if (action.status !== "open")
|
|
157
|
+
return { ok: false, code: 409, error: "Human action already resolved" };
|
|
158
|
+
if (choice !== "retry_send" && choice !== "reject") {
|
|
159
|
+
return { ok: false, code: 400, error: `Invalid choice "${choice}" for outbound_delivery_interaction_required` };
|
|
160
|
+
}
|
|
161
|
+
if (choice === "reject") {
|
|
162
|
+
resolveHumanAction(actionId, resolvedBy, { choice });
|
|
163
|
+
rejectPendingOutboundDrafts(action.runId);
|
|
164
|
+
const updated = finalizeRunWithoutDelivery(action.runId);
|
|
165
|
+
return { ok: true, runStatus: updated.status, delivered: false };
|
|
166
|
+
}
|
|
167
|
+
// retry_send — the draft is still pending; re-attempt under the run's deck header.
|
|
168
|
+
// actionResolution carries the real operator identity through to
|
|
169
|
+
// finalizeRunWithoutDelivery's auto-close on success.
|
|
170
|
+
const result = await approveRunWithDeliver(action.runId, { ...deps, actionResolution: { resolvedBy, choice } });
|
|
171
|
+
if (!result.ok) {
|
|
172
|
+
// Left open on purpose — see the doc comment above.
|
|
173
|
+
return { ok: false, code: result.code, error: result.error };
|
|
174
|
+
}
|
|
175
|
+
return { ok: true, runStatus: result.run.status, delivered: result.delivered };
|
|
176
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { checkAgentDeckHealth } from "../adapters/agent-deck.js";
|
|
2
|
+
import { runReflect } from "../runners/reflect.js";
|
|
3
|
+
const activeReflects = new Set();
|
|
4
|
+
/** Fire-and-forget: post-review playbook reflect (D3 learning loop). */
|
|
5
|
+
export function scheduleReflect(run, opts) {
|
|
6
|
+
if (!run.playbookId || !run.deckId)
|
|
7
|
+
return;
|
|
8
|
+
if (activeReflects.has(run.id))
|
|
9
|
+
return;
|
|
10
|
+
void (async () => {
|
|
11
|
+
const online = await checkAgentDeckHealth();
|
|
12
|
+
if (!online)
|
|
13
|
+
return;
|
|
14
|
+
activeReflects.add(run.id);
|
|
15
|
+
try {
|
|
16
|
+
await runReflect(run, opts);
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
console.error("[reflect]", run.id, e);
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
activeReflects.delete(run.id);
|
|
23
|
+
}
|
|
24
|
+
})();
|
|
25
|
+
}
|
|
@@ -3,7 +3,7 @@ import { addArtifact, getRun } from "../repository/runs.js";
|
|
|
3
3
|
import { hasPendingQaExchange, latestExecuteSessionId } from "../repository/result-qa.js";
|
|
4
4
|
import { runQa } from "../runners/qa.js";
|
|
5
5
|
const ASKABLE_STATUSES = new Set(["review", "done"]);
|
|
6
|
-
const QA_RUNTIMES = new Set(["claude_code", "cursor_local"]);
|
|
6
|
+
const QA_RUNTIMES = new Set(["claude_code", "cursor_local", "codex_local"]);
|
|
7
7
|
/** Ask the run's agent about its finished result. Read-only; never changes run status. */
|
|
8
8
|
export function askResultQuestion(runId, question, opts) {
|
|
9
9
|
// An empty question would persist a result_qa artifact that ResultQaContent then
|