@gevezex/gdt 0.4.0 → 0.5.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/dist/workflow.js CHANGED
@@ -3,14 +3,15 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
3
3
  import { relative } from "node:path";
4
4
  import { headless } from "./backends/headless.js";
5
5
  import { backendFor } from "./backends/index.js";
6
- import { loadConfig, ROLES, userConfigPath } from "./config.js";
6
+ import { DEFAULT_TURN_MAX_MINUTES, DEFAULT_TURN_TIMEOUT_MINUTES, loadConfig, ROLES, userConfigPath, } from "./config.js";
7
7
  import { validateContract } from "./contract.js";
8
8
  import { findRepository, herdrPreflight, unsupportedAgentFindings } from "./doctor.js";
9
9
  import { changedFiles } from "./git.js";
10
10
  import { issueBody, repository } from "./github.js";
11
11
  import { loadLocale } from "./locale.js";
12
- import { alive, lockHolder, paths, readOverrides, readState, writeState } from "./state.js";
13
- import { cliPath } from "./supervisor.js";
12
+ import { isInvalidRecordReason } from "./protocol.js";
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
15
  export const ok = (stdout) => ({ code: 0, stdout, stderr: "" });
15
16
  export const fail = (stderr) => ({ code: 1, stdout: "", stderr });
16
17
  /** Statuses that need a person or end the workflow: `gdt wait` returns when it reaches one. */
