agent-hitch 0.1.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.
@@ -0,0 +1,156 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { createQueuedRun, executeRun } from "./engine.js";
4
+ import { atomicWriteJSON, ensureDir, readJSON } from "./fs.js";
5
+ import { SCHEMA_VERSION } from "./config.js";
6
+ import { cancelPlannedWorkspace, recoverInterruptedWorkspace } from "./workspaces.js";
7
+
8
+ export class Scheduler {
9
+ constructor({ runsRoot, root = path.dirname(runsRoot), maxConcurrent = 4, onEvent = () => {} }) {
10
+ this.runsRoot = runsRoot;
11
+ this.root = root;
12
+ this.maxConcurrent = maxConcurrent;
13
+ this.onEvent = onEvent;
14
+ this.queue = [];
15
+ this.active = new Map();
16
+ this.completions = new Map();
17
+ this.accepting = true;
18
+ }
19
+
20
+ async initialize() {
21
+ await ensureDir(this.runsRoot);
22
+ await this.recoverInterruptedRuns();
23
+ }
24
+
25
+ async submit(request) {
26
+ if (!this.accepting) throw new Error("daemon is shutting down");
27
+ const queued = await createQueuedRun({ request, runsRoot: this.runsRoot, root: this.root });
28
+ this.queue.push(queued);
29
+ this.onEvent({ type: "run.queued", run_id: queued.runId });
30
+ queueMicrotask(() => this.drain());
31
+ return queued.runId;
32
+ }
33
+
34
+ async cancel(runId) {
35
+ const queuedIndex = this.queue.findIndex((entry) => entry.runId === runId);
36
+ if (queuedIndex >= 0) {
37
+ const [entry] = this.queue.splice(queuedIndex, 1);
38
+ const now = new Date().toISOString();
39
+ const result = {
40
+ schema_version: SCHEMA_VERSION,
41
+ run_id: runId,
42
+ status: "cancelled",
43
+ exit_code: 9,
44
+ error: { code: "cancelled", message: "run cancelled before launch" },
45
+ completed_at: now,
46
+ };
47
+ await atomicWriteJSON(path.join(entry.directory, "result.json"), result);
48
+ const manifest = await readJSON(path.join(entry.directory, "manifest.json"));
49
+ await atomicWriteJSON(path.join(entry.directory, "manifest.json"), { ...manifest, status: "cancelled", completed_at: now });
50
+ await cancelPlannedWorkspace({ root: this.root, runId });
51
+ return true;
52
+ }
53
+ const active = this.active.get(runId);
54
+ if (!active?.cancel) return false;
55
+ await active.cancel();
56
+ return true;
57
+ }
58
+
59
+ async status(runId) {
60
+ const directory = path.join(this.runsRoot, runId);
61
+ const manifest = await readJSON(path.join(directory, "manifest.json"), null);
62
+ if (!manifest) return null;
63
+ const result = await readJSON(path.join(directory, "result.json"), null);
64
+ return { manifest, result };
65
+ }
66
+
67
+ snapshot() {
68
+ return {
69
+ queued: this.queue.length,
70
+ running: this.active.size,
71
+ max_concurrent: this.maxConcurrent,
72
+ accepting: this.accepting,
73
+ };
74
+ }
75
+
76
+ async shutdown() {
77
+ this.accepting = false;
78
+ for (const entry of [...this.queue]) await this.cancel(entry.runId);
79
+ await Promise.all([...this.active.values()].map((run) => run.cancel?.()));
80
+ await Promise.all([...this.completions.values()]);
81
+ }
82
+
83
+ drain() {
84
+ while (this.accepting && this.active.size < this.maxConcurrent && this.queue.length > 0) {
85
+ const entry = this.queue.shift();
86
+ const controller = new AbortController();
87
+ this.active.set(entry.runId, { cancel: async () => controller.abort() });
88
+ const completion = executeRun({
89
+ runId: entry.runId,
90
+ request: entry.request,
91
+ runsRoot: this.runsRoot,
92
+ root: this.root,
93
+ resolvedRevision: entry.resolvedRevision,
94
+ workspacePlan: entry.workspacePlan,
95
+ onEvent: this.onEvent,
96
+ signal: controller.signal,
97
+ onProcess: (processControl) => {
98
+ if (processControl) this.active.set(entry.runId, {
99
+ ...processControl,
100
+ cancel: async () => controller.abort(),
101
+ });
102
+ },
103
+ }).catch(async (error) => {
104
+ await this.recordUnexpectedFailure(entry, error);
105
+ this.onEvent({ type: "scheduler.error", run_id: entry.runId, error: error.message });
106
+ }).finally(() => {
107
+ this.active.delete(entry.runId);
108
+ this.completions.delete(entry.runId);
109
+ this.drain();
110
+ });
111
+ this.completions.set(entry.runId, completion);
112
+ }
113
+ }
114
+
115
+ async recordUnexpectedFailure(entry, error) {
116
+ const resultPath = path.join(entry.directory, "result.json");
117
+ if (await readJSON(resultPath, null)) return;
118
+ const now = new Date().toISOString();
119
+ const result = {
120
+ schema_version: SCHEMA_VERSION,
121
+ run_id: entry.runId,
122
+ status: "failed",
123
+ exit_code: 12,
124
+ error: { code: "scheduler_error", message: error?.message || String(error) },
125
+ completed_at: now,
126
+ };
127
+ await atomicWriteJSON(resultPath, result);
128
+ const manifestPath = path.join(entry.directory, "manifest.json");
129
+ const manifest = await readJSON(manifestPath, { schema_version: SCHEMA_VERSION, run_id: entry.runId });
130
+ await atomicWriteJSON(manifestPath, { ...manifest, status: "failed", completed_at: now });
131
+ }
132
+
133
+ async recoverInterruptedRuns() {
134
+ const entries = await readdir(this.runsRoot, { withFileTypes: true });
135
+ for (const entry of entries) {
136
+ if (!entry.isDirectory() || !entry.name.startsWith("run_")) continue;
137
+ const directory = path.join(this.runsRoot, entry.name);
138
+ const manifestPath = path.join(directory, "manifest.json");
139
+ const manifest = await readJSON(manifestPath, null);
140
+ const result = await readJSON(path.join(directory, "result.json"), null);
141
+ if (!manifest || result || !["queued", "preparing", "running"].includes(manifest.status)) continue;
142
+ const now = new Date().toISOString();
143
+ const recovered = {
144
+ schema_version: SCHEMA_VERSION,
145
+ run_id: entry.name,
146
+ status: "failed",
147
+ exit_code: 12,
148
+ error: { code: "daemon_restarted", message: "daemon stopped before the run completed" },
149
+ completed_at: now,
150
+ };
151
+ await atomicWriteJSON(path.join(directory, "result.json"), recovered);
152
+ await atomicWriteJSON(manifestPath, { ...manifest, status: "failed", completed_at: now });
153
+ await recoverInterruptedWorkspace({ root: this.root, runId: entry.name });
154
+ }
155
+ }
156
+ }