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,350 @@
|
|
|
1
|
+
// packages/server/src/direct-start-liveness.integration.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Reproduces the exact reviewer-reported gap: `npm run dev` / `npm run start` launch
|
|
4
|
+
// packages/server/src/index.ts directly, bypassing the CLI daemon supervisor that writes
|
|
5
|
+
// run.json — so a liveness check that only looked at run.json silently reported "not
|
|
6
|
+
// running" for the two most common ways this app is actually launched. This spawns that
|
|
7
|
+
// real entrypoint (not a fake), the same way `npm run dev` does, and drives
|
|
8
|
+
// migrate-to-issues.ts's isServiceRunning() against it end to end.
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import net from "node:net";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { resolveTsxBin } from "./resolve-tsx-bin.js";
|
|
18
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const repoRoot = path.resolve(__dirname, "..", "..", "..");
|
|
20
|
+
const serverEntry = path.join(repoRoot, "packages", "server", "src", "index.ts");
|
|
21
|
+
const tsxBin = resolveTsxBin(repoRoot);
|
|
22
|
+
const { isServiceRunning, runMigration } = await import("./db/migrate-to-issues.js");
|
|
23
|
+
const Database = (await import("better-sqlite3")).default;
|
|
24
|
+
async function getEphemeralPort() {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const srv = net.createServer();
|
|
27
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
28
|
+
const addr = srv.address();
|
|
29
|
+
if (addr && typeof addr === "object") {
|
|
30
|
+
const { port } = addr;
|
|
31
|
+
srv.close(() => resolve(port));
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
srv.close(() => reject(new Error("could not allocate an ephemeral port")));
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
srv.on("error", reject);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
async function waitUntil(check, timeoutMs, stepMs = 150) {
|
|
41
|
+
const deadline = Date.now() + timeoutMs;
|
|
42
|
+
for (;;) {
|
|
43
|
+
if (await check())
|
|
44
|
+
return true;
|
|
45
|
+
if (Date.now() >= deadline)
|
|
46
|
+
return false;
|
|
47
|
+
await new Promise((r) => setTimeout(r, stepMs));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** A temp AGENT_DEALER_HOME that is deleted when the test ends, however it ends. Registered
|
|
51
|
+
* as a test hook rather than dropped in a `finally` on purpose: teardown here asserts (that
|
|
52
|
+
* the server it stopped is really dead), and an assertion that throws must not be able to
|
|
53
|
+
* skip the cleanup and leave a directory behind — which is precisely how $TMPDIR filled up
|
|
54
|
+
* with hundreds of these. Runs after the body's own `finally`, so servers are stopped by
|
|
55
|
+
* then. */
|
|
56
|
+
function makeHome(t, prefix) {
|
|
57
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
58
|
+
t.after(() => fs.rmSync(home, { recursive: true, force: true }));
|
|
59
|
+
return home;
|
|
60
|
+
}
|
|
61
|
+
// Every server spawned by this file that stopServer() has not yet confirmed dead. A
|
|
62
|
+
// detached child sits in its own session, so it no longer receives the terminal's SIGINT
|
|
63
|
+
// along with the test runner — the usual "Ctrl-C reaps the whole foreground group" safety
|
|
64
|
+
// net does not cover these, and an interrupted run would leak exactly what this file was
|
|
65
|
+
// leaking before. This registry is that net, re-implemented explicitly.
|
|
66
|
+
const liveServers = new Set();
|
|
67
|
+
function killAllServerGroups() {
|
|
68
|
+
for (const child of liveServers)
|
|
69
|
+
signalGroup(child, "SIGKILL");
|
|
70
|
+
liveServers.clear();
|
|
71
|
+
}
|
|
72
|
+
process.on("exit", killAllServerGroups);
|
|
73
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
74
|
+
// Registering a listener suppresses the default terminate, so exit explicitly.
|
|
75
|
+
process.on(signal, () => {
|
|
76
|
+
killAllServerGroups();
|
|
77
|
+
process.exit(1);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function spawnServer(home, port) {
|
|
81
|
+
const child = spawn(tsxBin, [serverEntry], {
|
|
82
|
+
cwd: repoRoot,
|
|
83
|
+
env: {
|
|
84
|
+
...process.env,
|
|
85
|
+
AGENT_DEALER_HOME: home,
|
|
86
|
+
AGENT_DEALER_ENV: "development",
|
|
87
|
+
PORT: String(port),
|
|
88
|
+
},
|
|
89
|
+
stdio: "ignore",
|
|
90
|
+
// The launcher and the child it forks get their own process group (pgid == launcher
|
|
91
|
+
// pid), so stopServer() can signal both at once.
|
|
92
|
+
detached: true,
|
|
93
|
+
});
|
|
94
|
+
// Every test awaits its own teardown, so this handle is never what keeps the run
|
|
95
|
+
// correct — but while it is ref'd it *does* keep the runner's event loop alive, so a
|
|
96
|
+
// teardown that throws before stopping a server wedges the whole file at 100% "waiting
|
|
97
|
+
// for a child that will never exit" instead of failing. unref() + killAllServerGroups()
|
|
98
|
+
// on exit is the pair that makes the failure path terminate and still reap.
|
|
99
|
+
child.unref();
|
|
100
|
+
liveServers.add(child);
|
|
101
|
+
return child;
|
|
102
|
+
}
|
|
103
|
+
function hasExited(child) {
|
|
104
|
+
return child.exitCode !== null || child.signalCode !== null;
|
|
105
|
+
}
|
|
106
|
+
function isPidAlive(pid) {
|
|
107
|
+
try {
|
|
108
|
+
process.kill(pid, 0);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** The process group `pid` belongs to, or undefined once it is gone. */
|
|
116
|
+
function pgidOf(pid) {
|
|
117
|
+
try {
|
|
118
|
+
const pgid = Number.parseInt(execFileSync("ps", ["-o", "pgid=", "-p", String(pid)], { encoding: "utf8" }).trim(), 10);
|
|
119
|
+
return Number.isFinite(pgid) ? pgid : undefined;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The pid recorded in `<home>/server.pid`, but only when that pid is part of `child`'s
|
|
127
|
+
* process group — i.e. only when this particular server is the marker's current owner.
|
|
128
|
+
*
|
|
129
|
+
* These tests deliberately point two servers at one AGENT_DEALER_HOME, and the loser never
|
|
130
|
+
* writes the marker, so "the pid in server.pid" is emphatically *not* "the pid of the
|
|
131
|
+
* server I am holding a handle to". Signalling or asserting on it unconditionally would
|
|
132
|
+
* reach across to the healthy server the test is still using.
|
|
133
|
+
*/
|
|
134
|
+
function ownedRecordedPid(child, home) {
|
|
135
|
+
if (child.pid === undefined)
|
|
136
|
+
return undefined;
|
|
137
|
+
let recorded;
|
|
138
|
+
try {
|
|
139
|
+
recorded = JSON.parse(fs.readFileSync(path.join(home, "server.pid"), "utf8")).pid;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return undefined; // no marker (never claimed, or already released on a clean exit)
|
|
143
|
+
}
|
|
144
|
+
if (typeof recorded !== "number" || !Number.isFinite(recorded))
|
|
145
|
+
return undefined;
|
|
146
|
+
return pgidOf(recorded) === child.pid ? recorded : undefined;
|
|
147
|
+
}
|
|
148
|
+
function signalGroup(child, signal) {
|
|
149
|
+
if (child.pid === undefined)
|
|
150
|
+
return;
|
|
151
|
+
try {
|
|
152
|
+
process.kill(-child.pid, signal);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// ESRCH — the whole group is already gone, which is exactly what we were after.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The one place in this file that knows how to terminate a server spawned by
|
|
160
|
+
* spawnServer() — every case goes through it, so this knowledge is not re-derived per test.
|
|
161
|
+
*
|
|
162
|
+
* spawnServer() launches `node_modules/.bin/tsx`, not node, and that bin *forks a child* to
|
|
163
|
+
* actually run the TypeScript: `child.pid` is the launcher, and the real server is its
|
|
164
|
+
* grandchild. tsx forwards a catchable SIGTERM but cannot forward an unblockable SIGKILL,
|
|
165
|
+
* so `child.kill("SIGKILL")` reaps the launcher and leaves the server alive, reparented to
|
|
166
|
+
* init, still bound to its PORT — forever. (pid-marker-hold-arbiter-child.ts documents the
|
|
167
|
+
* same wrapper behaviour for the same reason.) Hence: signal the whole process group,
|
|
168
|
+
* SIGTERM first so the server can release its own marker, SIGKILL only as a backstop, and
|
|
169
|
+
* then prove the pid the server actually recorded is dead rather than trusting the launcher
|
|
170
|
+
* handle's exit code to speak for it.
|
|
171
|
+
*/
|
|
172
|
+
async function stopServer(child, home) {
|
|
173
|
+
// Read while the server is still alive: a clean shutdown deletes the marker.
|
|
174
|
+
const recordedPid = ownedRecordedPid(child, home);
|
|
175
|
+
const allDead = () => hasExited(child) && (recordedPid === undefined || !isPidAlive(recordedPid));
|
|
176
|
+
signalGroup(child, "SIGTERM");
|
|
177
|
+
const exitedOnTerm = await waitUntil(allDead, 10000);
|
|
178
|
+
if (!exitedOnTerm) {
|
|
179
|
+
signalGroup(child, "SIGKILL");
|
|
180
|
+
await waitUntil(allDead, 5000);
|
|
181
|
+
}
|
|
182
|
+
if (recordedPid !== undefined) {
|
|
183
|
+
assert.equal(isPidAlive(recordedPid), false, `the server pid recorded in server.pid (${recordedPid}) survived teardown — the tsx launcher (${child.pid}) was reaped but its server child leaked`);
|
|
184
|
+
}
|
|
185
|
+
liveServers.delete(child);
|
|
186
|
+
return { exitedOnTerm, recordedPid };
|
|
187
|
+
}
|
|
188
|
+
async function waitForHealth(port, timeoutMs) {
|
|
189
|
+
return waitUntil(async () => {
|
|
190
|
+
try {
|
|
191
|
+
const res = await fetch(`http://127.0.0.1:${port}/health`);
|
|
192
|
+
return res.ok;
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}, timeoutMs);
|
|
198
|
+
}
|
|
199
|
+
test("a directly-launched server (npm run dev's exact entrypoint) is detected as running, and stops being detected once it exits", { timeout: 45000 }, async (t) => {
|
|
200
|
+
const home = makeHome(t, "dealer-direct-start-");
|
|
201
|
+
const dbPath = path.join(home, "dealer.db");
|
|
202
|
+
const port = await getEphemeralPort();
|
|
203
|
+
const child = spawnServer(home, port);
|
|
204
|
+
try {
|
|
205
|
+
const healthy = await waitForHealth(port, 20000);
|
|
206
|
+
assert.ok(healthy, "the directly-launched server should become healthy within 20s");
|
|
207
|
+
const running = isServiceRunning(dbPath);
|
|
208
|
+
assert.equal(running.running, true, "a directly-launched server should be detected as running");
|
|
209
|
+
assert.ok(running.detail?.includes("server.pid"), `expected server.pid evidence, got: ${running.detail}`);
|
|
210
|
+
const { exitedOnTerm } = await stopServer(child, home);
|
|
211
|
+
assert.ok(exitedOnTerm, "the server should exit within 10s of SIGTERM");
|
|
212
|
+
const stopped = await waitUntil(() => !isServiceRunning(dbPath).running, 5000);
|
|
213
|
+
assert.ok(stopped, "the server should no longer be detected as running once it has exited");
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
await stopServer(child, home);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
test("a second server losing the port race does not corrupt or delete the first server's liveness marker", { timeout: 45000 }, async (t) => {
|
|
220
|
+
// Reproduces the reviewer's exact second-round finding: two real index.ts launches
|
|
221
|
+
// sharing one AGENT_DEALER_HOME and port. Before the ownership-aware fix, B's write
|
|
222
|
+
// clobbered A's marker with B's own (dead-after-exit) pid, and B's unconditional exit
|
|
223
|
+
// cleanup then deleted it — leaving a healthy A with no liveness marker at all.
|
|
224
|
+
const home = makeHome(t, "dealer-direct-start-race-");
|
|
225
|
+
const dbPath = path.join(home, "dealer.db");
|
|
226
|
+
const port = await getEphemeralPort();
|
|
227
|
+
const serverA = spawnServer(home, port);
|
|
228
|
+
let serverB;
|
|
229
|
+
let serverAActualPid;
|
|
230
|
+
try {
|
|
231
|
+
const aHealthy = await waitForHealth(port, 20000);
|
|
232
|
+
assert.ok(aHealthy, "server A should become healthy within 20s");
|
|
233
|
+
// The marker's own recorded pid is the real process.pid inside index.ts, which is
|
|
234
|
+
// *not* necessarily serverA.pid: the tsx bin wrapper spawn() returns forks a child
|
|
235
|
+
// to actually run the TypeScript, so serverA.pid is the launcher, not the server.
|
|
236
|
+
// Compare against this recorded value, not serverA.pid, for that reason — and, just
|
|
237
|
+
// as importantly, signal that value too (stopServer()), not serverA.pid.
|
|
238
|
+
const stateBeforeB = JSON.parse(fs.readFileSync(path.join(home, "server.pid"), "utf8"));
|
|
239
|
+
serverAActualPid = stateBeforeB.pid;
|
|
240
|
+
assert.ok(Number.isFinite(serverAActualPid));
|
|
241
|
+
// Same home, same port — B can only ever lose the app.listen() race.
|
|
242
|
+
serverB = spawnServer(home, port);
|
|
243
|
+
const bExited = await waitUntil(() => serverB.exitCode !== null || serverB.signalCode !== null, 20000);
|
|
244
|
+
assert.ok(bExited, "server B should exit (losing the port bind) within 20s");
|
|
245
|
+
assert.notEqual(serverB.exitCode, 0, "server B should exit non-zero, having failed to bind the port");
|
|
246
|
+
// A must still be healthy, and the marker must still be A's — never overwritten by
|
|
247
|
+
// B, and never deleted by B's own exit cleanup.
|
|
248
|
+
const aStillHealthy = await waitForHealth(port, 5000);
|
|
249
|
+
assert.ok(aStillHealthy, "server A should remain healthy after B's failed launch and exit");
|
|
250
|
+
const running = isServiceRunning(dbPath);
|
|
251
|
+
assert.equal(running.running, true, "the migration guard must still see server A as running");
|
|
252
|
+
const stateAfterB = JSON.parse(fs.readFileSync(path.join(home, "server.pid"), "utf8"));
|
|
253
|
+
assert.equal(stateAfterB.pid, serverAActualPid, "the marker must still name server A, not server B");
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
if (serverB)
|
|
257
|
+
await stopServer(serverB, home);
|
|
258
|
+
// A is healthy and unsignalled at this point, so this teardown is the *only* thing
|
|
259
|
+
// between it and outliving the test run: it must reach the real server, not the
|
|
260
|
+
// launcher (see stopServer()).
|
|
261
|
+
const { recordedPid } = await stopServer(serverA, home);
|
|
262
|
+
if (serverAActualPid !== undefined) {
|
|
263
|
+
assert.equal(recordedPid, serverAActualPid, "teardown must have signalled server A's own recorded pid, not the launcher's");
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
test("a second server on a different port is refused at startup rather than running unmonitored", { timeout: 45000 }, async (t) => {
|
|
268
|
+
// Reproduces the reviewer's exact third-round finding: with a *different* port, B is
|
|
269
|
+
// no longer stopped by app.listen()'s EADDRINUSE. Before treating a failed claim as
|
|
270
|
+
// fatal, B ran to completion fully healthy but untracked (A owns server.pid), so once
|
|
271
|
+
// A stopped — correctly removing its own marker — B kept running with no liveness
|
|
272
|
+
// marker at all, and isServiceRunning() returned false despite B being very much alive.
|
|
273
|
+
const home = makeHome(t, "dealer-direct-start-diffport-");
|
|
274
|
+
const dbPath = path.join(home, "dealer.db");
|
|
275
|
+
const portA = await getEphemeralPort();
|
|
276
|
+
const portB = await getEphemeralPort();
|
|
277
|
+
const serverA = spawnServer(home, portA);
|
|
278
|
+
let serverB;
|
|
279
|
+
try {
|
|
280
|
+
const aHealthy = await waitForHealth(portA, 20000);
|
|
281
|
+
assert.ok(aHealthy, "server A should become healthy within 20s");
|
|
282
|
+
const stateBeforeB = JSON.parse(fs.readFileSync(path.join(home, "server.pid"), "utf8"));
|
|
283
|
+
serverB = spawnServer(home, portB);
|
|
284
|
+
const bExited = await waitUntil(() => serverB.exitCode !== null || serverB.signalCode !== null, 20000);
|
|
285
|
+
assert.ok(bExited, "server B should exit within 20s of failing to claim the liveness marker");
|
|
286
|
+
assert.notEqual(serverB.exitCode, 0, "server B should exit non-zero, having refused to start");
|
|
287
|
+
// B must never have become healthy on its own port — it should have aborted before
|
|
288
|
+
// ever reaching app.listen().
|
|
289
|
+
const bEverHealthy = await waitUntil(async () => {
|
|
290
|
+
try {
|
|
291
|
+
const res = await fetch(`http://127.0.0.1:${portB}/health`);
|
|
292
|
+
return res.ok;
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
}, 1500);
|
|
298
|
+
assert.equal(bEverHealthy, false, "server B must never have started serving on its own port");
|
|
299
|
+
// A is unaffected, and the marker still names A.
|
|
300
|
+
const aStillHealthy = await waitForHealth(portA, 5000);
|
|
301
|
+
assert.ok(aStillHealthy);
|
|
302
|
+
const stateAfterB = JSON.parse(fs.readFileSync(path.join(home, "server.pid"), "utf8"));
|
|
303
|
+
assert.equal(stateAfterB.pid, stateBeforeB.pid);
|
|
304
|
+
assert.equal(isServiceRunning(dbPath).running, true);
|
|
305
|
+
// And once A stops (removing its own marker as intended), isServiceRunning correctly
|
|
306
|
+
// reports nothing running — B never having claimed the marker means there is no
|
|
307
|
+
// now-invisible second server left behind for this check to miss.
|
|
308
|
+
const { exitedOnTerm } = await stopServer(serverA, home);
|
|
309
|
+
assert.ok(exitedOnTerm, "server A should exit within 10s of SIGTERM");
|
|
310
|
+
assert.equal(isServiceRunning(dbPath).running, false, "nothing should be left running or tracked after A stops");
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
if (serverB)
|
|
314
|
+
await stopServer(serverB, home);
|
|
315
|
+
await stopServer(serverA, home);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
test("a real running server blocks the migration end to end, and the migration succeeds once the server is stopped", { timeout: 45000 }, async (t) => {
|
|
319
|
+
const home = makeHome(t, "dealer-direct-start-migration-");
|
|
320
|
+
const dbPath = path.join(home, "dealer.db");
|
|
321
|
+
const port = await getEphemeralPort();
|
|
322
|
+
const server = spawnServer(home, port);
|
|
323
|
+
try {
|
|
324
|
+
const healthy = await waitForHealth(port, 20000);
|
|
325
|
+
assert.ok(healthy, "the server should become healthy within 20s (this also runs migrate(), creating the schema)");
|
|
326
|
+
// Seed one legacy lineage directly — a separate connection to the same file is fine
|
|
327
|
+
// alongside the server's own WAL-mode connection.
|
|
328
|
+
const now = new Date().toISOString();
|
|
329
|
+
const seedDb = new Database(dbPath);
|
|
330
|
+
seedDb
|
|
331
|
+
.prepare(`INSERT INTO runs (id, source, external_id, task_category, title, repo, agent_id, status,
|
|
332
|
+
lineage_id, created_at, updated_at)
|
|
333
|
+
VALUES ('run-live-block', 'manual', 'run-live-block', 'code', 'Blocked task', '/repo', NULL, 'done', NULL, ?, ?)`)
|
|
334
|
+
.run(now, now);
|
|
335
|
+
seedDb.close();
|
|
336
|
+
const refused = runMigration(dbPath);
|
|
337
|
+
assert.ok(refused.mismatches.length > 0);
|
|
338
|
+
assert.match(refused.mismatches[0], /service is running/);
|
|
339
|
+
assert.equal(fs.existsSync(`${dbPath}.pre-issue-migration-backup`), false);
|
|
340
|
+
const { exitedOnTerm } = await stopServer(server, home);
|
|
341
|
+
assert.ok(exitedOnTerm, "the server should exit within 10s of SIGTERM");
|
|
342
|
+
await waitUntil(() => !isServiceRunning(dbPath).running, 5000);
|
|
343
|
+
const succeeded = runMigration(dbPath);
|
|
344
|
+
assert.deepStrictEqual(succeeded.mismatches, []);
|
|
345
|
+
assert.equal(succeeded.issuesCreated, 1);
|
|
346
|
+
}
|
|
347
|
+
finally {
|
|
348
|
+
await stopServer(server, home);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
@@ -3,24 +3,77 @@ import Fastify from "fastify";
|
|
|
3
3
|
import cors from "@fastify/cors";
|
|
4
4
|
import { enrichPathForCliTools } from "./cli-env.js";
|
|
5
5
|
import { migrate } from "./db/index.js";
|
|
6
|
+
import { writeServerPidFile, removeServerPidFile } from "./server-liveness.js";
|
|
6
7
|
import { registerRoutes } from "./routes/index.js";
|
|
7
|
-
import {
|
|
8
|
+
import { registerIssueRoutes } from "./routes/issues.js";
|
|
9
|
+
import { registerQueueRoutes } from "./routes/queue.js";
|
|
10
|
+
import { registerHumanActionRoutes } from "./routes/human-actions.js";
|
|
11
|
+
import { recoverCoordinator } from "./coordinator/recovery.js";
|
|
12
|
+
import { startCoordinatorLoop } from "./coordinator/worker-loop.js";
|
|
13
|
+
import { registerEffectHandler } from "./coordinator/effect-registry.js";
|
|
14
|
+
import { runDeveloperEffect } from "./coordinator/developer-effect.js";
|
|
15
|
+
import { runReviewerEffect } from "./coordinator/reviewer-effect.js";
|
|
8
16
|
import { registerStaticUi } from "./static-ui.js";
|
|
17
|
+
import { cleanupOrphanedWorkerMcpConfig } from "./paths.js";
|
|
18
|
+
import net from "node:net";
|
|
9
19
|
const { mode, envFile } = loadAgentDealerEnv();
|
|
10
20
|
console.log(formatEnvStartupLine(mode, envFile));
|
|
11
21
|
const port = Number(process.env.PORT ?? 2221);
|
|
22
|
+
// Hosts that publish AAAA records but have no usable IPv6 route make Node's
|
|
23
|
+
// happy-eyeballs burn its whole connect budget on unreachable addresses, so
|
|
24
|
+
// outbound fetch (Linear GraphQL) fails with UND_ERR_CONNECT_TIMEOUT.
|
|
25
|
+
net.setDefaultAutoSelectFamily(false);
|
|
12
26
|
async function main() {
|
|
13
27
|
enrichPathForCliTools();
|
|
28
|
+
// Written before migrate() touches the database, and independent of how this process
|
|
29
|
+
// was launched — see server-liveness.ts for why this exists alongside the CLI's own
|
|
30
|
+
// run.json. Cleaned up on a normal shutdown; a stale file from a crash is harmless since
|
|
31
|
+
// every reader checks the pid is actually alive, not just that the file exists.
|
|
32
|
+
//
|
|
33
|
+
// A false return means a different, still-alive process already owns this
|
|
34
|
+
// AGENT_DEALER_HOME — this must be fatal, not merely logged: a server that continued
|
|
35
|
+
// unmonitored (e.g. on a different port than the owner, so app.listen() below would
|
|
36
|
+
// have succeeded) would run invisibly to isServiceRunning()/the migration guard, and to
|
|
37
|
+
// the owner's own eventual shutdown, which only ever removes a marker it still owns —
|
|
38
|
+
// so a second, unclaimed server surviving past the first one's clean exit would leave
|
|
39
|
+
// nothing recording that it is still running against this same database.
|
|
40
|
+
if (!writeServerPidFile(port)) {
|
|
41
|
+
console.error("[startup] another live process already owns this AGENT_DEALER_HOME's liveness marker — refusing to start. " +
|
|
42
|
+
"Stop it first, or point AGENT_DEALER_HOME at a different directory.");
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
process.on("SIGINT", () => {
|
|
46
|
+
removeServerPidFile();
|
|
47
|
+
process.exit(0);
|
|
48
|
+
});
|
|
49
|
+
process.on("SIGTERM", () => {
|
|
50
|
+
removeServerPidFile();
|
|
51
|
+
process.exit(0);
|
|
52
|
+
});
|
|
53
|
+
process.on("exit", removeServerPidFile);
|
|
14
54
|
migrate();
|
|
55
|
+
cleanupOrphanedWorkerMcpConfig();
|
|
15
56
|
const app = Fastify({ logger: true });
|
|
16
57
|
await app.register(cors, { origin: true });
|
|
17
58
|
await registerRoutes(app);
|
|
59
|
+
await registerIssueRoutes(app);
|
|
60
|
+
await registerQueueRoutes(app);
|
|
61
|
+
await registerHumanActionRoutes(app);
|
|
18
62
|
const uiDist = await registerStaticUi(app);
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
63
|
+
// Without this, getEffectHandler() falls back to the always-session_failed placeholders
|
|
64
|
+
// (effect-registry.ts) and every started issue would burn its infra retries and
|
|
65
|
+
// escalate without ever launching an agent, creating a worktree, or opening a PR.
|
|
66
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx));
|
|
67
|
+
registerEffectHandler("reviewer", (ctx) => runReviewerEffect(ctx));
|
|
68
|
+
const coordinatorRecovery = await recoverCoordinator();
|
|
69
|
+
if (coordinatorRecovery.reclaimed.length ||
|
|
70
|
+
coordinatorRecovery.republished.length ||
|
|
71
|
+
coordinatorRecovery.deadLettered.length ||
|
|
72
|
+
coordinatorRecovery.deferredForCap.length ||
|
|
73
|
+
coordinatorRecovery.autoMergesFinalized.length) {
|
|
74
|
+
console.warn(`[startup] coordinator recovery: reclaimed ${coordinatorRecovery.reclaimed.length}, republished ${coordinatorRecovery.republished.length}, dead-lettered ${coordinatorRecovery.deadLettered.length}, deferred for cap ${coordinatorRecovery.deferredForCap.length}, auto-merges finalized ${coordinatorRecovery.autoMergesFinalized.length}`);
|
|
22
75
|
}
|
|
23
|
-
|
|
76
|
+
startCoordinatorLoop();
|
|
24
77
|
await app.listen({ port, host: "127.0.0.1" });
|
|
25
78
|
const base = `http://127.0.0.1:${port}`;
|
|
26
79
|
console.log(`agent-dealer API ${base}`);
|
|
@@ -4,13 +4,49 @@ import { getDataDir } from "./db/index.js";
|
|
|
4
4
|
export function getTemporalDir() {
|
|
5
5
|
return path.join(getDataDir(), ".temporal");
|
|
6
6
|
}
|
|
7
|
-
export function
|
|
8
|
-
const dir = path.join(getTemporalDir(), "
|
|
7
|
+
export function getTemporalLogsDir() {
|
|
8
|
+
const dir = path.join(getTemporalDir(), "logs");
|
|
9
9
|
fs.mkdirSync(dir, { recursive: true });
|
|
10
10
|
return dir;
|
|
11
11
|
}
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Per-attempt Agent Deck MCP config files for worker CLIs (claude `--mcp-config`,
|
|
14
|
+
* codex `CODEX_HOME`) — never the worktree (a generated checkout must not carry a
|
|
15
|
+
* persistent-looking config) and never `.temporal/logs` (those are retained; this
|
|
16
|
+
* directory is scrubbed per-attempt by the caller and at coordinator startup).
|
|
17
|
+
* Mode 0700 — configs are process-local and short-lived.
|
|
18
|
+
*/
|
|
19
|
+
export function getWorkerMcpConfigDir() {
|
|
20
|
+
const dir = path.join(getTemporalDir(), "worker-mcp-config");
|
|
21
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
22
|
+
return dir;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Startup-only: removes every per-attempt entry left under `getWorkerMcpConfigDir()`
|
|
26
|
+
* by a crash (a clean shutdown never reaches here — `releaseWorkerDeckConnection` already
|
|
27
|
+
* removed its own entry). Safe unconditionally: every entry is scoped to exactly one
|
|
28
|
+
* attempt's spawn, and that attempt cannot still be running (this *is* the coordinator
|
|
29
|
+
* restarting). Claude and codex still write per-attempt MCP configs outside the worktree;
|
|
30
|
+
* cursor's config lives inside the worktree and is not cleaned here.
|
|
31
|
+
*/
|
|
32
|
+
export function cleanupOrphanedWorkerMcpConfig() {
|
|
33
|
+
const dir = getWorkerMcpConfigDir();
|
|
34
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
35
|
+
fs.rmSync(path.join(dir, entry), { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Root for Dealer-managed repository clones and session worktrees (NOT-149).
|
|
40
|
+
* Defaults under AGENT_DEALER_HOME; override with AGENT_DEALER_EXECUTION_ROOT for a
|
|
41
|
+
* mounted disk / cloud volume.
|
|
42
|
+
*/
|
|
43
|
+
export function getExecutionRoot() {
|
|
44
|
+
const override = process.env.AGENT_DEALER_EXECUTION_ROOT?.trim();
|
|
45
|
+
if (override) {
|
|
46
|
+
fs.mkdirSync(override, { recursive: true });
|
|
47
|
+
return override;
|
|
48
|
+
}
|
|
49
|
+
const dir = path.join(getDataDir(), "execution");
|
|
14
50
|
fs.mkdirSync(dir, { recursive: true });
|
|
15
51
|
return dir;
|
|
16
52
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// packages/server/src/paths.test.ts
|
|
2
|
+
//
|
|
3
|
+
// cleanupOrphanedWorkerMcpConfig: a coordinator crash mid-attempt skips
|
|
4
|
+
// releaseWorkerDeckConnection's own cleanup, leaving leftover per-attempt MCP
|
|
5
|
+
// configs (claude JSON files, codex CODEX_HOME dirs) under worker-mcp-config.
|
|
6
|
+
// Every entry there is scoped to one already-unrecoverable attempt, so a startup
|
|
7
|
+
// sweep can remove all of them unconditionally.
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-paths-"));
|
|
14
|
+
const { getWorkerMcpConfigDir, cleanupOrphanedWorkerMcpConfig } = await import("./paths.js");
|
|
15
|
+
test("cleanupOrphanedWorkerMcpConfig removes leftover per-attempt files and directories", () => {
|
|
16
|
+
const dir = getWorkerMcpConfigDir();
|
|
17
|
+
fs.writeFileSync(path.join(dir, "deck-abc.json"), JSON.stringify({ mcpServers: {} }));
|
|
18
|
+
const codexHome = path.join(dir, "codex-home-deck-def");
|
|
19
|
+
fs.mkdirSync(codexHome);
|
|
20
|
+
fs.writeFileSync(path.join(codexHome, "config.toml"), "");
|
|
21
|
+
cleanupOrphanedWorkerMcpConfig();
|
|
22
|
+
assert.deepEqual(fs.readdirSync(dir), []);
|
|
23
|
+
});
|
|
24
|
+
test("cleanupOrphanedWorkerMcpConfig removes a leftover auth.json symlink without touching its target", () => {
|
|
25
|
+
const dir = getWorkerMcpConfigDir();
|
|
26
|
+
const ambientAuthPath = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-ambient-auth-"));
|
|
27
|
+
const realAuth = path.join(ambientAuthPath, "auth.json");
|
|
28
|
+
fs.writeFileSync(realAuth, "real-credential");
|
|
29
|
+
const codexHome = path.join(dir, "codex-home-deck-ghi");
|
|
30
|
+
fs.mkdirSync(codexHome);
|
|
31
|
+
fs.symlinkSync(realAuth, path.join(codexHome, "auth.json"));
|
|
32
|
+
cleanupOrphanedWorkerMcpConfig();
|
|
33
|
+
assert.equal(fs.existsSync(codexHome), false);
|
|
34
|
+
assert.ok(fs.existsSync(realAuth));
|
|
35
|
+
assert.equal(fs.readFileSync(realAuth, "utf8"), "real-credential");
|
|
36
|
+
fs.rmSync(ambientAuthPath, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
test("cleanupOrphanedWorkerMcpConfig is a no-op on an already-empty directory", () => {
|
|
39
|
+
const dir = getWorkerMcpConfigDir();
|
|
40
|
+
assert.deepEqual(fs.readdirSync(dir), []);
|
|
41
|
+
assert.doesNotThrow(() => cleanupOrphanedWorkerMcpConfig());
|
|
42
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// packages/server/src/pid-marker-claim-child.ts
|
|
2
|
+
//
|
|
3
|
+
// Test helper only (spawned by pid-marker.test.ts as a real child process) — attempts a
|
|
4
|
+
// single claimPidMarker() call against the path given as argv[2] and prints the outcome
|
|
5
|
+
// as JSON, so a test can spawn several of these at once to exercise a genuine multi-process
|
|
6
|
+
// race, not just an in-process simulation of one.
|
|
7
|
+
import { claimPidMarker } from "./pid-marker.js";
|
|
8
|
+
const filePath = process.argv[2];
|
|
9
|
+
const claimed = claimPidMarker(filePath, { role: "test-child" });
|
|
10
|
+
console.log(JSON.stringify({ pid: process.pid, claimed }));
|
|
11
|
+
if (claimed) {
|
|
12
|
+
// Stay alive so any other racing child sees a genuinely *live* owner when it checks —
|
|
13
|
+
// matching how a real long-running server holds this marker for its whole lifetime, not
|
|
14
|
+
// a one-shot script that claims and immediately exits (which would make every later
|
|
15
|
+
// racer see a "stale" marker regardless of who legitimately won). The test kills this
|
|
16
|
+
// process once it has read the result.
|
|
17
|
+
setInterval(() => { }, 1000);
|
|
18
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// packages/server/src/pid-marker-hold-arbiter-child.ts
|
|
2
|
+
//
|
|
3
|
+
// Test helper only (spawned by pid-marker.test.ts) — opens the same `.arbiter` SQLite file
|
|
4
|
+
// claimPidMarker() uses, takes its BEGIN EXCLUSIVE lock, prints "held" once acquired, then
|
|
5
|
+
// blocks forever (until killed). Used to prove that a process crashing while it holds the
|
|
6
|
+
// arbiter lock does not wedge it: the OS releases the advisory lock the instant this
|
|
7
|
+
// process dies, for any reason — there is no lease/timeout heuristic to get right, unlike
|
|
8
|
+
// a hand-rolled lock *file* (which is exactly the bug this design replaced).
|
|
9
|
+
import Database from "better-sqlite3";
|
|
10
|
+
const filePath = process.argv[2];
|
|
11
|
+
const db = new Database(`${filePath}.arbiter`);
|
|
12
|
+
db.pragma("busy_timeout = 3000");
|
|
13
|
+
db.transaction(() => {
|
|
14
|
+
// Print our *real* pid, not the tsx launcher's: the test kills this exact pid directly
|
|
15
|
+
// with SIGKILL to simulate a crash — killing the tsx wrapper process alone does not
|
|
16
|
+
// reach this actual node process running the script (the wrapper forks a child to run
|
|
17
|
+
// it and does not forward an unblockable SIGKILL, only a catchable SIGTERM).
|
|
18
|
+
console.log(JSON.stringify({ event: "held", pid: process.pid }));
|
|
19
|
+
// Block forever while still inside the transaction (still holding the exclusive lock) —
|
|
20
|
+
// this process is only ever meant to be terminated externally (SIGKILL), simulating a
|
|
21
|
+
// crash mid-lock.
|
|
22
|
+
const SharedArrayBufferBlock = new Int32Array(new SharedArrayBuffer(4));
|
|
23
|
+
Atomics.wait(SharedArrayBufferBlock, 0, 0);
|
|
24
|
+
}).exclusive();
|