@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 CHANGED
@@ -1,25 +1,37 @@
1
1
  # gdt
2
2
 
3
- **GitHub issues to merge-ready pull requests, with a developer, tester and reviewer agent.**
3
+ **A deterministic supervisor that takes a GitHub issue through develop → test → review, with a different model in each role.**
4
4
 
5
- > Status: early development (0.x), used daily on this repository (see
6
- > [docs/dogfooding.md](docs/dogfooding.md)). Background:
7
- > [docs/design.md](docs/design.md).
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
- gdt takes one GitHub issue and runs it through three independent agent roles
10
- until a single draft pull request is ready to merge, or until it needs your
11
- decision:
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
+ ![gdt in herdr: the developer, tester and reviewer roles working on one issue](docs/assets/gdt-animation.gif)
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
- A deterministic **supervisor** (plain code, no model) decides whose turn it is.
18
- Waiting, polling and deciding cost no model tokens; a model only runs during a
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
@@ -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
+ }
@@ -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: ["claude", "-p", "--model", model, "--permission-mode", "bypassPermissions", "--no-session-persistence"],
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 result = command === "start"
449
- ? start(issue, io.cwd, io.env)
450
- : command === "stop"
451
- ? stop(issue, io.cwd, io.env)
452
- : command === "retry"
453
- ? retry(issue, io.cwd, io.env)
454
- : command === "pause"
455
- ? pause(issue, io.cwd, io.env)
456
- : command === "resume"
457
- ? resume(issue, io.cwd, io.env)
458
- : command === "allow-round"
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.strictObject({
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
- JSON.stringify(z.toJSONSchema(schemas[kind]), null, 2),
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`. */
@@ -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.setDisplay(role, `${role} · ${this.agents[role]}`);
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
- this.setStatus("blocked", `${running.role} finished without a visible handoff`, { role: running.role, round: running.round });
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
- log(`handoff check ${inflight.checks}/${this.config.workflow.handoff_checks} for ${inflight.key}: not visible`);
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
- this.setStatus("blocked", `${inflight.role} finished without a visible handoff`);
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
- // Its own process group, so a stop can end the agent and the children it started.
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
- done(code);
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, dispatch, { root, extraRules: report.contract.extra_rules }));
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 { alive, lockHolder, paths, readOverrides, readState, writeState } from "./state.js";
13
- import { cliPath } from "./supervisor.js";
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" && (state.reason.includes("without a visible handoff") || state.reason.includes("already ran"));
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
- if (reason.includes("without a visible handoff") || reason.includes("already ran"))
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
- /** The state as status and wait see it: the pause file reports `paused` until the workflow ends. */
272
- function effectiveState(p, state) {
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
- return paused ? { ...state, status: "paused", reason: "" } : state;
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
- return json
283
- ? { code: 1, stdout: `${JSON.stringify({ issue, status: null, next_step: `gdt start ${issue}` }, null, 2)}\n`, stderr: "" }
284
- : fail(`No workflow for #${issue}. Next: gdt start ${issue}\n`);
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 effective = effectiveState(p, state);
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
- let maxRounds = null;
291
- const { report } = loadConfig(root, env);
292
- if (report.valid)
293
- maxRounds = report.workflow.max_correction_rounds;
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).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gevezex/gdt",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "GitHub issues to merge-ready pull requests, with a developer, tester and reviewer agent.",
5
5
  "keywords": [
6
6
  "github",