td-barrage 0.1.3 → 0.1.5

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
@@ -1,12 +1,12 @@
1
1
  # td-barrage
2
2
 
3
- A small TypeScript queue runner for OpenCode tasks. It loads a JSON task file, resolves dependencies, runs ready tasks through the OpenCode SDK, persists state after every transition, and resumes interrupted work on the next invocation.
3
+ A persistent, dependency-aware task queue for the pi coding agent. It loads a JSON task file, runs ready tasks through pi, saves state after every transition, and resumes interrupted work on the next invocation.
4
4
 
5
5
  ## Requirements
6
6
 
7
- - Node.js 20.11 or newer
7
+ - Node.js 22.19 or newer
8
8
  - npm
9
- - OpenCode available when running against a real server
9
+ - pi authenticated with a model provider (`pi`, then `/login`), or a supported provider API key in the environment
10
10
 
11
11
  ## Install
12
12
 
@@ -62,14 +62,13 @@ Task files use a top-level `tasks` array. A bare array is also accepted when loa
62
62
  Supported task fields:
63
63
 
64
64
  - `id`: unique task id
65
- - `prompt`: prompt sent to OpenCode
65
+ - `prompt`: prompt sent to pi
66
66
  - `dependsOn`: dependency ids
67
67
  - `status`: `pending`, `running`, `done`, `failed`, or `blocked`
68
68
  - `attempts`: persisted attempt counter
69
69
  - `maxAttempts`: per-task retry limit
70
70
  - `timeoutMs`: per-task prompt timeout
71
71
  - `cwd`: per-task working directory
72
- - `resultPath`: optional metadata field
73
72
  - `success`: `default`, `file_exists`, or `result_json`
74
73
  - `recoveryNotes`, `error`, `startedAt`, `finishedAt`: runtime metadata
75
74
 
@@ -77,7 +76,7 @@ Supported task fields:
77
76
 
78
77
  ```sh
79
78
  npm run dev -- --tasks queue.json
80
- npm run dev -- queue.json --max-attempts 3 --cwd /path/to/work --results-dir .queue/results
79
+ npm run dev -- queue.json --max-attempts 3 --cwd /path/to/work
81
80
  npm run dev -- --tasks queue.json --dry-run
82
81
  npm run dev -- --tasks queue.json --json
83
82
  npm run dev -- --tasks queue.json --verbose --timestamps
@@ -88,7 +87,6 @@ Options:
88
87
  - `--tasks, -t <path>`: task file (also accepted as a positional argument)
89
88
  - `--max-attempts <n>`: global retry limit (per-task `maxAttempts` overrides it)
90
89
  - `--cwd <path>`: working directory for tasks that do not set their own
91
- - `--results-dir <path>`: where result files are expected, default `.queue/results`
92
90
  - `--dry-run`: print the topological plan (one id per line) and exit without running
93
91
  - `--no-commit`: skip the git commit stage that otherwise runs after each successful task
94
92
  - `--json`: emit newline-delimited JSON events instead of human output
@@ -98,18 +96,47 @@ Exit codes:
98
96
  - `0`: all reachable tasks completed
99
97
  - `1`: at least one task failed or became blocked
100
98
  - `2`: CLI usage or missing task-file error
99
+ - `75`: the run paused on a provider quota / rate-limit wall (see below)
100
+
101
+ ## Pi sessions
102
+
103
+ Each task runs in its own in-memory pi agent session scoped to the task working
104
+ directory. Barrage uses pi's existing authentication and model settings. Terminal
105
+ provider errors from pi's final assistant state feed into the pause/resume flow
106
+ below. Sessions are disposed after their task prompt settles.
107
+
108
+ ## Pausing on quota / rate limits
109
+
110
+ Running out of provider tokens is treated as a property of the run, not of any
111
+ task. When a prompt fails with a quota, rate-limit, or overload signal (HTTP
112
+ `402`/`429`/`503`/`529`, or matching error wording), the runner:
113
+
114
+ - rolls back the attempt it charged — the task did not really run, so its retry
115
+ budget is untouched,
116
+ - leaves the task `pending` (exactly as crash recovery does),
117
+ - stops the rest of the sweep immediately, since every ready task would hit the
118
+ same wall, and
119
+ - exits `75` (`EX_TEMPFAIL`) with the reason on stderr.
120
+
121
+ Because task state and workspace changes are persisted, resuming is just
122
+ re-running the same command once tokens are back. The pending task starts a fresh
123
+ in-memory pi session and can inspect the work already on disk; conversation
124
+ history is not restored. No failed or blocked task is recorded for the pause.
125
+ The distinct exit code lets a wrapper or scheduler tell "try again later" apart
126
+ from `1` ("something is broken"). A `Retry-After` header, when present, is
127
+ surfaced as a suggested wait in the pause message.
101
128
 
102
129
  ## Telemetry & output
103
130
 
104
131
  By default the runner is deliberately verbose, narrating progress as it happens:
105
- a startup header (version, task file, cwd, results dir, limits), the resolved
132
+ a startup header (version, task file, cwd, limits), the resolved
106
133
  execution plan, a line as each task starts, and a line as each task finishes with
107
134
  its duration and overall progress (`2/5 complete`). Failures, retries, and blocks
108
135
  are always reported, along with a closing summary and total elapsed time.
109
136
 
110
137
  Verbosity flags:
111
138
 
112
- - `--verbose, -v`: add per-step traces — the OpenCode session id and the
139
+ - `--verbose, -v`: add per-step traces — the agent session id and the
113
140
  success-check result for every task
114
141
  - `--quiet, -q`: show only failures, blocks, and the final summary
115
142
  - `--timestamps`: prefix each line with a wall-clock time
@@ -157,16 +184,14 @@ Integration tests are excluded by default. Run them with:
157
184
  RUN_INTEGRATION=1 npm run test:integration
158
185
  ```
159
186
 
160
- Optional integration environment variables:
161
-
162
- - `OPENCODE_BIN`: OpenCode executable, default `opencode`
163
- - `OPENCODE_PORT`: server port, default `4096`
187
+ The integration test runs real pi sessions using your configured authentication
188
+ and model, so it may consume provider credits.
164
189
 
165
190
  ## Architecture
166
191
 
167
192
  The orchestrator accepts its dependencies as arguments:
168
193
 
169
- - `client`: OpenCode client seam
194
+ - `client`: pi-compatible agent client seam
170
195
  - `fs`: filesystem seam
171
196
  - `clock`: timer seam for timeout tests
172
197
  - `log`: human or JSON event logger
