@gevezex/gdt 0.5.0 → 0.5.3
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 +3 -1
- package/dist/git.js +29 -0
- package/dist/github.js +20 -2
- package/dist/prompts.js +1 -0
- package/dist/supervisor.js +127 -48
- package/dist/worker.js +8 -6
- package/dist/workflow.js +6 -3
- package/package.json +1 -1
- package/roles/reviewer.md +2 -2
- package/roles/tester.md +2 -2
package/README.md
CHANGED
|
@@ -486,7 +486,9 @@ to be told: it blocks until the workflow needs attention.
|
|
|
486
486
|
|
|
487
487
|
The roles work in the checkout where you run `gdt start`: the developer checks
|
|
488
488
|
out the feature branch there, and the tester and reviewer read that same working
|
|
489
|
-
tree.
|
|
489
|
+
tree. When the pull request head is checked out in another worktree (for example
|
|
490
|
+
one the developer created) and not in that checkout, the tester and reviewer
|
|
491
|
+
turns run in that worktree instead. `gdt start` needs a **clean working tree** and refuses to run otherwise
|
|
490
492
|
("Commit or stash before starting."), so commit or stash first. Do not edit that
|
|
491
493
|
checkout while a workflow runs; if you want to keep working, use a separate
|
|
492
494
|
clone for gdt.
|
package/dist/git.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
2
3
|
function git(args, cwd, env) {
|
|
3
4
|
return spawnSync("git", args, { cwd, env, encoding: "utf8" }).stdout ?? "";
|
|
4
5
|
}
|
|
@@ -23,3 +24,31 @@ export function checkout(root, env) {
|
|
|
23
24
|
branch: git(["symbolic-ref", "--quiet", "--short", "HEAD"], root, env).trim(),
|
|
24
25
|
};
|
|
25
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* #76: the directory a tester or reviewer turn for `head` runs in. That is `root` when it has `head`
|
|
29
|
+
* checked out, otherwise the first other worktree at `head` whose directory exists, otherwise `root`.
|
|
30
|
+
*/
|
|
31
|
+
export function headWorktree(root, head, env) {
|
|
32
|
+
const real = (path) => {
|
|
33
|
+
try {
|
|
34
|
+
return realpathSync(path);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const main = real(root) ?? root;
|
|
41
|
+
const atHead = [];
|
|
42
|
+
for (const entry of git(["worktree", "list", "--porcelain"], root, env).split("\n\n")) {
|
|
43
|
+
const fields = entry.split("\n");
|
|
44
|
+
const path = fields.find((f) => f.startsWith("worktree "))?.slice("worktree ".length);
|
|
45
|
+
if (path === undefined || !fields.includes(`HEAD ${head}`))
|
|
46
|
+
continue;
|
|
47
|
+
const dir = real(path);
|
|
48
|
+
if (dir !== null)
|
|
49
|
+
atHead.push(dir);
|
|
50
|
+
}
|
|
51
|
+
if (atHead.includes(main))
|
|
52
|
+
return root;
|
|
53
|
+
return atHead[0] ?? root;
|
|
54
|
+
}
|
package/dist/github.js
CHANGED
|
@@ -89,10 +89,28 @@ export function detectedChecks(cwd, env) {
|
|
|
89
89
|
return [];
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
|
-
/**
|
|
92
|
+
/** True when `body` contains a closing keyword ("Closes #n", "fixes #n", ...) for exactly issue `issue`. */
|
|
93
|
+
export function closesIssue(body, issue) {
|
|
94
|
+
return new RegExp(`(?<![\\w])(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s+#${issue}(?!\\d)`, "i").test(body);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The issue body and the workflow's pull requests: the open pull requests GitHub links as closing the
|
|
98
|
+
* issue, or, when there are none, the open pull requests whose body closes the issue without GitHub
|
|
99
|
+
* having linked them (`unlinked` is then true). The fallback costs one `gh api --paginate` call that
|
|
100
|
+
* reads every open pull request, so an older pull request is never cut off by a list limit.
|
|
101
|
+
*/
|
|
93
102
|
export function issueSnapshot(issue, cwd, env) {
|
|
94
103
|
const data = ghJson(["issue", "view", String(issue), "--json", "body,closedByPullRequestsReferences"], cwd, env);
|
|
95
|
-
|
|
104
|
+
const linked = (data.closedByPullRequestsReferences ?? []).map((pr) => pr.number);
|
|
105
|
+
if (linked.length > 0)
|
|
106
|
+
return { body: data.body, pullRequests: linked, unlinked: false };
|
|
107
|
+
const pages = ghJson(["api", "--paginate", "--slurp", "repos/{owner}/{repo}/pulls?state=open&per_page=100"], cwd, env);
|
|
108
|
+
const unlinked = pages
|
|
109
|
+
.flat()
|
|
110
|
+
.filter((pr) => closesIssue(pr.body ?? "", issue))
|
|
111
|
+
.map((pr) => pr.number)
|
|
112
|
+
.sort((x, y) => x - y);
|
|
113
|
+
return { body: data.body, pullRequests: unlinked, unlinked: unlinked.length > 0 };
|
|
96
114
|
}
|
|
97
115
|
/** All comments on an issue or pull request conversation, oldest first. */
|
|
98
116
|
export function comments(repo, number, cwd, env) {
|
package/dist/prompts.js
CHANGED
|
@@ -72,6 +72,7 @@ export function buildPrompt(role, dispatch, project) {
|
|
|
72
72
|
`Round: ${dispatch.round}`,
|
|
73
73
|
`Pull request: ${dispatch.pr_number === null ? "none yet" : `#${dispatch.pr_number}`}`,
|
|
74
74
|
`Head: ${dispatch.head ?? "none yet"}`,
|
|
75
|
+
...(dispatch.workdir === undefined ? [] : [`Working directory: ${dispatch.workdir}`]),
|
|
75
76
|
`issue_body_sha256: ${dispatch.issue_body_sha256}`,
|
|
76
77
|
`Acceptance criteria: ${dispatch.acceptance_criteria.join(", ")}`,
|
|
77
78
|
`Language: ${locale.name}`,
|
package/dist/supervisor.js
CHANGED
|
@@ -3,11 +3,12 @@ import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { formatSignals, groupUsage, nextBaseline, opencodeDatabase, sample, signals } from "./activity.js";
|
|
6
|
+
import { formatSignals, groupUsage, nextBaseline, opencodeDatabase, opencodeMtime, sample, signals, } from "./activity.js";
|
|
7
7
|
import { backendFor } from "./backends/index.js";
|
|
8
8
|
import { loadConfig, ROLES } from "./config.js";
|
|
9
9
|
import { sectionText, validateContract } from "./contract.js";
|
|
10
10
|
import { decide, openFindings } from "./decision.js";
|
|
11
|
+
import { headWorktree } from "./git.js";
|
|
11
12
|
import { comments, issueSnapshot, pullRequest, repository, viewer } from "./github.js";
|
|
12
13
|
import { loadLocale } from "./locale.js";
|
|
13
14
|
import { notify } from "./notify.js";
|
|
@@ -47,6 +48,10 @@ export function inactiveReason(role, idle, limit) {
|
|
|
47
48
|
export function hardLimitReason(role, minutes) {
|
|
48
49
|
return `${role} turn still active after ${minutes} minutes`;
|
|
49
50
|
}
|
|
51
|
+
/** #71 AC-4: the blocked reason of a turn whose agent exited and stayed active until its hard limit. */
|
|
52
|
+
export function exitedActiveReason(role, minutes) {
|
|
53
|
+
return `${role} exited without a visible handoff and stayed active until the ${minutes}-minute hard limit`;
|
|
54
|
+
}
|
|
50
55
|
/** AC-6: the blocked reason of a turn whose agent and worker are gone without a result. */
|
|
51
56
|
export function exitedReason(role) {
|
|
52
57
|
return `${role} agent exited without a result`;
|
|
@@ -63,6 +68,10 @@ export function turnLastActivity(inflight) {
|
|
|
63
68
|
export function turnHardLimit(inflight, maxMinutes) {
|
|
64
69
|
return inflight.hard_limit === undefined ? turnDeadline(inflight.dispatched_at, maxMinutes) : new Date(inflight.hard_limit);
|
|
65
70
|
}
|
|
71
|
+
/** The turn's hard limit in minutes after dispatch, rounded to two decimals for a reason text. */
|
|
72
|
+
function hardLimitMinutes(inflight, hardLimit) {
|
|
73
|
+
return Math.round(((hardLimit.getTime() - Date.parse(inflight.dispatched_at)) / 60_000) * 100) / 100;
|
|
74
|
+
}
|
|
66
75
|
/** AC-2/AC-6: true when a blocked reason is a stopped or exited turn, so `gdt retry` can lift it. */
|
|
67
76
|
export function isTimeoutReason(reason) {
|
|
68
77
|
return /^\w+ turn inactive for [\d.]+ minutes after the [\d.]+-minute limit$/.test(reason) || / agent exited without a result$/.test(reason);
|
|
@@ -93,6 +102,24 @@ function roleOf(record) {
|
|
|
93
102
|
return null;
|
|
94
103
|
}
|
|
95
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* #76 AC-5/AC-6: why a dispatch that already ran is blocked. A tester or reviewer record for another
|
|
107
|
+
* head, posted after the role's dispatch `key`, is named with both heads and the turn's working directory.
|
|
108
|
+
*/
|
|
109
|
+
export function alreadyRanReason(role, key, dispatch, trusted, root) {
|
|
110
|
+
if (dispatch?.key === key && dispatch.head !== null) {
|
|
111
|
+
const since = Date.parse(dispatch.dispatched_at);
|
|
112
|
+
const heads = trusted.flatMap((r) => (r.kind === "test" || r.kind === "review") && r.data.role === role && Date.parse(r.created_at) >= since ? [r.data.head] : []);
|
|
113
|
+
const other = heads.filter((head) => head !== dispatch.head).at(-1);
|
|
114
|
+
if (other !== undefined)
|
|
115
|
+
return `${role} record is for head ${other}, not the dispatch head ${dispatch.head}; the turn ran in ${dispatch.workdir ?? root}`;
|
|
116
|
+
}
|
|
117
|
+
return `${role} already ran for this dispatch without a usable record`;
|
|
118
|
+
}
|
|
119
|
+
/** Whether `reason` is an already-ran block (#76: also when it names a record for another head); `gdt retry` lifts it. */
|
|
120
|
+
export function isAlreadyRanReason(reason) {
|
|
121
|
+
return reason.includes(" already ran for this dispatch ") || reason.includes(", not the dispatch head ");
|
|
122
|
+
}
|
|
96
123
|
/** The record kind each workflow role writes; a diagnostic of another kind is never that role's. */
|
|
97
124
|
const ROLE_OF_KIND = { handoff: "developer", test: "tester", review: "reviewer" };
|
|
98
125
|
/** AC-1: the newest invalid role record the role posted after the turn's `after_comment_id`. */
|
|
@@ -121,6 +148,8 @@ class Supervisor {
|
|
|
121
148
|
backend;
|
|
122
149
|
trusted = [];
|
|
123
150
|
reportWarned = false;
|
|
151
|
+
/** Unlinked pull requests already logged, so the fallback line appears once per pull request. */
|
|
152
|
+
unlinkedLogged = new Set();
|
|
124
153
|
agents;
|
|
125
154
|
constructor(p, issue, config, locale, env, state, backend) {
|
|
126
155
|
this.p = p;
|
|
@@ -242,18 +271,37 @@ class Supervisor {
|
|
|
242
271
|
sleepSync(100);
|
|
243
272
|
}
|
|
244
273
|
}
|
|
274
|
+
/** The opencode session database when the role's agent is opencode, otherwise null. */
|
|
275
|
+
opencodeDb(role) {
|
|
276
|
+
if (this.effectiveAgent(role) !== "opencode")
|
|
277
|
+
return null;
|
|
278
|
+
const home = this.env.HOME === undefined || this.env.HOME === "" ? homedir() : this.env.HOME;
|
|
279
|
+
return opencodeDatabase(this.env, home);
|
|
280
|
+
}
|
|
245
281
|
/** The activity sample of an in-flight turn (issue Definitions). */
|
|
246
282
|
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
283
|
return sample({
|
|
250
284
|
root: this.p.root,
|
|
251
285
|
env: this.env,
|
|
252
286
|
pgid,
|
|
253
287
|
logFile: this.config.workflow.terminal === "headless" ? join(this.p.logs, `${running.role}.log`) : null,
|
|
254
|
-
opencodeDb:
|
|
288
|
+
opencodeDb: this.opencodeDb(running.role),
|
|
255
289
|
});
|
|
256
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* #71 post-exit activity: true when the opencode database changed since the previous handoff check,
|
|
293
|
+
* or since the agent exited on the first check. Never fires for other agents or a stopped turn.
|
|
294
|
+
*/
|
|
295
|
+
postExitActivity(inflight, finishedAt) {
|
|
296
|
+
const database = inflight.stopped === true ? null : this.opencodeDb(inflight.role);
|
|
297
|
+
if (database === null)
|
|
298
|
+
return false;
|
|
299
|
+
const current = opencodeMtime(database);
|
|
300
|
+
const previous = inflight.handoff_opencode ?? (finishedAt === undefined ? undefined : Date.parse(finishedAt));
|
|
301
|
+
if (current !== null)
|
|
302
|
+
inflight.handoff_opencode = current;
|
|
303
|
+
return current !== null && previous !== undefined && current > previous;
|
|
304
|
+
}
|
|
257
305
|
/**
|
|
258
306
|
* AC-5: applies a hard limit granted by `gdt extend` for this turn. Returns true when a request was
|
|
259
307
|
* read; the caller removes it only after saving the poll's status, so `gdt status` never sees the
|
|
@@ -331,7 +379,7 @@ class Supervisor {
|
|
|
331
379
|
log(`activity check for ${running.key}: ${formatSignals(fired)}; last activity ${lastActivity}`);
|
|
332
380
|
const context = { role: running.role, round: running.round };
|
|
333
381
|
const hardLimit = turnHardLimit(running, workflow.turn_max_minutes);
|
|
334
|
-
const hardMinutes =
|
|
382
|
+
const hardMinutes = hardLimitMinutes(running, hardLimit);
|
|
335
383
|
if (running.at_hard_limit === true) {
|
|
336
384
|
// AC-4: the turn keeps running until `gdt extend`, `gdt retry` or its result.
|
|
337
385
|
this.setStatus("blocked", hardLimitReason(running.role, hardMinutes), context);
|
|
@@ -399,7 +447,7 @@ class Supervisor {
|
|
|
399
447
|
// leave a hung turn `running` forever.
|
|
400
448
|
if (this.settleTurn(now) !== null)
|
|
401
449
|
return "continue";
|
|
402
|
-
const { body, pullRequests } = issueSnapshot(this.issue, this.p.root, this.env);
|
|
450
|
+
const { body, pullRequests, unlinked } = issueSnapshot(this.issue, this.p.root, this.env);
|
|
403
451
|
const bodySha = sha256(body);
|
|
404
452
|
const changelog = sectionText(body, this.locale.sections.changelog) ?? "";
|
|
405
453
|
const known = this.state.contract;
|
|
@@ -421,48 +469,56 @@ class Supervisor {
|
|
|
421
469
|
}
|
|
422
470
|
// A turn in flight: nothing to fetch until its worker has reported.
|
|
423
471
|
const running = this.state.inflight;
|
|
472
|
+
let finishedAt;
|
|
424
473
|
if (running !== null) {
|
|
425
474
|
if (running.violation !== undefined) {
|
|
426
475
|
this.setRoleState(running.role, "FAILED");
|
|
427
476
|
this.setStatus("blocked", running.violation, { role: running.role, round: running.round });
|
|
428
477
|
return "continue";
|
|
429
478
|
}
|
|
430
|
-
if (running.missing) {
|
|
431
|
-
//
|
|
432
|
-
|
|
433
|
-
this.setStatus("blocked", reason, { role: running.role, round: running.round });
|
|
434
|
-
return "continue";
|
|
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.
|
|
438
|
-
const result = readJson(this.p.result(running.key));
|
|
439
|
-
if (result === null) {
|
|
440
|
-
this.setStatus("running", `${running.role} turn in progress`, { role: running.role, round: running.round });
|
|
441
|
-
return "continue";
|
|
442
|
-
}
|
|
443
|
-
if (result.violation !== undefined) {
|
|
444
|
-
// A broken role boundary outranks the exit code: the checkout can no longer be trusted.
|
|
445
|
-
running.violation = result.violation;
|
|
446
|
-
this.setRoleState(running.role, "FAILED");
|
|
447
|
-
this.setStatus("blocked", result.violation, { role: running.role, round: running.round, exit_code: result.exit_code });
|
|
479
|
+
if (running.missing && running.stopped === true) {
|
|
480
|
+
// #71 Out of scope: a stopped turn stays blocked until a retry, whatever record arrives later.
|
|
481
|
+
this.setStatus("blocked", running.missing_reason ?? `${running.role} finished without a visible handoff`, { role: running.role, round: running.round });
|
|
448
482
|
return "continue";
|
|
449
483
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
this.
|
|
455
|
-
|
|
456
|
-
|
|
484
|
+
// #71 AC-5: a turn blocked on a missing handoff goes on to the fetch, so a late record lifts the block.
|
|
485
|
+
if (!running.missing) {
|
|
486
|
+
// A turn past its deadline was already settled at the top of this tick, before the GitHub
|
|
487
|
+
// fetch. A result-less turn that reaches this point is still before its deadline.
|
|
488
|
+
const result = readJson(this.p.result(running.key));
|
|
489
|
+
if (result === null) {
|
|
490
|
+
this.setStatus("running", `${running.role} turn in progress`, { role: running.role, round: running.round });
|
|
491
|
+
return "continue";
|
|
492
|
+
}
|
|
493
|
+
if (result.violation !== undefined) {
|
|
494
|
+
// A broken role boundary outranks the exit code: the checkout can no longer be trusted.
|
|
495
|
+
running.violation = result.violation;
|
|
496
|
+
this.setRoleState(running.role, "FAILED");
|
|
497
|
+
this.setStatus("blocked", result.violation, { role: running.role, round: running.round, exit_code: result.exit_code });
|
|
498
|
+
return "continue";
|
|
499
|
+
}
|
|
500
|
+
if (result.exit_code !== 0) {
|
|
501
|
+
const reason = result.exit_code === null
|
|
502
|
+
? `${running.role} turn was interrupted`
|
|
503
|
+
: `${running.role} turn failed (exit code ${result.exit_code})`;
|
|
504
|
+
this.setRoleState(running.role, "FAILED");
|
|
505
|
+
this.setStatus("failed", reason, { role: running.role, round: running.round, exit_code: result.exit_code });
|
|
506
|
+
return "exit";
|
|
507
|
+
}
|
|
508
|
+
// AC-5: the turn finished successfully.
|
|
509
|
+
finishedAt = result.finished_at;
|
|
510
|
+
this.setRoleState(running.role, "DONE");
|
|
457
511
|
}
|
|
458
|
-
// AC-5: the turn finished successfully.
|
|
459
|
-
this.setRoleState(running.role, "DONE");
|
|
460
512
|
}
|
|
461
513
|
if (pullRequests.length > 1) {
|
|
462
514
|
this.setStatus("blocked", `more than one open pull request closes #${this.issue}: ${pullRequests.map((n) => `#${n}`).join(", ")}`);
|
|
463
515
|
return "continue";
|
|
464
516
|
}
|
|
465
517
|
const prNumber = pullRequests[0];
|
|
518
|
+
if (unlinked && prNumber !== undefined && !this.unlinkedLogged.has(prNumber)) {
|
|
519
|
+
this.unlinkedLogged.add(prNumber);
|
|
520
|
+
log(`pull request #${prNumber} closes #${this.issue} in its body but GitHub has not linked it; using it`);
|
|
521
|
+
}
|
|
466
522
|
const pr = prNumber === undefined ? null : pullRequest(prNumber, this.p.root, this.env);
|
|
467
523
|
if (pr !== null) {
|
|
468
524
|
if (this.state.head !== null && this.state.head !== pr.head) {
|
|
@@ -480,31 +536,52 @@ class Supervisor {
|
|
|
480
536
|
const inflight = this.state.inflight;
|
|
481
537
|
if (inflight !== null) {
|
|
482
538
|
const visible = trusted.some((r) => roleOf(r) === inflight.role && r.comment_id > inflight.after_comment_id);
|
|
539
|
+
const context = { role: inflight.role, round: inflight.round };
|
|
540
|
+
if (!visible && inflight.missing) {
|
|
541
|
+
// AC-1: an invalid record keeps its reason.
|
|
542
|
+
this.setStatus("blocked", inflight.missing_reason ?? `${inflight.role} finished without a visible handoff`, context);
|
|
543
|
+
return "continue";
|
|
544
|
+
}
|
|
483
545
|
if (!visible) {
|
|
484
|
-
inflight.checks += 1;
|
|
485
546
|
// AC-1: an invalid record is named with its validation error instead of `not visible`;
|
|
486
547
|
// AC-4: without any role record the message and log line stay as before.
|
|
487
548
|
const invalid = invalidRecordFor(inflight, diagnostics, this.trusted);
|
|
488
|
-
|
|
489
|
-
|
|
549
|
+
const seen = invalid === undefined ? "not visible" : invalid.reason;
|
|
550
|
+
const retryRecord = (extra) =>
|
|
551
|
+
// AC-2: `gdt retry` keeps this record; the role's next turn reads it into its prompt.
|
|
552
|
+
invalid === undefined
|
|
553
|
+
? extra
|
|
554
|
+
: { ...extra, retry_record: { role: inflight.role, kind: invalid.kind, comment_id: invalid.comment_id, reason: invalid.reason } };
|
|
555
|
+
// #71 AC-2: a check with post-exit activity is not a missed check.
|
|
556
|
+
if (this.postExitActivity(inflight, finishedAt)) {
|
|
557
|
+
log(`handoff check for ${inflight.key}: ${seen}, agent still active (opencode=yes)`);
|
|
558
|
+
const hardLimit = turnHardLimit(inflight, this.config.workflow.turn_max_minutes);
|
|
559
|
+
if (now.getTime() >= hardLimit.getTime()) {
|
|
560
|
+
// #71 AC-4: the hard limit bounds the wait, also when unrelated opencode use keeps it active.
|
|
561
|
+
inflight.missing = true;
|
|
562
|
+
inflight.missing_reason = exitedActiveReason(inflight.role, hardLimitMinutes(inflight, hardLimit));
|
|
563
|
+
this.setStatus("blocked", inflight.missing_reason, retryRecord(context));
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
this.setStatus("running", `waiting for the ${inflight.role} handoff; agent exited but is still active`, context);
|
|
567
|
+
}
|
|
568
|
+
return "continue";
|
|
569
|
+
}
|
|
570
|
+
inflight.checks += 1;
|
|
571
|
+
log(`handoff check ${inflight.checks}/${this.config.workflow.handoff_checks} for ${inflight.key}: ${seen}`);
|
|
490
572
|
if (inflight.checks >= this.config.workflow.handoff_checks) {
|
|
491
573
|
inflight.missing = true;
|
|
492
|
-
|
|
493
|
-
|
|
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);
|
|
574
|
+
inflight.missing_reason = invalid === undefined ? `${inflight.role} finished without a visible handoff` : invalidRecordReason(inflight.role, invalid);
|
|
575
|
+
this.setStatus("blocked", inflight.missing_reason, retryRecord(context));
|
|
502
576
|
}
|
|
503
577
|
else {
|
|
504
|
-
this.setStatus("running", `waiting for the ${inflight.role} handoff to become visible`,
|
|
578
|
+
this.setStatus("running", `waiting for the ${inflight.role} handoff to become visible`, context);
|
|
505
579
|
}
|
|
506
580
|
return "continue";
|
|
507
581
|
}
|
|
582
|
+
// #71 AC-5: a late record lifts the block as if it had arrived in time.
|
|
583
|
+
if (inflight.missing)
|
|
584
|
+
log(`late handoff for ${inflight.key} accepted`);
|
|
508
585
|
this.state.inflight = null;
|
|
509
586
|
// AC-2: the role's rejection explanation is spent once a valid record is visible.
|
|
510
587
|
if (this.state.retry_record?.role === inflight.role)
|
|
@@ -533,7 +610,7 @@ class Supervisor {
|
|
|
533
610
|
}
|
|
534
611
|
const key = dispatchKey(decision, pr?.head ?? null, bodySha);
|
|
535
612
|
if (this.state.dispatched.includes(key)) {
|
|
536
|
-
this.setStatus("blocked",
|
|
613
|
+
this.setStatus("blocked", alreadyRanReason(decision.role, key, readJson(this.p.dispatch(decision.role)), trusted, this.p.root), {
|
|
537
614
|
role: decision.role,
|
|
538
615
|
round: decision.round,
|
|
539
616
|
blocked_key: key,
|
|
@@ -556,6 +633,8 @@ class Supervisor {
|
|
|
556
633
|
language: this.config.language,
|
|
557
634
|
directives,
|
|
558
635
|
dispatched_at: now.toISOString(),
|
|
636
|
+
// #76 AC-1/AC-3: a verifier runs where the pull request head is checked out.
|
|
637
|
+
workdir: pr !== null && decision.role !== "developer" ? headWorktree(this.p.root, pr.head, this.env) : this.p.root,
|
|
559
638
|
};
|
|
560
639
|
// Dispatch file first: if gdt stop lands in between, the restarted supervisor dispatches the same key
|
|
561
640
|
// again, and the worker's exclusive "started" marker still runs it at most once.
|
package/dist/worker.js
CHANGED
|
@@ -21,7 +21,7 @@ export function waitingLine(role, last) {
|
|
|
21
21
|
return `${DIM}${role} waiting · ${detail}${RESET_COLOURS}`;
|
|
22
22
|
}
|
|
23
23
|
/** The command for one unattended turn: the role's agent adapter, or the test agent's script. */
|
|
24
|
-
export function invocation(roleName, role, root, promptFile, env) {
|
|
24
|
+
export function invocation(roleName, role, root, promptFile, env, cwd = root) {
|
|
25
25
|
if (role.agent === TEST_AGENT) {
|
|
26
26
|
if (role.script === undefined || !testAgentsEnabled(env))
|
|
27
27
|
throw new Error(`agent "${TEST_AGENT}" requires GDT_TEST_AGENTS=1 and a script`);
|
|
@@ -30,7 +30,7 @@ export function invocation(roleName, role, root, promptFile, env) {
|
|
|
30
30
|
const adapter = adapterFor(role.agent);
|
|
31
31
|
if (adapter === undefined)
|
|
32
32
|
throw new Error(`no adapter for agent "${role.agent}" yet`);
|
|
33
|
-
return adapter.buildInvocation(roleName, role.model, promptFile,
|
|
33
|
+
return adapter.buildInvocation(roleName, role.model, promptFile, cwd);
|
|
34
34
|
}
|
|
35
35
|
function log(line) {
|
|
36
36
|
process.stdout.write(`${logTimestamp()} ${line}\n`);
|
|
@@ -165,6 +165,8 @@ async function turn(root, role, dispatch, env, p) {
|
|
|
165
165
|
log("invalid config; run gdt doctor");
|
|
166
166
|
return finish(78);
|
|
167
167
|
}
|
|
168
|
+
// #76 AC-1/AC-2: the turn and its boundary check run where the supervisor found the head.
|
|
169
|
+
const workdir = dispatch.workdir ?? root;
|
|
168
170
|
const promptFile = p.prompt(dispatch.key);
|
|
169
171
|
// AC-2: a retried turn is told how its previous record was rejected. Reading the state (rather than
|
|
170
172
|
// the dispatch file) avoids a race with the worker that started before `gdt retry` finished.
|
|
@@ -182,7 +184,7 @@ async function turn(root, role, dispatch, env, p) {
|
|
|
182
184
|
const roleConfig = override === undefined ? report.roles[role] : { ...report.roles[role], agent: override.agent, model: override.model };
|
|
183
185
|
let inv;
|
|
184
186
|
try {
|
|
185
|
-
inv = invocation(role, roleConfig, root, promptFile, env);
|
|
187
|
+
inv = invocation(role, roleConfig, root, promptFile, env, workdir);
|
|
186
188
|
}
|
|
187
189
|
catch (err) {
|
|
188
190
|
log(err instanceof Error ? err.message : String(err));
|
|
@@ -200,9 +202,9 @@ async function turn(root, role, dispatch, env, p) {
|
|
|
200
202
|
GDT_ISSUE_BODY_SHA256: dispatch.issue_body_sha256,
|
|
201
203
|
GDT_DISPATCH_KEY: dispatch.key,
|
|
202
204
|
};
|
|
203
|
-
const before = checkout(
|
|
204
|
-
const exitCode = await runInvocation(inv,
|
|
205
|
-
const violation = boundaryViolation(role, before,
|
|
205
|
+
const before = checkout(workdir, env);
|
|
206
|
+
const exitCode = await runInvocation(inv, workdir, turnEnv, (pid) => writeFileSync(p.agent(dispatch.key), `${pid}\n`));
|
|
207
|
+
const violation = boundaryViolation(role, before, workdir, env);
|
|
206
208
|
if (violation !== undefined)
|
|
207
209
|
log(violation);
|
|
208
210
|
return finish(exitCode, violation);
|
package/dist/workflow.js
CHANGED
|
@@ -11,7 +11,7 @@ import { issueBody, repository } from "./github.js";
|
|
|
11
11
|
import { loadLocale } from "./locale.js";
|
|
12
12
|
import { isInvalidRecordReason } from "./protocol.js";
|
|
13
13
|
import { alive, lockHolder, paths, readJson, readOverrides, readState, writeJsonAtomic, writeState, } from "./state.js";
|
|
14
|
-
import { activeReason, cliPath, isHardLimitReason, isTimeoutReason, turnDeadline, turnHardLimit, turnLastActivity, } from "./supervisor.js";
|
|
14
|
+
import { activeReason, cliPath, isAlreadyRanReason, isHardLimitReason, isTimeoutReason, turnDeadline, turnHardLimit, turnLastActivity, } from "./supervisor.js";
|
|
15
15
|
export const ok = (stdout) => ({ code: 0, stdout, stderr: "" });
|
|
16
16
|
export const fail = (stderr) => ({ code: 1, stdout: "", stderr });
|
|
17
17
|
/** Statuses that need a person or end the workflow: `gdt wait` returns when it reaches one. */
|
|
@@ -185,6 +185,9 @@ export function stop(issue, cwd, env) {
|
|
|
185
185
|
stopProcesses(p, before, env);
|
|
186
186
|
// Re-read: the supervisor may have written state until it was stopped.
|
|
187
187
|
const state = readState(p) ?? before;
|
|
188
|
+
// #71 Out of scope: a late record for a stopped turn is never accepted.
|
|
189
|
+
if (state.inflight !== null)
|
|
190
|
+
state.inflight.stopped = true;
|
|
188
191
|
// A failed turn keeps its status and reason; only the recovery step (gdt retry) clears it.
|
|
189
192
|
if (state.status === "failed")
|
|
190
193
|
Object.assign(state, { pids: { supervisor: null, workers: {} } });
|
|
@@ -203,7 +206,7 @@ function retryable(state) {
|
|
|
203
206
|
return true;
|
|
204
207
|
return (state.status === "blocked" &&
|
|
205
208
|
(state.reason.includes("without a visible handoff") ||
|
|
206
|
-
state.reason
|
|
209
|
+
isAlreadyRanReason(state.reason) ||
|
|
207
210
|
isTimeoutReason(state.reason) ||
|
|
208
211
|
isHardLimitReason(state.reason) ||
|
|
209
212
|
isInvalidRecordReason(state.reason)));
|
|
@@ -247,7 +250,7 @@ function blockedHint(issue, reason) {
|
|
|
247
250
|
if (isHardLimitReason(reason))
|
|
248
251
|
return `gdt extend ${issue} or gdt retry ${issue}`;
|
|
249
252
|
if (reason.includes("without a visible handoff") ||
|
|
250
|
-
reason
|
|
253
|
+
isAlreadyRanReason(reason) ||
|
|
251
254
|
isTimeoutReason(reason) ||
|
|
252
255
|
isInvalidRecordReason(reason)) {
|
|
253
256
|
return `gdt retry ${issue}`;
|
package/package.json
CHANGED
package/roles/reviewer.md
CHANGED
|
@@ -7,8 +7,8 @@ quality to be merged?
|
|
|
7
7
|
## Your job this turn
|
|
8
8
|
|
|
9
9
|
1. Read the issue body with `gh issue view <issue>`. The current body is the only specification.
|
|
10
|
-
2. Read the pull request and its diff with `gh pr view <pr>` and `gh pr diff <pr>`.
|
|
11
|
-
checkout
|
|
10
|
+
2. Read the pull request and its diff with `gh pr view <pr>` and `gh pr diff <pr>`. Your turn runs in a
|
|
11
|
+
checkout at the pull request head (the working directory in this prompt): confirm that `git rev-parse HEAD` equals the
|
|
12
12
|
head in this prompt (otherwise post `blocked`), read the code in context, and run the build and
|
|
13
13
|
tests if that helps. Never check out another commit.
|
|
14
14
|
3. Check, in this order:
|
package/roles/tester.md
CHANGED
|
@@ -7,8 +7,8 @@ check behaviour yourself.
|
|
|
7
7
|
## Your job this turn
|
|
8
8
|
|
|
9
9
|
1. Read the issue body with `gh issue view <issue>`. The current body is the only specification.
|
|
10
|
-
2.
|
|
11
|
-
`git rev-parse HEAD` equals the head in this prompt. If it does not, post `blocked` and say so;
|
|
10
|
+
2. Your turn runs in a checkout at the pull request head: the working directory in this prompt.
|
|
11
|
+
Confirm that `git rev-parse HEAD` equals the head in this prompt. If it does not, post `blocked` and say so;
|
|
12
12
|
never check out another commit.
|
|
13
13
|
3. For every AC: turn Given, When, Then and Example into a concrete check. Run the project's tests,
|
|
14
14
|
run the program, and try the example and its edge cases. Record what you did and what you saw.
|