agent-dealer 1.0.1 → 1.0.2
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/linear-graphql.js +165 -0
- package/bundle/server/dist/adapters/linear-graphql.test.js +129 -0
- package/bundle/server/dist/adapters/linear-inbox.js +12 -21
- package/bundle/server/dist/adapters/linear-sync.js +6 -19
- package/bundle/server/dist/coordinator/dependencies.js +5 -1
- package/bundle/server/dist/coordinator/dependency-readiness.integration.test.js +33 -1
- package/bundle/server/dist/db/index.js +6 -0
- package/bundle/server/dist/direct-start-interrupt-probe.js +58 -0
- package/bundle/server/dist/direct-start-liveness.integration.test.js +114 -24
- package/bundle/server/dist/direct-start-temp-home-cleanup.js +124 -0
- package/bundle/server/dist/direct-start-temp-home-cleanup.test.js +92 -0
- package/bundle/server/dist/repository/queue-entries.js +49 -11
- package/bundle/server/dist/routes/queue-reorder.integration.test.js +144 -0
- package/bundle/server/dist/routes/queue.js +21 -2
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-0kT1vk6L.js +60 -0
- package/bundle/server/static-ui/assets/{index-BxTx4b1d.css → index-hXICi1rX.css} +1 -1
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/queue-entries.d.ts +47 -0
- package/bundle/shared/dist/queue-entries.js +14 -0
- package/bundle/shared/package.json +1 -1
- package/dist/index.js +1 -0
- package/dist/queue.d.ts +5 -0
- package/dist/queue.js +50 -0
- package/dist/queue.test.js +39 -0
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-BTtPGYpY.js +0 -60
|
@@ -15,12 +15,17 @@ import path from "node:path";
|
|
|
15
15
|
import net from "node:net";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
import { resolveTsxBin } from "./resolve-tsx-bin.js";
|
|
18
|
+
import { DirectStartLiveCleanup, sweepAbandonedDirectStartHomes, } from "./direct-start-temp-home-cleanup.js";
|
|
18
19
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
20
|
const repoRoot = path.resolve(__dirname, "..", "..", "..");
|
|
20
21
|
const serverEntry = path.join(repoRoot, "packages", "server", "src", "index.ts");
|
|
21
22
|
const tsxBin = resolveTsxBin(repoRoot);
|
|
22
23
|
const { isServiceRunning, runMigration } = await import("./db/migrate-to-issues.js");
|
|
23
24
|
const Database = (await import("better-sqlite3")).default;
|
|
25
|
+
// One-time sweep of abandoned dealer-direct-start-* homes left by earlier interrupted
|
|
26
|
+
// runs. Live-pid + recent-mtime guards keep this from deleting out from under a run
|
|
27
|
+
// that is still in flight on this machine (NOT-140).
|
|
28
|
+
sweepAbandonedDirectStartHomes(os.tmpdir());
|
|
24
29
|
async function getEphemeralPort() {
|
|
25
30
|
return new Promise((resolve, reject) => {
|
|
26
31
|
const srv = net.createServer();
|
|
@@ -47,35 +52,40 @@ async function waitUntil(check, timeoutMs, stepMs = 150) {
|
|
|
47
52
|
await new Promise((r) => setTimeout(r, stepMs));
|
|
48
53
|
}
|
|
49
54
|
}
|
|
55
|
+
// Every server spawned by this file that stopServer() has not yet confirmed dead, plus
|
|
56
|
+
// every temp home makeHome() still owns. A detached child sits in its own session, so it
|
|
57
|
+
// no longer receives the terminal's SIGINT along with the test runner — the usual
|
|
58
|
+
// "Ctrl-C reaps the whole foreground group" safety net does not cover these, and an
|
|
59
|
+
// interrupted run would leak exactly what this file was leaking before. This registry is
|
|
60
|
+
// that net, re-implemented explicitly — servers and homes share one reapAll so
|
|
61
|
+
// process.exit(1) cannot clear one and abandon the other (NOT-140).
|
|
62
|
+
const liveCleanup = new DirectStartLiveCleanup();
|
|
63
|
+
function reapAllLiveCleanup() {
|
|
64
|
+
liveCleanup.reapAll(signalGroup);
|
|
65
|
+
}
|
|
66
|
+
process.on("exit", reapAllLiveCleanup);
|
|
67
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
68
|
+
// Registering a listener suppresses the default terminate, so exit explicitly.
|
|
69
|
+
process.on(signal, () => {
|
|
70
|
+
reapAllLiveCleanup();
|
|
71
|
+
process.exit(1);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
50
74
|
/** A temp AGENT_DEALER_HOME that is deleted when the test ends, however it ends. Registered
|
|
51
75
|
* as a test hook rather than dropped in a `finally` on purpose: teardown here asserts (that
|
|
52
76
|
* the server it stopped is really dead), and an assertion that throws must not be able to
|
|
53
77
|
* skip the cleanup and leave a directory behind — which is precisely how $TMPDIR filled up
|
|
54
78
|
* with hundreds of these. Runs after the body's own `finally`, so servers are stopped by
|
|
55
|
-
* then.
|
|
79
|
+
* then. The interrupt path (SIGINT/SIGTERM/SIGHUP → process.exit) skips t.after, so the
|
|
80
|
+
* home is also tracked on `liveCleanup` and reaped there (NOT-140). */
|
|
56
81
|
function makeHome(t, prefix) {
|
|
57
82
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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);
|
|
83
|
+
liveCleanup.trackHome(home);
|
|
84
|
+
t.after(() => {
|
|
85
|
+
liveCleanup.untrackHome(home);
|
|
86
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
78
87
|
});
|
|
88
|
+
return home;
|
|
79
89
|
}
|
|
80
90
|
function spawnServer(home, port) {
|
|
81
91
|
const child = spawn(tsxBin, [serverEntry], {
|
|
@@ -94,10 +104,10 @@ function spawnServer(home, port) {
|
|
|
94
104
|
// Every test awaits its own teardown, so this handle is never what keeps the run
|
|
95
105
|
// correct — but while it is ref'd it *does* keep the runner's event loop alive, so a
|
|
96
106
|
// 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() +
|
|
107
|
+
// for a child that will never exit" instead of failing. unref() + reapAllLiveCleanup()
|
|
98
108
|
// on exit is the pair that makes the failure path terminate and still reap.
|
|
99
109
|
child.unref();
|
|
100
|
-
|
|
110
|
+
liveCleanup.addServer(child);
|
|
101
111
|
return child;
|
|
102
112
|
}
|
|
103
113
|
function hasExited(child) {
|
|
@@ -182,7 +192,7 @@ async function stopServer(child, home) {
|
|
|
182
192
|
if (recordedPid !== undefined) {
|
|
183
193
|
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
194
|
}
|
|
185
|
-
|
|
195
|
+
liveCleanup.removeServer(child);
|
|
186
196
|
return { exitedOnTerm, recordedPid };
|
|
187
197
|
}
|
|
188
198
|
async function waitForHealth(port, timeoutMs) {
|
|
@@ -348,3 +358,83 @@ test("a real running server blocks the migration end to end, and the migration s
|
|
|
348
358
|
await stopServer(server, home);
|
|
349
359
|
}
|
|
350
360
|
});
|
|
361
|
+
function listOrphanIndexTsPids() {
|
|
362
|
+
try {
|
|
363
|
+
const out = execFileSync("ps", ["-axo", "pid=,ppid=,command="], { encoding: "utf8" });
|
|
364
|
+
const orphans = [];
|
|
365
|
+
for (const line of out.split("\n")) {
|
|
366
|
+
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);
|
|
367
|
+
if (!match)
|
|
368
|
+
continue;
|
|
369
|
+
const pid = Number(match[1]);
|
|
370
|
+
const ppid = Number(match[2]);
|
|
371
|
+
const command = match[3];
|
|
372
|
+
if (ppid === 1 && command.includes("packages/server/src/index.ts"))
|
|
373
|
+
orphans.push(pid);
|
|
374
|
+
}
|
|
375
|
+
return orphans;
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return [];
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
test("SIGINT mid-run leaves zero dealer-direct-start homes from that run and no PPID=1 index.ts orphans", { timeout: 60000 }, async () => {
|
|
382
|
+
// Acceptance for NOT-140: the probe mirrors this file's signal handler (reapAll then
|
|
383
|
+
// process.exit(1)). process.exit skips t.after, so only the shared liveCleanup registry
|
|
384
|
+
// can remove the temp home — and it must still reap the detached server group.
|
|
385
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-direct-start-sigint-"));
|
|
386
|
+
const readyFile = path.join(home, "ready.json");
|
|
387
|
+
const port = await getEphemeralPort();
|
|
388
|
+
const probe = path.join(__dirname, "direct-start-interrupt-probe.ts");
|
|
389
|
+
const orphansBefore = new Set(listOrphanIndexTsPids());
|
|
390
|
+
const probeChild = spawn(tsxBin, [probe, home, readyFile, serverEntry, String(port)], {
|
|
391
|
+
cwd: repoRoot,
|
|
392
|
+
stdio: "ignore",
|
|
393
|
+
});
|
|
394
|
+
try {
|
|
395
|
+
const ready = await waitUntil(() => fs.existsSync(readyFile), 15000);
|
|
396
|
+
assert.ok(ready, "interrupt probe should write its ready file");
|
|
397
|
+
const healthy = await waitForHealth(port, 20000);
|
|
398
|
+
assert.ok(healthy, "interrupt probe's server should become healthy before SIGINT");
|
|
399
|
+
probeChild.kill("SIGINT");
|
|
400
|
+
const exited = await waitUntil(() => probeChild.exitCode !== null || probeChild.signalCode !== null, 15000);
|
|
401
|
+
assert.ok(exited, "interrupt probe should exit after SIGINT");
|
|
402
|
+
assert.equal(fs.existsSync(home), false, "SIGINT must rmSync the tracked temp home — t.after never runs after process.exit(1)");
|
|
403
|
+
const orphansAfter = listOrphanIndexTsPids().filter((pid) => !orphansBefore.has(pid));
|
|
404
|
+
assert.deepEqual(orphansAfter, [], `SIGINT must not leave packages/server/src/index.ts reparented to init; new orphans: ${orphansAfter.join(",")}`);
|
|
405
|
+
}
|
|
406
|
+
finally {
|
|
407
|
+
// SIGKILL skips the probe's exit handler, so a stuck probe would leave its
|
|
408
|
+
// detached server group alive (often reparented to init). Prefer SIGINT so
|
|
409
|
+
// reapAll runs; only then SIGKILL the process group ourselves as a backstop.
|
|
410
|
+
if (probeChild.exitCode === null && probeChild.signalCode === null) {
|
|
411
|
+
probeChild.kill("SIGINT");
|
|
412
|
+
await waitUntil(() => probeChild.exitCode !== null || probeChild.signalCode !== null, 5000);
|
|
413
|
+
}
|
|
414
|
+
if (probeChild.exitCode === null && probeChild.signalCode === null) {
|
|
415
|
+
let launcherPid;
|
|
416
|
+
try {
|
|
417
|
+
launcherPid = JSON.parse(fs.readFileSync(readyFile, "utf8")).serverLauncherPid;
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
// ready file may be missing if the probe died before writing it
|
|
421
|
+
}
|
|
422
|
+
if (typeof launcherPid === "number" && Number.isFinite(launcherPid)) {
|
|
423
|
+
try {
|
|
424
|
+
process.kill(-launcherPid, "SIGKILL");
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
// ESRCH — group already gone
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
probeChild.kill("SIGKILL");
|
|
431
|
+
}
|
|
432
|
+
// If the probe failed before reaping, do not leave debris behind for the suite.
|
|
433
|
+
try {
|
|
434
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
// already gone on the success path
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export const DIRECT_START_HOME_PREFIX = "dealer-direct-start-";
|
|
4
|
+
/** Homes modified more recently than this are assumed to belong to a run in flight. */
|
|
5
|
+
export const DEFAULT_RECENT_MS = 60_000;
|
|
6
|
+
export function isPidAlive(pid) {
|
|
7
|
+
try {
|
|
8
|
+
process.kill(pid, 0);
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function readRecordedPid(home) {
|
|
16
|
+
try {
|
|
17
|
+
const recorded = JSON.parse(fs.readFileSync(path.join(home, "server.pid"), "utf8"))
|
|
18
|
+
.pid;
|
|
19
|
+
return typeof recorded === "number" && Number.isFinite(recorded) ? recorded : undefined;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* One registry for the interrupt path: every server spawnServer() still owns, and
|
|
27
|
+
* every temp home makeHome() still owns. reapAll() is the single net used by both
|
|
28
|
+
* process.on("exit") and the SIGINT/SIGTERM/SIGHUP handlers.
|
|
29
|
+
*/
|
|
30
|
+
export class DirectStartLiveCleanup {
|
|
31
|
+
servers = new Set();
|
|
32
|
+
homes = new Set();
|
|
33
|
+
trackHome(home) {
|
|
34
|
+
this.homes.add(home);
|
|
35
|
+
}
|
|
36
|
+
untrackHome(home) {
|
|
37
|
+
this.homes.delete(home);
|
|
38
|
+
}
|
|
39
|
+
addServer(child) {
|
|
40
|
+
this.servers.add(child);
|
|
41
|
+
}
|
|
42
|
+
removeServer(child) {
|
|
43
|
+
this.servers.delete(child);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Kill every tracked server group, then rmSync every tracked home.
|
|
47
|
+
* Order matters: never delete a home out from under a still-live server.
|
|
48
|
+
*/
|
|
49
|
+
reapAll(signalGroup) {
|
|
50
|
+
for (const child of this.servers)
|
|
51
|
+
signalGroup(child, "SIGKILL");
|
|
52
|
+
this.servers.clear();
|
|
53
|
+
for (const home of this.homes) {
|
|
54
|
+
try {
|
|
55
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Best-effort on the interrupt path — do not throw out of a signal handler.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
this.homes.clear();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function shouldSweepHome(home, opts) {
|
|
65
|
+
let mtimeMs;
|
|
66
|
+
try {
|
|
67
|
+
mtimeMs = fs.statSync(home).mtimeMs;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
if (opts.now - mtimeMs < opts.recentMs)
|
|
73
|
+
return false;
|
|
74
|
+
const pid = readRecordedPid(home);
|
|
75
|
+
if (pid !== undefined && opts.isPidAlive(pid))
|
|
76
|
+
return false;
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* One-time (or on-load) sweep of abandoned `$TMPDIR/dealer-direct-start-*` homes.
|
|
81
|
+
* Skips any home whose recorded server.pid is still alive, and any home modified
|
|
82
|
+
* within `recentMs`, so a concurrent in-flight run is never deleted out from under.
|
|
83
|
+
*/
|
|
84
|
+
export function sweepAbandonedDirectStartHomes(tmpDir, opts) {
|
|
85
|
+
const now = opts?.now ?? Date.now();
|
|
86
|
+
const recentMs = opts?.recentMs ?? DEFAULT_RECENT_MS;
|
|
87
|
+
const alive = opts?.isPidAlive ?? isPidAlive;
|
|
88
|
+
const prefix = opts?.prefix ?? DIRECT_START_HOME_PREFIX;
|
|
89
|
+
const removed = [];
|
|
90
|
+
const skipped = [];
|
|
91
|
+
let entries;
|
|
92
|
+
try {
|
|
93
|
+
entries = fs.readdirSync(tmpDir);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return { removed, skipped };
|
|
97
|
+
}
|
|
98
|
+
for (const name of entries) {
|
|
99
|
+
if (!name.startsWith(prefix))
|
|
100
|
+
continue;
|
|
101
|
+
const home = path.join(tmpDir, name);
|
|
102
|
+
let st;
|
|
103
|
+
try {
|
|
104
|
+
st = fs.statSync(home);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (!st.isDirectory())
|
|
110
|
+
continue;
|
|
111
|
+
if (!shouldSweepHome(home, { now, recentMs, isPidAlive: alive })) {
|
|
112
|
+
skipped.push(home);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
117
|
+
removed.push(home);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
skipped.push(home);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { removed, skipped };
|
|
124
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// packages/server/src/direct-start-temp-home-cleanup.test.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-140: interrupt path must reap temp AGENT_DEALER_HOME dirs (process.exit skips
|
|
4
|
+
// t.after), and a guarded one-time sweep must clear abandoned dealer-direct-start-* debris.
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { DirectStartLiveCleanup, DEFAULT_RECENT_MS, sweepAbandonedDirectStartHomes, } from "./direct-start-temp-home-cleanup.js";
|
|
11
|
+
function makeStaleHome(tmpDir, name, ageMs) {
|
|
12
|
+
const home = path.join(tmpDir, name);
|
|
13
|
+
fs.mkdirSync(home);
|
|
14
|
+
const when = new Date(Date.now() - ageMs);
|
|
15
|
+
fs.utimesSync(home, when, when);
|
|
16
|
+
return home;
|
|
17
|
+
}
|
|
18
|
+
test("DirectStartLiveCleanup.reapAll removes tracked homes after signalling servers", () => {
|
|
19
|
+
const cleanup = new DirectStartLiveCleanup();
|
|
20
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-direct-start-reap-"));
|
|
21
|
+
cleanup.trackHome(home);
|
|
22
|
+
fs.writeFileSync(path.join(home, "marker"), "alive");
|
|
23
|
+
const signalled = [];
|
|
24
|
+
cleanup.reapAll((child, signal) => {
|
|
25
|
+
signalled.push({ child, signal });
|
|
26
|
+
});
|
|
27
|
+
assert.equal(fs.existsSync(home), false, "tracked home must be rmSync'd on reap");
|
|
28
|
+
assert.equal(cleanup.homes.size, 0);
|
|
29
|
+
assert.equal(cleanup.servers.size, 0);
|
|
30
|
+
assert.deepEqual(signalled, []);
|
|
31
|
+
});
|
|
32
|
+
test("sweepAbandonedDirectStartHomes removes stale homes without a live server.pid", () => {
|
|
33
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-sweep-root-"));
|
|
34
|
+
const stale = path.join(tmpDir, "dealer-direct-start-stale");
|
|
35
|
+
fs.mkdirSync(stale);
|
|
36
|
+
fs.writeFileSync(path.join(stale, "dealer.db"), "");
|
|
37
|
+
// utimes after writes — creating children bumps the directory mtime.
|
|
38
|
+
const when = new Date(Date.now() - (DEFAULT_RECENT_MS + 5_000));
|
|
39
|
+
fs.utimesSync(stale, when, when);
|
|
40
|
+
const result = sweepAbandonedDirectStartHomes(tmpDir, {
|
|
41
|
+
now: Date.now(),
|
|
42
|
+
recentMs: DEFAULT_RECENT_MS,
|
|
43
|
+
isPidAlive: () => false,
|
|
44
|
+
});
|
|
45
|
+
assert.equal(fs.existsSync(stale), false);
|
|
46
|
+
assert.deepEqual(result.removed, [stale]);
|
|
47
|
+
assert.deepEqual(result.skipped, []);
|
|
48
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
49
|
+
});
|
|
50
|
+
test("sweepAbandonedDirectStartHomes skips a home whose server.pid names a live process", () => {
|
|
51
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-sweep-root-"));
|
|
52
|
+
const liveHome = path.join(tmpDir, "dealer-direct-start-live");
|
|
53
|
+
fs.mkdirSync(liveHome);
|
|
54
|
+
fs.writeFileSync(path.join(liveHome, "server.pid"), JSON.stringify({ pid: 424242 }));
|
|
55
|
+
const when = new Date(Date.now() - (DEFAULT_RECENT_MS + 5_000));
|
|
56
|
+
fs.utimesSync(liveHome, when, when);
|
|
57
|
+
const result = sweepAbandonedDirectStartHomes(tmpDir, {
|
|
58
|
+
now: Date.now(),
|
|
59
|
+
recentMs: DEFAULT_RECENT_MS,
|
|
60
|
+
isPidAlive: (pid) => pid === 424242,
|
|
61
|
+
});
|
|
62
|
+
assert.equal(fs.existsSync(liveHome), true, "must not delete out from under a live server");
|
|
63
|
+
assert.deepEqual(result.removed, []);
|
|
64
|
+
assert.deepEqual(result.skipped, [liveHome]);
|
|
65
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
66
|
+
});
|
|
67
|
+
test("sweepAbandonedDirectStartHomes skips a recently-modified home", () => {
|
|
68
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-sweep-root-"));
|
|
69
|
+
const fresh = makeStaleHome(tmpDir, "dealer-direct-start-fresh", 1_000);
|
|
70
|
+
const result = sweepAbandonedDirectStartHomes(tmpDir, {
|
|
71
|
+
now: Date.now(),
|
|
72
|
+
recentMs: DEFAULT_RECENT_MS,
|
|
73
|
+
isPidAlive: () => false,
|
|
74
|
+
});
|
|
75
|
+
assert.equal(fs.existsSync(fresh), true, "must not delete a home still in use by a run in flight");
|
|
76
|
+
assert.deepEqual(result.removed, []);
|
|
77
|
+
assert.deepEqual(result.skipped, [fresh]);
|
|
78
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
79
|
+
});
|
|
80
|
+
test("sweepAbandonedDirectStartHomes ignores directories that do not use the dealer-direct-start- prefix", () => {
|
|
81
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-sweep-root-"));
|
|
82
|
+
const other = makeStaleHome(tmpDir, "dealer-paths-unrelated", DEFAULT_RECENT_MS + 5_000);
|
|
83
|
+
const result = sweepAbandonedDirectStartHomes(tmpDir, {
|
|
84
|
+
now: Date.now(),
|
|
85
|
+
recentMs: DEFAULT_RECENT_MS,
|
|
86
|
+
isPidAlive: () => false,
|
|
87
|
+
});
|
|
88
|
+
assert.equal(fs.existsSync(other), true);
|
|
89
|
+
assert.deepEqual(result.removed, []);
|
|
90
|
+
assert.deepEqual(result.skipped, []);
|
|
91
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
92
|
+
});
|
|
@@ -94,24 +94,62 @@ export function enqueueIssueWithOutcome(issueId) {
|
|
|
94
94
|
return { entry: getQueueEntry(id), created: true };
|
|
95
95
|
}
|
|
96
96
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* This is the only reorder primitive in the repo today — NOT-112's general reorder reuses
|
|
100
|
-
* it rather than adding a second way to renumber the queue.
|
|
97
|
+
* Single position-computation path: write 1-based ranks for the given order.
|
|
98
|
+
* Callers must already hold a DB transaction when they need atomic reorder + lookup.
|
|
101
99
|
*/
|
|
102
|
-
|
|
100
|
+
function applyQueuedOrder(ordered) {
|
|
101
|
+
const setPosition = getDb().prepare("UPDATE queue_entries SET position = ? WHERE id = ?");
|
|
102
|
+
ordered.forEach((entry, index) => setPosition.run(index + 1, entry.id));
|
|
103
|
+
}
|
|
104
|
+
function buildOrderAfterMove(entry, without, to) {
|
|
105
|
+
if (to === "top")
|
|
106
|
+
return [entry, ...without];
|
|
107
|
+
if (to === "bottom")
|
|
108
|
+
return [...without, entry];
|
|
109
|
+
if ("before" in to) {
|
|
110
|
+
const idx = without.findIndex((e) => e.issueId === to.before);
|
|
111
|
+
if (idx < 0) {
|
|
112
|
+
throw Object.assign(new Error("Reference issue is not in the queue"), { code: 409 });
|
|
113
|
+
}
|
|
114
|
+
return [...without.slice(0, idx), entry, ...without.slice(idx)];
|
|
115
|
+
}
|
|
116
|
+
const idx = without.findIndex((e) => e.issueId === to.after);
|
|
117
|
+
if (idx < 0) {
|
|
118
|
+
throw Object.assign(new Error("Reference issue is not in the queue"), { code: 409 });
|
|
119
|
+
}
|
|
120
|
+
return [...without.slice(0, idx + 1), entry, ...without.slice(idx + 1)];
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* NOT-112: move a queued entry relative to the live queue (top / bottom / before / after).
|
|
124
|
+
* Only `queued` entries are orderable. Throws `{ code: 404 }` when the issue is not queued
|
|
125
|
+
* (missing or already admitted) and `{ code: 409 }` when a relative reference is gone.
|
|
126
|
+
*/
|
|
127
|
+
export function moveQueueEntry(issueId, to) {
|
|
103
128
|
const db = getDb();
|
|
104
129
|
return db.transaction(() => {
|
|
105
130
|
const entry = getQueuedEntryForIssue(issueId);
|
|
106
|
-
if (!entry)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
rest.forEach((e, index) => setPosition.run(index + 2, e.id));
|
|
131
|
+
if (!entry) {
|
|
132
|
+
throw Object.assign(new Error("Issue is not in the queue"), { code: 404 });
|
|
133
|
+
}
|
|
134
|
+
const without = listQueuedEntries().filter((e) => e.issueId !== issueId);
|
|
135
|
+
applyQueuedOrder(buildOrderAfterMove(entry, without, to));
|
|
112
136
|
return getQueueEntry(entry.id);
|
|
113
137
|
})();
|
|
114
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* NOT-118 Start: move to front. Thin wrapper over the shared renumber path — returns null
|
|
141
|
+
* when the issue is not queued (Start's enqueue step normally prevents that).
|
|
142
|
+
*/
|
|
143
|
+
export function moveQueueEntryToTop(issueId) {
|
|
144
|
+
try {
|
|
145
|
+
return moveQueueEntry(issueId, "top");
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
if (err.code === 404)
|
|
149
|
+
return null;
|
|
150
|
+
throw err;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
115
153
|
/** Operator dequeue — marks removed (keeps history row). Returns the removed entry or null. */
|
|
116
154
|
export function dequeueIssue(issueId) {
|
|
117
155
|
const entry = getQueuedEntryForIssue(issueId);
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// packages/server/src/routes/queue-reorder.integration.test.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-112 acceptance: operator can reorder queued issues via relative moves; admission
|
|
4
|
+
// respects the new order; moving a missing/admitted entry fails clearly without corrupting
|
|
5
|
+
// the remaining order.
|
|
6
|
+
import { test, before, beforeEach, after } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import Fastify from "fastify";
|
|
12
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not112-"));
|
|
13
|
+
const { migrate, getDb } = await import("../db/index.js");
|
|
14
|
+
const { createAgent } = await import("../repository/agents.js");
|
|
15
|
+
const { getIssue } = await import("../repository/issues.js");
|
|
16
|
+
const { listQueuedEntries, getQueuedEntryForIssue, markQueueEntryAdmitted } = await import("../repository/queue-entries.js");
|
|
17
|
+
const { registerIssueRoutes } = await import("./issues.js");
|
|
18
|
+
const { registerQueueRoutes } = await import("./queue.js");
|
|
19
|
+
const { admitNext, setAdmissionHealthCheckerForTests, resetCapacityPolicyForTests, resetEligibilityRulesForTests, } = await import("../coordinator/admission.js");
|
|
20
|
+
const { setBlockersProviderForTests, resetDependenciesForTests } = await import("../coordinator/dependencies.js");
|
|
21
|
+
before(() => migrate());
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
getDb().exec(`
|
|
24
|
+
DELETE FROM work_items;
|
|
25
|
+
DELETE FROM human_actions;
|
|
26
|
+
DELETE FROM workflow_events;
|
|
27
|
+
DELETE FROM worker_sessions;
|
|
28
|
+
DELETE FROM artifacts;
|
|
29
|
+
DELETE FROM workflow_instances;
|
|
30
|
+
DELETE FROM queue_entries;
|
|
31
|
+
DELETE FROM issues;
|
|
32
|
+
`);
|
|
33
|
+
setAdmissionHealthCheckerForTests(async () => ({ ok: true }));
|
|
34
|
+
setBlockersProviderForTests(async (issues) => new Map(issues.map((i) => [i.externalId, []])));
|
|
35
|
+
resetCapacityPolicyForTests();
|
|
36
|
+
resetEligibilityRulesForTests();
|
|
37
|
+
});
|
|
38
|
+
after(() => {
|
|
39
|
+
setAdmissionHealthCheckerForTests(null);
|
|
40
|
+
resetDependenciesForTests();
|
|
41
|
+
});
|
|
42
|
+
async function buildApp() {
|
|
43
|
+
const app = Fastify();
|
|
44
|
+
await registerIssueRoutes(app);
|
|
45
|
+
await registerQueueRoutes(app);
|
|
46
|
+
return app;
|
|
47
|
+
}
|
|
48
|
+
const repo = "acme/not112";
|
|
49
|
+
const dev = createAgent({
|
|
50
|
+
name: `not112-dev-${Math.random()}`,
|
|
51
|
+
runtime: "claude_code",
|
|
52
|
+
deckId: "00000000-0000-4000-a000-000000000112",
|
|
53
|
+
});
|
|
54
|
+
const rev = createAgent({
|
|
55
|
+
name: `not112-rev-${Math.random()}`,
|
|
56
|
+
runtime: "claude_code",
|
|
57
|
+
deckId: "00000000-0000-4000-a000-000000000112",
|
|
58
|
+
});
|
|
59
|
+
async function createIssueViaApi(app, title) {
|
|
60
|
+
const res = await app.inject({
|
|
61
|
+
method: "POST",
|
|
62
|
+
url: "/api/issues",
|
|
63
|
+
payload: {
|
|
64
|
+
title,
|
|
65
|
+
repo,
|
|
66
|
+
baseBranch: "main",
|
|
67
|
+
developerAgentId: dev.id,
|
|
68
|
+
reviewerAgentId: rev.id,
|
|
69
|
+
acceptanceCriteria: "It works",
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
assert.equal(res.statusCode, 200);
|
|
73
|
+
return res.json().id;
|
|
74
|
+
}
|
|
75
|
+
async function moveViaApi(app, issueId, to) {
|
|
76
|
+
return app.inject({
|
|
77
|
+
method: "POST",
|
|
78
|
+
url: `/api/queue/${issueId}/move`,
|
|
79
|
+
payload: { to },
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
function queuedIds() {
|
|
83
|
+
return listQueuedEntries().map((e) => e.issueId);
|
|
84
|
+
}
|
|
85
|
+
test("POST /api/queue/:id/move supports top, bottom, before, and after", async () => {
|
|
86
|
+
const app = await buildApp();
|
|
87
|
+
const a = await createIssueViaApi(app, "A");
|
|
88
|
+
const b = await createIssueViaApi(app, "B");
|
|
89
|
+
const c = await createIssueViaApi(app, "C");
|
|
90
|
+
assert.deepEqual(queuedIds(), [a, b, c]);
|
|
91
|
+
let res = await moveViaApi(app, c, "top");
|
|
92
|
+
assert.equal(res.statusCode, 200);
|
|
93
|
+
assert.equal(res.json().issueId, c);
|
|
94
|
+
assert.equal(res.json().position, 1);
|
|
95
|
+
assert.deepEqual(queuedIds(), [c, a, b]);
|
|
96
|
+
res = await moveViaApi(app, c, "bottom");
|
|
97
|
+
assert.equal(res.statusCode, 200);
|
|
98
|
+
assert.deepEqual(queuedIds(), [a, b, c]);
|
|
99
|
+
res = await moveViaApi(app, c, { before: a });
|
|
100
|
+
assert.equal(res.statusCode, 200);
|
|
101
|
+
assert.deepEqual(queuedIds(), [c, a, b]);
|
|
102
|
+
res = await moveViaApi(app, c, { after: a });
|
|
103
|
+
assert.equal(res.statusCode, 200);
|
|
104
|
+
assert.deepEqual(queuedIds(), [a, c, b]);
|
|
105
|
+
await app.close();
|
|
106
|
+
});
|
|
107
|
+
test("next admission respects the reordered queue", async () => {
|
|
108
|
+
const app = await buildApp();
|
|
109
|
+
const a = await createIssueViaApi(app, "First");
|
|
110
|
+
const b = await createIssueViaApi(app, "Second");
|
|
111
|
+
const c = await createIssueViaApi(app, "Urgent");
|
|
112
|
+
assert.deepEqual(queuedIds(), [a, b, c]);
|
|
113
|
+
const moved = await moveViaApi(app, c, "top");
|
|
114
|
+
assert.equal(moved.statusCode, 200);
|
|
115
|
+
assert.deepEqual(queuedIds(), [c, a, b]);
|
|
116
|
+
const admitted = await admitNext();
|
|
117
|
+
assert.equal(admitted?.issueId, c);
|
|
118
|
+
assert.equal(getIssue(c).status, "developing");
|
|
119
|
+
assert.equal(getQueuedEntryForIssue(c), null);
|
|
120
|
+
assert.deepEqual(queuedIds(), [a, b]);
|
|
121
|
+
await app.close();
|
|
122
|
+
});
|
|
123
|
+
test("moving a missing or admitted entry returns a clear error and leaves order unchanged", async () => {
|
|
124
|
+
const app = await buildApp();
|
|
125
|
+
const a = await createIssueViaApi(app, "A");
|
|
126
|
+
const b = await createIssueViaApi(app, "B");
|
|
127
|
+
const c = await createIssueViaApi(app, "C");
|
|
128
|
+
assert.deepEqual(queuedIds(), [a, b, c]);
|
|
129
|
+
const missing = await moveViaApi(app, "00000000-0000-4000-a000-000000000099", "top");
|
|
130
|
+
assert.equal(missing.statusCode, 404);
|
|
131
|
+
assert.match(String(missing.json().error ?? ""), /not in the queue|not found/i);
|
|
132
|
+
assert.deepEqual(queuedIds(), [a, b, c]);
|
|
133
|
+
markQueueEntryAdmitted(c);
|
|
134
|
+
assert.equal(getQueuedEntryForIssue(c), null);
|
|
135
|
+
const admittedMove = await moveViaApi(app, c, "top");
|
|
136
|
+
assert.ok(admittedMove.statusCode === 404 || admittedMove.statusCode === 409);
|
|
137
|
+
assert.match(String(admittedMove.json().error ?? ""), /not in the queue|admitted/i);
|
|
138
|
+
assert.deepEqual(queuedIds(), [a, b]);
|
|
139
|
+
const relativeGone = await moveViaApi(app, a, { before: c });
|
|
140
|
+
assert.equal(relativeGone.statusCode, 409);
|
|
141
|
+
assert.match(String(relativeGone.json().error ?? ""), /not in the queue|reference/i);
|
|
142
|
+
assert.deepEqual(queuedIds(), [a, b]);
|
|
143
|
+
await app.close();
|
|
144
|
+
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// packages/server/src/routes/queue.ts
|
|
2
2
|
//
|
|
3
3
|
// NOT-103: operator enqueue / dequeue / list for the sequential admission queue.
|
|
4
|
-
|
|
5
|
-
import {
|
|
4
|
+
// NOT-112: relative reorder (top / bottom / before / after).
|
|
5
|
+
import { EnqueueIssueInput, MoveQueueEntryInput } from "@agent-dealer/shared";
|
|
6
|
+
import { dequeueIssue, enqueueIssue, getQueuedEntryForIssue, moveQueueEntry, } from "../repository/queue-entries.js";
|
|
6
7
|
import { listQueuedEntriesForRead } from "../coordinator/admission.js";
|
|
7
8
|
export async function registerQueueRoutes(app) {
|
|
8
9
|
/** NOT-118: positions are 1-based ranks and a full system derives its own
|
|
@@ -28,6 +29,24 @@ export async function registerQueueRoutes(app) {
|
|
|
28
29
|
throw err;
|
|
29
30
|
}
|
|
30
31
|
});
|
|
32
|
+
app.post("/api/queue/:issueId/move", async (req, reply) => {
|
|
33
|
+
const { issueId } = req.params;
|
|
34
|
+
const parsed = MoveQueueEntryInput.safeParse(req.body);
|
|
35
|
+
if (!parsed.success)
|
|
36
|
+
return reply.status(400).send({ error: parsed.error.message });
|
|
37
|
+
try {
|
|
38
|
+
return moveQueueEntry(issueId, parsed.data.to);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
const code = err.code;
|
|
42
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
43
|
+
if (code === 404)
|
|
44
|
+
return reply.status(404).send({ error: message });
|
|
45
|
+
if (code === 409)
|
|
46
|
+
return reply.status(409).send({ error: message });
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
31
50
|
app.delete("/api/queue/:issueId", async (req, reply) => {
|
|
32
51
|
const { issueId } = req.params;
|
|
33
52
|
if (!getQueuedEntryForIssue(issueId)) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-dealer/server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"files": [
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"typecheck": "tsc --noEmit"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@agent-dealer/shared": "1.0.
|
|
25
|
+
"@agent-dealer/shared": "1.0.2",
|
|
26
26
|
"@fastify/cors": "^11.0.1",
|
|
27
27
|
"@fastify/static": "^8.2.0",
|
|
28
28
|
"@modelcontextprotocol/sdk": "^1.29.0",
|