package/dist/client.d.ts CHANGED
@@ -1,9 +1,11 @@
1
- import type { OpenCodeClient } from "./orchestrator.js";
2
- export interface ClientOptions {
3
- baseUrl?: string;
4
- hostname?: string;
5
- port?: number;
6
- timeout?: number;
7
- config?: unknown;
8
- }
9
- export declare function createOpenCodeClient(options?: ClientOptions): Promise<OpenCodeClient>;
1
+ import type { CreateAgentSessionOptions } from "@earendil-works/pi-coding-agent";
2
+ import type { AgentClient } from "./orchestrator.js";
3
+ /** Pi session settings Barrage allows callers to override. */
4
+ export type PiClientOptions = Pick<CreateAgentSessionOptions, "model" | "tools" | "excludeTools" | "noTools" | "thinkingLevel">;
5
+ /** Imports an SDK module by specifier. Injectable so the lazy wiring is testable. */
6
+ export type SdkImport = (specifier: string) => Promise<unknown>;
7
+ /**
8
+ * Builds an orchestrator client backed by pi (`@earendil-works/pi-coding-agent`).
9
+ * Each task gets a one-shot in-memory session scoped to its working directory.
10
+ */
11
+ export declare function createPiClient(options?: PiClientOptions, importSdk?: SdkImport): Promise<AgentClient>;
package/dist/client.js CHANGED
@@ -1,96 +1,81 @@
1
- export async function createOpenCodeClient(options = {}) {
2
- const importSdk = new Function("specifier", "return import(specifier)");
3
- const sdk = (await importSdkWithFallback(importSdk));
4
- // A bare client only works when we already know where the server lives.
5
- // Without a baseUrl it would issue requests against relative URLs like
6
- // "/session?directory=..." and fail with "Failed to parse URL".
7
- if (options.baseUrl && typeof sdk.createOpencodeClient === "function") {
8
- return wrapSdkClient(sdk.createOpencodeClient({ ...options, throwOnError: true }));
1
+ const defaultImport = (specifier) => import(specifier);
2
+ const PI_SPECIFIER = "@earendil-works/pi-coding-agent";
3
+ /**
4
+ * Builds an orchestrator client backed by pi (`@earendil-works/pi-coding-agent`).
5
+ * Each task gets a one-shot in-memory session scoped to its working directory.
6
+ */
7
+ export async function createPiClient(options = {}, importSdk = defaultImport) {
8
+ const sdk = (await importSdk(PI_SPECIFIER));
9
+ if (typeof sdk.createAgentSession !== "function") {
10
+ throw new Error("Unable to locate the pi createAgentSession export.");
9
11
  }
10
- // No baseUrl: spawn a local server and use the client it wires up for us.
11
- if (typeof sdk.createOpencode === "function") {
12
- const created = (await sdk.createOpencode(options));
13
- if (created.client) {
14
- registerServerShutdown(created.server);
15
- return wrapSdkClient(created.client);
16
- }
12
+ if (typeof sdk.SessionManager?.inMemory !== "function") {
13
+ throw new Error("Unable to locate the pi SessionManager.inMemory export.");
17
14
  }
18
- if (typeof sdk.createOpencodeClient === "function") {
19
- return wrapSdkClient(sdk.createOpencodeClient({ ...options, throwOnError: true }));
20
- }
21
- const Client = (sdk.OpenCode ?? sdk.Opencode ?? sdk.Client ?? sdk.default);
22
- if (!Client) {
23
- throw new Error("Unable to locate an OpenCode SDK client export.");
24
- }
25
- return new Client(options);
26
- }
27
- function registerServerShutdown(server) {
28
- if (typeof server?.close !== "function")
29
- return;
30
- let closed = false;
31
- const close = () => {
32
- if (closed)
33
- return;
34
- closed = true;
35
- server.close?.();
36
- };
37
- process.once("exit", close);
38
- process.once("SIGINT", () => {
39
- close();
40
- process.exit(130);
41
- });
42
- process.once("SIGTERM", () => {
43
- close();
44
- process.exit(143);
45
- });
46
- }
47
- function wrapSdkClient(client) {
48
- const sessionDirs = new Map();
49
- const raw = client;
15
+ const createAgentSession = sdk.createAgentSession;
16
+ const SessionManager = sdk.SessionManager;
17
+ const sessions = new Map();
50
18
  return {
51
19
  session: {
52
- create: async (input) => {
53
- const result = unwrapData(await raw.session.create({ directory: input.cwd, title: "orchestrator task" }));
54
- const id = result.id;
55
- if (typeof id !== "string" || id.length === 0) {
56
- throw new Error("OpenCode SDK session.create did not return a session id.");
57
- }
58
- sessionDirs.set(id, input.cwd);
59
- return { id };
20
+ create: async ({ cwd }) => {
21
+ const sessionOptions = {
22
+ ...options,
23
+ cwd,
24
+ sessionManager: SessionManager.inMemory(cwd),
25
+ };
26
+ const { session } = await createAgentSession(sessionOptions);
27
+ sessions.set(session.sessionId, session);
28
+ return { id: session.sessionId };
60
29
  },
61
30
  prompt: async (sessionId, input) => {
62
- const directory = sessionDirs.get(sessionId) ?? process.cwd();
63
- const abort = async () => {
64
- await raw.session.abort?.({ sessionID: sessionId, directory });
65
- };
66
- if (input.signal?.aborted)
67
- await abort();
68
- input.signal?.addEventListener("abort", abort, { once: true });
31
+ const session = sessions.get(sessionId);
32
+ if (!session)
33
+ throw new Error(`Unknown pi session: ${sessionId}`);
69
34
  try {
70
- return await raw.session.prompt({
71
- sessionID: sessionId,
72
- directory,
73
- parts: [{ type: "text", text: input.prompt }],
74
- }, { signal: input.signal });
35
+ return await runPrompt(session, input.prompt, input.signal);
75
36
  }
76
37
  finally {
77
- input.signal?.removeEventListener("abort", abort);
38
+ sessions.delete(sessionId);
39
+ // Pi documents dispose() as best-effort cleanup, and its implementation
40
+ // deliberately does not throw. Keep that guarantee at the adapter seam.
41
+ try {
42
+ session.dispose();
43
+ }
44
+ catch {
45
+ // Cleanup must not replace the prompt result or provider error.
46
+ }
78
47
  }
79
48
  },
80
49
  },
81
50
  };
82
51
  }
83
- function unwrapData(value) {
84
- if (value && typeof value === "object" && "data" in value) {
85
- return value.data;
86
- }
87
- return value;
88
- }
89
- async function importSdkWithFallback(importSdk) {
52
+ async function runPrompt(session, prompt, signal) {
53
+ // Aborting an idle Pi session is a no-op, so never start work that its caller
54
+ // has already cancelled.
55
+ if (signal?.aborted)
56
+ throw abortError(signal);
57
+ const abort = () => {
58
+ void session.abort().catch(() => {
59
+ // The timeout/cancellation error belongs to the caller. An abort cleanup
60
+ // failure must not become an unhandled rejection or mask that error.
61
+ });
62
+ };
63
+ signal?.addEventListener("abort", abort, { once: true });
90
64
  try {
91
- return await importSdk("@opencode-ai/sdk/v2");
65
+ await session.prompt(prompt);
66
+ }
67
+ finally {
68
+ signal?.removeEventListener("abort", abort);
92
69
  }
93
- catch {
94
- return importSdk("@opencode-ai/sdk");
70
+ // Pi encodes provider/runtime failures in the terminal assistant message and
71
+ // resolves prompt(). Its state exposes only the final error, after Pi's own
72
+ // retry loop has settled, so successful retries are not misclassified.
73
+ if (session.state.errorMessage) {
74
+ throw new Error(session.state.errorMessage);
95
75
  }
96
76
  }
77
+ function abortError(signal) {
78
+ if (signal.reason instanceof Error)
79
+ return signal.reason;
80
+ return new DOMException("The operation was aborted.", "AbortError");
81
+ }
package/dist/deps.d.ts CHANGED
@@ -1,4 +1,11 @@
1
1
  import type { Task } from "./schema.js";
2
- export type DependencyState = "ready" | "waiting" | "blocked";
2
+ export type DependencyState = {
3
+ state: "ready";
4
+ } | {
5
+ state: "waiting";
6
+ } | {
7
+ state: "blocked";
8
+ blockedBy: string;
9
+ };
3
10
  export declare function dependencyState(task: Task, tasks: Task[]): DependencyState;
4
11
  export declare function topologicalOrder(tasks: Task[]): Task[];
package/dist/deps.js CHANGED
@@ -3,13 +3,14 @@ export function dependencyState(task, tasks) {
3
3
  for (const depId of task.dependsOn) {
4
4
  const dep = byId.get(depId);
5
5
  if (!dep)
6
- return "blocked";
7
- if (dep.status === "failed" || dep.status === "blocked")
8
- return "blocked";
6
+ return { state: "blocked", blockedBy: `${depId} (missing)` };
7
+ if (dep.status === "failed" || dep.status === "blocked") {
8
+ return { state: "blocked", blockedBy: `${depId} (${dep.status})` };
9
+ }
9
10
  if (dep.status !== "done")
10
- return "waiting";
11
+ return { state: "waiting" };
11
12
  }
12
- return "ready";
13
+ return { state: "ready" };
13
14
  }
14
15
  export function topologicalOrder(tasks) {
15
16
  const byId = new Map(tasks.map((task) => [task.id, task]));
package/dist/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs/promises";
3
3
  import { type GitCommitter } from "./git.js";
4
- import { type OpenCodeClient } from "./orchestrator.js";
4
+ import { type AgentClient } from "./orchestrator.js";
5
5
  export interface MainDeps {
6
6
  fs?: typeof fs;
7
- client?: OpenCodeClient;
7
+ client?: AgentClient;
8
8
  git?: GitCommitter;
9
9
  stdout?: Pick<NodeJS.WritableStream, "write">;
10
10
  stderr?: Pick<NodeJS.WritableStream, "write">;
package/dist/index.js CHANGED
@@ -5,11 +5,11 @@ import { createRequire } from "node:module";
5
5
  import process from "node:process";
6
6
  import { pathToFileURL } from "node:url";
7
7
  import { parseArgs } from "node:util";
8
- import { createOpenCodeClient } from "./client.js";
8
+ import { createPiClient } from "./client.js";
9
9
  import { topologicalOrder } from "./deps.js";
10
10
  import { createGitCommitter } from "./git.js";
11
11
  import { createLogger } from "./log.js";
12
- import { defaultResultsDir, runOrchestrator } from "./orchestrator.js";
12
+ import { runOrchestrator } from "./orchestrator.js";
13
13
  import { loadTasks } from "./tasks.js";
14
14
  export async function main(argv = process.argv.slice(2), deps = {}) {
15
15
  const stdout = deps.stdout ?? process.stdout;
@@ -21,7 +21,6 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
21
21
  tasks: { type: "string", short: "t" },
22
22
  "max-attempts": { type: "string" },
23
23
  cwd: { type: "string" },
24
- "results-dir": { type: "string" },
25
24
  json: { type: "boolean", default: false },
26
25
  "dry-run": { type: "boolean", default: false },
27
26
  "no-commit": { type: "boolean", default: false },
@@ -51,7 +50,6 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
51
50
  timestamps: Boolean(parsed.values.timestamps),
52
51
  }, stdout);
53
52
  const maxAttempts = parsed.values["max-attempts"] ? Number(parsed.values["max-attempts"]) : undefined;
54
- const resultsDir = parsed.values["results-dir"] ?? defaultResultsDir(cwd);
55
53
  if (maxAttempts !== undefined && (!Number.isInteger(maxAttempts) || maxAttempts < 1)) {
56
54
  stderr.write("--max-attempts must be a positive integer.\n");
57
55
  return 2;
@@ -63,7 +61,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
63
61
  logger.event({ type: "dry_run", taskIds: ordered.map((task) => task.id) });
64
62
  return 0;
65
63
  }
66
- const client = deps.client ?? (await createOpenCodeClient());
64
+ const client = deps.client ?? (await createPiClient());
67
65
  const git = parsed.values["no-commit"] ? undefined : deps.git ?? createGitCommitter();
68
66
  const summary = await runOrchestrator({
69
67
  tasksPath,
@@ -71,11 +69,17 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
71
69
  fs: queueFs,
72
70
  log: logger,
73
71
  cwd,
74
- resultsDir,
75
72
  maxAttempts,
76
73
  git,
77
74
  version: readVersion(),
78
75
  });
76
+ // 75 = EX_TEMPFAIL: the run hit a quota/rate-limit wall and stopped cleanly
77
+ // with work still pending. Distinct from 1 (genuine failures) so a wrapper
78
+ // or scheduler can tell "try again later" apart from "something is broken".
79
+ if (summary.paused) {
80
+ stderr.write(`Paused: ${summary.pauseReason ?? "out of provider tokens"} Re-run to resume.\n`);
81
+ return 75;
82
+ }
79
83
  return summary.failed > 0 || summary.blocked > 0 ? 1 : 0;
80
84
  }
81
85
  catch (error) {
package/dist/log.d.ts CHANGED
@@ -6,7 +6,6 @@ export interface RunInfo {
6
6
  maxAttempts: number;
7
7
  timeoutMs: number;
8
8
  cwd: string;
9
- resultsDir: string;
10
9
  }
11
10
  export type LogEvent = {
12
11
  type: "run_started";
@@ -60,6 +59,11 @@ export type LogEvent = {
60
59
  taskId: string;
61
60
  reason: string;
62
61
  blockedBy?: string;
62
+ } | {
63
+ type: "run_paused";
64
+ taskId: string;
65
+ reason: string;
66
+ resumeAfterMs?: number;
63
67
  } | {
64
68
  type: "summary";
65
69
  summary: Summary;
package/dist/log.js CHANGED
@@ -12,6 +12,7 @@ const EVENT_LEVEL = {
12
12
  task_commit_failed: 0,
13
13
  task_failed: 0,
14
14
  task_blocked: 0,
15
+ run_paused: 0,
15
16
  summary: 0,
16
17
  dry_run: 0,
17
18
  server: 1,
@@ -63,42 +64,31 @@ function createPrettyRenderer(output, s) {
63
64
  return (event) => {
64
65
  switch (event.type) {
65
66
  case "run_started": {
66
- const i = event.info;
67
- intro(s.bold(`td-barrage ${i.version}`), opts);
68
- const details = [
69
- `${s.dim("tasks ")} ${i.tasksPath} ${s.dim(`(${i.total} ${plural(i.total, "task")})`)}`,
70
- `${s.dim("cwd ")} ${i.cwd}`,
71
- `${s.dim("results")} ${i.resultsDir}`,
72
- `${s.dim("limits ")} ${i.maxAttempts} ${plural(i.maxAttempts, "attempt")} ${s.dim("·")} ${fmtDuration(i.timeoutMs)} timeout`,
73
- ].join("\n");
74
- note(details, s.dim("run details"), opts);
67
+ intro(s.bold(`td-barrage ${event.info.version}`), opts);
68
+ note(runDetailLines(event.info, s).join("\n"), s.dim("run details"), opts);
75
69
  return;
76
70
  }
77
- case "plan": {
78
- const body = event.taskIds.length === 0
79
- ? s.dim("(no tasks)")
80
- : event.taskIds.map((id) => s.cyan(id)).join(s.dim(" → "));
81
- clack.step(`${s.dim("plan")} ${body}`, opts);
71
+ case "plan":
72
+ clack.step(`${s.dim("plan")} ${planBody(event.taskIds, s)}`, opts);
82
73
  return;
83
- }
84
74
  case "task_started":
85
75
  active = spinner(opts);
86
76
  active.start(`${s.bold(event.taskId)} ${s.dim(`running (attempt ${event.attempt}/${event.maxAttempts})`)}`);
87
77
  return;
88
78
  case "task_session":
89
79
  if (active)
90
- active.message(s.dim(`${event.taskId} · session ${event.sessionId}`));
80
+ active.message(s.dim(`${event.taskId} · ${sessionText(event.sessionId)}`));
91
81
  else
92
- clack.message(s.dim(`↳ session ${event.sessionId}`), opts);
82
+ clack.message(s.dim(`↳ ${sessionText(event.sessionId)}`), opts);
93
83
  return;
94
84
  case "task_check":
95
85
  if (active)
96
- active.message(s.dim(`${event.taskId} · check ${event.strategy} → ${event.ok ? "ok" : "fail"}`));
86
+ active.message(s.dim(`${event.taskId} · ${checkText(event.strategy, event.ok)}`));
97
87
  else
98
- clack.message(s.dim(`↳ check ${event.strategy} → ${event.ok ? "ok" : "fail"}`), opts);
88
+ clack.message(s.dim(`↳ ${checkText(event.strategy, event.ok)}`), opts);
99
89
  return;
100
90
  case "task_done": {
101
- const msg = `${s.bold(event.taskId)} ${s.green("done")} ${s.dim(`in ${fmtDuration(event.durationMs)} · ${event.done}/${event.total} complete`)}`;
91
+ const msg = doneMessage(event, s);
102
92
  if (active) {
103
93
  active.stop(msg);
104
94
  active = null;
@@ -107,21 +97,14 @@ function createPrettyRenderer(output, s) {
107
97
  clack.success(msg, opts);
108
98
  return;
109
99
  }
110
- case "task_committed": {
111
- const detail = event.status === "nothing"
112
- ? s.dim("no changes to commit")
113
- : `${s.dim("committed")} ${event.commit ? s.cyan(event.commit) : ""} ${s.dim(event.message)}`;
114
- clack.message(detail, opts);
100
+ case "task_committed":
101
+ clack.message(committedDetail(event, s), opts);
115
102
  return;
116
- }
117
103
  case "task_commit_failed":
118
104
  clack.warn(`${s.bold(event.taskId)} ${s.yellow("commit failed")} ${s.dim(event.detail)}`, opts);
119
105
  return;
120
106
  case "task_failed": {
121
- const tail = event.willRetry
122
- ? s.yellow(`will retry (attempt ${event.attempt}/${event.maxAttempts})`)
123
- : s.red(`gave up after ${event.attempt} ${plural(event.attempt, "attempt")}`);
124
- const msg = `${s.bold(event.taskId)} ${s.red("failed")} ${s.dim(`in ${fmtDuration(event.durationMs)}`)}: ${event.error} ${tail}`;
107
+ const msg = failureMessage(event, s);
125
108
  if (active) {
126
109
  // cancel() finalizes the spinner with a red ■ — distinct from the
127
110
  // ▲ clack uses for a blocked task, and matching clack.error's glyph
@@ -133,9 +116,15 @@ function createPrettyRenderer(output, s) {
133
116
  clack.error(msg, opts);
134
117
  return;
135
118
  }
136
- case "task_blocked": {
137
- const by = event.blockedBy ? s.dim(` (by ${event.blockedBy})`) : "";
138
- clack.warn(`${s.bold(event.taskId)} ${s.yellow("blocked")}${by} ${s.dim(event.reason)}`, opts);
119
+ case "task_blocked":
120
+ clack.warn(`${s.bold(event.taskId)} ${s.yellow("blocked")}${blockedSuffix(event, s)} ${s.dim(event.reason)}`, opts);
121
+ return;
122
+ case "run_paused": {
123
+ if (active) {
124
+ active.stop();
125
+ active = null;
126
+ }
127
+ clack.warn(pausedMessage(event, s), opts);
139
128
  return;
140
129
  }
141
130
  case "summary": {
@@ -143,16 +132,8 @@ function createPrettyRenderer(output, s) {
143
132
  active.stop();
144
133
  active = null;
145
134
  }
146
- const sm = event.summary;
147
- const parts = [
148
- s.green(`${sm.done} done`),
149
- count(sm.failed, "failed", s.red, s),
150
- count(sm.blocked, "blocked", s.yellow, s),
151
- count(sm.pending, "pending", s.yellow, s),
152
- ];
153
- const ok = sm.failed === 0 && sm.blocked === 0;
154
- const head = ok ? s.green("done") : s.red("finished with issues");
155
- outro(`${head} ${parts.join(s.dim(" · "))} ${s.dim(`(${fmtDuration(event.durationMs)})`)}`, opts);
135
+ const head = summaryHead(event.summary, s, false);
136
+ outro(`${head} ${summaryParts(event.summary, s).join(s.dim(" · "))} ${s.dim(`(${fmtDuration(event.durationMs)})`)}`, opts);
156
137
  return;
157
138
  }
158
139
  case "server":
@@ -170,56 +151,36 @@ function createPrettyRenderer(output, s) {
170
151
  }
171
152
  function formatHuman(event, s) {
172
153
  switch (event.type) {
173
- case "run_started": {
174
- const i = event.info;
175
- const lines = [
176
- s.bold(`▶ td-barrage ${i.version}`),
177
- ` ${s.dim("tasks ")} ${i.tasksPath} ${s.dim(`(${i.total} ${plural(i.total, "task")})`)}`,
178
- ` ${s.dim("cwd ")} ${i.cwd}`,
179
- ` ${s.dim("results")} ${i.resultsDir}`,
180
- ` ${s.dim("limits ")} ${i.maxAttempts} ${plural(i.maxAttempts, "attempt")} ${s.dim("·")} ${fmtDuration(i.timeoutMs)} timeout`,
181
- ];
182
- return lines.join("\n");
183
- }
154
+ case "run_started":
155
+ return [
156
+ s.bold(`▶ td-barrage ${event.info.version}`),
157
+ ...runDetailLines(event.info, s).map((line) => ` ${line}`),
158
+ ].join("\n");
184
159
  case "plan":
185
- if (event.taskIds.length === 0)
186
- return s.dim("plan (no tasks)");
187
- return `${s.dim("plan ")} ${event.taskIds.map((id) => s.cyan(id)).join(s.dim(" → "))}`;
160
+ return event.taskIds.length === 0
161
+ ? s.dim("plan (no tasks)")
162
+ : `${s.dim("plan ")} ${planBody(event.taskIds, s)}`;
188
163
  case "task_started":
189
164
  return `${s.blue("▶")} ${s.bold(event.taskId)} ${s.dim(`starting (attempt ${event.attempt}/${event.maxAttempts})`)}`;
190
165
  case "task_session":
191
- return s.dim(` ↳ session ${event.sessionId}`);
166
+ return s.dim(` ↳ ${sessionText(event.sessionId)}`);
192
167
  case "task_check":
193
- return s.dim(` ↳ check ${event.strategy} → ${event.ok ? "ok" : "fail"}`);
168
+ return s.dim(` ↳ ${checkText(event.strategy, event.ok)}`);
194
169
  case "task_done":
195
- return `${s.green("✔")} ${s.bold(event.taskId)} ${s.green("done")} ${s.dim(`in ${fmtDuration(event.durationMs)} · ${event.done}/${event.total} complete`)}`;
170
+ return `${s.green("✔")} ${doneMessage(event, s)}`;
196
171
  case "task_committed":
197
- return event.status === "nothing"
198
- ? s.dim(" ↳ no changes to commit")
199
- : s.dim(` ↳ committed ${event.commit ? `${event.commit} ` : ""}${event.message}`);
172
+ return s.dim(" ↳ ") + committedDetail(event, s);
200
173
  case "task_commit_failed":
201
174
  return `${s.yellow("⚠")} ${s.bold(event.taskId)} ${s.yellow("commit failed")} ${s.dim(event.detail)}`;
202
- case "task_failed": {
203
- const tail = event.willRetry
204
- ? s.yellow(`will retry (attempt ${event.attempt}/${event.maxAttempts})`)
205
- : s.red(`gave up after ${event.attempt} ${plural(event.attempt, "attempt")}`);
206
- return `${s.red("")} ${s.bold(event.taskId)} ${s.red("failed")} ${s.dim(`in ${fmtDuration(event.durationMs)}`)}: ${event.error} ${tail}`;
207
- }
208
- case "task_blocked": {
209
- const by = event.blockedBy ? s.dim(` (by ${event.blockedBy})`) : "";
210
- return `${s.yellow("⊘")} ${s.bold(event.taskId)} ${s.yellow("blocked")}${by} ${s.dim(event.reason)}`;
211
- }
175
+ case "task_failed":
176
+ return `${s.red("✖")} ${failureMessage(event, s)}`;
177
+ case "task_blocked":
178
+ return `${s.yellow("⊘")} ${s.bold(event.taskId)} ${s.yellow("blocked")}${blockedSuffix(event, s)} ${s.dim(event.reason)}`;
179
+ case "run_paused":
180
+ return `${s.yellow("⏸")} ${pausedMessage(event, s)}`;
212
181
  case "summary": {
213
- const sm = event.summary;
214
- const parts = [
215
- s.green(`${sm.done} done`),
216
- count(sm.failed, "failed", s.red, s),
217
- count(sm.blocked, "blocked", s.yellow, s),
218
- count(sm.pending, "pending", s.yellow, s),
219
- ];
220
- const ok = sm.failed === 0 && sm.blocked === 0;
221
- const head = ok ? s.green("✔ done") : s.red("✖ finished with issues");
222
- return `${head} ${parts.join(s.dim(" · "))} ${s.dim(`(${fmtDuration(event.durationMs)})`)}`;
182
+ const head = summaryHead(event.summary, s, true);
183
+ return `${head} ${summaryParts(event.summary, s).join(s.dim(" · "))} ${s.dim(`(${fmtDuration(event.durationMs)})`)}`;
223
184
  }
224
185
  case "dry_run":
225
186
  // Plain, uncolored, pipeable plan — one task id per line.
@@ -228,6 +189,70 @@ function formatHuman(event, s) {
228
189
  return s.dim(`• ${event.message}`);
229
190
  }
230
191
  }
192
+ // Shared content builders. Both renderers — the plain line writer and the clack
193
+ // pretty renderer — produce the same message bodies; only the framing (gutters,
194
+ // spinners, boxes) differs. Keeping the text in one place means a field added to
195
+ // an event is formatted once, not twice.
196
+ function runDetailLines(i, s) {
197
+ return [
198
+ `${s.dim("tasks ")} ${i.tasksPath} ${s.dim(`(${i.total} ${plural(i.total, "task")})`)}`,
199
+ `${s.dim("cwd ")} ${i.cwd}`,
200
+ `${s.dim("limits ")} ${i.maxAttempts} ${plural(i.maxAttempts, "attempt")} ${s.dim("·")} ${fmtDuration(i.timeoutMs)} timeout`,
201
+ ];
202
+ }
203
+ function planBody(taskIds, s) {
204
+ return taskIds.length === 0 ? s.dim("(no tasks)") : taskIds.map((id) => s.cyan(id)).join(s.dim(" → "));
205
+ }
206
+ function sessionText(sessionId) {
207
+ return `session ${sessionId}`;
208
+ }
209
+ function checkText(strategy, ok) {
210
+ return `check ${strategy} → ${ok ? "ok" : "fail"}`;
211
+ }
212
+ function doneMessage(event, s) {
213
+ return `${s.bold(event.taskId)} ${s.green("done")} ${s.dim(`in ${fmtDuration(event.durationMs)} · ${event.done}/${event.total} complete`)}`;
214
+ }
215
+ function committedDetail(event, s) {
216
+ if (event.status === "nothing")
217
+ return s.dim("no changes to commit");
218
+ const sha = event.commit ? `${s.cyan(event.commit)} ` : "";
219
+ return `${s.dim("committed")} ${sha}${s.dim(event.message)}`;
220
+ }
221
+ function failureTail(event, s) {
222
+ return event.willRetry
223
+ ? s.yellow(`will retry (attempt ${event.attempt}/${event.maxAttempts})`)
224
+ : s.red(`gave up after ${event.attempt} ${plural(event.attempt, "attempt")}`);
225
+ }
226
+ function failureMessage(event, s) {
227
+ return `${s.bold(event.taskId)} ${s.red("failed")} ${s.dim(`in ${fmtDuration(event.durationMs)}`)}: ${event.error} ${failureTail(event, s)}`;
228
+ }
229
+ function blockedSuffix(event, s) {
230
+ return event.blockedBy ? s.dim(` (by ${event.blockedBy})`) : "";
231
+ }
232
+ function pausedMessage(event, s) {
233
+ const resume = event.resumeAfterMs !== undefined ? s.dim(` · retry after ${fmtDuration(event.resumeAfterMs)}`) : "";
234
+ return `${s.bold(event.taskId)} ${s.yellow("paused")} ${s.dim(event.reason)}${resume} ${s.dim("— re-run to resume")}`;
235
+ }
236
+ function summaryParts(sm, s) {
237
+ return [
238
+ s.green(`${sm.done} done`),
239
+ count(sm.failed, "failed", s.red, s),
240
+ count(sm.blocked, "blocked", s.yellow, s),
241
+ count(sm.pending, "pending", s.yellow, s),
242
+ ];
243
+ }
244
+ function summaryOk(sm) {
245
+ return sm.failed === 0 && sm.blocked === 0;
246
+ }
247
+ // `glyph` selects whether the leading symbol is included (plain renderer) or
248
+ // omitted (the clack outro draws its own framing).
249
+ function summaryHead(sm, s, glyph) {
250
+ if (sm.paused)
251
+ return glyph ? s.yellow("⏸ paused") : s.yellow("paused");
252
+ if (summaryOk(sm))
253
+ return glyph ? s.green("✔ done") : s.green("done");
254
+ return glyph ? s.red("✖ finished with issues") : s.red("finished with issues");
255
+ }
231
256
  function count(n, label, color, s) {
232
257
  const text = `${n} ${label}`;
233
258
  return n > 0 ? color(text) : s.dim(text);
@@ -4,7 +4,7 @@ import { type TaskFs } from "./tasks.js";
4
4
  import type { Logger } from "./log.js";
5
5
  export declare const DEFAULT_TIMEOUT_MS: number;
6
6
  export declare const DEFAULT_MAX_ATTEMPTS = 2;
7
- export interface OpenCodeClient {
7
+ export interface AgentClient {
8
8
  session: {
9
9
  create(input: {
10
10
  cwd: string;
@@ -26,17 +26,14 @@ export interface OrchestratorFs extends TaskFs, SuccessFs {
26
26
  }
27
27
  export interface OrchestratorOptions {
28
28
  tasksPath: string;
29
- client: OpenCodeClient;
29
+ client: AgentClient;
30
30
  fs?: OrchestratorFs;
31
31
  clock?: Clock;
32
32
  log?: Logger;
33
33
  cwd?: string;
34
- resultsDir?: string;
35
34
  maxAttempts?: number;
36
35
  timeoutMs?: number;
37
36
  version?: string;
38
- /** Commit the working tree after each task succeeds. Defaults to true. */
39
- commit?: boolean;
40
37
  /** Commits the working tree after each successful task. Omit to skip committing entirely. */
41
38
  git?: GitCommitter;
42
39
  }
@@ -47,6 +44,9 @@ export interface Summary {
47
44
  done: number;
48
45
  failed: number;
49
46
  blocked: number;
47
+ /** Set only when the run halted on a quota/rate-limit wall; otherwise absent. */
48
+ paused?: boolean;
49
+ /** Why the run paused — present iff `paused`. */
50
+ pauseReason?: string;
50
51
  }
51
52
  export declare function runOrchestrator(options: OrchestratorOptions): Promise<Summary>;
52
- export declare function defaultResultsDir(cwd: string): string;
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
- import path from "node:path";
3
2
  import { dependencyState, topologicalOrder } from "./deps.js";
3
+ import { classifyQuotaError, RunPausedError } from "./pause.js";
4
4
  import { checkSuccess } from "./success.js";
5
5
  import { loadTasks, saveTasks } from "./tasks.js";
6
6
  export const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
@@ -27,7 +27,6 @@ export async function runOrchestrator(options) {
27
27
  maxAttempts: options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
28
28
  timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
29
29
  cwd,
30
- resultsDir: options.resultsDir ?? defaultResultsDir(cwd),
31
30
  },
32
31
  });
33
32
  // The plan is a courtesy preview; a dependency cycle is handled gracefully
@@ -40,35 +39,58 @@ export async function runOrchestrator(options) {
40
39
  // Cycle or missing dependency — the loop reports it as a deadlock/block.
41
40
  }
42
41
  }
43
- while (true) {
42
+ let paused;
43
+ loop: while (true) {
44
44
  let progressed = false;
45
45
  for (const task of taskFile.tasks) {
46
46
  if (!isRunnable(task, options.maxAttempts))
47
47
  continue;
48
48
  const state = dependencyState(task, taskFile.tasks);
49
- if (state === "waiting")
49
+ if (state.state === "waiting")
50
50
  continue;
51
- if (state === "blocked") {
51
+ if (state.state === "blocked") {
52
52
  task.status = "blocked";
53
53
  task.error = "Dependency failed, blocked, or missing.";
54
- task.finishedAt = nowIso(options.clock);
54
+ task.finishedAt = nowIso(clock);
55
55
  options.log?.event({
56
56
  type: "task_blocked",
57
57
  taskId: task.id,
58
58
  reason: task.error,
59
- blockedBy: blockingDependency(task, taskFile.tasks),
59
+ blockedBy: state.blockedBy,
60
60
  });
61
61
  await saveTasks(options.tasksPath, taskFile, queueFs);
62
62
  progressed = true;
63
63
  continue;
64
64
  }
65
- await runTask(task, taskFile.tasks, options, queueFs);
65
+ try {
66
+ await runTask(task, taskFile.tasks, options, queueFs);
67
+ }
68
+ catch (error) {
69
+ // A quota/rate-limit wall halts the whole run: the task has already been
70
+ // reset to pending (attempt rolled back) inside runTask, so we just stop
71
+ // the sweep — every other ready task would hit the same wall this round.
72
+ if (error instanceof RunPausedError) {
73
+ paused = error;
74
+ break loop;
75
+ }
76
+ throw error;
77
+ }
66
78
  progressed = true;
67
79
  }
68
80
  if (!progressed)
69
81
  break;
70
82
  }
71
83
  const summary = summarize(taskFile.tasks);
84
+ if (paused) {
85
+ summary.paused = true;
86
+ summary.pauseReason = paused.message;
87
+ options.log?.event({
88
+ type: "run_paused",
89
+ taskId: paused.taskId,
90
+ reason: paused.message,
91
+ resumeAfterMs: paused.resumeAfterMs,
92
+ });
93
+ }
72
94
  options.log?.event({ type: "summary", summary, durationMs: clock.now().getTime() - runStart });
73
95
  return summary;
74
96
  }
@@ -80,7 +102,7 @@ async function runTask(task, tasks, options, queueFs) {
80
102
  task.status = "running";
81
103
  task.attempts += 1;
82
104
  const startMs = clock.now().getTime();
83
- task.startedAt = clock.now().toISOString();
105
+ task.startedAt = nowIso(clock);
84
106
  task.finishedAt = undefined;
85
107
  task.error = undefined;
86
108
  options.log?.event({
@@ -104,7 +126,7 @@ async function runTask(task, tasks, options, queueFs) {
104
126
  if (!success)
105
127
  throw new Error("Success check failed.");
106
128
  task.status = "done";
107
- task.finishedAt = clock.now().toISOString();
129
+ task.finishedAt = nowIso(clock);
108
130
  options.log?.event({
109
131
  type: "task_done",
110
132
  taskId: task.id,
@@ -114,9 +136,23 @@ async function runTask(task, tasks, options, queueFs) {
114
136
  });
115
137
  }
116
138
  catch (error) {
139
+ // Out of tokens is not a task defect. Roll back the attempt we charged at
140
+ // the top, leave the task pending (exactly as crash recovery would), persist
141
+ // that clean state, then unwind the run — index.ts maps the pause to a
142
+ // distinct exit code so re-invoking resumes once tokens return.
143
+ const quota = classifyQuotaError(error);
144
+ if (quota) {
145
+ task.attempts -= 1;
146
+ task.status = "pending";
147
+ task.startedAt = undefined;
148
+ task.finishedAt = undefined;
149
+ task.error = undefined;
150
+ await saveTasks(options.tasksPath, { tasks }, queueFs);
151
+ throw new RunPausedError(task.id, quota.reason, quota.resumeAfterMs);
152
+ }
117
153
  task.error = error instanceof Error ? error.message : String(error);
118
154
  task.status = task.attempts >= maxAttempts ? "failed" : "pending";
119
- task.finishedAt = clock.now().toISOString();
155
+ task.finishedAt = nowIso(clock);
120
156
  options.log?.event({
121
157
  type: "task_failed",
122
158
  taskId: task.id,
@@ -132,7 +168,7 @@ async function runTask(task, tasks, options, queueFs) {
132
168
  // commit captures the task file in its final "done" state. A failed commit
133
169
  // is reported but never flips the task back to failed — the task itself
134
170
  // already passed its success check. Skipped when no committer is wired in.
135
- if (task.status === "done" && options.commit !== false && options.git) {
171
+ if (task.status === "done" && options.git) {
136
172
  await commitTask(task, options.git, options.log, cwd);
137
173
  }
138
174
  }
@@ -164,18 +200,6 @@ async function commitTask(task, committer, log, cwd) {
164
200
  function countDone(tasks) {
165
201
  return tasks.reduce((total, task) => (task.status === "done" ? total + 1 : total), 0);
166
202
  }
167
- // Names the first dependency that is failed, blocked, or missing, for telemetry.
168
- function blockingDependency(task, tasks) {
169
- const byId = new Map(tasks.map((candidate) => [candidate.id, candidate]));
170
- for (const depId of task.dependsOn) {
171
- const dep = byId.get(depId);
172
- if (!dep)
173
- return `${depId} (missing)`;
174
- if (dep.status === "failed" || dep.status === "blocked")
175
- return `${depId} (${dep.status})`;
176
- }
177
- return undefined;
178
- }
179
203
  function isRunnable(task, globalMaxAttempts = DEFAULT_MAX_ATTEMPTS) {
180
204
  if (task.status === "pending")
181
205
  return true;
@@ -217,6 +241,3 @@ function summarize(tasks) {
217
241
  function nowIso(clock = defaultClock) {
218
242
  return clock.now().toISOString();
219
243
  }
220
- export function defaultResultsDir(cwd) {
221
- return path.join(cwd, ".queue", "results");
222
- }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Pause/resume support for the orchestrator.
3
+ *
4
+ * Running out of provider tokens is a property of the *run*, not the *task*:
5
+ * nothing is wrong with the task that hit the wall, and every other ready task
6
+ * would hit the same wall this sweep. So instead of letting a quota error burn
7
+ * the task's attempt budget and cascade into `blocked` dependents, we classify
8
+ * it, leave the task resumable, and unwind the whole run with `RunPausedError`.
9
+ * The persisted state (running -> pending, atomic save after every transition)
10
+ * is already the resume substrate — re-invoking the CLI just picks up where it
11
+ * left off once tokens are back.
12
+ */
13
+ /**
14
+ * Thrown out of a task to halt the whole run cleanly. The task it names has
15
+ * been reset to `pending` (its attempt rolled back), so no failure is recorded.
16
+ */
17
+ export declare class RunPausedError extends Error {
18
+ readonly taskId: string;
19
+ /** Wall-clock ms the provider asked us to wait, when it told us (Retry-After). */
20
+ readonly resumeAfterMs?: number;
21
+ constructor(taskId: string, reason: string, resumeAfterMs?: number);
22
+ }
23
+ export interface QuotaSignal {
24
+ /** Human-readable reason, surfaced in logs and on the paused exit. */
25
+ reason: string;
26
+ /** Suggested wait before resuming, derived from a Retry-After header. */
27
+ resumeAfterMs?: number;
28
+ }
29
+ /**
30
+ * Inspects an unknown thrown value for a quota / rate-limit / overload signal.
31
+ * Returns a `QuotaSignal` when the run should pause, or `null` when the error
32
+ * is a genuine task failure that should follow the normal failure path.
33
+ */
34
+ export declare function classifyQuotaError(error: unknown): QuotaSignal | null;
package/dist/pause.js ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Pause/resume support for the orchestrator.
3
+ *
4
+ * Running out of provider tokens is a property of the *run*, not the *task*:
5
+ * nothing is wrong with the task that hit the wall, and every other ready task
6
+ * would hit the same wall this sweep. So instead of letting a quota error burn
7
+ * the task's attempt budget and cascade into `blocked` dependents, we classify
8
+ * it, leave the task resumable, and unwind the whole run with `RunPausedError`.
9
+ * The persisted state (running -> pending, atomic save after every transition)
10
+ * is already the resume substrate — re-invoking the CLI just picks up where it
11
+ * left off once tokens are back.
12
+ */
13
+ /**
14
+ * Thrown out of a task to halt the whole run cleanly. The task it names has
15
+ * been reset to `pending` (its attempt rolled back), so no failure is recorded.
16
+ */
17
+ export class RunPausedError extends Error {
18
+ taskId;
19
+ /** Wall-clock ms the provider asked us to wait, when it told us (Retry-After). */
20
+ resumeAfterMs;
21
+ constructor(taskId, reason, resumeAfterMs) {
22
+ super(reason);
23
+ this.name = "RunPausedError";
24
+ this.taskId = taskId;
25
+ this.resumeAfterMs = resumeAfterMs;
26
+ }
27
+ }
28
+ // HTTP statuses that mean "retryable later, not a task defect":
29
+ // 429 too many requests (rate limit) 402 payment required (out of credit)
30
+ // 529 overloaded (Anthropic) 503 service unavailable
31
+ const QUOTA_STATUSES = new Set([402, 429, 503, 529]);
32
+ // Message fragments that signal a quota / rate / availability wall even when no
33
+ // numeric status is exposed. Kept deliberately broad — a false positive only
34
+ // pauses (recoverable) where a false negative would burn the retry budget.
35
+ const QUOTA_PATTERNS = [
36
+ /\brate.?limit/i,
37
+ /too many requests/i,
38
+ /\bquota\b/i,
39
+ /insufficient\s+(?:credit|quota|balance|funds?)/i,
40
+ /out of (?:credit|tokens?)/i,
41
+ /payment required/i,
42
+ /overloaded/i,
43
+ /\b(?:429|402|529)\b/,
44
+ ];
45
+ /**
46
+ * Inspects an unknown thrown value for a quota / rate-limit / overload signal.
47
+ * Returns a `QuotaSignal` when the run should pause, or `null` when the error
48
+ * is a genuine task failure that should follow the normal failure path.
49
+ */
50
+ export function classifyQuotaError(error) {
51
+ const status = extractStatus(error);
52
+ if (status !== undefined && QUOTA_STATUSES.has(status)) {
53
+ return { reason: reasonForStatus(status), resumeAfterMs: extractRetryAfterMs(error) };
54
+ }
55
+ const message = extractMessage(error);
56
+ if (message && QUOTA_PATTERNS.some((pattern) => pattern.test(message))) {
57
+ return { reason: message, resumeAfterMs: extractRetryAfterMs(error) };
58
+ }
59
+ return null;
60
+ }
61
+ function reasonForStatus(status) {
62
+ switch (status) {
63
+ case 402:
64
+ return "Provider returned 402 (payment required / out of credit).";
65
+ case 429:
66
+ return "Provider returned 429 (rate limit / quota exceeded).";
67
+ case 503:
68
+ return "Provider returned 503 (service unavailable).";
69
+ case 529:
70
+ return "Provider returned 529 (overloaded).";
71
+ default:
72
+ return `Provider returned ${status}.`;
73
+ }
74
+ }
75
+ // Provider/SDK errors expose the status under several names and nesting depths,
76
+ // so probe the common shapes rather than assuming one.
77
+ function extractStatus(error) {
78
+ if (!error || typeof error !== "object")
79
+ return undefined;
80
+ const candidates = [
81
+ error.status,
82
+ error.statusCode,
83
+ asObject(error.response)?.status,
84
+ asObject(error.error)?.status,
85
+ asObject(error.cause)?.status,
86
+ ];
87
+ for (const candidate of candidates) {
88
+ const n = typeof candidate === "string" ? Number(candidate) : candidate;
89
+ if (typeof n === "number" && Number.isInteger(n))
90
+ return n;
91
+ }
92
+ return undefined;
93
+ }
94
+ function extractMessage(error) {
95
+ if (error instanceof Error)
96
+ return error.message;
97
+ if (typeof error === "string")
98
+ return error;
99
+ if (error && typeof error === "object") {
100
+ const message = error.message;
101
+ if (typeof message === "string")
102
+ return message;
103
+ }
104
+ return "";
105
+ }
106
+ // Retry-After is seconds (or, per RFC, an HTTP date — we only honour the numeric
107
+ // form). Look in the header bags the common SDK error shapes carry.
108
+ function extractRetryAfterMs(error) {
109
+ const headers = asObject(error?.headers) ??
110
+ asObject(asObject(error?.response)?.headers);
111
+ if (!headers)
112
+ return undefined;
113
+ const raw = headers["retry-after"] ?? headers["Retry-After"];
114
+ const seconds = typeof raw === "string" ? Number(raw) : typeof raw === "number" ? raw : NaN;
115
+ return Number.isFinite(seconds) && seconds >= 0 ? Math.round(seconds * 1000) : undefined;
116
+ }
117
+ function asObject(value) {
118
+ return value && typeof value === "object" ? value : undefined;
119
+ }
package/dist/schema.d.ts CHANGED
@@ -34,7 +34,6 @@ export declare const taskSchema: z.ZodObject<{
34
34
  maxAttempts: z.ZodOptional<z.ZodNumber>;
35
35
  timeoutMs: z.ZodOptional<z.ZodNumber>;
36
36
  cwd: z.ZodOptional<z.ZodString>;
37
- resultPath: z.ZodOptional<z.ZodString>;
38
37
  success: z.ZodOptional<z.ZodDefault<z.ZodDiscriminatedUnion<"strategy", [z.ZodObject<{
39
38
  strategy: z.ZodLiteral<"file_exists">;
40
39
  path: z.ZodString;
@@ -74,7 +73,6 @@ export declare const taskSchema: z.ZodObject<{
74
73
  maxAttempts?: number | undefined;
75
74
  timeoutMs?: number | undefined;
76
75
  cwd?: string | undefined;
77
- resultPath?: string | undefined;
78
76
  success?: {
79
77
  strategy: "file_exists";
80
78
  path: string;
@@ -96,7 +94,6 @@ export declare const taskSchema: z.ZodObject<{
96
94
  maxAttempts?: number | undefined;
97
95
  timeoutMs?: number | undefined;
98
96
  cwd?: string | undefined;
99
- resultPath?: string | undefined;
100
97
  success?: {
101
98
  strategy: "file_exists";
102
99
  path: string;
@@ -121,7 +118,6 @@ export declare const taskFileSchema: z.ZodUnion<[z.ZodObject<{
121
118
  maxAttempts: z.ZodOptional<z.ZodNumber>;
122
119
  timeoutMs: z.ZodOptional<z.ZodNumber>;
123
120
  cwd: z.ZodOptional<z.ZodString>;
124
- resultPath: z.ZodOptional<z.ZodString>;
125
121
  success: z.ZodOptional<z.ZodDefault<z.ZodDiscriminatedUnion<"strategy", [z.ZodObject<{
126
122
  strategy: z.ZodLiteral<"file_exists">;
127
123
  path: z.ZodString;
@@ -161,7 +157,6 @@ export declare const taskFileSchema: z.ZodUnion<[z.ZodObject<{
161
157
  maxAttempts?: number | undefined;
162
158
  timeoutMs?: number | undefined;
163
159
  cwd?: string | undefined;
164
- resultPath?: string | undefined;
165
160
  success?: {
166
161
  strategy: "file_exists";
167
162
  path: string;
@@ -183,7 +178,6 @@ export declare const taskFileSchema: z.ZodUnion<[z.ZodObject<{
183
178
  maxAttempts?: number | undefined;
184
179
  timeoutMs?: number | undefined;
185
180
  cwd?: string | undefined;
186
- resultPath?: string | undefined;
187
181
  success?: {
188
182
  strategy: "file_exists";
189
183
  path: string;
@@ -209,7 +203,6 @@ export declare const taskFileSchema: z.ZodUnion<[z.ZodObject<{
209
203
  maxAttempts?: number | undefined;
210
204
  timeoutMs?: number | undefined;
211
205
  cwd?: string | undefined;
212
- resultPath?: string | undefined;
213
206
  success?: {
214
207
  strategy: "file_exists";
215
208
  path: string;
@@ -233,7 +226,6 @@ export declare const taskFileSchema: z.ZodUnion<[z.ZodObject<{
233
226
  maxAttempts?: number | undefined;
234
227
  timeoutMs?: number | undefined;
235
228
  cwd?: string | undefined;
236
- resultPath?: string | undefined;
237
229
  success?: {
238
230
  strategy: "file_exists";
239
231
  path: string;
@@ -257,7 +249,6 @@ export declare const taskFileSchema: z.ZodUnion<[z.ZodObject<{
257
249
  maxAttempts: z.ZodOptional<z.ZodNumber>;
258
250
  timeoutMs: z.ZodOptional<z.ZodNumber>;
259
251
  cwd: z.ZodOptional<z.ZodString>;
260
- resultPath: z.ZodOptional<z.ZodString>;
261
252
  success: z.ZodOptional<z.ZodDefault<z.ZodDiscriminatedUnion<"strategy", [z.ZodObject<{
262
253
  strategy: z.ZodLiteral<"file_exists">;
263
254
  path: z.ZodString;
@@ -297,7 +288,6 @@ export declare const taskFileSchema: z.ZodUnion<[z.ZodObject<{
297
288
  maxAttempts?: number | undefined;
298
289
  timeoutMs?: number | undefined;
299
290
  cwd?: string | undefined;
300
- resultPath?: string | undefined;
301
291
  success?: {
302
292
  strategy: "file_exists";
303
293
  path: string;
@@ -319,7 +309,6 @@ export declare const taskFileSchema: z.ZodUnion<[z.ZodObject<{
319
309
  maxAttempts?: number | undefined;
320
310
  timeoutMs?: number | undefined;
321
311
  cwd?: string | undefined;
322
- resultPath?: string | undefined;
323
312
  success?: {
324
313
  strategy: "file_exists";
325
314
  path: string;
@@ -344,7 +333,6 @@ export declare const taskFileSchema: z.ZodUnion<[z.ZodObject<{
344
333
  maxAttempts?: number | undefined;
345
334
  timeoutMs?: number | undefined;
346
335
  cwd?: string | undefined;
347
- resultPath?: string | undefined;
348
336
  success?: {
349
337
  strategy: "file_exists";
350
338
  path: string;
@@ -367,7 +355,6 @@ export declare const taskFileSchema: z.ZodUnion<[z.ZodObject<{
367
355
  maxAttempts?: number | undefined;
368
356
  timeoutMs?: number | undefined;
369
357
  cwd?: string | undefined;
370
- resultPath?: string | undefined;
371
358
  success?: {
372
359
  strategy: "file_exists";
373
360
  path: string;
package/dist/schema.js CHANGED
@@ -24,7 +24,6 @@ export const taskSchema = z.object({
24
24
  maxAttempts: z.number().int().min(1).optional(),
25
25
  timeoutMs: z.number().int().positive().optional(),
26
26
  cwd: z.string().min(1).optional(),
27
- resultPath: z.string().min(1).optional(),
28
27
  success: successCheckSchema.optional(),
29
28
  recoveryNotes: z.array(z.string()).default([]),
30
29
  error: z.string().optional(),
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "td-barrage",
3
- "version": "0.1.3",
4
- "description": "TypeScript queue runner that loads a JSON task file, resolves dependencies, runs tasks through the OpenCode SDK, and resumes interrupted work.",
3
+ "version": "0.1.5",
4
+ "description": "A persistent, dependency-aware task queue for the pi coding agent.",
5
5
  "type": "module",
6
6
  "engines": {
7
- "node": ">=20.11"
7
+ "node": ">=22.19"
8
8
  },
9
9
  "bin": {
10
10
  "td-barrage": "dist/index.js"
@@ -24,7 +24,8 @@
24
24
  "access": "public"
25
25
  },
26
26
  "scripts": {
27
- "build": "tsc -p tsconfig.json",
27
+ "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
28
+ "build": "npm run clean && tsc -p tsconfig.json",
28
29
  "start": "node dist/index.js",
29
30
  "dev": "tsx src/index.ts",
30
31
  "test": "vitest run",
@@ -35,11 +36,11 @@
35
36
  },
36
37
  "dependencies": {
37
38
  "@clack/prompts": "^1.4.0",
38
- "@opencode-ai/sdk": "^1.15.10",
39
+ "@earendil-works/pi-coding-agent": "^0.85.1",
39
40
  "zod": "^3.25.0"
40
41
  },
41
42
  "devDependencies": {
42
- "@types/node": "^20.11.0",
43
+ "@types/node": "^22.19.0",
43
44
  "tsx": "^4.19.0",
44
45
  "typescript": "^5.8.0",
45
46
  "vitest": "^3.1.0"
package/dist/server.d.ts DELETED
@@ -1,18 +0,0 @@
1
- import { spawn, type ChildProcess } from "node:child_process";
2
- export interface StartServerOptions {
3
- command?: string;
4
- args?: string[];
5
- port: number;
6
- host?: string;
7
- timeoutMs?: number;
8
- pollMs?: number;
9
- spawnProcess?: typeof spawn;
10
- portOpen?: (host: string, port: number) => Promise<boolean>;
11
- sleep?: (ms: number) => Promise<void>;
12
- now?: () => number;
13
- }
14
- export interface StopServerOptions {
15
- graceMs?: number;
16
- }
17
- export declare function startServer(options: StartServerOptions): Promise<ChildProcess>;
18
- export declare function stopServer(child: Pick<ChildProcess, "kill" | "killed" | "exitCode" | "once">, options?: StopServerOptions): Promise<void>;
package/dist/server.js DELETED
@@ -1,60 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import net from "node:net";
3
- export async function startServer(options) {
4
- const command = options.command ?? "opencode";
5
- const args = options.args ?? ["serve", "--port", String(options.port)];
6
- const host = options.host ?? "127.0.0.1";
7
- const timeoutMs = options.timeoutMs ?? 30_000;
8
- const pollMs = options.pollMs ?? 100;
9
- const portOpen = options.portOpen ?? isPortOpen;
10
- const wait = options.sleep ?? sleep;
11
- const now = options.now ?? Date.now;
12
- const child = (options.spawnProcess ?? spawn)(command, args, {
13
- stdio: "ignore",
14
- detached: false,
15
- });
16
- const startedAt = now();
17
- while (now() - startedAt < timeoutMs) {
18
- if (await portOpen(host, options.port))
19
- return child;
20
- if (child.exitCode !== null)
21
- throw new Error(`Server exited before port ${options.port} opened.`);
22
- await wait(pollMs);
23
- }
24
- child.kill("SIGTERM");
25
- throw new Error(`Timed out waiting for ${host}:${options.port}.`);
26
- }
27
- export async function stopServer(child, options = {}) {
28
- const graceMs = options.graceMs ?? 5_000;
29
- if (child.exitCode !== null)
30
- return;
31
- child.kill("SIGTERM");
32
- await new Promise((resolve) => {
33
- const timeout = setTimeout(() => {
34
- if (child.exitCode === null)
35
- child.kill("SIGKILL");
36
- resolve();
37
- }, graceMs);
38
- child.once("exit", () => {
39
- clearTimeout(timeout);
40
- resolve();
41
- });
42
- });
43
- }
44
- async function isPortOpen(host, port) {
45
- return new Promise((resolve) => {
46
- const socket = net.createConnection({ host, port });
47
- socket.once("connect", () => {
48
- socket.destroy();
49
- resolve(true);
50
- });
51
- socket.once("error", () => resolve(false));
52
- socket.setTimeout(250, () => {
53
- socket.destroy();
54
- resolve(false);
55
- });
56
- });
57
- }
58
- function sleep(ms) {
59
- return new Promise((resolve) => setTimeout(resolve, ms));
60
- }