@gevezex/gdt 0.5.0 → 0.5.2

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.
@@ -3,7 +3,7 @@ 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";
@@ -47,6 +47,10 @@ export function inactiveReason(role, idle, limit) {
47
47
  export function hardLimitReason(role, minutes) {
48
48
  return `${role} turn still active after ${minutes} minutes`;
49
49
  }
50
+ /** #71 AC-4: the blocked reason of a turn whose agent exited and stayed active until its hard limit. */
51
+ export function exitedActiveReason(role, minutes) {
52
+ return `${role} exited without a visible handoff and stayed active until the ${minutes}-minute hard limit`;
53
+ }
50
54
  /** AC-6: the blocked reason of a turn whose agent and worker are gone without a result. */
51
55
  export function exitedReason(role) {
52
56
  return `${role} agent exited without a result`;
@@ -63,6 +67,10 @@ export function turnLastActivity(inflight) {
63
67
  export function turnHardLimit(inflight, maxMinutes) {
64
68
  return inflight.hard_limit === undefined ? turnDeadline(inflight.dispatched_at, maxMinutes) : new Date(inflight.hard_limit);
65
69
  }
70
+ /** The turn's hard limit in minutes after dispatch, rounded to two decimals for a reason text. */
71
+ function hardLimitMinutes(inflight, hardLimit) {
72
+ return Math.round(((hardLimit.getTime() - Date.parse(inflight.dispatched_at)) / 60_000) * 100) / 100;
73
+ }
66
74
  /** AC-2/AC-6: true when a blocked reason is a stopped or exited turn, so `gdt retry` can lift it. */
67
75
  export function isTimeoutReason(reason) {
68
76
  return /^\w+ turn inactive for [\d.]+ minutes after the [\d.]+-minute limit$/.test(reason) || / agent exited without a result$/.test(reason);
@@ -242,18 +250,37 @@ class Supervisor {
242
250
  sleepSync(100);
243
251
  }
244
252
  }
253
+ /** The opencode session database when the role's agent is opencode, otherwise null. */
254
+ opencodeDb(role) {
255
+ if (this.effectiveAgent(role) !== "opencode")
256
+ return null;
257
+ const home = this.env.HOME === undefined || this.env.HOME === "" ? homedir() : this.env.HOME;
258
+ return opencodeDatabase(this.env, home);
259
+ }
245
260
  /** The activity sample of an in-flight turn (issue Definitions). */
246
261
  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
262
  return sample({
250
263
  root: this.p.root,
251
264
  env: this.env,
252
265
  pgid,
253
266
  logFile: this.config.workflow.terminal === "headless" ? join(this.p.logs, `${running.role}.log`) : null,
254
- opencodeDb: agent === "opencode" ? opencodeDatabase(this.env, home) : null,
267
+ opencodeDb: this.opencodeDb(running.role),
255
268
  });
256
269
  }
270
+ /**
271
+ * #71 post-exit activity: true when the opencode database changed since the previous handoff check,
272
+ * or since the agent exited on the first check. Never fires for other agents or a stopped turn.
273
+ */
274
+ postExitActivity(inflight, finishedAt) {
275
+ const database = inflight.stopped === true ? null : this.opencodeDb(inflight.role);
276
+ if (database === null)
277
+ return false;
278
+ const current = opencodeMtime(database);
279
+ const previous = inflight.handoff_opencode ?? (finishedAt === undefined ? undefined : Date.parse(finishedAt));
280
+ if (current !== null)
281
+ inflight.handoff_opencode = current;
282
+ return current !== null && previous !== undefined && current > previous;
283
+ }
257
284
  /**
258
285
  * AC-5: applies a hard limit granted by `gdt extend` for this turn. Returns true when a request was
259
286
  * read; the caller removes it only after saving the poll's status, so `gdt status` never sees the
@@ -331,7 +358,7 @@ class Supervisor {
331
358
  log(`activity check for ${running.key}: ${formatSignals(fired)}; last activity ${lastActivity}`);
332
359
  const context = { role: running.role, round: running.round };
333
360
  const hardLimit = turnHardLimit(running, workflow.turn_max_minutes);
334
- const hardMinutes = Math.round(((hardLimit.getTime() - Date.parse(running.dispatched_at)) / 60_000) * 100) / 100;
361
+ const hardMinutes = hardLimitMinutes(running, hardLimit);
335
362
  if (running.at_hard_limit === true) {
336
363
  // AC-4: the turn keeps running until `gdt extend`, `gdt retry` or its result.
337
364
  this.setStatus("blocked", hardLimitReason(running.role, hardMinutes), context);
@@ -421,42 +448,46 @@ class Supervisor {
421
448
  }
422
449
  // A turn in flight: nothing to fetch until its worker has reported.
423
450
  const running = this.state.inflight;
451
+ let finishedAt;
424
452
  if (running !== null) {
425
453
  if (running.violation !== undefined) {
426
454
  this.setRoleState(running.role, "FAILED");
427
455
  this.setStatus("blocked", running.violation, { role: running.role, round: running.round });
428
456
  return "continue";
429
457
  }
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 });
458
+ if (running.missing && running.stopped === true) {
459
+ // #71 Out of scope: a stopped turn stays blocked until a retry, whatever record arrives later.
460
+ this.setStatus("blocked", running.missing_reason ?? `${running.role} finished without a visible handoff`, { role: running.role, round: running.round });
448
461
  return "continue";
449
462
  }
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";
463
+ // #71 AC-5: a turn blocked on a missing handoff goes on to the fetch, so a late record lifts the block.
464
+ if (!running.missing) {
465
+ // A turn past its deadline was already settled at the top of this tick, before the GitHub
466
+ // fetch. A result-less turn that reaches this point is still before its deadline.
467
+ const result = readJson(this.p.result(running.key));
468
+ if (result === null) {
469
+ this.setStatus("running", `${running.role} turn in progress`, { role: running.role, round: running.round });
470
+ return "continue";
471
+ }
472
+ if (result.violation !== undefined) {
473
+ // A broken role boundary outranks the exit code: the checkout can no longer be trusted.
474
+ running.violation = result.violation;
475
+ this.setRoleState(running.role, "FAILED");
476
+ this.setStatus("blocked", result.violation, { role: running.role, round: running.round, exit_code: result.exit_code });
477
+ return "continue";
478
+ }
479
+ if (result.exit_code !== 0) {
480
+ const reason = result.exit_code === null
481
+ ? `${running.role} turn was interrupted`
482
+ : `${running.role} turn failed (exit code ${result.exit_code})`;
483
+ this.setRoleState(running.role, "FAILED");
484
+ this.setStatus("failed", reason, { role: running.role, round: running.round, exit_code: result.exit_code });
485
+ return "exit";
486
+ }
487
+ // AC-5: the turn finished successfully.
488
+ finishedAt = result.finished_at;
489
+ this.setRoleState(running.role, "DONE");
457
490
  }
458
- // AC-5: the turn finished successfully.
459
- this.setRoleState(running.role, "DONE");
460
491
  }
461
492
  if (pullRequests.length > 1) {
462
493
  this.setStatus("blocked", `more than one open pull request closes #${this.issue}: ${pullRequests.map((n) => `#${n}`).join(", ")}`);
@@ -480,31 +511,52 @@ class Supervisor {
480
511
  const inflight = this.state.inflight;
481
512
  if (inflight !== null) {
482
513
  const visible = trusted.some((r) => roleOf(r) === inflight.role && r.comment_id > inflight.after_comment_id);
514
+ const context = { role: inflight.role, round: inflight.round };
515
+ if (!visible && inflight.missing) {
516
+ // AC-1: an invalid record keeps its reason.
517
+ this.setStatus("blocked", inflight.missing_reason ?? `${inflight.role} finished without a visible handoff`, context);
518
+ return "continue";
519
+ }
483
520
  if (!visible) {
484
- inflight.checks += 1;
485
521
  // AC-1: an invalid record is named with its validation error instead of `not visible`;
486
522
  // AC-4: without any role record the message and log line stay as before.
487
523
  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));
524
+ const seen = invalid === undefined ? "not visible" : invalid.reason;
525
+ const retryRecord = (extra) =>
526
+ // AC-2: `gdt retry` keeps this record; the role's next turn reads it into its prompt.
527
+ invalid === undefined
528
+ ? extra
529
+ : { ...extra, retry_record: { role: inflight.role, kind: invalid.kind, comment_id: invalid.comment_id, reason: invalid.reason } };
530
+ // #71 AC-2: a check with post-exit activity is not a missed check.
531
+ if (this.postExitActivity(inflight, finishedAt)) {
532
+ log(`handoff check for ${inflight.key}: ${seen}, agent still active (opencode=yes)`);
533
+ const hardLimit = turnHardLimit(inflight, this.config.workflow.turn_max_minutes);
534
+ if (now.getTime() >= hardLimit.getTime()) {
535
+ // #71 AC-4: the hard limit bounds the wait, also when unrelated opencode use keeps it active.
536
+ inflight.missing = true;
537
+ inflight.missing_reason = exitedActiveReason(inflight.role, hardLimitMinutes(inflight, hardLimit));
538
+ this.setStatus("blocked", inflight.missing_reason, retryRecord(context));
539
+ }
540
+ else {
541
+ this.setStatus("running", `waiting for the ${inflight.role} handoff; agent exited but is still active`, context);
542
+ }
543
+ return "continue";
544
+ }
545
+ inflight.checks += 1;
546
+ log(`handoff check ${inflight.checks}/${this.config.workflow.handoff_checks} for ${inflight.key}: ${seen}`);
490
547
  if (inflight.checks >= this.config.workflow.handoff_checks) {
491
548
  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);
549
+ inflight.missing_reason = invalid === undefined ? `${inflight.role} finished without a visible handoff` : invalidRecordReason(inflight.role, invalid);
550
+ this.setStatus("blocked", inflight.missing_reason, retryRecord(context));
502
551
  }
503
552
  else {
504
- this.setStatus("running", `waiting for the ${inflight.role} handoff to become visible`, { role: inflight.role, round: inflight.round });
553
+ this.setStatus("running", `waiting for the ${inflight.role} handoff to become visible`, context);
505
554
  }
506
555
  return "continue";
507
556
  }
557
+ // #71 AC-5: a late record lifts the block as if it had arrived in time.
558
+ if (inflight.missing)
559
+ log(`late handoff for ${inflight.key} accepted`);
508
560
  this.state.inflight = null;
509
561
  // AC-2: the role's rejection explanation is spent once a valid record is visible.
510
562
  if (this.state.retry_record?.role === inflight.role)
package/dist/workflow.js CHANGED
@@ -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: {} } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gevezex/gdt",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "GitHub issues to merge-ready pull requests, with a developer, tester and reviewer agent.",
5
5
  "keywords": [
6
6
  "github",