@gevezex/gdt 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +263 -9
- package/dist/worker.js +34 -6
- package/dist/workflow.js +93 -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, 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,43 @@ 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
|
+
/** AC-6: the blocked reason of a turn whose agent and worker are gone without a result. */
|
|
51
|
+
export function exitedReason(role) {
|
|
52
|
+
return `${role} agent exited without a result`;
|
|
53
|
+
}
|
|
54
|
+
/** The deadline of a turn dispatched at `dispatchedAt`, with `minutes` as the configured limit. */
|
|
55
|
+
export function turnDeadline(dispatchedAt, minutes) {
|
|
56
|
+
return new Date(new Date(dispatchedAt).getTime() + minutes * 60_000);
|
|
57
|
+
}
|
|
58
|
+
/** AC-8: the turn's last activity: `dispatched_at` until an activity signal fires. */
|
|
59
|
+
export function turnLastActivity(inflight) {
|
|
60
|
+
return inflight.last_activity ?? inflight.dispatched_at;
|
|
61
|
+
}
|
|
62
|
+
/** AC-8: the turn's hard limit: `dispatched_at + turn_max_minutes` until `gdt extend` moves it. */
|
|
63
|
+
export function turnHardLimit(inflight, maxMinutes) {
|
|
64
|
+
return inflight.hard_limit === undefined ? turnDeadline(inflight.dispatched_at, maxMinutes) : new Date(inflight.hard_limit);
|
|
65
|
+
}
|
|
66
|
+
/** AC-2/AC-6: true when a blocked reason is a stopped or exited turn, so `gdt retry` can lift it. */
|
|
67
|
+
export function isTimeoutReason(reason) {
|
|
68
|
+
return /^\w+ turn inactive for [\d.]+ minutes after the [\d.]+-minute limit$/.test(reason) || / agent exited without a result$/.test(reason);
|
|
69
|
+
}
|
|
70
|
+
/** AC-4: true when a blocked reason is a turn still active at its hard limit. */
|
|
71
|
+
export function isHardLimitReason(reason) {
|
|
72
|
+
return /^\w+ turn still active after [\d.]+ minutes$/.test(reason);
|
|
73
|
+
}
|
|
74
|
+
const NO_SIGNALS = { cpu: false, tree: false, log: false, opencode: false };
|
|
35
75
|
/** One key per role, round, head and contract; a resumed question makes the resumed turn distinct. */
|
|
36
76
|
export function dispatchKey(decision, head, bodySha) {
|
|
37
77
|
const parts = [decision.role, `r${decision.round}`, head ?? "no-pr", bodySha];
|
|
@@ -53,6 +93,18 @@ function roleOf(record) {
|
|
|
53
93
|
return null;
|
|
54
94
|
}
|
|
55
95
|
}
|
|
96
|
+
/** The record kind each workflow role writes; a diagnostic of another kind is never that role's. */
|
|
97
|
+
const ROLE_OF_KIND = { handoff: "developer", test: "tester", review: "reviewer" };
|
|
98
|
+
/** AC-1: the newest invalid role record the role posted after the turn's `after_comment_id`. */
|
|
99
|
+
function invalidRecordFor(inflight, diagnostics, trusted) {
|
|
100
|
+
return diagnostics
|
|
101
|
+
.filter((d) => ROLE_OF_KIND[d.kind] === inflight.role && trusted.includes(d.author) && d.comment_id > inflight.after_comment_id)
|
|
102
|
+
// Newest comment wins when the role posted several invalid records.
|
|
103
|
+
.sort((a, b) => b.comment_id - a.comment_id)[0];
|
|
104
|
+
}
|
|
105
|
+
function sleepSync(ms) {
|
|
106
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
107
|
+
}
|
|
56
108
|
function sleep(ms) {
|
|
57
109
|
return new Promise((done) => setTimeout(done, ms));
|
|
58
110
|
}
|
|
@@ -107,6 +159,16 @@ class Supervisor {
|
|
|
107
159
|
this.backend.setTitle(role, this.roleTitle(role, state));
|
|
108
160
|
this.reportState(role, ROLE_PANE_STATE[state]);
|
|
109
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* AC-1: ends a role's worker, which stops its agent process group on SIGTERM. Its pid is dropped so
|
|
164
|
+
* a later `gdt retry` does not try to stop an already-dead process.
|
|
165
|
+
*/
|
|
166
|
+
stopRole(role) {
|
|
167
|
+
const pid = this.state.pids.workers[role];
|
|
168
|
+
if (pid !== undefined && this.backend.alive(pid))
|
|
169
|
+
this.backend.close(pid);
|
|
170
|
+
this.state.pids.workers = Object.fromEntries(Object.entries(this.state.pids.workers).filter(([name]) => name !== role));
|
|
171
|
+
}
|
|
110
172
|
/** AC-1: sets the display-only agent label, for example `developer · opencode` or `gdt · supervisor`. */
|
|
111
173
|
setDisplay(name, label) {
|
|
112
174
|
try {
|
|
@@ -119,6 +181,21 @@ class Supervisor {
|
|
|
119
181
|
log(`warning: herdr display label failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
120
182
|
}
|
|
121
183
|
}
|
|
184
|
+
/** The agent of a role's next turn: its `gdt set-agent` override, otherwise the config agent. */
|
|
185
|
+
effectiveAgent(role) {
|
|
186
|
+
return readOverrides(this.p)[role]?.agent ?? this.agents[role];
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* #68 AC-1/AC-2: sets a role label naming the effective agent. A `gdt set-agent` that writes its
|
|
190
|
+
* override and label between our read and our write would be overwritten, so the label is written
|
|
191
|
+
* again until the override read after writing it matches.
|
|
192
|
+
*/
|
|
193
|
+
setRoleDisplay(role) {
|
|
194
|
+
for (let agent = this.effectiveAgent(role), written = ""; agent !== written; agent = this.effectiveAgent(role)) {
|
|
195
|
+
this.setDisplay(role, `${role} · ${agent}`);
|
|
196
|
+
written = agent;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
122
199
|
/** Records a status; notifies once when entering a notifying status. */
|
|
123
200
|
setStatus(status, reason, extra = {}) {
|
|
124
201
|
if (this.state.status !== status || this.state.reason !== reason)
|
|
@@ -138,15 +215,169 @@ class Supervisor {
|
|
|
138
215
|
}
|
|
139
216
|
this.save();
|
|
140
217
|
}
|
|
218
|
+
/** The agent process group id the worker recorded for the turn, or null before the agent started. */
|
|
219
|
+
agentGroup(key) {
|
|
220
|
+
try {
|
|
221
|
+
const pid = Number(readFileSync(this.p.agent(key), "utf8").trim());
|
|
222
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/** AC-2: ends what is left of the agent process group once its worker is stopped. */
|
|
229
|
+
stopGroup(pgid) {
|
|
230
|
+
if (pgid === null)
|
|
231
|
+
return;
|
|
232
|
+
for (const signal of ["SIGTERM", "SIGKILL"]) {
|
|
233
|
+
if (groupUsage(pgid, this.env).processes === 0)
|
|
234
|
+
return;
|
|
235
|
+
try {
|
|
236
|
+
process.kill(-pgid, signal);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
for (let waited = 0; waited < 2000 && groupUsage(pgid, this.env).processes !== 0; waited += 100)
|
|
242
|
+
sleepSync(100);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/** The activity sample of an in-flight turn (issue Definitions). */
|
|
246
|
+
sampleTurn(running, pgid) {
|
|
247
|
+
const agent = this.effectiveAgent(running.role);
|
|
248
|
+
const home = this.env.HOME === undefined || this.env.HOME === "" ? homedir() : this.env.HOME;
|
|
249
|
+
return sample({
|
|
250
|
+
root: this.p.root,
|
|
251
|
+
env: this.env,
|
|
252
|
+
pgid,
|
|
253
|
+
logFile: this.config.workflow.terminal === "headless" ? join(this.p.logs, `${running.role}.log`) : null,
|
|
254
|
+
opencodeDb: agent === "opencode" ? opencodeDatabase(this.env, home) : null,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* AC-5: applies a hard limit granted by `gdt extend` for this turn. Returns true when a request was
|
|
259
|
+
* read; the caller removes it only after saving the poll's status, so `gdt status` never sees the
|
|
260
|
+
* turn blocked again in between.
|
|
261
|
+
*/
|
|
262
|
+
applyExtension(running) {
|
|
263
|
+
const extension = readJson(this.p.extend);
|
|
264
|
+
if (extension === null)
|
|
265
|
+
return false;
|
|
266
|
+
if (extension.key === running.key) {
|
|
267
|
+
running.hard_limit = extension.hard_limit;
|
|
268
|
+
running.at_hard_limit = false;
|
|
269
|
+
log(`${running.role} turn hard limit extended to ${extension.hard_limit}`);
|
|
270
|
+
}
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
/** Blocks the workflow on a stopped or exited turn; the block stays until `gdt retry`. */
|
|
274
|
+
blockStopped(running) {
|
|
275
|
+
this.setRoleState(running.role, "FAILED");
|
|
276
|
+
this.setStatus("blocked", running.stop_reason ?? exitedReason(running.role), { role: running.role, round: running.round });
|
|
277
|
+
return "continue";
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Checks a result-less in-flight turn on every poll, before any GitHub fetch, so a failing `gh`
|
|
281
|
+
* call never postpones it. It samples the turn's activity and moves its last activity when a
|
|
282
|
+
* signal fires. AC-6: a turn whose worker and agent are gone on two consecutive polls blocks at
|
|
283
|
+
* once. Past the deadline, AC-1: an active turn keeps running; AC-2: an inactive one is stopped
|
|
284
|
+
* and blocked; AC-4: an active one at its hard limit blocks without being stopped. A result file
|
|
285
|
+
* ends the turn, also at the hard limit; a stopped turn stays blocked even if a result appears.
|
|
286
|
+
* With `startup`, the worker was not started yet, so a missing worker is not an exited agent.
|
|
287
|
+
* Returns "continue" when the poll ends here, or null to let `tick` go on.
|
|
288
|
+
*/
|
|
289
|
+
settleTurn(now, startup = false) {
|
|
290
|
+
const running = this.state.inflight;
|
|
291
|
+
if (running === null || running.violation !== undefined || running.missing)
|
|
292
|
+
return null;
|
|
293
|
+
if (running.stop_reason !== undefined)
|
|
294
|
+
return this.blockStopped(running);
|
|
295
|
+
if (readJson(this.p.result(running.key)) !== null)
|
|
296
|
+
return null;
|
|
297
|
+
const extended = this.applyExtension(running);
|
|
298
|
+
const outcome = this.checkActivity(running, now, startup);
|
|
299
|
+
if (extended) {
|
|
300
|
+
this.save();
|
|
301
|
+
rmSync(this.p.extend, { force: true });
|
|
302
|
+
}
|
|
303
|
+
return outcome;
|
|
304
|
+
}
|
|
305
|
+
/** The activity part of `settleTurn` for a result-less in-flight turn. */
|
|
306
|
+
checkActivity(running, now, startup) {
|
|
307
|
+
const workflow = this.config.workflow;
|
|
308
|
+
const pgid = this.agentGroup(running.key);
|
|
309
|
+
const current = this.sampleTurn(running, pgid);
|
|
310
|
+
// On startup the agent of the previous run is gone and gdt's own stop line grew the log, so the
|
|
311
|
+
// sample only becomes the new baseline: the kept last activity gets no fresh window.
|
|
312
|
+
const fired = startup ? NO_SIGNALS : signals(current, running.activity);
|
|
313
|
+
const active = fired.cpu || fired.tree || fired.log || fired.opencode;
|
|
314
|
+
running.activity = nextBaseline(current, startup ? undefined : running.activity, startup || active);
|
|
315
|
+
if (active)
|
|
316
|
+
running.last_activity = now.toISOString();
|
|
317
|
+
// AC-6: an exited agent needs two consecutive polls, so a single unlucky sample never blocks a turn.
|
|
318
|
+
const workerAlive = this.backend.alive(this.state.pids.workers[running.role] ?? -1);
|
|
319
|
+
running.exited_polls = !startup && !workerAlive && current.processes === 0 ? (running.exited_polls ?? 0) + 1 : 0;
|
|
320
|
+
if (running.exited_polls >= 2) {
|
|
321
|
+
running.stop_reason = exitedReason(running.role);
|
|
322
|
+
this.stopRole(running.role);
|
|
323
|
+
log(`${running.role} agent exited without a result`);
|
|
324
|
+
return this.blockStopped(running);
|
|
325
|
+
}
|
|
326
|
+
const limit = workflow.turn_timeout_minutes;
|
|
327
|
+
if (now.getTime() < turnDeadline(running.dispatched_at, limit).getTime())
|
|
328
|
+
return null;
|
|
329
|
+
const lastActivity = turnLastActivity(running);
|
|
330
|
+
// AC-1/AC-3: one line per poll past the deadline, naming every signal and whether it fired.
|
|
331
|
+
log(`activity check for ${running.key}: ${formatSignals(fired)}; last activity ${lastActivity}`);
|
|
332
|
+
const context = { role: running.role, round: running.round };
|
|
333
|
+
const hardLimit = turnHardLimit(running, workflow.turn_max_minutes);
|
|
334
|
+
const hardMinutes = Math.round(((hardLimit.getTime() - Date.parse(running.dispatched_at)) / 60_000) * 100) / 100;
|
|
335
|
+
if (running.at_hard_limit === true) {
|
|
336
|
+
// AC-4: the turn keeps running until `gdt extend`, `gdt retry` or its result.
|
|
337
|
+
this.setStatus("blocked", hardLimitReason(running.role, hardMinutes), context);
|
|
338
|
+
return "continue";
|
|
339
|
+
}
|
|
340
|
+
if (now.getTime() - Date.parse(lastActivity) >= workflow.turn_idle_minutes * 60_000) {
|
|
341
|
+
running.timed_out = true;
|
|
342
|
+
running.stop_reason = inactiveReason(running.role, workflow.turn_idle_minutes, limit);
|
|
343
|
+
this.stopRole(running.role);
|
|
344
|
+
this.stopGroup(pgid);
|
|
345
|
+
log(`${running.stop_reason}; worker and agent stopped`);
|
|
346
|
+
return this.blockStopped(running);
|
|
347
|
+
}
|
|
348
|
+
if (now.getTime() >= hardLimit.getTime()) {
|
|
349
|
+
running.at_hard_limit = true;
|
|
350
|
+
this.setStatus("blocked", hardLimitReason(running.role, hardMinutes), context);
|
|
351
|
+
return "continue";
|
|
352
|
+
}
|
|
353
|
+
this.setStatus("running", activeReason(running.role, limit), context);
|
|
354
|
+
return "continue";
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Settles an in-flight turn before replacement workers start. A replacement worker that finds the
|
|
358
|
+
* turn's `started` marker writes an interruption result (exit code null), which would otherwise
|
|
359
|
+
* turn an inactive overdue turn into `failed` instead of the retryable `blocked`. Returns the
|
|
360
|
+
* stopped role, so `startWorkers` keeps its FAILED pane and starts no worker for it.
|
|
361
|
+
*/
|
|
362
|
+
settleOverdueTurn(now) {
|
|
363
|
+
this.settleTurn(now, true);
|
|
364
|
+
const running = this.state.inflight;
|
|
365
|
+
return running?.stop_reason === undefined ? null : running.role;
|
|
366
|
+
}
|
|
141
367
|
startWorkers() {
|
|
142
368
|
this.backend.ensureWorkspace();
|
|
143
369
|
// AC-1: the agents overview shows the role next to the agent name, once per `gdt start`.
|
|
144
370
|
this.setDisplay("supervisor", "gdt · supervisor");
|
|
145
371
|
this.backend.setTitle("supervisor", "supervisor · starting");
|
|
146
372
|
this.reportState("supervisor", supervisorAgentState(this.state.status));
|
|
373
|
+
// AC-1: settle an overdue in-flight turn before its replacement worker can write a result.
|
|
374
|
+
const settled = this.settleOverdueTurn(new Date());
|
|
147
375
|
for (const role of ROLES) {
|
|
376
|
+
// A settled role keeps its FAILED pane and gets no worker until `gdt retry` and `gdt start`.
|
|
377
|
+
if (role === settled)
|
|
378
|
+
continue;
|
|
148
379
|
this.setRoleState(role, "WAITING");
|
|
149
|
-
this.
|
|
380
|
+
this.setRoleDisplay(role);
|
|
150
381
|
if (this.backend.alive(this.state.pids.workers[role] ?? -1))
|
|
151
382
|
continue;
|
|
152
383
|
this.state.pids.workers[role] = this.backend.spawnPane(role, [process.execPath, cliPath(), "_worker", String(this.issue), role]);
|
|
@@ -164,6 +395,10 @@ class Supervisor {
|
|
|
164
395
|
this.setStatus("paused", "", { role: null });
|
|
165
396
|
return "continue";
|
|
166
397
|
}
|
|
398
|
+
// The turn check runs before any GitHub fetch, so a failing `gh` call cannot postpone it and
|
|
399
|
+
// leave a hung turn `running` forever.
|
|
400
|
+
if (this.settleTurn(now) !== null)
|
|
401
|
+
return "continue";
|
|
167
402
|
const { body, pullRequests } = issueSnapshot(this.issue, this.p.root, this.env);
|
|
168
403
|
const bodySha = sha256(body);
|
|
169
404
|
const changelog = sectionText(body, this.locale.sections.changelog) ?? "";
|
|
@@ -193,10 +428,13 @@ class Supervisor {
|
|
|
193
428
|
return "continue";
|
|
194
429
|
}
|
|
195
430
|
if (running.missing) {
|
|
196
|
-
// Also restores the status after a stop and start.
|
|
197
|
-
|
|
431
|
+
// Also restores the status after a stop and start. AC-1: an invalid record keeps its reason.
|
|
432
|
+
const reason = running.missing_reason ?? `${running.role} finished without a visible handoff`;
|
|
433
|
+
this.setStatus("blocked", reason, { role: running.role, round: running.round });
|
|
198
434
|
return "continue";
|
|
199
435
|
}
|
|
436
|
+
// A turn past its deadline was already settled at the top of this tick, before the GitHub
|
|
437
|
+
// fetch. A result-less turn that reaches this point is still before its deadline.
|
|
200
438
|
const result = readJson(this.p.result(running.key));
|
|
201
439
|
if (result === null) {
|
|
202
440
|
this.setStatus("running", `${running.role} turn in progress`, { role: running.role, round: running.round });
|
|
@@ -237,17 +475,30 @@ class Supervisor {
|
|
|
237
475
|
const thread = [...comments(this.state.repository, this.issue, this.p.root, this.env)];
|
|
238
476
|
if (pr !== null)
|
|
239
477
|
thread.push(...comments(this.state.repository, pr.number, this.p.root, this.env));
|
|
240
|
-
const { records } = parseRecords(thread);
|
|
478
|
+
const { records, diagnostics } = parseRecords(thread);
|
|
241
479
|
const trusted = records.filter((r) => this.trusted.includes(r.author));
|
|
242
480
|
const inflight = this.state.inflight;
|
|
243
481
|
if (inflight !== null) {
|
|
244
482
|
const visible = trusted.some((r) => roleOf(r) === inflight.role && r.comment_id > inflight.after_comment_id);
|
|
245
483
|
if (!visible) {
|
|
246
484
|
inflight.checks += 1;
|
|
247
|
-
|
|
485
|
+
// AC-1: an invalid record is named with its validation error instead of `not visible`;
|
|
486
|
+
// AC-4: without any role record the message and log line stay as before.
|
|
487
|
+
const invalid = invalidRecordFor(inflight, diagnostics, this.trusted);
|
|
488
|
+
log(`handoff check ${inflight.checks}/${this.config.workflow.handoff_checks} for ${inflight.key}: ` +
|
|
489
|
+
(invalid === undefined ? "not visible" : invalid.reason));
|
|
248
490
|
if (inflight.checks >= this.config.workflow.handoff_checks) {
|
|
249
491
|
inflight.missing = true;
|
|
250
|
-
|
|
492
|
+
const reason = invalid === undefined
|
|
493
|
+
? `${inflight.role} finished without a visible handoff`
|
|
494
|
+
: invalidRecordReason(inflight.role, invalid);
|
|
495
|
+
inflight.missing_reason = reason;
|
|
496
|
+
const extra = { role: inflight.role, round: inflight.round };
|
|
497
|
+
// AC-2: `gdt retry` keeps this record; the role's next turn reads it into its prompt.
|
|
498
|
+
if (invalid !== undefined) {
|
|
499
|
+
extra.retry_record = { role: inflight.role, kind: invalid.kind, comment_id: invalid.comment_id, reason: invalid.reason };
|
|
500
|
+
}
|
|
501
|
+
this.setStatus("blocked", reason, extra);
|
|
251
502
|
}
|
|
252
503
|
else {
|
|
253
504
|
this.setStatus("running", `waiting for the ${inflight.role} handoff to become visible`, { role: inflight.role, round: inflight.round });
|
|
@@ -255,6 +506,9 @@ class Supervisor {
|
|
|
255
506
|
return "continue";
|
|
256
507
|
}
|
|
257
508
|
this.state.inflight = null;
|
|
509
|
+
// AC-2: the role's rejection explanation is spent once a valid record is visible.
|
|
510
|
+
if (this.state.retry_record?.role === inflight.role)
|
|
511
|
+
this.state.retry_record = null;
|
|
258
512
|
}
|
|
259
513
|
const snapshot = {
|
|
260
514
|
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. */
|
|
@@ -200,7 +201,12 @@ export function stop(issue, cwd, env) {
|
|
|
200
201
|
function retryable(state) {
|
|
201
202
|
if (state.status === "failed")
|
|
202
203
|
return true;
|
|
203
|
-
return state.status === "blocked" &&
|
|
204
|
+
return (state.status === "blocked" &&
|
|
205
|
+
(state.reason.includes("without a visible handoff") ||
|
|
206
|
+
state.reason.includes("already ran") ||
|
|
207
|
+
isTimeoutReason(state.reason) ||
|
|
208
|
+
isHardLimitReason(state.reason) ||
|
|
209
|
+
isInvalidRecordReason(state.reason)));
|
|
204
210
|
}
|
|
205
211
|
/** `gdt retry <n>`: stops the workflow and clears the interrupted turn so `gdt start` runs it again. */
|
|
206
212
|
export function retry(issue, cwd, env) {
|
|
@@ -217,6 +223,7 @@ export function retry(issue, cwd, env) {
|
|
|
217
223
|
if (key !== undefined) {
|
|
218
224
|
rmSync(p.started(key), { force: true });
|
|
219
225
|
rmSync(p.result(key), { force: true });
|
|
226
|
+
rmSync(p.agent(key), { force: true });
|
|
220
227
|
state.dispatched = state.dispatched.filter((known) => known !== key);
|
|
221
228
|
}
|
|
222
229
|
Object.assign(state, {
|
|
@@ -228,6 +235,7 @@ export function retry(issue, cwd, env) {
|
|
|
228
235
|
pids: { supervisor: null, workers: {} },
|
|
229
236
|
});
|
|
230
237
|
writeState(p, state);
|
|
238
|
+
rmSync(p.extend, { force: true });
|
|
231
239
|
// Release the lock only now: `gdt wait` must return on the final status, never on the vanished lock.
|
|
232
240
|
releaseAfterStop(p);
|
|
233
241
|
return ok(`Retry prepared for #${issue}. Next: ${describe(state, false).next}\n`);
|
|
@@ -235,8 +243,15 @@ export function retry(issue, cwd, env) {
|
|
|
235
243
|
function blockedHint(issue, reason) {
|
|
236
244
|
if (reason.startsWith("round budget exhausted"))
|
|
237
245
|
return `gdt allow-round ${issue}`;
|
|
238
|
-
|
|
246
|
+
// AC-4: a turn still active at its hard limit gets more time or is stopped and run again.
|
|
247
|
+
if (isHardLimitReason(reason))
|
|
248
|
+
return `gdt extend ${issue} or gdt retry ${issue}`;
|
|
249
|
+
if (reason.includes("without a visible handoff") ||
|
|
250
|
+
reason.includes("already ran") ||
|
|
251
|
+
isTimeoutReason(reason) ||
|
|
252
|
+
isInvalidRecordReason(reason)) {
|
|
239
253
|
return `gdt retry ${issue}`;
|
|
254
|
+
}
|
|
240
255
|
if (reason.includes("without a Changelog update"))
|
|
241
256
|
return `add a Changelog entry to issue #${issue}, or revert the body change`;
|
|
242
257
|
return "resolve the cause; the supervisor checks again on every poll";
|
|
@@ -268,10 +283,54 @@ export function describe(state, supervisorAlive) {
|
|
|
268
283
|
return { line: withReason, next: "wait" };
|
|
269
284
|
}
|
|
270
285
|
}
|
|
271
|
-
/**
|
|
272
|
-
function
|
|
286
|
+
/** A `gdt extend` request for the state's in-flight turn that the supervisor has not applied yet. */
|
|
287
|
+
function pendingExtension(p, state) {
|
|
288
|
+
const extension = readJson(p.extend);
|
|
289
|
+
return extension !== null && extension.key === state.inflight?.key ? extension : null;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* The state as status and wait see it: the pause file reports `paused` until the workflow ends, and
|
|
293
|
+
* AC-5: a pending `gdt extend` reports the turn `running` with its new hard limit before the
|
|
294
|
+
* supervisor's next poll applies it.
|
|
295
|
+
*/
|
|
296
|
+
function effectiveState(p, state, turnTimeoutMinutes = DEFAULT_TURN_TIMEOUT_MINUTES) {
|
|
273
297
|
const paused = existsSync(p.pause) && state.status !== "stopped" && state.status !== "failed";
|
|
274
|
-
|
|
298
|
+
if (paused)
|
|
299
|
+
return { ...state, status: "paused", reason: "" };
|
|
300
|
+
const extension = pendingExtension(p, state);
|
|
301
|
+
if (extension === null || state.inflight === null || state.status !== "blocked" || !isHardLimitReason(state.reason))
|
|
302
|
+
return state;
|
|
303
|
+
const inflight = { ...state.inflight, hard_limit: extension.hard_limit, at_hard_limit: false };
|
|
304
|
+
return { ...state, status: "running", reason: activeReason(inflight.role, turnTimeoutMinutes), inflight };
|
|
305
|
+
}
|
|
306
|
+
/** The workflow's turn limits, or the defaults when the config is unreadable. */
|
|
307
|
+
function turnLimits(report) {
|
|
308
|
+
return report.valid
|
|
309
|
+
? { timeout: report.workflow.turn_timeout_minutes, max: report.workflow.turn_max_minutes }
|
|
310
|
+
: { timeout: DEFAULT_TURN_TIMEOUT_MINUTES, max: DEFAULT_TURN_MAX_MINUTES };
|
|
311
|
+
}
|
|
312
|
+
/** `gdt extend <n>`: grants a turn blocked at its hard limit `turn_max_minutes` more from now (AC-5). */
|
|
313
|
+
export function extend(issue, cwd, env) {
|
|
314
|
+
const root = findRepository(cwd) ?? cwd;
|
|
315
|
+
const p = paths(root, issue, env);
|
|
316
|
+
const state = readState(p);
|
|
317
|
+
if (state === null)
|
|
318
|
+
return fail(`No workflow for #${issue}. Next: gdt start ${issue}\n`);
|
|
319
|
+
const limits = turnLimits(loadConfig(root, env).report);
|
|
320
|
+
const effective = effectiveState(p, state, limits.timeout);
|
|
321
|
+
const inflight = state.inflight;
|
|
322
|
+
if (effective.status !== "blocked" ||
|
|
323
|
+
!isHardLimitReason(effective.reason) ||
|
|
324
|
+
inflight === null ||
|
|
325
|
+
readJson(p.result(inflight.key)) !== null) {
|
|
326
|
+
const { line, next } = describe(effective, lockHolder(p) !== null);
|
|
327
|
+
return fail(`Workflow for #${issue} is not waiting at a turn's hard limit (${line}). Next: ${next}\n`);
|
|
328
|
+
}
|
|
329
|
+
const hardLimit = new Date(Date.now() + limits.max * 60_000).toISOString();
|
|
330
|
+
// The supervisor applies the request on its next poll; status and wait report it at once.
|
|
331
|
+
writeJsonAtomic(p.extend, { key: inflight.key, hard_limit: hardLimit });
|
|
332
|
+
const { next } = describe(effectiveState(p, state, limits.timeout), lockHolder(p) !== null);
|
|
333
|
+
return ok(`Extended the ${inflight.role} turn of #${issue}; new hard limit ${hardLimit}. Next: ${next}\n`);
|
|
275
334
|
}
|
|
276
335
|
/** `gdt status <n>`. The pause file reports `paused` even before the supervisor notices it. */
|
|
277
336
|
export function status(issue, cwd, env, json) {
|
|
@@ -279,18 +338,29 @@ export function status(issue, cwd, env, json) {
|
|
|
279
338
|
const p = paths(root, issue, env);
|
|
280
339
|
const state = readState(p);
|
|
281
340
|
if (state === null) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
341
|
+
// AC-5: the same keys, null without a dispatched turn.
|
|
342
|
+
const empty = {
|
|
343
|
+
issue,
|
|
344
|
+
status: null,
|
|
345
|
+
turn_started_at: null,
|
|
346
|
+
turn_deadline: null,
|
|
347
|
+
turn_last_activity: null,
|
|
348
|
+
turn_hard_limit: null,
|
|
349
|
+
next_step: `gdt start ${issue}`,
|
|
350
|
+
};
|
|
351
|
+
return json ? { code: 1, stdout: `${JSON.stringify(empty, null, 2)}\n`, stderr: "" } : fail(`No workflow for #${issue}. Next: gdt start ${issue}\n`);
|
|
285
352
|
}
|
|
286
|
-
const
|
|
353
|
+
const { report } = loadConfig(root, env);
|
|
354
|
+
const limits = turnLimits(report);
|
|
355
|
+
const effective = effectiveState(p, state, limits.timeout);
|
|
287
356
|
const { line, next } = describe(effective, lockHolder(p) !== null);
|
|
288
357
|
if (!json)
|
|
289
358
|
return ok(`${line}. Next: ${next}\n`);
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
359
|
+
const maxRounds = report.valid ? report.workflow.max_correction_rounds : null;
|
|
360
|
+
// The in-flight turn's start, deadline, last activity and hard limit. A result file ends the turn,
|
|
361
|
+
// so a turn that has finished but still awaits its handoff check reports null for all four.
|
|
362
|
+
const inflight = effective.inflight !== null && readJson(p.result(effective.inflight.key)) === null ? effective.inflight : null;
|
|
363
|
+
const turnStart = inflight?.dispatched_at ?? null;
|
|
294
364
|
const out = {
|
|
295
365
|
issue,
|
|
296
366
|
workflow_id: state.workflow_id,
|
|
@@ -302,6 +372,11 @@ export function status(issue, cwd, env, json) {
|
|
|
302
372
|
exit_code: state.exit_code,
|
|
303
373
|
pr_number: state.pr_number,
|
|
304
374
|
open_findings: state.open_findings ?? [],
|
|
375
|
+
turn_started_at: turnStart,
|
|
376
|
+
turn_deadline: turnStart === null ? null : turnDeadline(turnStart, limits.timeout).toISOString(),
|
|
377
|
+
// AC-8: null without a result-less in-flight turn.
|
|
378
|
+
turn_last_activity: inflight === null ? null : turnLastActivity(inflight),
|
|
379
|
+
turn_hard_limit: inflight === null ? null : turnHardLimit(inflight, limits.max).toISOString(),
|
|
305
380
|
overrides: readOverrides(p),
|
|
306
381
|
next_step: next,
|
|
307
382
|
};
|
|
@@ -316,12 +391,13 @@ export function wait(issue, cwd, env, options) {
|
|
|
316
391
|
const root = findRepository(cwd) ?? cwd;
|
|
317
392
|
const p = paths(root, issue, env);
|
|
318
393
|
const deadline = options.timeoutSeconds === null ? null : Date.now() + options.timeoutSeconds * 1000;
|
|
394
|
+
const turnTimeout = turnLimits(loadConfig(root, env).report).timeout;
|
|
319
395
|
for (;;) {
|
|
320
396
|
const state = readState(p);
|
|
321
397
|
// No workflow is status's error path (AC-6); an action status returns at once (AC-1, AC-2).
|
|
322
398
|
if (state === null)
|
|
323
399
|
return status(issue, cwd, env, options.json);
|
|
324
|
-
const effective = effectiveState(p, state);
|
|
400
|
+
const effective = effectiveState(p, state, turnTimeout);
|
|
325
401
|
if (ACTION_STATUSES.includes(effective.status))
|
|
326
402
|
return status(issue, cwd, env, options.json);
|
|
327
403
|
// A dead supervisor ends the wait, except while the operator paused it deliberately (AC-4).
|