@gevezex/gdt 0.4.0 → 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/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);