@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 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. `gdt start` needs a **clean working tree** and refuses to run otherwise
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
- /** The issue body and the open pull requests that close the issue ("Closes #n"). */
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
- return { body: data.body, pullRequests: (data.closedByPullRequestsReferences ?? []).map((pr) => pr.number) };
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}`,
@@ -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: agent === "opencode" ? opencodeDatabase(this.env, home) : null,
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 = Math.round(((hardLimit.getTime() - Date.parse(running.dispatched_at)) / 60_000) * 100) / 100;
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
- // 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 });
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
- if (result.exit_code !== 0) {
451
- const reason = result.exit_code === null
452
- ? `${running.role} turn was interrupted`
453
- : `${running.role} turn failed (exit code ${result.exit_code})`;
454
- this.setRoleState(running.role, "FAILED");
455
- this.setStatus("failed", reason, { role: running.role, round: running.round, exit_code: result.exit_code });
456
- return "exit";
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
- log(`handoff check ${inflight.checks}/${this.config.workflow.handoff_checks} for ${inflight.key}: ` +
489
- (invalid === undefined ? "not visible" : invalid.reason));
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
- 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);
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`, { role: inflight.role, round: inflight.round });
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", `${decision.role} already ran for this dispatch without a usable record`, {
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, root);
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(root, env);
204
- const exitCode = await runInvocation(inv, root, turnEnv, (pid) => writeFileSync(p.agent(dispatch.key), `${pid}\n`));
205
- const violation = boundaryViolation(role, before, root, env);
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.includes("already ran") ||
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.includes("already ran") ||
253
+ isAlreadyRanReason(reason) ||
251
254
  isTimeoutReason(reason) ||
252
255
  isInvalidRecordReason(reason)) {
253
256
  return `gdt retry ${issue}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gevezex/gdt",
3
- "version": "0.5.0",
3
+ "version": "0.5.3",
4
4
  "description": "GitHub issues to merge-ready pull requests, with a developer, tester and reviewer agent.",
5
5
  "keywords": [
6
6
  "github",
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>`. All roles share one
11
- checkout, and it is already at the pull request head: confirm that `git rev-parse HEAD` equals the
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. All roles share one checkout, and it is already at the pull request head. Confirm that
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.