@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/README.md +166 -38
- 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/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);
|