@gevezex/gdt 0.2.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/LICENSE +21 -0
- package/README.md +375 -0
- package/dist/agents/adapter.js +7 -0
- package/dist/agents/claude.js +13 -0
- package/dist/agents/codex.js +13 -0
- package/dist/agents/index.js +15 -0
- package/dist/agents/mcode.js +14 -0
- package/dist/agents/omp.js +14 -0
- package/dist/agents/opencode.js +15 -0
- package/dist/agents/pi.js +14 -0
- package/dist/backends/backend.js +1 -0
- package/dist/backends/headless.js +55 -0
- package/dist/backends/herdr.js +286 -0
- package/dist/backends/index.js +20 -0
- package/dist/cli.js +466 -0
- package/dist/config.js +210 -0
- package/dist/contract.js +133 -0
- package/dist/decision.js +175 -0
- package/dist/doctor.js +231 -0
- package/dist/finding.js +3 -0
- package/dist/git.js +25 -0
- package/dist/github.js +95 -0
- package/dist/locale.js +44 -0
- package/dist/notify.js +29 -0
- package/dist/prompts.js +80 -0
- package/dist/protocol.js +114 -0
- package/dist/state.js +113 -0
- package/dist/steering.js +165 -0
- package/dist/supervisor.js +369 -0
- package/dist/worker.js +232 -0
- package/dist/workflow.js +333 -0
- package/locales/en.toml +28 -0
- package/locales/nl.toml +28 -0
- package/package.json +61 -0
- package/roles/developer.md +66 -0
- package/roles/issue-writer.md +40 -0
- package/roles/reviewer.md +65 -0
- package/roles/tester.md +58 -0
- package/schemas/gdt-answer.v1.schema.json +32 -0
- package/schemas/gdt-directive.v1.schema.json +36 -0
- package/schemas/gdt-handoff.v1.schema.json +117 -0
- package/schemas/gdt-question.v1.schema.json +82 -0
- package/schemas/gdt-review.v1.schema.json +131 -0
- package/schemas/gdt-round.v1.schema.json +28 -0
- package/schemas/gdt-test.v1.schema.json +131 -0
- package/skill/SKILL.md +46 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, rmSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { backendFor } from "./backends/index.js";
|
|
5
|
+
import { loadConfig, ROLES } from "./config.js";
|
|
6
|
+
import { sectionText, validateContract } from "./contract.js";
|
|
7
|
+
import { decide, openFindings } from "./decision.js";
|
|
8
|
+
import { comments, issueSnapshot, pullRequest, repository, viewer } from "./github.js";
|
|
9
|
+
import { loadLocale } from "./locale.js";
|
|
10
|
+
import { notify } from "./notify.js";
|
|
11
|
+
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";
|
|
14
|
+
/** AC-4: the herdr agent state reported for each role pane state. */
|
|
15
|
+
const ROLE_PANE_STATE = {
|
|
16
|
+
RUNNING: "working",
|
|
17
|
+
WAITING: "idle",
|
|
18
|
+
DONE: "idle",
|
|
19
|
+
FAILED: "blocked",
|
|
20
|
+
};
|
|
21
|
+
/** AC-5: the herdr agent state reported for the supervisor pane, by workflow status. */
|
|
22
|
+
export function supervisorAgentState(status) {
|
|
23
|
+
if (status === "awaiting_human" || status === "blocked" || status === "failed")
|
|
24
|
+
return "blocked";
|
|
25
|
+
if (status === "ready_to_merge" || status === "stopped" || status === "paused")
|
|
26
|
+
return "idle";
|
|
27
|
+
return "working";
|
|
28
|
+
}
|
|
29
|
+
export function cliPath() {
|
|
30
|
+
return fileURLToPath(new URL("./cli.js", import.meta.url));
|
|
31
|
+
}
|
|
32
|
+
export function sha256(text) {
|
|
33
|
+
return createHash("sha256").update(text).digest("hex");
|
|
34
|
+
}
|
|
35
|
+
/** One key per role, round, head and contract; a resumed question makes the resumed turn distinct. */
|
|
36
|
+
export function dispatchKey(decision, head, bodySha) {
|
|
37
|
+
const parts = [decision.role, `r${decision.round}`, head ?? "no-pr", bodySha];
|
|
38
|
+
if (decision.question_id !== undefined)
|
|
39
|
+
parts.push(decision.question_id);
|
|
40
|
+
return parts.join(".");
|
|
41
|
+
}
|
|
42
|
+
function roleOf(record) {
|
|
43
|
+
switch (record.kind) {
|
|
44
|
+
case "handoff":
|
|
45
|
+
return "developer";
|
|
46
|
+
case "test":
|
|
47
|
+
return "tester";
|
|
48
|
+
case "review":
|
|
49
|
+
return "reviewer";
|
|
50
|
+
case "question":
|
|
51
|
+
return record.data.role;
|
|
52
|
+
default:
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function sleep(ms) {
|
|
57
|
+
return new Promise((done) => setTimeout(done, ms));
|
|
58
|
+
}
|
|
59
|
+
function log(line) {
|
|
60
|
+
process.stdout.write(`${logTimestamp()} ${line}\n`);
|
|
61
|
+
}
|
|
62
|
+
class Supervisor {
|
|
63
|
+
p;
|
|
64
|
+
issue;
|
|
65
|
+
config;
|
|
66
|
+
locale;
|
|
67
|
+
env;
|
|
68
|
+
state;
|
|
69
|
+
backend;
|
|
70
|
+
trusted = [];
|
|
71
|
+
reportWarned = false;
|
|
72
|
+
agents;
|
|
73
|
+
constructor(p, issue, config, locale, env, state, backend) {
|
|
74
|
+
this.p = p;
|
|
75
|
+
this.issue = issue;
|
|
76
|
+
this.config = config;
|
|
77
|
+
this.locale = locale;
|
|
78
|
+
this.env = env;
|
|
79
|
+
this.state = state;
|
|
80
|
+
this.backend = backend;
|
|
81
|
+
this.agents = Object.fromEntries(ROLES.map((role) => [role, config.roles[role].agent]));
|
|
82
|
+
}
|
|
83
|
+
save() {
|
|
84
|
+
writeState(this.p, this.state);
|
|
85
|
+
}
|
|
86
|
+
/** AC-5: the title of a role pane, for example `developer · opencode · RUNNING`. */
|
|
87
|
+
roleTitle(role, state) {
|
|
88
|
+
return `${role} · ${this.agents[role]} · ${state}`;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* AC-4/AC-5: reports a pane's agent state to the backend. AC-6: a failing report never affects the
|
|
92
|
+
* workflow, and only the first failure is logged.
|
|
93
|
+
*/
|
|
94
|
+
reportState(name, state) {
|
|
95
|
+
try {
|
|
96
|
+
this.backend.reportState(name, state);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
if (this.reportWarned)
|
|
100
|
+
return;
|
|
101
|
+
this.reportWarned = true;
|
|
102
|
+
log(`warning: herdr state report failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** AC-4: sets a role pane's title and reports the matching herdr agent state. */
|
|
106
|
+
setRoleState(role, state) {
|
|
107
|
+
this.backend.setTitle(role, this.roleTitle(role, state));
|
|
108
|
+
this.reportState(role, ROLE_PANE_STATE[state]);
|
|
109
|
+
}
|
|
110
|
+
/** Records a status; notifies once when entering a notifying status. */
|
|
111
|
+
setStatus(status, reason, extra = {}) {
|
|
112
|
+
if (this.state.status !== status || this.state.reason !== reason)
|
|
113
|
+
log(`status ${status}${reason === "" ? "" : `: ${reason}`}`);
|
|
114
|
+
Object.assign(this.state, { status, reason }, extra);
|
|
115
|
+
this.backend.setTitle("supervisor", `supervisor · ${status}`);
|
|
116
|
+
this.reportState("supervisor", supervisorAgentState(status));
|
|
117
|
+
if (NOTIFY_STATUSES.includes(status)) {
|
|
118
|
+
if (this.state.notified_status !== status) {
|
|
119
|
+
const used = notify(`gdt #${this.issue}: ${status}`, reason === "" ? status : reason, this.env, log);
|
|
120
|
+
log(`notified via ${used}`);
|
|
121
|
+
this.state.notified_status = status;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
this.state.notified_status = null;
|
|
126
|
+
}
|
|
127
|
+
this.save();
|
|
128
|
+
}
|
|
129
|
+
startWorkers() {
|
|
130
|
+
this.backend.ensureWorkspace();
|
|
131
|
+
this.backend.setTitle("supervisor", "supervisor · starting");
|
|
132
|
+
this.reportState("supervisor", supervisorAgentState(this.state.status));
|
|
133
|
+
for (const role of ROLES) {
|
|
134
|
+
this.setRoleState(role, "WAITING");
|
|
135
|
+
if (this.backend.alive(this.state.pids.workers[role] ?? -1))
|
|
136
|
+
continue;
|
|
137
|
+
this.state.pids.workers[role] = this.backend.spawnPane(role, [process.execPath, cliPath(), "_worker", String(this.issue), role]);
|
|
138
|
+
}
|
|
139
|
+
this.save();
|
|
140
|
+
}
|
|
141
|
+
init() {
|
|
142
|
+
this.trusted = [viewer(this.p.root, this.env)];
|
|
143
|
+
this.state.repository = repository(this.p.root, this.env);
|
|
144
|
+
}
|
|
145
|
+
/** One poll. Returns "exit" when the supervisor must stop. */
|
|
146
|
+
tick(now) {
|
|
147
|
+
// `gdt pause` sets this file; `gdt resume` removes it. While it exists no turn is dispatched.
|
|
148
|
+
if (existsSync(this.p.pause)) {
|
|
149
|
+
this.setStatus("paused", "", { role: null });
|
|
150
|
+
return "continue";
|
|
151
|
+
}
|
|
152
|
+
const { body, pullRequests } = issueSnapshot(this.issue, this.p.root, this.env);
|
|
153
|
+
const bodySha = sha256(body);
|
|
154
|
+
const changelog = sectionText(body, this.locale.sections.changelog) ?? "";
|
|
155
|
+
const known = this.state.contract;
|
|
156
|
+
if (known !== null && known.sha256 !== bodySha) {
|
|
157
|
+
if (known.changelog === changelog) {
|
|
158
|
+
this.setStatus("blocked", "issue body changed without a Changelog update");
|
|
159
|
+
return "continue";
|
|
160
|
+
}
|
|
161
|
+
this.state.contract = { sha256: bodySha, changelog };
|
|
162
|
+
this.setStatus("contract_changed", "issue body and Changelog changed; evidence reset");
|
|
163
|
+
return "continue";
|
|
164
|
+
}
|
|
165
|
+
if (known === null)
|
|
166
|
+
this.state.contract = { sha256: bodySha, changelog };
|
|
167
|
+
const contract = validateContract(body, this.locale, { maxAcceptanceCriteria: this.config.contract.max_acceptance_criteria });
|
|
168
|
+
if (!contract.valid) {
|
|
169
|
+
this.setStatus("blocked", `issue contract invalid: ${contract.errors.join("; ")}`);
|
|
170
|
+
return "continue";
|
|
171
|
+
}
|
|
172
|
+
// A turn in flight: nothing to fetch until its worker has reported.
|
|
173
|
+
const running = this.state.inflight;
|
|
174
|
+
if (running !== null) {
|
|
175
|
+
if (running.violation !== undefined) {
|
|
176
|
+
this.setRoleState(running.role, "FAILED");
|
|
177
|
+
this.setStatus("blocked", running.violation, { role: running.role, round: running.round });
|
|
178
|
+
return "continue";
|
|
179
|
+
}
|
|
180
|
+
if (running.missing) {
|
|
181
|
+
// Also restores the status after a stop and start.
|
|
182
|
+
this.setStatus("blocked", `${running.role} finished without a visible handoff`, { role: running.role, round: running.round });
|
|
183
|
+
return "continue";
|
|
184
|
+
}
|
|
185
|
+
const result = readJson(this.p.result(running.key));
|
|
186
|
+
if (result === null) {
|
|
187
|
+
this.setStatus("running", `${running.role} turn in progress`, { role: running.role, round: running.round });
|
|
188
|
+
return "continue";
|
|
189
|
+
}
|
|
190
|
+
if (result.violation !== undefined) {
|
|
191
|
+
// A broken role boundary outranks the exit code: the checkout can no longer be trusted.
|
|
192
|
+
running.violation = result.violation;
|
|
193
|
+
this.setRoleState(running.role, "FAILED");
|
|
194
|
+
this.setStatus("blocked", result.violation, { role: running.role, round: running.round, exit_code: result.exit_code });
|
|
195
|
+
return "continue";
|
|
196
|
+
}
|
|
197
|
+
if (result.exit_code !== 0) {
|
|
198
|
+
const reason = result.exit_code === null
|
|
199
|
+
? `${running.role} turn was interrupted`
|
|
200
|
+
: `${running.role} turn failed (exit code ${result.exit_code})`;
|
|
201
|
+
this.setRoleState(running.role, "FAILED");
|
|
202
|
+
this.setStatus("failed", reason, { role: running.role, round: running.round, exit_code: result.exit_code });
|
|
203
|
+
return "exit";
|
|
204
|
+
}
|
|
205
|
+
// AC-5: the turn finished successfully.
|
|
206
|
+
this.setRoleState(running.role, "DONE");
|
|
207
|
+
}
|
|
208
|
+
if (pullRequests.length > 1) {
|
|
209
|
+
this.setStatus("blocked", `more than one open pull request closes #${this.issue}: ${pullRequests.map((n) => `#${n}`).join(", ")}`);
|
|
210
|
+
return "continue";
|
|
211
|
+
}
|
|
212
|
+
const prNumber = pullRequests[0];
|
|
213
|
+
const pr = prNumber === undefined ? null : pullRequest(prNumber, this.p.root, this.env);
|
|
214
|
+
if (pr !== null) {
|
|
215
|
+
if (this.state.head !== null && this.state.head !== pr.head) {
|
|
216
|
+
this.state.head_transition_at = now.toISOString();
|
|
217
|
+
log(`head ${this.state.head} -> ${pr.head}`);
|
|
218
|
+
}
|
|
219
|
+
this.state.head = pr.head;
|
|
220
|
+
}
|
|
221
|
+
this.state.pr_number = pr?.number ?? null;
|
|
222
|
+
const thread = [...comments(this.state.repository, this.issue, this.p.root, this.env)];
|
|
223
|
+
if (pr !== null)
|
|
224
|
+
thread.push(...comments(this.state.repository, pr.number, this.p.root, this.env));
|
|
225
|
+
const { records } = parseRecords(thread);
|
|
226
|
+
const trusted = records.filter((r) => this.trusted.includes(r.author));
|
|
227
|
+
const inflight = this.state.inflight;
|
|
228
|
+
if (inflight !== null) {
|
|
229
|
+
const visible = trusted.some((r) => roleOf(r) === inflight.role && r.comment_id > inflight.after_comment_id);
|
|
230
|
+
if (!visible) {
|
|
231
|
+
inflight.checks += 1;
|
|
232
|
+
log(`handoff check ${inflight.checks}/${this.config.workflow.handoff_checks} for ${inflight.key}: not visible`);
|
|
233
|
+
if (inflight.checks >= this.config.workflow.handoff_checks) {
|
|
234
|
+
inflight.missing = true;
|
|
235
|
+
this.setStatus("blocked", `${inflight.role} finished without a visible handoff`);
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
this.setStatus("running", `waiting for the ${inflight.role} handoff to become visible`, { role: inflight.role, round: inflight.round });
|
|
239
|
+
}
|
|
240
|
+
return "continue";
|
|
241
|
+
}
|
|
242
|
+
this.state.inflight = null;
|
|
243
|
+
}
|
|
244
|
+
const snapshot = {
|
|
245
|
+
repository: this.state.repository,
|
|
246
|
+
issue: this.issue,
|
|
247
|
+
records,
|
|
248
|
+
trusted_authors: this.trusted,
|
|
249
|
+
pr,
|
|
250
|
+
issue_body_sha256: bodySha,
|
|
251
|
+
acceptance_criteria: contract.acceptance_criteria,
|
|
252
|
+
head_transition_at: this.state.head_transition_at,
|
|
253
|
+
config: this.config.workflow,
|
|
254
|
+
};
|
|
255
|
+
this.state.open_findings = openFindings(snapshot);
|
|
256
|
+
const decision = decide(snapshot);
|
|
257
|
+
if (decision.action !== "dispatch") {
|
|
258
|
+
this.setStatus(decision.action, decision.reason, { role: null });
|
|
259
|
+
// AC-7: on completion the workers exit on their own when they see `ready_to_merge`; in herdr
|
|
260
|
+
// mode the supervisor exits too, after printing the completion line as its final output.
|
|
261
|
+
if (decision.action === "ready_to_merge" && this.config.workflow.terminal === "herdr")
|
|
262
|
+
return "exit";
|
|
263
|
+
return "continue";
|
|
264
|
+
}
|
|
265
|
+
const key = dispatchKey(decision, pr?.head ?? null, bodySha);
|
|
266
|
+
if (this.state.dispatched.includes(key)) {
|
|
267
|
+
this.setStatus("blocked", `${decision.role} already ran for this dispatch without a usable record`, {
|
|
268
|
+
role: decision.role,
|
|
269
|
+
round: decision.round,
|
|
270
|
+
blocked_key: key,
|
|
271
|
+
});
|
|
272
|
+
return "continue";
|
|
273
|
+
}
|
|
274
|
+
const afterCommentId = Math.max(0, ...thread.map((c) => c.id));
|
|
275
|
+
const cursor = (this.state.directive_cursor ??= {});
|
|
276
|
+
const directives = pendingDirectives(records, this.trusted, decision.role, cursor[decision.role] ?? 0);
|
|
277
|
+
const dispatch = {
|
|
278
|
+
key,
|
|
279
|
+
issue: this.issue,
|
|
280
|
+
repository: this.state.repository,
|
|
281
|
+
role: decision.role,
|
|
282
|
+
round: decision.round,
|
|
283
|
+
pr_number: pr?.number ?? null,
|
|
284
|
+
head: pr?.head ?? null,
|
|
285
|
+
issue_body_sha256: bodySha,
|
|
286
|
+
acceptance_criteria: contract.acceptance_criteria,
|
|
287
|
+
language: this.config.language,
|
|
288
|
+
directives,
|
|
289
|
+
dispatched_at: now.toISOString(),
|
|
290
|
+
};
|
|
291
|
+
// Dispatch file first: if gdt stop lands in between, the restarted supervisor dispatches the same key
|
|
292
|
+
// again, and the worker's exclusive "started" marker still runs it at most once.
|
|
293
|
+
writeJsonAtomic(this.p.dispatch(decision.role), dispatch);
|
|
294
|
+
this.state.dispatched.push(key);
|
|
295
|
+
cursor[decision.role] = afterCommentId;
|
|
296
|
+
this.state.inflight = {
|
|
297
|
+
key,
|
|
298
|
+
role: decision.role,
|
|
299
|
+
round: decision.round,
|
|
300
|
+
dispatched_at: dispatch.dispatched_at,
|
|
301
|
+
after_comment_id: afterCommentId,
|
|
302
|
+
checks: 0,
|
|
303
|
+
missing: false,
|
|
304
|
+
};
|
|
305
|
+
this.setRoleState(decision.role, "RUNNING");
|
|
306
|
+
this.setStatus("running", `dispatched ${decision.role}: ${decision.reason}`, { role: decision.role, round: decision.round, exit_code: null });
|
|
307
|
+
log(`dispatched ${key}`);
|
|
308
|
+
return "continue";
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/** Runs the supervisor loop for `issue` until a failed turn or a signal. Returns the process exit code. */
|
|
312
|
+
export async function supervise(root, issue, env) {
|
|
313
|
+
const p = paths(root, issue, env);
|
|
314
|
+
const holder = acquireLock(p);
|
|
315
|
+
if (holder !== null) {
|
|
316
|
+
log(`Supervisor for #${issue} is already running (pid ${holder})`);
|
|
317
|
+
return 1;
|
|
318
|
+
}
|
|
319
|
+
const release = () => rmSync(p.lock, { force: true });
|
|
320
|
+
process.on("SIGTERM", () => {
|
|
321
|
+
// AC-6: the pane's last output line explains how to resume.
|
|
322
|
+
process.stdout.write(`gdt stopped. Resume with: gdt start ${issue}\n`);
|
|
323
|
+
// The lock stays in place: `gdt stop`/`gdt retry` release it only after writing the final state.
|
|
324
|
+
// If this handler removed it first, a concurrent `gdt wait` could see the lock vanish while the
|
|
325
|
+
// state is still a waiting status and report a dead supervisor for a deliberate stop.
|
|
326
|
+
process.exit(143);
|
|
327
|
+
});
|
|
328
|
+
const { report } = loadConfig(root, env);
|
|
329
|
+
const state = readState(p);
|
|
330
|
+
if (!report.valid || state === null) {
|
|
331
|
+
log(report.valid ? "no state; run gdt start" : "invalid config; run gdt doctor");
|
|
332
|
+
release();
|
|
333
|
+
return 1;
|
|
334
|
+
}
|
|
335
|
+
state.pids.supervisor = process.pid;
|
|
336
|
+
writeState(p, state);
|
|
337
|
+
const backend = backendFor(report, root, issue, env, p);
|
|
338
|
+
const supervisor = new Supervisor(p, issue, report, loadLocale(report.language), env, state, backend);
|
|
339
|
+
log(`supervisor ${process.pid} for #${issue}, workflow ${state.workflow_id}`);
|
|
340
|
+
try {
|
|
341
|
+
supervisor.init();
|
|
342
|
+
supervisor.startWorkers();
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
log(`startup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
346
|
+
release();
|
|
347
|
+
return 1;
|
|
348
|
+
}
|
|
349
|
+
for (;;) {
|
|
350
|
+
try {
|
|
351
|
+
if (supervisor.tick(new Date()) === "exit")
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
// GitHub or network trouble: log and try again on the next poll.
|
|
356
|
+
log(`poll failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
357
|
+
}
|
|
358
|
+
await sleep(report.workflow.poll_seconds * 1000);
|
|
359
|
+
}
|
|
360
|
+
// AC-7: the completion line is the pane's final output, so it is written after the last log line.
|
|
361
|
+
if (report.workflow.terminal === "herdr" && readState(p)?.status === "ready_to_merge") {
|
|
362
|
+
process.stdout.write(`Workflow complete: #${issue} is ready to merge. This pane can be closed.\n`);
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
log("supervisor exiting");
|
|
366
|
+
}
|
|
367
|
+
release();
|
|
368
|
+
return 0;
|
|
369
|
+
}
|
package/dist/worker.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { adapterFor } from "./agents/index.js";
|
|
5
|
+
import { loadConfig, TEST_AGENT, testAgentsEnabled } from "./config.js";
|
|
6
|
+
import { changedFiles, checkout } from "./git.js";
|
|
7
|
+
import { buildPrompt } from "./prompts.js";
|
|
8
|
+
import { alive, logTimestamp, paths, readJson, readOverrides, readState, writeJsonAtomic } from "./state.js";
|
|
9
|
+
/** The clear-screen sequence: it erases the visible screen and keeps the scrollback (AC-1). */
|
|
10
|
+
export const CLEAR_SCREEN = "\x1b[2J";
|
|
11
|
+
/** Resets colours, so the agent's output follows in normal colours (AC-3). */
|
|
12
|
+
export const RESET_COLOURS = "\x1b[0m";
|
|
13
|
+
/** SGR 2, dim; the waiting line uses it (AC-2). */
|
|
14
|
+
export const DIM = "\x1b[2m";
|
|
15
|
+
/** AC-2: the one dim line a waiting role pane shows. */
|
|
16
|
+
export function waitingLine(role, last) {
|
|
17
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
18
|
+
const detail = last === undefined ? "no turn yet" : `last turn ${last.state} ${pad(last.at.getHours())}:${pad(last.at.getMinutes())}`;
|
|
19
|
+
return `${DIM}${role} waiting · ${detail}${RESET_COLOURS}`;
|
|
20
|
+
}
|
|
21
|
+
/** The command for one unattended turn: the role's agent adapter, or the test agent's script. */
|
|
22
|
+
export function invocation(roleName, role, root, promptFile, env) {
|
|
23
|
+
if (role.agent === TEST_AGENT) {
|
|
24
|
+
if (role.script === undefined || !testAgentsEnabled(env))
|
|
25
|
+
throw new Error(`agent "${TEST_AGENT}" requires GDT_TEST_AGENTS=1 and a script`);
|
|
26
|
+
return { argv: ["/bin/sh", resolve(root, role.script)], env: { GDT_PROMPT_FILE: promptFile }, stdin: null };
|
|
27
|
+
}
|
|
28
|
+
const adapter = adapterFor(role.agent);
|
|
29
|
+
if (adapter === undefined)
|
|
30
|
+
throw new Error(`no adapter for agent "${role.agent}" yet`);
|
|
31
|
+
return adapter.buildInvocation(roleName, role.model, promptFile, root);
|
|
32
|
+
}
|
|
33
|
+
function log(line) {
|
|
34
|
+
process.stdout.write(`${logTimestamp()} ${line}\n`);
|
|
35
|
+
}
|
|
36
|
+
function sleep(ms) {
|
|
37
|
+
return new Promise((done) => setTimeout(done, ms));
|
|
38
|
+
}
|
|
39
|
+
/** The turn's agent process, so a stop can end it with the worker. */
|
|
40
|
+
let activeAgent = null;
|
|
41
|
+
/** AC-6: stops the running agent (and its own children) before the worker exits. */
|
|
42
|
+
function killAgent() {
|
|
43
|
+
const child = activeAgent;
|
|
44
|
+
if (child === null || child.pid === undefined)
|
|
45
|
+
return;
|
|
46
|
+
try {
|
|
47
|
+
process.kill(-child.pid, "SIGTERM");
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
try {
|
|
51
|
+
child.kill("SIGTERM");
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Already gone.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function stopLine(issue) {
|
|
59
|
+
return `gdt stopped. Resume with: gdt start ${issue}`;
|
|
60
|
+
}
|
|
61
|
+
export function completeLine(issue) {
|
|
62
|
+
return `Workflow complete: #${issue} is ready to merge. This pane can be closed.`;
|
|
63
|
+
}
|
|
64
|
+
/** A tester or reviewer must leave tracked files, the branch and HEAD as they were. */
|
|
65
|
+
export function boundaryViolation(role, before, root, env) {
|
|
66
|
+
if (role === "developer")
|
|
67
|
+
return undefined;
|
|
68
|
+
const changed = changedFiles(root, env, false);
|
|
69
|
+
if (changed.length > 0)
|
|
70
|
+
return `${role} changed tracked files: ${changed.join(", ")}`;
|
|
71
|
+
const after = checkout(root, env);
|
|
72
|
+
const name = (branch) => (branch === "" ? "(detached)" : branch);
|
|
73
|
+
if (after.branch !== before.branch)
|
|
74
|
+
return `${role} switched branch from ${name(before.branch)} to ${name(after.branch)}`;
|
|
75
|
+
if (after.head !== before.head)
|
|
76
|
+
return `${role} moved HEAD from ${before.head} to ${after.head}`;
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
/** 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) {
|
|
81
|
+
const [command, ...args] = inv.argv;
|
|
82
|
+
return new Promise((done) => {
|
|
83
|
+
let stdin = "ignore";
|
|
84
|
+
try {
|
|
85
|
+
if (inv.stdin !== null)
|
|
86
|
+
stdin = openSync(inv.stdin, "r");
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
log(`cannot open prompt file: ${err instanceof Error ? err.message : String(err)}`);
|
|
90
|
+
done(66);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// Its own process group, so a stop can end the agent and the children it started.
|
|
94
|
+
const child = spawn(command ?? "", args, {
|
|
95
|
+
cwd,
|
|
96
|
+
env: { ...env, ...inv.env },
|
|
97
|
+
stdio: [stdin, "inherit", "inherit"],
|
|
98
|
+
detached: true,
|
|
99
|
+
});
|
|
100
|
+
activeAgent = child;
|
|
101
|
+
const close = () => {
|
|
102
|
+
if (typeof stdin === "number")
|
|
103
|
+
closeSync(stdin);
|
|
104
|
+
};
|
|
105
|
+
child.on("error", (err) => {
|
|
106
|
+
activeAgent = null;
|
|
107
|
+
log(`agent could not start: ${err.message}`);
|
|
108
|
+
close();
|
|
109
|
+
done(127);
|
|
110
|
+
});
|
|
111
|
+
child.on("close", (code) => {
|
|
112
|
+
activeAgent = null;
|
|
113
|
+
close();
|
|
114
|
+
done(code);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
async function turn(root, role, dispatch, env, p) {
|
|
119
|
+
let outcome = { state: "FAILED", at: new Date() };
|
|
120
|
+
const finish = (exit_code, violation) => {
|
|
121
|
+
const result = { key: dispatch.key, exit_code, finished_at: new Date().toISOString() };
|
|
122
|
+
if (violation !== undefined)
|
|
123
|
+
result.violation = violation;
|
|
124
|
+
writeJsonAtomic(p.result(dispatch.key), result);
|
|
125
|
+
outcome = { state: exit_code === 0 ? "DONE" : "FAILED", at: new Date() };
|
|
126
|
+
log(`turn ${dispatch.key} finished with exit code ${exit_code}`);
|
|
127
|
+
return outcome;
|
|
128
|
+
};
|
|
129
|
+
const started = p.started(dispatch.key);
|
|
130
|
+
mkdirSync(dirname(started), { recursive: true });
|
|
131
|
+
try {
|
|
132
|
+
// Exclusive create: at most one run per dispatch key, even across restarts.
|
|
133
|
+
writeFileSync(started, `${process.pid}\n`, { flag: "wx" });
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// Started before but never finished: the earlier run was interrupted. Never run it again.
|
|
137
|
+
return finish(null);
|
|
138
|
+
}
|
|
139
|
+
const { report } = loadConfig(root, env);
|
|
140
|
+
if (!report.valid) {
|
|
141
|
+
log("invalid config; run gdt doctor");
|
|
142
|
+
return finish(78);
|
|
143
|
+
}
|
|
144
|
+
const promptFile = p.prompt(dispatch.key);
|
|
145
|
+
try {
|
|
146
|
+
writeFileSync(promptFile, buildPrompt(role, dispatch, { root, extraRules: report.contract.extra_rules }));
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
log(`cannot build the prompt: ${err instanceof Error ? err.message : String(err)}`);
|
|
150
|
+
return finish(78);
|
|
151
|
+
}
|
|
152
|
+
// A `gdt set-agent` override wins over `.gdt/config.toml` for this role from this turn on.
|
|
153
|
+
const override = readOverrides(p)[role];
|
|
154
|
+
const roleConfig = override === undefined ? report.roles[role] : { ...report.roles[role], agent: override.agent, model: override.model };
|
|
155
|
+
let inv;
|
|
156
|
+
try {
|
|
157
|
+
inv = invocation(role, roleConfig, root, promptFile, env);
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
log(err instanceof Error ? err.message : String(err));
|
|
161
|
+
return finish(127);
|
|
162
|
+
}
|
|
163
|
+
log(`turn ${dispatch.key}: ${inv.argv.join(" ")}`);
|
|
164
|
+
const turnEnv = {
|
|
165
|
+
...env,
|
|
166
|
+
GDT_ISSUE: String(dispatch.issue),
|
|
167
|
+
GDT_REPOSITORY: dispatch.repository,
|
|
168
|
+
GDT_ROLE: role,
|
|
169
|
+
GDT_ROUND: String(dispatch.round),
|
|
170
|
+
GDT_PR: dispatch.pr_number === null ? "" : String(dispatch.pr_number),
|
|
171
|
+
GDT_HEAD: dispatch.head ?? "",
|
|
172
|
+
GDT_ISSUE_BODY_SHA256: dispatch.issue_body_sha256,
|
|
173
|
+
GDT_DISPATCH_KEY: dispatch.key,
|
|
174
|
+
};
|
|
175
|
+
const before = checkout(root, env);
|
|
176
|
+
const exitCode = await runInvocation(inv, root, turnEnv);
|
|
177
|
+
const violation = boundaryViolation(role, before, root, env);
|
|
178
|
+
if (violation !== undefined)
|
|
179
|
+
log(violation);
|
|
180
|
+
return finish(exitCode, violation);
|
|
181
|
+
}
|
|
182
|
+
/** Waits for dispatches for `role` without a model and runs one agent turn per dispatch key. */
|
|
183
|
+
export async function work(root, issue, role, env) {
|
|
184
|
+
const p = paths(root, issue, env);
|
|
185
|
+
process.on("SIGTERM", () => {
|
|
186
|
+
killAgent();
|
|
187
|
+
process.stdout.write(`${stopLine(issue)}\n`);
|
|
188
|
+
process.exit(143);
|
|
189
|
+
});
|
|
190
|
+
log(`${role} worker ${process.pid} for #${issue} waiting`);
|
|
191
|
+
// In headless mode the workers keep waiting after ready_to_merge, as the supervisor does.
|
|
192
|
+
const { report } = loadConfig(root, env);
|
|
193
|
+
const herdr = report.valid && report.workflow.terminal === "herdr";
|
|
194
|
+
/** AC-1/AC-2: clears the pane and shows the dim waiting line; only herdr panes get escapes (AC-7). */
|
|
195
|
+
const showWaiting = (last) => {
|
|
196
|
+
if (herdr)
|
|
197
|
+
process.stdout.write(`${CLEAR_SCREEN}${waitingLine(role, last)}\n`);
|
|
198
|
+
};
|
|
199
|
+
/** The last turn of this role, so a restarted worker names it instead of claiming none ran. */
|
|
200
|
+
const finished = (dispatch) => {
|
|
201
|
+
const result = dispatch === null ? null : readJson(p.result(dispatch.key));
|
|
202
|
+
return result === null ? undefined : { state: result.exit_code === 0 ? "DONE" : "FAILED", at: new Date(result.finished_at) };
|
|
203
|
+
};
|
|
204
|
+
let last = finished(readJson(p.dispatch(role)));
|
|
205
|
+
showWaiting(last);
|
|
206
|
+
for (;;) {
|
|
207
|
+
const state = readState(p);
|
|
208
|
+
// AC-7: the workflow is done; the final line explains that this pane can be closed.
|
|
209
|
+
if (herdr && state !== null && state.status === "ready_to_merge") {
|
|
210
|
+
log(`${role} worker exiting (workflow ${state.status})`);
|
|
211
|
+
process.stdout.write(`${completeLine(issue)}\n`);
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
if (state === null || state.status === "failed" || state.status === "stopped" || !alive(state.pids.supervisor)) {
|
|
215
|
+
log(`${role} worker exiting (workflow ${state?.status ?? "missing"})`);
|
|
216
|
+
// AC-6: a worker that is alive when its workflow stops (or its supervisor disappears)
|
|
217
|
+
// explains how to resume. A failed turn keeps its error and prints nothing extra.
|
|
218
|
+
if (state !== null && state.status !== "failed")
|
|
219
|
+
process.stdout.write(`${stopLine(issue)}\n`);
|
|
220
|
+
return 0;
|
|
221
|
+
}
|
|
222
|
+
const dispatch = readJson(p.dispatch(role));
|
|
223
|
+
if (dispatch !== null && !existsSync(p.result(dispatch.key))) {
|
|
224
|
+
// AC-3: a starting turn clears the pane and resets colours before the agent's output.
|
|
225
|
+
if (herdr)
|
|
226
|
+
process.stdout.write(`${CLEAR_SCREEN}${RESET_COLOURS}`);
|
|
227
|
+
last = await turn(root, role, dispatch, env, p);
|
|
228
|
+
showWaiting(last);
|
|
229
|
+
}
|
|
230
|
+
await sleep(250);
|
|
231
|
+
}
|
|
232
|
+
}
|