@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.
- package/dist/supervisor.js +98 -46
- package/dist/workflow.js +3 -0
- package/package.json +1 -1
package/dist/supervisor.js
CHANGED
|
@@ -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:
|
|
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 =
|
|
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
|
-
//
|
|
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 });
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
this.
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
489
|
-
|
|
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
|
-
|
|
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);
|
|
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`,
|
|
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: {} } });
|