@gevezex/gdt 0.4.1 → 0.5.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/README.md +42 -15
- package/dist/activity.js +124 -0
- package/dist/agents/claude-stream.js +74 -0
- package/dist/agents/claude.js +13 -1
- package/dist/cli.js +20 -14
- package/dist/config.js +23 -1
- package/dist/prompts.js +23 -1
- package/dist/protocol.js +10 -2
- package/dist/state.js +2 -0
- package/dist/supervisor.js +338 -32
- package/dist/worker.js +34 -6
- package/dist/workflow.js +96 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,25 +1,37 @@
|
|
|
1
1
|
# gdt
|
|
2
2
|
|
|
3
|
-
**GitHub
|
|
3
|
+
**A deterministic supervisor that takes a GitHub issue through develop → test → review, with a different model in each role.**
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
gdt (GitHub Development and Test) is a supervisor that makes every GitHub issue
|
|
6
|
+
follow the same path: **develop → test → review**. Each step is done by a
|
|
7
|
+
separate agent role, and you ideally give each role a different model, so one
|
|
8
|
+
model's blind spots don't end up in your code unchecked.
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
When the tester or reviewer finds problems, the issue goes back to the
|
|
11
|
+
developer, but only a limited number of times: 2 correction rounds by default,
|
|
12
|
+
shared between tester and reviewer. After that gdt stops and asks you, so an
|
|
13
|
+
issue can never bounce between the roles forever.
|
|
14
|
+
|
|
15
|
+
**Built to avoid burning tokens.** GitHub is the shared record: the roles don't
|
|
16
|
+
talk to each other or to a long-running chat session, they each leave a
|
|
17
|
+
structured comment on the pull request, and the supervisor reads those. The
|
|
18
|
+
supervisor itself is plain code, not a model, so waiting, polling and deciding
|
|
19
|
+
whose turn it is cost no tokens; a model only runs during a role's turn. It
|
|
20
|
+
also means the workflow survives when your chat session ends.
|
|
21
|
+
|
|
22
|
+
gdt currently supports **Claude Code, Codex, OpenCode, MCode, pi and omp**, both
|
|
23
|
+
for the roles and for the agent you drive gdt from; more harnesses are on the
|
|
24
|
+
way. You can watch the roles work in [herdr](https://herdr.dev). gdt stops at a
|
|
25
|
+
draft pull request that is ready to merge; **you always merge yourself**.
|
|
26
|
+
|
|
27
|
+

|
|
12
28
|
|
|
13
29
|
- the **developer** implements the issue and opens a draft pull request;
|
|
14
30
|
- the **tester** checks every acceptance criterion against the running code;
|
|
15
31
|
- the **reviewer** reads the diff against the issue.
|
|
16
32
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
role's turn. You drive gdt by talking to any coding agent (Claude Code, Codex,
|
|
20
|
-
OpenCode, MCode, pi or omp) and can watch the roles work in
|
|
21
|
-
[herdr](https://herdr.dev). **You always merge yourself**: gdt never merges,
|
|
22
|
-
deploys or closes issues.
|
|
33
|
+
More background in [docs/design.md](docs/design.md); how we use gdt on this
|
|
34
|
+
repository itself is in [docs/dogfooding.md](docs/dogfooding.md).
|
|
23
35
|
|
|
24
36
|
## How it works
|
|
25
37
|
|
|
@@ -28,8 +40,8 @@ deploys or closes issues.
|
|
|
28
40
|
```text
|
|
29
41
|
┌──────────┐ "pick up issue 251 ┌──────────────────────┐
|
|
30
42
|
│ you │ ──────────────────────► │ operator agent │ Claude Code, Codex,
|
|
31
|
-
└──────────┘ with gdt" │ (your chat session) │ OpenCode,
|
|
32
|
-
▲ └──────────┬───────────┘
|
|
43
|
+
└──────────┘ with gdt" │ (your chat session) │ OpenCode, MCode,
|
|
44
|
+
▲ └──────────┬───────────┘ pi or omp
|
|
33
45
|
│ │ gdt start 251 (returns at once)
|
|
34
46
|
│ │ gdt wait 251 (background, 0 tokens)
|
|
35
47
|
│ ▼
|
|
@@ -447,6 +459,21 @@ Everything gdt keeps for an issue lives under `.git/gdt/issue-<n>/`: `state.json
|
|
|
447
459
|
(the workflow state), `logs/` (one log per process) and `runs/` (the prompt and
|
|
448
460
|
result of every turn). It is never committed.
|
|
449
461
|
|
|
462
|
+
### What a role pane shows
|
|
463
|
+
|
|
464
|
+
During a turn a role pane (herdr) or role log (headless, `logs/<role>.log`)
|
|
465
|
+
shows what the agent CLI prints with the invocation in
|
|
466
|
+
[docs/agents.md](docs/agents.md):
|
|
467
|
+
|
|
468
|
+
| Agent | What the pane shows during a turn |
|
|
469
|
+
|---|---|
|
|
470
|
+
| `claude` | Live progress rendered by gdt from Claude Code's event stream: assistant text, tool calls (`→ Bash npm test`), tool results (`✓ ok` or `✗ error`) and the final result, while the turn runs |
|
|
471
|
+
| `codex` | What `codex exec` prints: its progress (messages and the commands it runs) while the turn runs, then the final message |
|
|
472
|
+
| `opencode` | What `opencode run` prints: messages and tool calls while the turn runs |
|
|
473
|
+
| `mcode` | What `mcode exec` prints in its default text output |
|
|
474
|
+
| `pi` | What `pi --print` prints: the final answer, when the turn ends |
|
|
475
|
+
| `omp` | What `omp --print` prints: the final answer, when the turn ends |
|
|
476
|
+
|
|
450
477
|
### Notifications
|
|
451
478
|
|
|
452
479
|
When a workflow needs you, gdt notifies you through the first of
|
package/dist/activity.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { statSync } from "node:fs";
|
|
4
|
+
import { isAbsolute, join } from "node:path";
|
|
5
|
+
/** The CPU signal fires when the agent process group used at least this many more CPU seconds. */
|
|
6
|
+
export const CPU_SIGNAL_SECONDS = 1;
|
|
7
|
+
/** Parses a `ps` `time` value: `[dd-]hh:mm:ss` on Linux, `m:ss.cc` or `h:mm:ss.cc` on macOS. */
|
|
8
|
+
export function parseCpuTime(value) {
|
|
9
|
+
const [days, rest] = value.includes("-") ? value.split("-", 2) : ["0", value];
|
|
10
|
+
const parts = (rest ?? "").split(":").map(Number);
|
|
11
|
+
let seconds = 0;
|
|
12
|
+
for (const part of parts)
|
|
13
|
+
seconds = seconds * 60 + (Number.isFinite(part) ? part : 0);
|
|
14
|
+
return Number(days) * 86_400 + seconds;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The summed CPU time and number of live (non-zombie) processes of process group `pgid`. The count is
|
|
18
|
+
* null when `ps` fails, so an unreadable process table never looks like an exited agent.
|
|
19
|
+
*/
|
|
20
|
+
export function groupUsage(pgid, env) {
|
|
21
|
+
if (pgid === null || pgid <= 0)
|
|
22
|
+
return { cpu: 0, processes: 0 };
|
|
23
|
+
const result = spawnSync("ps", ["-A", "-o", "pgid=", "-o", "stat=", "-o", "time="], { env, encoding: "utf8" });
|
|
24
|
+
if (result.status !== 0)
|
|
25
|
+
return { cpu: 0, processes: null };
|
|
26
|
+
let cpu = 0;
|
|
27
|
+
let processes = 0;
|
|
28
|
+
for (const line of result.stdout.split("\n")) {
|
|
29
|
+
const [group, stat, time] = line.trim().split(/\s+/);
|
|
30
|
+
if (Number(group) !== pgid || stat === undefined || time === undefined || stat.startsWith("Z"))
|
|
31
|
+
continue;
|
|
32
|
+
processes += 1;
|
|
33
|
+
cpu += parseCpuTime(time);
|
|
34
|
+
}
|
|
35
|
+
return { cpu, processes };
|
|
36
|
+
}
|
|
37
|
+
/** `HEAD`, `git status --porcelain` and the mtime of every listed file, hashed. */
|
|
38
|
+
export function treeFingerprint(root, env) {
|
|
39
|
+
const git = (...args) => spawnSync("git", args, { cwd: root, env, encoding: "utf8" });
|
|
40
|
+
const head = git("rev-parse", "HEAD");
|
|
41
|
+
const status = git("status", "--porcelain", "-z");
|
|
42
|
+
if (status.status !== 0)
|
|
43
|
+
return null;
|
|
44
|
+
const hash = createHash("sha256").update(head.status === 0 ? head.stdout : "").update("\0").update(status.stdout);
|
|
45
|
+
const entries = status.stdout.split("\0").filter((entry) => entry !== "");
|
|
46
|
+
for (let i = 0; i < entries.length; i++) {
|
|
47
|
+
const entry = entries[i] ?? "";
|
|
48
|
+
const path = entry.slice(3);
|
|
49
|
+
// A rename or copy is followed by its original path, which no longer exists as listed.
|
|
50
|
+
if (entry[0] === "R" || entry[0] === "C")
|
|
51
|
+
i += 1;
|
|
52
|
+
hash.update(`\0${path}\0${mtimeOf(join(root, path)) ?? "-"}`);
|
|
53
|
+
}
|
|
54
|
+
return hash.digest("hex");
|
|
55
|
+
}
|
|
56
|
+
function mtimeOf(path) {
|
|
57
|
+
try {
|
|
58
|
+
return statSync(path).mtimeMs;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function sizeOf(path) {
|
|
65
|
+
try {
|
|
66
|
+
return statSync(path).size;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** The opencode session database: `$XDG_DATA_HOME/opencode/opencode.db`, by default under `~/.local/share`. */
|
|
73
|
+
export function opencodeDatabase(env, home) {
|
|
74
|
+
const xdg = env.XDG_DATA_HOME;
|
|
75
|
+
const base = xdg !== undefined && isAbsolute(xdg) ? xdg : join(home, ".local", "share");
|
|
76
|
+
return join(base, "opencode", "opencode.db");
|
|
77
|
+
}
|
|
78
|
+
/** The mtime of `opencode.db-wal`, or of `opencode.db` when there is no write-ahead log. */
|
|
79
|
+
export function opencodeMtime(database) {
|
|
80
|
+
return mtimeOf(`${database}-wal`) ?? mtimeOf(database);
|
|
81
|
+
}
|
|
82
|
+
/** Reads one activity sample with `ps`, `git` and file metadata only. */
|
|
83
|
+
export function sample(input) {
|
|
84
|
+
const usage = groupUsage(input.pgid, input.env);
|
|
85
|
+
return {
|
|
86
|
+
cpu: usage.cpu,
|
|
87
|
+
processes: usage.processes,
|
|
88
|
+
tree: treeFingerprint(input.root, input.env),
|
|
89
|
+
log: input.logFile === null ? null : (sizeOf(input.logFile) ?? 0),
|
|
90
|
+
opencode: input.opencodeDb === null ? null : opencodeMtime(input.opencodeDb),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Compares a sample with the stored baseline. Without a baseline (the turn's first sample) no signal
|
|
95
|
+
* fires except CPU, which counts from zero: the agent process group started without CPU time.
|
|
96
|
+
*/
|
|
97
|
+
export function signals(current, baseline) {
|
|
98
|
+
const cpuBase = baseline?.cpu ?? 0;
|
|
99
|
+
const changed = (now, before) => baseline !== undefined && now !== null && before !== null && before !== undefined && now !== before;
|
|
100
|
+
return {
|
|
101
|
+
cpu: current.cpu - cpuBase >= CPU_SIGNAL_SECONDS,
|
|
102
|
+
tree: changed(current.tree, baseline?.tree),
|
|
103
|
+
log: baseline !== undefined && current.log !== null && current.log > (baseline.log ?? 0),
|
|
104
|
+
opencode: baseline !== undefined && current.opencode !== null && (baseline.opencode === null || current.opencode > baseline.opencode),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The baseline for the next poll. The CPU value only moves with recorded activity, or down when a
|
|
109
|
+
* process of the group exits and takes its CPU time with it.
|
|
110
|
+
*/
|
|
111
|
+
export function nextBaseline(current, previous, active) {
|
|
112
|
+
const cpuBase = previous?.cpu ?? 0;
|
|
113
|
+
return {
|
|
114
|
+
cpu: active || current.cpu < cpuBase ? current.cpu : cpuBase,
|
|
115
|
+
tree: current.tree,
|
|
116
|
+
log: current.log,
|
|
117
|
+
opencode: current.opencode,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** The `supervisor.log` fragment naming every signal and whether it fired, for example `cpu=yes tree=no`. */
|
|
121
|
+
export function formatSignals(fired) {
|
|
122
|
+
const yn = (value) => (value ? "yes" : "no");
|
|
123
|
+
return `cpu=${yn(fired.cpu)} tree=${yn(fired.tree)} log=${yn(fired.log)} opencode=${yn(fired.opencode)}`;
|
|
124
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders Claude Code's stream-json output (`claude -p --output-format stream-json --verbose`) as
|
|
3
|
+
* readable lines while a turn runs (issue #62). A line it cannot read is passed through, never thrown.
|
|
4
|
+
*/
|
|
5
|
+
const DIM = "\x1b[2m";
|
|
6
|
+
const RESET = "\x1b[0m";
|
|
7
|
+
/** The longest tool call summary, in characters. */
|
|
8
|
+
const SUMMARY_MAX = 120;
|
|
9
|
+
function isObject(value) {
|
|
10
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
/** A passthrough line for a valid object: `[<type>]`, dimmed on a TTY. */
|
|
13
|
+
function passthrough(type, tty) {
|
|
14
|
+
const line = `[${typeof type === "string" ? type : String(JSON.stringify(type))}]`;
|
|
15
|
+
return tty ? `${DIM}${line}${RESET}` : line;
|
|
16
|
+
}
|
|
17
|
+
/** `input.command`, else `input.file_path`, else `input.pattern`, else the compact JSON of `input`. */
|
|
18
|
+
function summary(input) {
|
|
19
|
+
let text;
|
|
20
|
+
if (isObject(input) && typeof input.command === "string")
|
|
21
|
+
text = input.command;
|
|
22
|
+
else if (isObject(input) && typeof input.file_path === "string")
|
|
23
|
+
text = input.file_path;
|
|
24
|
+
else if (isObject(input) && typeof input.pattern === "string")
|
|
25
|
+
text = input.pattern;
|
|
26
|
+
else
|
|
27
|
+
text = JSON.stringify(input) ?? "";
|
|
28
|
+
return (text.split("\n")[0] ?? "").slice(0, SUMMARY_MAX);
|
|
29
|
+
}
|
|
30
|
+
function contentItems(event) {
|
|
31
|
+
const message = event.message;
|
|
32
|
+
return isObject(message) && Array.isArray(message.content) ? message.content : [];
|
|
33
|
+
}
|
|
34
|
+
function renderItem(item, eventType, tty) {
|
|
35
|
+
if (!isObject(item))
|
|
36
|
+
return [passthrough(typeof item, tty)];
|
|
37
|
+
if (eventType === "assistant" && item.type === "text" && typeof item.text === "string")
|
|
38
|
+
return item.text.split("\n");
|
|
39
|
+
if (eventType === "assistant" && item.type === "tool_use") {
|
|
40
|
+
const name = typeof item.name === "string" ? item.name : "";
|
|
41
|
+
return [`→ ${name} ${summary(item.input)}`];
|
|
42
|
+
}
|
|
43
|
+
if (eventType === "user" && item.type === "tool_result")
|
|
44
|
+
return [item.is_error === true ? " ✗ error" : " ✓ ok"];
|
|
45
|
+
return [passthrough(item.type, tty)];
|
|
46
|
+
}
|
|
47
|
+
/** The rendered lines for one line of stream-json output; an empty line gives none. */
|
|
48
|
+
export function renderClaudeLine(line, tty) {
|
|
49
|
+
if (line.trim() === "")
|
|
50
|
+
return [];
|
|
51
|
+
let event;
|
|
52
|
+
try {
|
|
53
|
+
event = JSON.parse(line);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return [line];
|
|
57
|
+
}
|
|
58
|
+
if (!isObject(event))
|
|
59
|
+
return [line];
|
|
60
|
+
switch (event.type) {
|
|
61
|
+
case "assistant":
|
|
62
|
+
case "user": {
|
|
63
|
+
const type = event.type;
|
|
64
|
+
return contentItems(event).flatMap((item) => renderItem(item, type, tty));
|
|
65
|
+
}
|
|
66
|
+
case "result": {
|
|
67
|
+
const subtype = typeof event.subtype === "string" ? event.subtype : "";
|
|
68
|
+
const text = typeof event.result === "string" ? event.result.split("\n") : [];
|
|
69
|
+
return [`result: ${subtype}`, ...text];
|
|
70
|
+
}
|
|
71
|
+
default:
|
|
72
|
+
return [passthrough(event.type, tty)];
|
|
73
|
+
}
|
|
74
|
+
}
|
package/dist/agents/claude.js
CHANGED
|
@@ -6,9 +6,21 @@ export const claude = {
|
|
|
6
6
|
modelFormat: "<model>",
|
|
7
7
|
modelExample: "claude-sonnet-5",
|
|
8
8
|
buildInvocation: (_role, model, promptFile) => ({
|
|
9
|
-
argv: [
|
|
9
|
+
argv: [
|
|
10
|
+
"claude",
|
|
11
|
+
"-p",
|
|
12
|
+
"--model",
|
|
13
|
+
model,
|
|
14
|
+
"--permission-mode",
|
|
15
|
+
"bypassPermissions",
|
|
16
|
+
"--no-session-persistence",
|
|
17
|
+
"--output-format",
|
|
18
|
+
"stream-json",
|
|
19
|
+
"--verbose",
|
|
20
|
+
],
|
|
10
21
|
env: {},
|
|
11
22
|
stdin: promptFile,
|
|
23
|
+
output: "claude-stream-json",
|
|
12
24
|
}),
|
|
13
25
|
vendorOf: () => "anthropic",
|
|
14
26
|
skillDir: () => "~/.claude/skills/gdt",
|
package/dist/cli.js
CHANGED
|
@@ -11,7 +11,7 @@ import { loadLocale, shippedLanguages } from "./locale.js";
|
|
|
11
11
|
import { allowRound, answer, installSkill, pause, resume, setAgent, steer } from "./steering.js";
|
|
12
12
|
import { supervise } from "./supervisor.js";
|
|
13
13
|
import { work } from "./worker.js";
|
|
14
|
-
import { retry, start, status, stop, wait } from "./workflow.js";
|
|
14
|
+
import { extend, retry, start, status, stop, wait } from "./workflow.js";
|
|
15
15
|
/** Exit codes: 0 success, 1 a check failed, 2 usage error. */
|
|
16
16
|
export const EXIT_OK = 0;
|
|
17
17
|
export const EXIT_FAILED = 1;
|
|
@@ -30,6 +30,7 @@ Commands:
|
|
|
30
30
|
wait Wait until the workflow needs attention
|
|
31
31
|
stop Stop the workflow for an issue; start resumes it
|
|
32
32
|
retry Prepare a controlled retry of the failed turn
|
|
33
|
+
extend Give a turn that is still active at its hard limit more time
|
|
33
34
|
answer Answer an open question
|
|
34
35
|
steer Send a directive to one role
|
|
35
36
|
pause Stop dispatching new turns
|
|
@@ -108,6 +109,12 @@ Stops the supervisor, the role workers and any running agent turn.
|
|
|
108
109
|
|
|
109
110
|
Stops the failed or interrupted workflow and clears that turn so
|
|
110
111
|
"gdt start <issue>" runs it again.
|
|
112
|
+
`,
|
|
113
|
+
extend: `Usage: gdt extend <issue>
|
|
114
|
+
|
|
115
|
+
Gives a turn that is still active at its hard limit (workflow.turn_max_minutes)
|
|
116
|
+
that many more minutes, counted from now. Only valid while the workflow is
|
|
117
|
+
blocked at a turn's hard limit.
|
|
111
118
|
`,
|
|
112
119
|
pause: `Usage: gdt pause <issue>
|
|
113
120
|
|
|
@@ -445,19 +452,17 @@ function workflowCommand(command, args, io) {
|
|
|
445
452
|
if (typeof parsed === "number")
|
|
446
453
|
return parsed;
|
|
447
454
|
const { issue, json } = parsed;
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
:
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
? allowRound(issue, io.cwd, io.env)
|
|
460
|
-
: status(issue, io.cwd, io.env, json);
|
|
455
|
+
const commands = {
|
|
456
|
+
start: () => start(issue, io.cwd, io.env),
|
|
457
|
+
status: () => status(issue, io.cwd, io.env, json),
|
|
458
|
+
stop: () => stop(issue, io.cwd, io.env),
|
|
459
|
+
retry: () => retry(issue, io.cwd, io.env),
|
|
460
|
+
extend: () => extend(issue, io.cwd, io.env),
|
|
461
|
+
pause: () => pause(issue, io.cwd, io.env),
|
|
462
|
+
resume: () => resume(issue, io.cwd, io.env),
|
|
463
|
+
"allow-round": () => allowRound(issue, io.cwd, io.env),
|
|
464
|
+
};
|
|
465
|
+
const result = commands[command]();
|
|
461
466
|
return emit(result, io);
|
|
462
467
|
}
|
|
463
468
|
/** `gdt wait <issue> [--timeout <seconds>] [--json]`: blocks until the workflow needs attention. */
|
|
@@ -597,6 +602,7 @@ export function run(argv, io) {
|
|
|
597
602
|
case "status":
|
|
598
603
|
case "stop":
|
|
599
604
|
case "retry":
|
|
605
|
+
case "extend":
|
|
600
606
|
case "pause":
|
|
601
607
|
case "resume":
|
|
602
608
|
case "allow-round":
|
package/dist/config.js
CHANGED
|
@@ -15,6 +15,12 @@ export const LOCAL_CONFIG_PATH = ".gdt/config.local.toml";
|
|
|
15
15
|
export const USER_CONFIG_DIR = "gdt";
|
|
16
16
|
export const DEFAULT_LANGUAGE = "en";
|
|
17
17
|
export const DEFAULT_MAX_ACCEPTANCE_CRITERIA = 8;
|
|
18
|
+
/** AC-4: the turn time limit in minutes when `workflow.turn_timeout_minutes` is absent. */
|
|
19
|
+
export const DEFAULT_TURN_TIMEOUT_MINUTES = 60;
|
|
20
|
+
/** The inactivity window in minutes when `workflow.turn_idle_minutes` is absent. */
|
|
21
|
+
export const DEFAULT_TURN_IDLE_MINUTES = 10;
|
|
22
|
+
/** The hard limit in minutes when `workflow.turn_max_minutes` is absent. */
|
|
23
|
+
export const DEFAULT_TURN_MAX_MINUTES = 120;
|
|
18
24
|
/** The test agent: runs `script` instead of a coding agent. Only accepted when GDT_TEST_AGENTS=1. */
|
|
19
25
|
export const TEST_AGENT = "fake";
|
|
20
26
|
/** The OS home directory, or `env.HOME` when it names one (AC-1 of issue #51). */
|
|
@@ -58,7 +64,8 @@ function configSchemaFor(testAgents) {
|
|
|
58
64
|
roles: z
|
|
59
65
|
.strictObject({ developer: role.optional(), tester: role.optional(), reviewer: role.optional() })
|
|
60
66
|
.optional(),
|
|
61
|
-
workflow: z
|
|
67
|
+
workflow: z
|
|
68
|
+
.strictObject({
|
|
62
69
|
max_correction_rounds: z.int().min(0).default(2),
|
|
63
70
|
// Deliberately without a default: an empty gate must be an explicit choice.
|
|
64
71
|
required_checks: z.array(z.string().min(1)),
|
|
@@ -69,6 +76,21 @@ function configSchemaFor(testAgents) {
|
|
|
69
76
|
herdr_layout: z.enum(HERDR_LAYOUTS).default("tabs"),
|
|
70
77
|
poll_seconds: z.number().positive().default(30),
|
|
71
78
|
handoff_checks: z.int().min(1).default(5),
|
|
79
|
+
// AC-4: a positive number of minutes per turn, 60 by default; zero or negative is invalid.
|
|
80
|
+
turn_timeout_minutes: z.number().positive().default(DEFAULT_TURN_TIMEOUT_MINUTES),
|
|
81
|
+
// A turn past its deadline keeps running while it was active within this many minutes.
|
|
82
|
+
turn_idle_minutes: z.number().positive().default(DEFAULT_TURN_IDLE_MINUTES),
|
|
83
|
+
// A turn still active this long after its dispatch asks the user (`gdt extend` or `gdt retry`).
|
|
84
|
+
turn_max_minutes: z.number().positive().default(DEFAULT_TURN_MAX_MINUTES),
|
|
85
|
+
})
|
|
86
|
+
.superRefine((workflow, ctx) => {
|
|
87
|
+
if (workflow.turn_max_minutes < workflow.turn_timeout_minutes) {
|
|
88
|
+
ctx.addIssue({
|
|
89
|
+
code: "custom",
|
|
90
|
+
path: ["turn_max_minutes"],
|
|
91
|
+
message: `must be at least workflow.turn_timeout_minutes (${workflow.turn_timeout_minutes})`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
72
94
|
}),
|
|
73
95
|
contract: z
|
|
74
96
|
.strictObject({
|
package/dist/prompts.js
CHANGED
|
@@ -37,6 +37,16 @@ export function pendingDirectives(records, trustedAuthors, role, afterCommentId)
|
|
|
37
37
|
.filter((r) => trustedAuthors.includes(r.author) && r.data.role === role && r.comment_id > afterCommentId)
|
|
38
38
|
.map((r) => ({ comment_id: r.comment_id, directive: r.data.directive }));
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* The record schema as embedded in a prompt. `z.toJSONSchema` starts with a `"$schema"` key and
|
|
42
|
+
* agents copy it into their record, where the strict schema rejects it; the key is dropped from the
|
|
43
|
+
* prompt (AC-3).
|
|
44
|
+
*/
|
|
45
|
+
function recordSchema(kind) {
|
|
46
|
+
const schema = { ...z.toJSONSchema(schemas[kind]) };
|
|
47
|
+
delete schema["$schema"];
|
|
48
|
+
return JSON.stringify(schema, null, 2);
|
|
49
|
+
}
|
|
40
50
|
function schemaSection(kind) {
|
|
41
51
|
return [
|
|
42
52
|
`## Protocol: ${marker(kind)}`,
|
|
@@ -44,8 +54,10 @@ function schemaSection(kind) {
|
|
|
44
54
|
`Close the record with ${marker(kind, true)}. The JSON object must match this JSON Schema:`,
|
|
45
55
|
"",
|
|
46
56
|
"```json",
|
|
47
|
-
|
|
57
|
+
recordSchema(kind),
|
|
48
58
|
"```",
|
|
59
|
+
"",
|
|
60
|
+
`The record must not contain a \`"$schema"\` key.`,
|
|
49
61
|
].join("\n");
|
|
50
62
|
}
|
|
51
63
|
/** The prompt for one turn: role file, dispatch facts, language, protocol, directives and project rules. */
|
|
@@ -77,6 +89,16 @@ export function buildPrompt(role, dispatch, project) {
|
|
|
77
89
|
`Acceptance-criterion fields: ${f.given}, ${f.when}, ${f.then}, ${f.example}`,
|
|
78
90
|
`"None" marker: ${locale.markers.none}`,
|
|
79
91
|
].join("\n"));
|
|
92
|
+
// AC-2: the retried turn is told which record was rejected and why, so it can post a fixed one.
|
|
93
|
+
if (dispatch.invalid_record !== undefined) {
|
|
94
|
+
const rejected = dispatch.invalid_record;
|
|
95
|
+
parts.push([
|
|
96
|
+
`## Invalid record in comment ${rejected.comment_id}`,
|
|
97
|
+
"",
|
|
98
|
+
`Your previous ${marker(rejected.kind)} record in comment ${rejected.comment_id} was rejected: ${rejected.reason}`,
|
|
99
|
+
`Post a new, complete record ${marker(rejected.kind)} that matches the schema exactly.`,
|
|
100
|
+
].join("\n"));
|
|
101
|
+
}
|
|
80
102
|
parts.push(schemaSection(RECORD_OF[role]), schemaSection("question"));
|
|
81
103
|
if (dispatch.directives.length > 0) {
|
|
82
104
|
parts.push([
|
package/dist/protocol.js
CHANGED
|
@@ -63,6 +63,14 @@ export const schemas = {
|
|
|
63
63
|
export function marker(kind, closing = false) {
|
|
64
64
|
return `[${closing ? "/" : ""}gdt-${kind}:v1]`;
|
|
65
65
|
}
|
|
66
|
+
/** AC-1: the blocked reason for a turn whose role posted only an invalid record. */
|
|
67
|
+
export function invalidRecordReason(role, diagnostic) {
|
|
68
|
+
return `${role} posted an invalid record ${marker(diagnostic.kind)} in comment ${diagnostic.comment_id}: ${diagnostic.reason}`;
|
|
69
|
+
}
|
|
70
|
+
/** True when a blocked reason reports an invalid record, so `gdt retry` can lift it (AC-1). */
|
|
71
|
+
export function isInvalidRecordReason(reason) {
|
|
72
|
+
return reason.includes(" posted an invalid record ");
|
|
73
|
+
}
|
|
66
74
|
/** The comment body carrying one record: opening marker, JSON object and closing marker. */
|
|
67
75
|
export function formatRecord(kind, data) {
|
|
68
76
|
return `${marker(kind)}\n${JSON.stringify(data, null, 2)}\n${marker(kind, true)}\n`;
|
|
@@ -93,12 +101,12 @@ export function parseRecords(comments) {
|
|
|
93
101
|
json = JSON.parse(unfence(match[2] ?? ""));
|
|
94
102
|
}
|
|
95
103
|
catch {
|
|
96
|
-
diagnostics.push({ comment_id: comment.id, kind, reason: "invalid JSON" });
|
|
104
|
+
diagnostics.push({ comment_id: comment.id, author: comment.author, kind, reason: "invalid JSON" });
|
|
97
105
|
continue;
|
|
98
106
|
}
|
|
99
107
|
const parsed = schemas[kind].safeParse(json);
|
|
100
108
|
if (!parsed.success) {
|
|
101
|
-
diagnostics.push({ comment_id: comment.id, kind, reason: schemaReason(parsed.error) });
|
|
109
|
+
diagnostics.push({ comment_id: comment.id, author: comment.author, kind, reason: schemaReason(parsed.error) });
|
|
102
110
|
continue;
|
|
103
111
|
}
|
|
104
112
|
records.push({
|
package/dist/state.js
CHANGED
|
@@ -31,6 +31,7 @@ export function paths(root, issue, env) {
|
|
|
31
31
|
lock: join(dir, "supervisor.lock"),
|
|
32
32
|
pause: join(dir, "paused"),
|
|
33
33
|
stopping: join(dir, "stopping"),
|
|
34
|
+
extend: join(dir, "extend.json"),
|
|
34
35
|
overrides: join(dir, "overrides.json"),
|
|
35
36
|
logs: join(dir, "logs"),
|
|
36
37
|
panes: join(dir, "panes.json"),
|
|
@@ -39,6 +40,7 @@ export function paths(root, issue, env) {
|
|
|
39
40
|
started: (key) => join(dir, "runs", `${key}.started`),
|
|
40
41
|
result: (key) => join(dir, "runs", `${key}.result.json`),
|
|
41
42
|
prompt: (key) => join(dir, "runs", `${key}.prompt.md`),
|
|
43
|
+
agent: (key) => join(dir, "runs", `${key}.agent`),
|
|
42
44
|
};
|
|
43
45
|
}
|
|
44
46
|
/** A log line timestamp in local time, `YYYY-MM-DD hh:mm:ss`. */
|
package/dist/supervisor.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { existsSync, rmSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
3
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { formatSignals, groupUsage, nextBaseline, opencodeDatabase, opencodeMtime, sample, signals, } from "./activity.js";
|
|
4
7
|
import { backendFor } from "./backends/index.js";
|
|
5
8
|
import { loadConfig, ROLES } from "./config.js";
|
|
6
9
|
import { sectionText, validateContract } from "./contract.js";
|
|
@@ -9,8 +12,8 @@ import { comments, issueSnapshot, pullRequest, repository, viewer } from "./gith
|
|
|
9
12
|
import { loadLocale } from "./locale.js";
|
|
10
13
|
import { notify } from "./notify.js";
|
|
11
14
|
import { pendingDirectives } from "./prompts.js";
|
|
12
|
-
import { parseRecords } from "./protocol.js";
|
|
13
|
-
import { acquireLock, logTimestamp, NOTIFY_STATUSES, paths, readJson, readState, writeJsonAtomic, writeState, } from "./state.js";
|
|
15
|
+
import { invalidRecordReason, parseRecords } from "./protocol.js";
|
|
16
|
+
import { acquireLock, logTimestamp, NOTIFY_STATUSES, paths, readJson, readOverrides, readState, writeJsonAtomic, writeState, } from "./state.js";
|
|
14
17
|
/** AC-4: the herdr agent state reported for each role pane state. */
|
|
15
18
|
const ROLE_PANE_STATE = {
|
|
16
19
|
RUNNING: "working",
|
|
@@ -32,6 +35,51 @@ export function cliPath() {
|
|
|
32
35
|
export function sha256(text) {
|
|
33
36
|
return createHash("sha256").update(text).digest("hex");
|
|
34
37
|
}
|
|
38
|
+
/** AC-1: the running reason of a turn past its deadline that is still active. */
|
|
39
|
+
export function activeReason(role, limit) {
|
|
40
|
+
return `${role} turn past the ${limit}-minute limit, still active`;
|
|
41
|
+
}
|
|
42
|
+
/** AC-2: the blocked reason of a turn stopped because it was inactive after its deadline. */
|
|
43
|
+
export function inactiveReason(role, idle, limit) {
|
|
44
|
+
return `${role} turn inactive for ${idle} minutes after the ${limit}-minute limit`;
|
|
45
|
+
}
|
|
46
|
+
/** AC-4: the blocked reason of a turn that is still active at its hard limit. */
|
|
47
|
+
export function hardLimitReason(role, minutes) {
|
|
48
|
+
return `${role} turn still active after ${minutes} minutes`;
|
|
49
|
+
}
|
|
50
|
+
/** #71 AC-4: the blocked reason of a turn whose agent exited and stayed active until its hard limit. */
|
|
51
|
+
export function exitedActiveReason(role, minutes) {
|
|
52
|
+
return `${role} exited without a visible handoff and stayed active until the ${minutes}-minute hard limit`;
|
|
53
|
+
}
|
|
54
|
+
/** AC-6: the blocked reason of a turn whose agent and worker are gone without a result. */
|
|
55
|
+
export function exitedReason(role) {
|
|
56
|
+
return `${role} agent exited without a result`;
|
|
57
|
+
}
|
|
58
|
+
/** The deadline of a turn dispatched at `dispatchedAt`, with `minutes` as the configured limit. */
|
|
59
|
+
export function turnDeadline(dispatchedAt, minutes) {
|
|
60
|
+
return new Date(new Date(dispatchedAt).getTime() + minutes * 60_000);
|
|
61
|
+
}
|
|
62
|
+
/** AC-8: the turn's last activity: `dispatched_at` until an activity signal fires. */
|
|
63
|
+
export function turnLastActivity(inflight) {
|
|
64
|
+
return inflight.last_activity ?? inflight.dispatched_at;
|
|
65
|
+
}
|
|
66
|
+
/** AC-8: the turn's hard limit: `dispatched_at + turn_max_minutes` until `gdt extend` moves it. */
|
|
67
|
+
export function turnHardLimit(inflight, maxMinutes) {
|
|
68
|
+
return inflight.hard_limit === undefined ? turnDeadline(inflight.dispatched_at, maxMinutes) : new Date(inflight.hard_limit);
|
|
69
|
+
}
|
|
70
|
+
/** The turn's hard limit in minutes after dispatch, rounded to two decimals for a reason text. */
|
|
71
|
+
function hardLimitMinutes(inflight, hardLimit) {
|
|
72
|
+
return Math.round(((hardLimit.getTime() - Date.parse(inflight.dispatched_at)) / 60_000) * 100) / 100;
|
|
73
|
+
}
|
|
74
|
+
/** AC-2/AC-6: true when a blocked reason is a stopped or exited turn, so `gdt retry` can lift it. */
|
|
75
|
+
export function isTimeoutReason(reason) {
|
|
76
|
+
return /^\w+ turn inactive for [\d.]+ minutes after the [\d.]+-minute limit$/.test(reason) || / agent exited without a result$/.test(reason);
|
|
77
|
+
}
|
|
78
|
+
/** AC-4: true when a blocked reason is a turn still active at its hard limit. */
|
|
79
|
+
export function isHardLimitReason(reason) {
|
|
80
|
+
return /^\w+ turn still active after [\d.]+ minutes$/.test(reason);
|
|
81
|
+
}
|
|
82
|
+
const NO_SIGNALS = { cpu: false, tree: false, log: false, opencode: false };
|
|
35
83
|
/** One key per role, round, head and contract; a resumed question makes the resumed turn distinct. */
|
|
36
84
|
export function dispatchKey(decision, head, bodySha) {
|
|
37
85
|
const parts = [decision.role, `r${decision.round}`, head ?? "no-pr", bodySha];
|
|
@@ -53,6 +101,18 @@ function roleOf(record) {
|
|
|
53
101
|
return null;
|
|
54
102
|
}
|
|
55
103
|
}
|
|
104
|
+
/** The record kind each workflow role writes; a diagnostic of another kind is never that role's. */
|
|
105
|
+
const ROLE_OF_KIND = { handoff: "developer", test: "tester", review: "reviewer" };
|
|
106
|
+
/** AC-1: the newest invalid role record the role posted after the turn's `after_comment_id`. */
|
|
107
|
+
function invalidRecordFor(inflight, diagnostics, trusted) {
|
|
108
|
+
return diagnostics
|
|
109
|
+
.filter((d) => ROLE_OF_KIND[d.kind] === inflight.role && trusted.includes(d.author) && d.comment_id > inflight.after_comment_id)
|
|
110
|
+
// Newest comment wins when the role posted several invalid records.
|
|
111
|
+
.sort((a, b) => b.comment_id - a.comment_id)[0];
|
|
112
|
+
}
|
|
113
|
+
function sleepSync(ms) {
|
|
114
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
115
|
+
}
|
|
56
116
|
function sleep(ms) {
|
|
57
117
|
return new Promise((done) => setTimeout(done, ms));
|
|
58
118
|
}
|
|
@@ -107,6 +167,16 @@ class Supervisor {
|
|
|
107
167
|
this.backend.setTitle(role, this.roleTitle(role, state));
|
|
108
168
|
this.reportState(role, ROLE_PANE_STATE[state]);
|
|
109
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* AC-1: ends a role's worker, which stops its agent process group on SIGTERM. Its pid is dropped so
|
|
172
|
+
* a later `gdt retry` does not try to stop an already-dead process.
|
|
173
|
+
*/
|
|
174
|
+
stopRole(role) {
|
|
175
|
+
const pid = this.state.pids.workers[role];
|
|
176
|
+
if (pid !== undefined && this.backend.alive(pid))
|
|
177
|
+
this.backend.close(pid);
|
|
178
|
+
this.state.pids.workers = Object.fromEntries(Object.entries(this.state.pids.workers).filter(([name]) => name !== role));
|
|
179
|
+
}
|
|
110
180
|
/** AC-1: sets the display-only agent label, for example `developer · opencode` or `gdt · supervisor`. */
|
|
111
181
|
setDisplay(name, label) {
|
|
112
182
|
try {
|
|
@@ -119,6 +189,21 @@ class Supervisor {
|
|
|
119
189
|
log(`warning: herdr display label failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
120
190
|
}
|
|
121
191
|
}
|
|
192
|
+
/** The agent of a role's next turn: its `gdt set-agent` override, otherwise the config agent. */
|
|
193
|
+
effectiveAgent(role) {
|
|
194
|
+
return readOverrides(this.p)[role]?.agent ?? this.agents[role];
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* #68 AC-1/AC-2: sets a role label naming the effective agent. A `gdt set-agent` that writes its
|
|
198
|
+
* override and label between our read and our write would be overwritten, so the label is written
|
|
199
|
+
* again until the override read after writing it matches.
|
|
200
|
+
*/
|
|
201
|
+
setRoleDisplay(role) {
|
|
202
|
+
for (let agent = this.effectiveAgent(role), written = ""; agent !== written; agent = this.effectiveAgent(role)) {
|
|
203
|
+
this.setDisplay(role, `${role} · ${agent}`);
|
|
204
|
+
written = agent;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
122
207
|
/** Records a status; notifies once when entering a notifying status. */
|
|
123
208
|
setStatus(status, reason, extra = {}) {
|
|
124
209
|
if (this.state.status !== status || this.state.reason !== reason)
|
|
@@ -138,15 +223,188 @@ class Supervisor {
|
|
|
138
223
|
}
|
|
139
224
|
this.save();
|
|
140
225
|
}
|
|
226
|
+
/** The agent process group id the worker recorded for the turn, or null before the agent started. */
|
|
227
|
+
agentGroup(key) {
|
|
228
|
+
try {
|
|
229
|
+
const pid = Number(readFileSync(this.p.agent(key), "utf8").trim());
|
|
230
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** AC-2: ends what is left of the agent process group once its worker is stopped. */
|
|
237
|
+
stopGroup(pgid) {
|
|
238
|
+
if (pgid === null)
|
|
239
|
+
return;
|
|
240
|
+
for (const signal of ["SIGTERM", "SIGKILL"]) {
|
|
241
|
+
if (groupUsage(pgid, this.env).processes === 0)
|
|
242
|
+
return;
|
|
243
|
+
try {
|
|
244
|
+
process.kill(-pgid, signal);
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
for (let waited = 0; waited < 2000 && groupUsage(pgid, this.env).processes !== 0; waited += 100)
|
|
250
|
+
sleepSync(100);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** The opencode session database when the role's agent is opencode, otherwise null. */
|
|
254
|
+
opencodeDb(role) {
|
|
255
|
+
if (this.effectiveAgent(role) !== "opencode")
|
|
256
|
+
return null;
|
|
257
|
+
const home = this.env.HOME === undefined || this.env.HOME === "" ? homedir() : this.env.HOME;
|
|
258
|
+
return opencodeDatabase(this.env, home);
|
|
259
|
+
}
|
|
260
|
+
/** The activity sample of an in-flight turn (issue Definitions). */
|
|
261
|
+
sampleTurn(running, pgid) {
|
|
262
|
+
return sample({
|
|
263
|
+
root: this.p.root,
|
|
264
|
+
env: this.env,
|
|
265
|
+
pgid,
|
|
266
|
+
logFile: this.config.workflow.terminal === "headless" ? join(this.p.logs, `${running.role}.log`) : null,
|
|
267
|
+
opencodeDb: this.opencodeDb(running.role),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* #71 post-exit activity: true when the opencode database changed since the previous handoff check,
|
|
272
|
+
* or since the agent exited on the first check. Never fires for other agents or a stopped turn.
|
|
273
|
+
*/
|
|
274
|
+
postExitActivity(inflight, finishedAt) {
|
|
275
|
+
const database = inflight.stopped === true ? null : this.opencodeDb(inflight.role);
|
|
276
|
+
if (database === null)
|
|
277
|
+
return false;
|
|
278
|
+
const current = opencodeMtime(database);
|
|
279
|
+
const previous = inflight.handoff_opencode ?? (finishedAt === undefined ? undefined : Date.parse(finishedAt));
|
|
280
|
+
if (current !== null)
|
|
281
|
+
inflight.handoff_opencode = current;
|
|
282
|
+
return current !== null && previous !== undefined && current > previous;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* AC-5: applies a hard limit granted by `gdt extend` for this turn. Returns true when a request was
|
|
286
|
+
* read; the caller removes it only after saving the poll's status, so `gdt status` never sees the
|
|
287
|
+
* turn blocked again in between.
|
|
288
|
+
*/
|
|
289
|
+
applyExtension(running) {
|
|
290
|
+
const extension = readJson(this.p.extend);
|
|
291
|
+
if (extension === null)
|
|
292
|
+
return false;
|
|
293
|
+
if (extension.key === running.key) {
|
|
294
|
+
running.hard_limit = extension.hard_limit;
|
|
295
|
+
running.at_hard_limit = false;
|
|
296
|
+
log(`${running.role} turn hard limit extended to ${extension.hard_limit}`);
|
|
297
|
+
}
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
/** Blocks the workflow on a stopped or exited turn; the block stays until `gdt retry`. */
|
|
301
|
+
blockStopped(running) {
|
|
302
|
+
this.setRoleState(running.role, "FAILED");
|
|
303
|
+
this.setStatus("blocked", running.stop_reason ?? exitedReason(running.role), { role: running.role, round: running.round });
|
|
304
|
+
return "continue";
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Checks a result-less in-flight turn on every poll, before any GitHub fetch, so a failing `gh`
|
|
308
|
+
* call never postpones it. It samples the turn's activity and moves its last activity when a
|
|
309
|
+
* signal fires. AC-6: a turn whose worker and agent are gone on two consecutive polls blocks at
|
|
310
|
+
* once. Past the deadline, AC-1: an active turn keeps running; AC-2: an inactive one is stopped
|
|
311
|
+
* and blocked; AC-4: an active one at its hard limit blocks without being stopped. A result file
|
|
312
|
+
* ends the turn, also at the hard limit; a stopped turn stays blocked even if a result appears.
|
|
313
|
+
* With `startup`, the worker was not started yet, so a missing worker is not an exited agent.
|
|
314
|
+
* Returns "continue" when the poll ends here, or null to let `tick` go on.
|
|
315
|
+
*/
|
|
316
|
+
settleTurn(now, startup = false) {
|
|
317
|
+
const running = this.state.inflight;
|
|
318
|
+
if (running === null || running.violation !== undefined || running.missing)
|
|
319
|
+
return null;
|
|
320
|
+
if (running.stop_reason !== undefined)
|
|
321
|
+
return this.blockStopped(running);
|
|
322
|
+
if (readJson(this.p.result(running.key)) !== null)
|
|
323
|
+
return null;
|
|
324
|
+
const extended = this.applyExtension(running);
|
|
325
|
+
const outcome = this.checkActivity(running, now, startup);
|
|
326
|
+
if (extended) {
|
|
327
|
+
this.save();
|
|
328
|
+
rmSync(this.p.extend, { force: true });
|
|
329
|
+
}
|
|
330
|
+
return outcome;
|
|
331
|
+
}
|
|
332
|
+
/** The activity part of `settleTurn` for a result-less in-flight turn. */
|
|
333
|
+
checkActivity(running, now, startup) {
|
|
334
|
+
const workflow = this.config.workflow;
|
|
335
|
+
const pgid = this.agentGroup(running.key);
|
|
336
|
+
const current = this.sampleTurn(running, pgid);
|
|
337
|
+
// On startup the agent of the previous run is gone and gdt's own stop line grew the log, so the
|
|
338
|
+
// sample only becomes the new baseline: the kept last activity gets no fresh window.
|
|
339
|
+
const fired = startup ? NO_SIGNALS : signals(current, running.activity);
|
|
340
|
+
const active = fired.cpu || fired.tree || fired.log || fired.opencode;
|
|
341
|
+
running.activity = nextBaseline(current, startup ? undefined : running.activity, startup || active);
|
|
342
|
+
if (active)
|
|
343
|
+
running.last_activity = now.toISOString();
|
|
344
|
+
// AC-6: an exited agent needs two consecutive polls, so a single unlucky sample never blocks a turn.
|
|
345
|
+
const workerAlive = this.backend.alive(this.state.pids.workers[running.role] ?? -1);
|
|
346
|
+
running.exited_polls = !startup && !workerAlive && current.processes === 0 ? (running.exited_polls ?? 0) + 1 : 0;
|
|
347
|
+
if (running.exited_polls >= 2) {
|
|
348
|
+
running.stop_reason = exitedReason(running.role);
|
|
349
|
+
this.stopRole(running.role);
|
|
350
|
+
log(`${running.role} agent exited without a result`);
|
|
351
|
+
return this.blockStopped(running);
|
|
352
|
+
}
|
|
353
|
+
const limit = workflow.turn_timeout_minutes;
|
|
354
|
+
if (now.getTime() < turnDeadline(running.dispatched_at, limit).getTime())
|
|
355
|
+
return null;
|
|
356
|
+
const lastActivity = turnLastActivity(running);
|
|
357
|
+
// AC-1/AC-3: one line per poll past the deadline, naming every signal and whether it fired.
|
|
358
|
+
log(`activity check for ${running.key}: ${formatSignals(fired)}; last activity ${lastActivity}`);
|
|
359
|
+
const context = { role: running.role, round: running.round };
|
|
360
|
+
const hardLimit = turnHardLimit(running, workflow.turn_max_minutes);
|
|
361
|
+
const hardMinutes = hardLimitMinutes(running, hardLimit);
|
|
362
|
+
if (running.at_hard_limit === true) {
|
|
363
|
+
// AC-4: the turn keeps running until `gdt extend`, `gdt retry` or its result.
|
|
364
|
+
this.setStatus("blocked", hardLimitReason(running.role, hardMinutes), context);
|
|
365
|
+
return "continue";
|
|
366
|
+
}
|
|
367
|
+
if (now.getTime() - Date.parse(lastActivity) >= workflow.turn_idle_minutes * 60_000) {
|
|
368
|
+
running.timed_out = true;
|
|
369
|
+
running.stop_reason = inactiveReason(running.role, workflow.turn_idle_minutes, limit);
|
|
370
|
+
this.stopRole(running.role);
|
|
371
|
+
this.stopGroup(pgid);
|
|
372
|
+
log(`${running.stop_reason}; worker and agent stopped`);
|
|
373
|
+
return this.blockStopped(running);
|
|
374
|
+
}
|
|
375
|
+
if (now.getTime() >= hardLimit.getTime()) {
|
|
376
|
+
running.at_hard_limit = true;
|
|
377
|
+
this.setStatus("blocked", hardLimitReason(running.role, hardMinutes), context);
|
|
378
|
+
return "continue";
|
|
379
|
+
}
|
|
380
|
+
this.setStatus("running", activeReason(running.role, limit), context);
|
|
381
|
+
return "continue";
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Settles an in-flight turn before replacement workers start. A replacement worker that finds the
|
|
385
|
+
* turn's `started` marker writes an interruption result (exit code null), which would otherwise
|
|
386
|
+
* turn an inactive overdue turn into `failed` instead of the retryable `blocked`. Returns the
|
|
387
|
+
* stopped role, so `startWorkers` keeps its FAILED pane and starts no worker for it.
|
|
388
|
+
*/
|
|
389
|
+
settleOverdueTurn(now) {
|
|
390
|
+
this.settleTurn(now, true);
|
|
391
|
+
const running = this.state.inflight;
|
|
392
|
+
return running?.stop_reason === undefined ? null : running.role;
|
|
393
|
+
}
|
|
141
394
|
startWorkers() {
|
|
142
395
|
this.backend.ensureWorkspace();
|
|
143
396
|
// AC-1: the agents overview shows the role next to the agent name, once per `gdt start`.
|
|
144
397
|
this.setDisplay("supervisor", "gdt · supervisor");
|
|
145
398
|
this.backend.setTitle("supervisor", "supervisor · starting");
|
|
146
399
|
this.reportState("supervisor", supervisorAgentState(this.state.status));
|
|
400
|
+
// AC-1: settle an overdue in-flight turn before its replacement worker can write a result.
|
|
401
|
+
const settled = this.settleOverdueTurn(new Date());
|
|
147
402
|
for (const role of ROLES) {
|
|
403
|
+
// A settled role keeps its FAILED pane and gets no worker until `gdt retry` and `gdt start`.
|
|
404
|
+
if (role === settled)
|
|
405
|
+
continue;
|
|
148
406
|
this.setRoleState(role, "WAITING");
|
|
149
|
-
this.
|
|
407
|
+
this.setRoleDisplay(role);
|
|
150
408
|
if (this.backend.alive(this.state.pids.workers[role] ?? -1))
|
|
151
409
|
continue;
|
|
152
410
|
this.state.pids.workers[role] = this.backend.spawnPane(role, [process.execPath, cliPath(), "_worker", String(this.issue), role]);
|
|
@@ -164,6 +422,10 @@ class Supervisor {
|
|
|
164
422
|
this.setStatus("paused", "", { role: null });
|
|
165
423
|
return "continue";
|
|
166
424
|
}
|
|
425
|
+
// The turn check runs before any GitHub fetch, so a failing `gh` call cannot postpone it and
|
|
426
|
+
// leave a hung turn `running` forever.
|
|
427
|
+
if (this.settleTurn(now) !== null)
|
|
428
|
+
return "continue";
|
|
167
429
|
const { body, pullRequests } = issueSnapshot(this.issue, this.p.root, this.env);
|
|
168
430
|
const bodySha = sha256(body);
|
|
169
431
|
const changelog = sectionText(body, this.locale.sections.changelog) ?? "";
|
|
@@ -186,39 +448,46 @@ class Supervisor {
|
|
|
186
448
|
}
|
|
187
449
|
// A turn in flight: nothing to fetch until its worker has reported.
|
|
188
450
|
const running = this.state.inflight;
|
|
451
|
+
let finishedAt;
|
|
189
452
|
if (running !== null) {
|
|
190
453
|
if (running.violation !== undefined) {
|
|
191
454
|
this.setRoleState(running.role, "FAILED");
|
|
192
455
|
this.setStatus("blocked", running.violation, { role: running.role, round: running.round });
|
|
193
456
|
return "continue";
|
|
194
457
|
}
|
|
195
|
-
if (running.missing) {
|
|
196
|
-
//
|
|
197
|
-
this.setStatus("blocked", `${running.role} finished without a visible handoff`, { role: running.role, round: running.round });
|
|
198
|
-
return "continue";
|
|
199
|
-
}
|
|
200
|
-
const result = readJson(this.p.result(running.key));
|
|
201
|
-
if (result === null) {
|
|
202
|
-
this.setStatus("running", `${running.role} turn in progress`, { role: running.role, round: running.round });
|
|
458
|
+
if (running.missing && running.stopped === true) {
|
|
459
|
+
// #71 Out of scope: a stopped turn stays blocked until a retry, whatever record arrives later.
|
|
460
|
+
this.setStatus("blocked", running.missing_reason ?? `${running.role} finished without a visible handoff`, { role: running.role, round: running.round });
|
|
203
461
|
return "continue";
|
|
204
462
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
this.
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
463
|
+
// #71 AC-5: a turn blocked on a missing handoff goes on to the fetch, so a late record lifts the block.
|
|
464
|
+
if (!running.missing) {
|
|
465
|
+
// A turn past its deadline was already settled at the top of this tick, before the GitHub
|
|
466
|
+
// fetch. A result-less turn that reaches this point is still before its deadline.
|
|
467
|
+
const result = readJson(this.p.result(running.key));
|
|
468
|
+
if (result === null) {
|
|
469
|
+
this.setStatus("running", `${running.role} turn in progress`, { role: running.role, round: running.round });
|
|
470
|
+
return "continue";
|
|
471
|
+
}
|
|
472
|
+
if (result.violation !== undefined) {
|
|
473
|
+
// A broken role boundary outranks the exit code: the checkout can no longer be trusted.
|
|
474
|
+
running.violation = result.violation;
|
|
475
|
+
this.setRoleState(running.role, "FAILED");
|
|
476
|
+
this.setStatus("blocked", result.violation, { role: running.role, round: running.round, exit_code: result.exit_code });
|
|
477
|
+
return "continue";
|
|
478
|
+
}
|
|
479
|
+
if (result.exit_code !== 0) {
|
|
480
|
+
const reason = result.exit_code === null
|
|
481
|
+
? `${running.role} turn was interrupted`
|
|
482
|
+
: `${running.role} turn failed (exit code ${result.exit_code})`;
|
|
483
|
+
this.setRoleState(running.role, "FAILED");
|
|
484
|
+
this.setStatus("failed", reason, { role: running.role, round: running.round, exit_code: result.exit_code });
|
|
485
|
+
return "exit";
|
|
486
|
+
}
|
|
487
|
+
// AC-5: the turn finished successfully.
|
|
488
|
+
finishedAt = result.finished_at;
|
|
489
|
+
this.setRoleState(running.role, "DONE");
|
|
219
490
|
}
|
|
220
|
-
// AC-5: the turn finished successfully.
|
|
221
|
-
this.setRoleState(running.role, "DONE");
|
|
222
491
|
}
|
|
223
492
|
if (pullRequests.length > 1) {
|
|
224
493
|
this.setStatus("blocked", `more than one open pull request closes #${this.issue}: ${pullRequests.map((n) => `#${n}`).join(", ")}`);
|
|
@@ -237,24 +506,61 @@ class Supervisor {
|
|
|
237
506
|
const thread = [...comments(this.state.repository, this.issue, this.p.root, this.env)];
|
|
238
507
|
if (pr !== null)
|
|
239
508
|
thread.push(...comments(this.state.repository, pr.number, this.p.root, this.env));
|
|
240
|
-
const { records } = parseRecords(thread);
|
|
509
|
+
const { records, diagnostics } = parseRecords(thread);
|
|
241
510
|
const trusted = records.filter((r) => this.trusted.includes(r.author));
|
|
242
511
|
const inflight = this.state.inflight;
|
|
243
512
|
if (inflight !== null) {
|
|
244
513
|
const visible = trusted.some((r) => roleOf(r) === inflight.role && r.comment_id > inflight.after_comment_id);
|
|
514
|
+
const context = { role: inflight.role, round: inflight.round };
|
|
515
|
+
if (!visible && inflight.missing) {
|
|
516
|
+
// AC-1: an invalid record keeps its reason.
|
|
517
|
+
this.setStatus("blocked", inflight.missing_reason ?? `${inflight.role} finished without a visible handoff`, context);
|
|
518
|
+
return "continue";
|
|
519
|
+
}
|
|
245
520
|
if (!visible) {
|
|
521
|
+
// AC-1: an invalid record is named with its validation error instead of `not visible`;
|
|
522
|
+
// AC-4: without any role record the message and log line stay as before.
|
|
523
|
+
const invalid = invalidRecordFor(inflight, diagnostics, this.trusted);
|
|
524
|
+
const seen = invalid === undefined ? "not visible" : invalid.reason;
|
|
525
|
+
const retryRecord = (extra) =>
|
|
526
|
+
// AC-2: `gdt retry` keeps this record; the role's next turn reads it into its prompt.
|
|
527
|
+
invalid === undefined
|
|
528
|
+
? extra
|
|
529
|
+
: { ...extra, retry_record: { role: inflight.role, kind: invalid.kind, comment_id: invalid.comment_id, reason: invalid.reason } };
|
|
530
|
+
// #71 AC-2: a check with post-exit activity is not a missed check.
|
|
531
|
+
if (this.postExitActivity(inflight, finishedAt)) {
|
|
532
|
+
log(`handoff check for ${inflight.key}: ${seen}, agent still active (opencode=yes)`);
|
|
533
|
+
const hardLimit = turnHardLimit(inflight, this.config.workflow.turn_max_minutes);
|
|
534
|
+
if (now.getTime() >= hardLimit.getTime()) {
|
|
535
|
+
// #71 AC-4: the hard limit bounds the wait, also when unrelated opencode use keeps it active.
|
|
536
|
+
inflight.missing = true;
|
|
537
|
+
inflight.missing_reason = exitedActiveReason(inflight.role, hardLimitMinutes(inflight, hardLimit));
|
|
538
|
+
this.setStatus("blocked", inflight.missing_reason, retryRecord(context));
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
this.setStatus("running", `waiting for the ${inflight.role} handoff; agent exited but is still active`, context);
|
|
542
|
+
}
|
|
543
|
+
return "continue";
|
|
544
|
+
}
|
|
246
545
|
inflight.checks += 1;
|
|
247
|
-
log(`handoff check ${inflight.checks}/${this.config.workflow.handoff_checks} for ${inflight.key}:
|
|
546
|
+
log(`handoff check ${inflight.checks}/${this.config.workflow.handoff_checks} for ${inflight.key}: ${seen}`);
|
|
248
547
|
if (inflight.checks >= this.config.workflow.handoff_checks) {
|
|
249
548
|
inflight.missing = true;
|
|
250
|
-
|
|
549
|
+
inflight.missing_reason = invalid === undefined ? `${inflight.role} finished without a visible handoff` : invalidRecordReason(inflight.role, invalid);
|
|
550
|
+
this.setStatus("blocked", inflight.missing_reason, retryRecord(context));
|
|
251
551
|
}
|
|
252
552
|
else {
|
|
253
|
-
this.setStatus("running", `waiting for the ${inflight.role} handoff to become visible`,
|
|
553
|
+
this.setStatus("running", `waiting for the ${inflight.role} handoff to become visible`, context);
|
|
254
554
|
}
|
|
255
555
|
return "continue";
|
|
256
556
|
}
|
|
557
|
+
// #71 AC-5: a late record lifts the block as if it had arrived in time.
|
|
558
|
+
if (inflight.missing)
|
|
559
|
+
log(`late handoff for ${inflight.key} accepted`);
|
|
257
560
|
this.state.inflight = null;
|
|
561
|
+
// AC-2: the role's rejection explanation is spent once a valid record is visible.
|
|
562
|
+
if (this.state.retry_record?.role === inflight.role)
|
|
563
|
+
this.state.retry_record = null;
|
|
258
564
|
}
|
|
259
565
|
const snapshot = {
|
|
260
566
|
repository: this.state.repository,
|
package/dist/worker.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { closeSync, existsSync, mkdirSync, openSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
import { renderClaudeLine } from "./agents/claude-stream.js";
|
|
4
6
|
import { adapterFor } from "./agents/index.js";
|
|
5
7
|
import { loadConfig, TEST_AGENT, testAgentsEnabled } from "./config.js";
|
|
6
8
|
import { changedFiles, checkout } from "./git.js";
|
|
@@ -77,7 +79,7 @@ export function boundaryViolation(role, before, root, env) {
|
|
|
77
79
|
return undefined;
|
|
78
80
|
}
|
|
79
81
|
/** Runs one invocation and passes the agent's exit code through unchanged (null when killed by a signal). */
|
|
80
|
-
export function runInvocation(inv, cwd, env) {
|
|
82
|
+
export function runInvocation(inv, cwd, env, onSpawn) {
|
|
81
83
|
const [command, ...args] = inv.argv;
|
|
82
84
|
return new Promise((done) => {
|
|
83
85
|
let stdin = "ignore";
|
|
@@ -90,18 +92,39 @@ export function runInvocation(inv, cwd, env) {
|
|
|
90
92
|
done(66);
|
|
91
93
|
return;
|
|
92
94
|
}
|
|
93
|
-
|
|
95
|
+
const rendered = inv.output === "claude-stream-json";
|
|
96
|
+
// Its own process group, so a stop can end the agent and the children it started. A rendered
|
|
97
|
+
// stream is read in this process, so the renderer adds no process that could outlive the agent.
|
|
94
98
|
const child = spawn(command ?? "", args, {
|
|
95
99
|
cwd,
|
|
96
100
|
env: { ...env, ...inv.env },
|
|
97
|
-
stdio: [stdin, "inherit", "inherit"],
|
|
101
|
+
stdio: [stdin, rendered ? "pipe" : "inherit", "inherit"],
|
|
98
102
|
detached: true,
|
|
99
103
|
});
|
|
100
104
|
activeAgent = child;
|
|
105
|
+
// The agent's pid is its process group id; the supervisor samples that group's activity.
|
|
106
|
+
if (child.pid !== undefined)
|
|
107
|
+
onSpawn?.(child.pid);
|
|
101
108
|
const close = () => {
|
|
102
109
|
if (typeof stdin === "number")
|
|
103
110
|
closeSync(stdin);
|
|
104
111
|
};
|
|
112
|
+
// With a rendered stream the turn ends once the agent has exited and its last line is written.
|
|
113
|
+
let pending = rendered && child.stdout !== null ? 2 : 1;
|
|
114
|
+
let exitCode = null;
|
|
115
|
+
const settle = () => {
|
|
116
|
+
if (--pending === 0)
|
|
117
|
+
done(exitCode);
|
|
118
|
+
};
|
|
119
|
+
if (rendered && child.stdout !== null) {
|
|
120
|
+
const tty = process.stdout.isTTY === true;
|
|
121
|
+
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
122
|
+
lines.on("line", (line) => {
|
|
123
|
+
for (const out of renderClaudeLine(line, tty))
|
|
124
|
+
process.stdout.write(`${out}\n`);
|
|
125
|
+
});
|
|
126
|
+
lines.on("close", settle);
|
|
127
|
+
}
|
|
105
128
|
child.on("error", (err) => {
|
|
106
129
|
activeAgent = null;
|
|
107
130
|
log(`agent could not start: ${err.message}`);
|
|
@@ -111,7 +134,8 @@ export function runInvocation(inv, cwd, env) {
|
|
|
111
134
|
child.on("close", (code) => {
|
|
112
135
|
activeAgent = null;
|
|
113
136
|
close();
|
|
114
|
-
|
|
137
|
+
exitCode = code;
|
|
138
|
+
settle();
|
|
115
139
|
});
|
|
116
140
|
});
|
|
117
141
|
}
|
|
@@ -142,8 +166,12 @@ async function turn(root, role, dispatch, env, p) {
|
|
|
142
166
|
return finish(78);
|
|
143
167
|
}
|
|
144
168
|
const promptFile = p.prompt(dispatch.key);
|
|
169
|
+
// AC-2: a retried turn is told how its previous record was rejected. Reading the state (rather than
|
|
170
|
+
// the dispatch file) avoids a race with the worker that started before `gdt retry` finished.
|
|
171
|
+
const rejected = readState(p)?.retry_record;
|
|
172
|
+
const promptDispatch = rejected != null && rejected.role === role ? { ...dispatch, invalid_record: rejected } : dispatch;
|
|
145
173
|
try {
|
|
146
|
-
writeFileSync(promptFile, buildPrompt(role,
|
|
174
|
+
writeFileSync(promptFile, buildPrompt(role, promptDispatch, { root, extraRules: report.contract.extra_rules }));
|
|
147
175
|
}
|
|
148
176
|
catch (err) {
|
|
149
177
|
log(`cannot build the prompt: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -173,7 +201,7 @@ async function turn(root, role, dispatch, env, p) {
|
|
|
173
201
|
GDT_DISPATCH_KEY: dispatch.key,
|
|
174
202
|
};
|
|
175
203
|
const before = checkout(root, env);
|
|
176
|
-
const exitCode = await runInvocation(inv, root, turnEnv);
|
|
204
|
+
const exitCode = await runInvocation(inv, root, turnEnv, (pid) => writeFileSync(p.agent(dispatch.key), `${pid}\n`));
|
|
177
205
|
const violation = boundaryViolation(role, before, root, env);
|
|
178
206
|
if (violation !== undefined)
|
|
179
207
|
log(violation);
|
package/dist/workflow.js
CHANGED
|
@@ -3,14 +3,15 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
|
|
|
3
3
|
import { relative } from "node:path";
|
|
4
4
|
import { headless } from "./backends/headless.js";
|
|
5
5
|
import { backendFor } from "./backends/index.js";
|
|
6
|
-
import { loadConfig, ROLES, userConfigPath } from "./config.js";
|
|
6
|
+
import { DEFAULT_TURN_MAX_MINUTES, DEFAULT_TURN_TIMEOUT_MINUTES, loadConfig, ROLES, userConfigPath, } from "./config.js";
|
|
7
7
|
import { validateContract } from "./contract.js";
|
|
8
8
|
import { findRepository, herdrPreflight, unsupportedAgentFindings } from "./doctor.js";
|
|
9
9
|
import { changedFiles } from "./git.js";
|
|
10
10
|
import { issueBody, repository } from "./github.js";
|
|
11
11
|
import { loadLocale } from "./locale.js";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import { isInvalidRecordReason } from "./protocol.js";
|
|
13
|
+
import { alive, lockHolder, paths, readJson, readOverrides, readState, writeJsonAtomic, writeState, } from "./state.js";
|
|
14
|
+
import { activeReason, cliPath, isHardLimitReason, isTimeoutReason, turnDeadline, turnHardLimit, turnLastActivity, } from "./supervisor.js";
|
|
14
15
|
export const ok = (stdout) => ({ code: 0, stdout, stderr: "" });
|
|
15
16
|
export const fail = (stderr) => ({ code: 1, stdout: "", stderr });
|
|
16
17
|
/** Statuses that need a person or end the workflow: `gdt wait` returns when it reaches one. */
|
|
@@ -184,6 +185,9 @@ export function stop(issue, cwd, env) {
|
|
|
184
185
|
stopProcesses(p, before, env);
|
|
185
186
|
// Re-read: the supervisor may have written state until it was stopped.
|
|
186
187
|
const state = readState(p) ?? before;
|
|
188
|
+
// #71 Out of scope: a late record for a stopped turn is never accepted.
|
|
189
|
+
if (state.inflight !== null)
|
|
190
|
+
state.inflight.stopped = true;
|
|
187
191
|
// A failed turn keeps its status and reason; only the recovery step (gdt retry) clears it.
|
|
188
192
|
if (state.status === "failed")
|
|
189
193
|
Object.assign(state, { pids: { supervisor: null, workers: {} } });
|
|
@@ -200,7 +204,12 @@ export function stop(issue, cwd, env) {
|
|
|
200
204
|
function retryable(state) {
|
|
201
205
|
if (state.status === "failed")
|
|
202
206
|
return true;
|
|
203
|
-
return state.status === "blocked" &&
|
|
207
|
+
return (state.status === "blocked" &&
|
|
208
|
+
(state.reason.includes("without a visible handoff") ||
|
|
209
|
+
state.reason.includes("already ran") ||
|
|
210
|
+
isTimeoutReason(state.reason) ||
|
|
211
|
+
isHardLimitReason(state.reason) ||
|
|
212
|
+
isInvalidRecordReason(state.reason)));
|
|
204
213
|
}
|
|
205
214
|
/** `gdt retry <n>`: stops the workflow and clears the interrupted turn so `gdt start` runs it again. */
|
|
206
215
|
export function retry(issue, cwd, env) {
|
|
@@ -217,6 +226,7 @@ export function retry(issue, cwd, env) {
|
|
|
217
226
|
if (key !== undefined) {
|
|
218
227
|
rmSync(p.started(key), { force: true });
|
|
219
228
|
rmSync(p.result(key), { force: true });
|
|
229
|
+
rmSync(p.agent(key), { force: true });
|
|
220
230
|
state.dispatched = state.dispatched.filter((known) => known !== key);
|
|
221
231
|
}
|
|
222
232
|
Object.assign(state, {
|
|
@@ -228,6 +238,7 @@ export function retry(issue, cwd, env) {
|
|
|
228
238
|
pids: { supervisor: null, workers: {} },
|
|
229
239
|
});
|
|
230
240
|
writeState(p, state);
|
|
241
|
+
rmSync(p.extend, { force: true });
|
|
231
242
|
// Release the lock only now: `gdt wait` must return on the final status, never on the vanished lock.
|
|
232
243
|
releaseAfterStop(p);
|
|
233
244
|
return ok(`Retry prepared for #${issue}. Next: ${describe(state, false).next}\n`);
|
|
@@ -235,8 +246,15 @@ export function retry(issue, cwd, env) {
|
|
|
235
246
|
function blockedHint(issue, reason) {
|
|
236
247
|
if (reason.startsWith("round budget exhausted"))
|
|
237
248
|
return `gdt allow-round ${issue}`;
|
|
238
|
-
|
|
249
|
+
// AC-4: a turn still active at its hard limit gets more time or is stopped and run again.
|
|
250
|
+
if (isHardLimitReason(reason))
|
|
251
|
+
return `gdt extend ${issue} or gdt retry ${issue}`;
|
|
252
|
+
if (reason.includes("without a visible handoff") ||
|
|
253
|
+
reason.includes("already ran") ||
|
|
254
|
+
isTimeoutReason(reason) ||
|
|
255
|
+
isInvalidRecordReason(reason)) {
|
|
239
256
|
return `gdt retry ${issue}`;
|
|
257
|
+
}
|
|
240
258
|
if (reason.includes("without a Changelog update"))
|
|
241
259
|
return `add a Changelog entry to issue #${issue}, or revert the body change`;
|
|
242
260
|
return "resolve the cause; the supervisor checks again on every poll";
|
|
@@ -268,10 +286,54 @@ export function describe(state, supervisorAlive) {
|
|
|
268
286
|
return { line: withReason, next: "wait" };
|
|
269
287
|
}
|
|
270
288
|
}
|
|
271
|
-
/**
|
|
272
|
-
function
|
|
289
|
+
/** A `gdt extend` request for the state's in-flight turn that the supervisor has not applied yet. */
|
|
290
|
+
function pendingExtension(p, state) {
|
|
291
|
+
const extension = readJson(p.extend);
|
|
292
|
+
return extension !== null && extension.key === state.inflight?.key ? extension : null;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* The state as status and wait see it: the pause file reports `paused` until the workflow ends, and
|
|
296
|
+
* AC-5: a pending `gdt extend` reports the turn `running` with its new hard limit before the
|
|
297
|
+
* supervisor's next poll applies it.
|
|
298
|
+
*/
|
|
299
|
+
function effectiveState(p, state, turnTimeoutMinutes = DEFAULT_TURN_TIMEOUT_MINUTES) {
|
|
273
300
|
const paused = existsSync(p.pause) && state.status !== "stopped" && state.status !== "failed";
|
|
274
|
-
|
|
301
|
+
if (paused)
|
|
302
|
+
return { ...state, status: "paused", reason: "" };
|
|
303
|
+
const extension = pendingExtension(p, state);
|
|
304
|
+
if (extension === null || state.inflight === null || state.status !== "blocked" || !isHardLimitReason(state.reason))
|
|
305
|
+
return state;
|
|
306
|
+
const inflight = { ...state.inflight, hard_limit: extension.hard_limit, at_hard_limit: false };
|
|
307
|
+
return { ...state, status: "running", reason: activeReason(inflight.role, turnTimeoutMinutes), inflight };
|
|
308
|
+
}
|
|
309
|
+
/** The workflow's turn limits, or the defaults when the config is unreadable. */
|
|
310
|
+
function turnLimits(report) {
|
|
311
|
+
return report.valid
|
|
312
|
+
? { timeout: report.workflow.turn_timeout_minutes, max: report.workflow.turn_max_minutes }
|
|
313
|
+
: { timeout: DEFAULT_TURN_TIMEOUT_MINUTES, max: DEFAULT_TURN_MAX_MINUTES };
|
|
314
|
+
}
|
|
315
|
+
/** `gdt extend <n>`: grants a turn blocked at its hard limit `turn_max_minutes` more from now (AC-5). */
|
|
316
|
+
export function extend(issue, cwd, env) {
|
|
317
|
+
const root = findRepository(cwd) ?? cwd;
|
|
318
|
+
const p = paths(root, issue, env);
|
|
319
|
+
const state = readState(p);
|
|
320
|
+
if (state === null)
|
|
321
|
+
return fail(`No workflow for #${issue}. Next: gdt start ${issue}\n`);
|
|
322
|
+
const limits = turnLimits(loadConfig(root, env).report);
|
|
323
|
+
const effective = effectiveState(p, state, limits.timeout);
|
|
324
|
+
const inflight = state.inflight;
|
|
325
|
+
if (effective.status !== "blocked" ||
|
|
326
|
+
!isHardLimitReason(effective.reason) ||
|
|
327
|
+
inflight === null ||
|
|
328
|
+
readJson(p.result(inflight.key)) !== null) {
|
|
329
|
+
const { line, next } = describe(effective, lockHolder(p) !== null);
|
|
330
|
+
return fail(`Workflow for #${issue} is not waiting at a turn's hard limit (${line}). Next: ${next}\n`);
|
|
331
|
+
}
|
|
332
|
+
const hardLimit = new Date(Date.now() + limits.max * 60_000).toISOString();
|
|
333
|
+
// The supervisor applies the request on its next poll; status and wait report it at once.
|
|
334
|
+
writeJsonAtomic(p.extend, { key: inflight.key, hard_limit: hardLimit });
|
|
335
|
+
const { next } = describe(effectiveState(p, state, limits.timeout), lockHolder(p) !== null);
|
|
336
|
+
return ok(`Extended the ${inflight.role} turn of #${issue}; new hard limit ${hardLimit}. Next: ${next}\n`);
|
|
275
337
|
}
|
|
276
338
|
/** `gdt status <n>`. The pause file reports `paused` even before the supervisor notices it. */
|
|
277
339
|
export function status(issue, cwd, env, json) {
|
|
@@ -279,18 +341,29 @@ export function status(issue, cwd, env, json) {
|
|
|
279
341
|
const p = paths(root, issue, env);
|
|
280
342
|
const state = readState(p);
|
|
281
343
|
if (state === null) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
344
|
+
// AC-5: the same keys, null without a dispatched turn.
|
|
345
|
+
const empty = {
|
|
346
|
+
issue,
|
|
347
|
+
status: null,
|
|
348
|
+
turn_started_at: null,
|
|
349
|
+
turn_deadline: null,
|
|
350
|
+
turn_last_activity: null,
|
|
351
|
+
turn_hard_limit: null,
|
|
352
|
+
next_step: `gdt start ${issue}`,
|
|
353
|
+
};
|
|
354
|
+
return json ? { code: 1, stdout: `${JSON.stringify(empty, null, 2)}\n`, stderr: "" } : fail(`No workflow for #${issue}. Next: gdt start ${issue}\n`);
|
|
285
355
|
}
|
|
286
|
-
const
|
|
356
|
+
const { report } = loadConfig(root, env);
|
|
357
|
+
const limits = turnLimits(report);
|
|
358
|
+
const effective = effectiveState(p, state, limits.timeout);
|
|
287
359
|
const { line, next } = describe(effective, lockHolder(p) !== null);
|
|
288
360
|
if (!json)
|
|
289
361
|
return ok(`${line}. Next: ${next}\n`);
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
362
|
+
const maxRounds = report.valid ? report.workflow.max_correction_rounds : null;
|
|
363
|
+
// The in-flight turn's start, deadline, last activity and hard limit. A result file ends the turn,
|
|
364
|
+
// so a turn that has finished but still awaits its handoff check reports null for all four.
|
|
365
|
+
const inflight = effective.inflight !== null && readJson(p.result(effective.inflight.key)) === null ? effective.inflight : null;
|
|
366
|
+
const turnStart = inflight?.dispatched_at ?? null;
|
|
294
367
|
const out = {
|
|
295
368
|
issue,
|
|
296
369
|
workflow_id: state.workflow_id,
|
|
@@ -302,6 +375,11 @@ export function status(issue, cwd, env, json) {
|
|
|
302
375
|
exit_code: state.exit_code,
|
|
303
376
|
pr_number: state.pr_number,
|
|
304
377
|
open_findings: state.open_findings ?? [],
|
|
378
|
+
turn_started_at: turnStart,
|
|
379
|
+
turn_deadline: turnStart === null ? null : turnDeadline(turnStart, limits.timeout).toISOString(),
|
|
380
|
+
// AC-8: null without a result-less in-flight turn.
|
|
381
|
+
turn_last_activity: inflight === null ? null : turnLastActivity(inflight),
|
|
382
|
+
turn_hard_limit: inflight === null ? null : turnHardLimit(inflight, limits.max).toISOString(),
|
|
305
383
|
overrides: readOverrides(p),
|
|
306
384
|
next_step: next,
|
|
307
385
|
};
|
|
@@ -316,12 +394,13 @@ export function wait(issue, cwd, env, options) {
|
|
|
316
394
|
const root = findRepository(cwd) ?? cwd;
|
|
317
395
|
const p = paths(root, issue, env);
|
|
318
396
|
const deadline = options.timeoutSeconds === null ? null : Date.now() + options.timeoutSeconds * 1000;
|
|
397
|
+
const turnTimeout = turnLimits(loadConfig(root, env).report).timeout;
|
|
319
398
|
for (;;) {
|
|
320
399
|
const state = readState(p);
|
|
321
400
|
// No workflow is status's error path (AC-6); an action status returns at once (AC-1, AC-2).
|
|
322
401
|
if (state === null)
|
|
323
402
|
return status(issue, cwd, env, options.json);
|
|
324
|
-
const effective = effectiveState(p, state);
|
|
403
|
+
const effective = effectiveState(p, state, turnTimeout);
|
|
325
404
|
if (ACTION_STATUSES.includes(effective.status))
|
|
326
405
|
return status(issue, cwd, env, options.json);
|
|
327
406
|
// A dead supervisor ends the wait, except while the operator paused it deliberately (AC-4).
|