@@ -200,7 +201,12 @@ export function stop(issue, cwd, env) {
200
201
  function retryable(state) {
201
202
  if (state.status === "failed")
202
203
  return true;
203
- return state.status === "blocked" && (state.reason.includes("without a visible handoff") || state.reason.includes("already ran"));
204
+ return (state.status === "blocked" &&
205
+ (state.reason.includes("without a visible handoff") ||
206
+ state.reason.includes("already ran") ||
207
+ isTimeoutReason(state.reason) ||
208
+ isHardLimitReason(state.reason) ||
209
+ isInvalidRecordReason(state.reason)));
204
210
  }
205
211
  /** `gdt retry <n>`: stops the workflow and clears the interrupted turn so `gdt start` runs it again. */
206
212
  export function retry(issue, cwd, env) {
@@ -217,6 +223,7 @@ export function retry(issue, cwd, env) {
217
223
  if (key !== undefined) {
218
224
  rmSync(p.started(key), { force: true });
219
225
  rmSync(p.result(key), { force: true });
226
+ rmSync(p.agent(key), { force: true });
220
227
  state.dispatched = state.dispatched.filter((known) => known !== key);
221
228
  }
222
229
  Object.assign(state, {
@@ -228,6 +235,7 @@ export function retry(issue, cwd, env) {
228
235
  pids: { supervisor: null, workers: {} },
229
236
  });
230
237
  writeState(p, state);
238
+ rmSync(p.extend, { force: true });
231
239
  // Release the lock only now: `gdt wait` must return on the final status, never on the vanished lock.
232
240
  releaseAfterStop(p);
233
241
  return ok(`Retry prepared for #${issue}. Next: ${describe(state, false).next}\n`);
@@ -235,8 +243,15 @@ export function retry(issue, cwd, env) {
235
243
  function blockedHint(issue, reason) {
236
244
  if (reason.startsWith("round budget exhausted"))
237
245
  return `gdt allow-round ${issue}`;
238
- if (reason.includes("without a visible handoff") || reason.includes("already ran"))
246
+ // AC-4: a turn still active at its hard limit gets more time or is stopped and run again.
247
+ if (isHardLimitReason(reason))
248
+ return `gdt extend ${issue} or gdt retry ${issue}`;
249
+ if (reason.includes("without a visible handoff") ||
250
+ reason.includes("already ran") ||
251
+ isTimeoutReason(reason) ||
252
+ isInvalidRecordReason(reason)) {
239
253
  return `gdt retry ${issue}`;
254
+ }
240
255
  if (reason.includes("without a Changelog update"))
241
256
  return `add a Changelog entry to issue #${issue}, or revert the body change`;
242
257
  return "resolve the cause; the supervisor checks again on every poll";
@@ -268,10 +283,54 @@ export function describe(state, supervisorAlive) {
268
283
  return { line: withReason, next: "wait" };
269
284
  }
270
285
  }
271
- /** The state as status and wait see it: the pause file reports `paused` until the workflow ends. */
272
- function effectiveState(p, state) {
286
+ /** A `gdt extend` request for the state's in-flight turn that the supervisor has not applied yet. */
287
+ function pendingExtension(p, state) {
288
+ const extension = readJson(p.extend);
289
+ return extension !== null && extension.key === state.inflight?.key ? extension : null;
290
+ }
291
+ /**
292
+ * The state as status and wait see it: the pause file reports `paused` until the workflow ends, and
293
+ * AC-5: a pending `gdt extend` reports the turn `running` with its new hard limit before the
294
+ * supervisor's next poll applies it.
295
+ */
296
+ function effectiveState(p, state, turnTimeoutMinutes = DEFAULT_TURN_TIMEOUT_MINUTES) {
273
297
  const paused = existsSync(p.pause) && state.status !== "stopped" && state.status !== "failed";
274
- return paused ? { ...state, status: "paused", reason: "" } : state;
298
+ if (paused)
299
+ return { ...state, status: "paused", reason: "" };
300
+ const extension = pendingExtension(p, state);
301
+ if (extension === null || state.inflight === null || state.status !== "blocked" || !isHardLimitReason(state.reason))
302
+ return state;
303
+ const inflight = { ...state.inflight, hard_limit: extension.hard_limit, at_hard_limit: false };
304
+ return { ...state, status: "running", reason: activeReason(inflight.role, turnTimeoutMinutes), inflight };
305
+ }
306
+ /** The workflow's turn limits, or the defaults when the config is unreadable. */
307
+ function turnLimits(report) {
308
+ return report.valid
309
+ ? { timeout: report.workflow.turn_timeout_minutes, max: report.workflow.turn_max_minutes }
310
+ : { timeout: DEFAULT_TURN_TIMEOUT_MINUTES, max: DEFAULT_TURN_MAX_MINUTES };
311
+ }
312
+ /** `gdt extend <n>`: grants a turn blocked at its hard limit `turn_max_minutes` more from now (AC-5). */
313
+ export function extend(issue, cwd, env) {
314
+ const root = findRepository(cwd) ?? cwd;
315
+ const p = paths(root, issue, env);
316
+ const state = readState(p);
317
+ if (state === null)
318
+ return fail(`No workflow for #${issue}. Next: gdt start ${issue}\n`);
319
+ const limits = turnLimits(loadConfig(root, env).report);
320
+ const effective = effectiveState(p, state, limits.timeout);
321
+ const inflight = state.inflight;
322
+ if (effective.status !== "blocked" ||
323
+ !isHardLimitReason(effective.reason) ||
324
+ inflight === null ||
325
+ readJson(p.result(inflight.key)) !== null) {
326
+ const { line, next } = describe(effective, lockHolder(p) !== null);
327
+ return fail(`Workflow for #${issue} is not waiting at a turn's hard limit (${line}). Next: ${next}\n`);
328
+ }
329
+ const hardLimit = new Date(Date.now() + limits.max * 60_000).toISOString();
330
+ // The supervisor applies the request on its next poll; status and wait report it at once.
331
+ writeJsonAtomic(p.extend, { key: inflight.key, hard_limit: hardLimit });
332
+ const { next } = describe(effectiveState(p, state, limits.timeout), lockHolder(p) !== null);
333
+ return ok(`Extended the ${inflight.role} turn of #${issue}; new hard limit ${hardLimit}. Next: ${next}\n`);
275
334
  }
276
335
  /** `gdt status <n>`. The pause file reports `paused` even before the supervisor notices it. */
277
336
  export function status(issue, cwd, env, json) {
@@ -279,18 +338,29 @@ export function status(issue, cwd, env, json) {
279
338
  const p = paths(root, issue, env);
280
339
  const state = readState(p);
281
340
  if (state === null) {
282
- return json
283
- ? { code: 1, stdout: `${JSON.stringify({ issue, status: null, next_step: `gdt start ${issue}` }, null, 2)}\n`, stderr: "" }
284
- : fail(`No workflow for #${issue}. Next: gdt start ${issue}\n`);
341
+ // AC-5: the same keys, null without a dispatched turn.
342
+ const empty = {
343
+ issue,
344
+ status: null,
345
+ turn_started_at: null,
346
+ turn_deadline: null,
347
+ turn_last_activity: null,
348
+ turn_hard_limit: null,
349
+ next_step: `gdt start ${issue}`,
350
+ };
351
+ return json ? { code: 1, stdout: `${JSON.stringify(empty, null, 2)}\n`, stderr: "" } : fail(`No workflow for #${issue}. Next: gdt start ${issue}\n`);
285
352
  }
286
- const effective = effectiveState(p, state);
353
+ const { report } = loadConfig(root, env);
354
+ const limits = turnLimits(report);
355
+ const effective = effectiveState(p, state, limits.timeout);
287
356
  const { line, next } = describe(effective, lockHolder(p) !== null);
288
357
  if (!json)
289
358
  return ok(`${line}. Next: ${next}\n`);
290
- let maxRounds = null;
291
- const { report } = loadConfig(root, env);
292
- if (report.valid)
293
- maxRounds = report.workflow.max_correction_rounds;
359
+ const maxRounds = report.valid ? report.workflow.max_correction_rounds : null;
360
+ // The in-flight turn's start, deadline, last activity and hard limit. A result file ends the turn,
361
+ // so a turn that has finished but still awaits its handoff check reports null for all four.
362
+ const inflight = effective.inflight !== null && readJson(p.result(effective.inflight.key)) === null ? effective.inflight : null;
363
+ const turnStart = inflight?.dispatched_at ?? null;
294
364
  const out = {
295
365
  issue,
296
366
  workflow_id: state.workflow_id,
@@ -302,6 +372,11 @@ export function status(issue, cwd, env, json) {
302
372
  exit_code: state.exit_code,
303
373
  pr_number: state.pr_number,
304
374
  open_findings: state.open_findings ?? [],
375
+ turn_started_at: turnStart,
376
+ turn_deadline: turnStart === null ? null : turnDeadline(turnStart, limits.timeout).toISOString(),
377
+ // AC-8: null without a result-less in-flight turn.
378
+ turn_last_activity: inflight === null ? null : turnLastActivity(inflight),
379
+ turn_hard_limit: inflight === null ? null : turnHardLimit(inflight, limits.max).toISOString(),
305
380
  overrides: readOverrides(p),
306
381
  next_step: next,
307
382
  };
@@ -316,12 +391,13 @@ export function wait(issue, cwd, env, options) {
316
391
  const root = findRepository(cwd) ?? cwd;
317
392
  const p = paths(root, issue, env);
318
393
  const deadline = options.timeoutSeconds === null ? null : Date.now() + options.timeoutSeconds * 1000;
394
+ const turnTimeout = turnLimits(loadConfig(root, env).report).timeout;
319
395
  for (;;) {
320
396
  const state = readState(p);
321
397
  // No workflow is status's error path (AC-6); an action status returns at once (AC-1, AC-2).
322
398
  if (state === null)
323
399
  return status(issue, cwd, env, options.json);
324
- const effective = effectiveState(p, state);
400
+ const effective = effectiveState(p, state, turnTimeout);
325
401
  if (ACTION_STATUSES.includes(effective.status))
326
402
  return status(issue, cwd, env, options.json);
327
403
  // A dead supervisor ends the wait, except while the operator paused it deliberately (AC-4).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gevezex/gdt",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "GitHub issues to merge-ready pull requests, with a developer, tester and reviewer agent.",
5
5
  "keywords": [
6
6
  "github",