@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/README.md +166 -38
- package/dist/activity.js +124 -0
- package/dist/agents/claude-stream.js +74 -0
- package/dist/agents/claude.js +13 -1
- package/dist/cli.js +20 -14
- package/dist/config.js +23 -1
- package/dist/prompts.js +23 -1
- package/dist/protocol.js +10 -2
- package/dist/state.js +2 -0
- package/dist/supervisor.js +263 -9
- package/dist/worker.js +34 -6
- package/dist/workflow.js +93 -17
- package/package.json +1 -1
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 {
|
|
13
|
-
import {
|
|
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" &&
|
|
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
|
-
|
|
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
|
-
/**
|
|
272
|
-
function
|
|
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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).
|