pi-harness-runtime 0.10.13 → 0.10.14

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,104 @@
1
+ /**
2
+ * Auto Quota Resume — schedules automatic job resume when quota resets.
3
+ *
4
+ * Flow:
5
+ * quota exhaustion detected
6
+ * → read h5_resets_at_epoch from mirror
7
+ * → compute resumeAt ISO timestamp
8
+ * → call machine.setResumeTime(resumeAt)
9
+ * → schedule setTimeout for auto-resume
10
+ * → at timeout: machine.transition("running")
11
+ *
12
+ * Only the 5h window is auto-resumed (provider-reported, predictable).
13
+ * Weekly/monthly quota exhaustion requires human monitoring.
14
+ */
15
+ /** Small buffer (ms) before the exact reset time to resume. */
16
+ const RESUME_BUFFER_MS = 10_000; // 10 seconds — resume just before reset
17
+ /** Minimum time to wait before auto-resuming (avoid immediate re-trigger). */
18
+ const MIN_RESUME_DELAY_MS = 5_000; // 5 seconds
19
+ /** Map of jobId → active auto-resume timer handle. */
20
+ const activeTimers = new Map();
21
+ /**
22
+ * Schedule an auto-resume for a job that was paused due to quota exhaustion.
23
+ *
24
+ * Reads the reset epoch from the mirror store for the given provider,
25
+ * computes the resume time, sets it on the checkpoint, and schedules
26
+ * a setTimeout to transition back to "running".
27
+ *
28
+ * Returns the scheduled resumeAt ISO string, or null if no epoch data
29
+ * is available (requires human resume).
30
+ */
31
+ export function scheduleAutoResume(provider, machine, mirrorStore) {
32
+ const jobId = machine.getCheckpoint()?.jobId;
33
+ if (!jobId)
34
+ return null;
35
+ // Cancel any existing timer for this job
36
+ cancelAutoResume(jobId);
37
+ const mirror = mirrorStore.readAll();
38
+ const record = mirror?.[provider];
39
+ if (!record)
40
+ return null;
41
+ // Only auto-resume on 5h quota exhaustion (has precise epoch)
42
+ if (typeof record.h5_resets_at_epoch !== "number")
43
+ return null;
44
+ const resetEpoch = record.h5_resets_at_epoch;
45
+ const now = Date.now();
46
+ const delayMs = Math.max(resetEpoch - now - RESUME_BUFFER_MS, MIN_RESUME_DELAY_MS);
47
+ const resumeAt = now + delayMs;
48
+ const resumeAtIso = new Date(resumeAt).toISOString();
49
+ // Persist to checkpoint so resumeAt survives worker restart
50
+ machine.setResumeTime(resumeAtIso);
51
+ // console.log(
52
+ // `[auto-quota-resume] ${provider} 5h quota exhausted.` +
53
+ // ` Auto-resume scheduled at ${resumeAtIso} (in ${Math.round(delayMs / 1000)}s)`,
54
+ // );
55
+ const timeout = setTimeout(async () => {
56
+ activeTimers.delete(jobId);
57
+ const checkpoint = machine.getCheckpoint();
58
+ if (!checkpoint || checkpoint.status !== "paused_quota") {
59
+ // Job was manually resumed or cancelled
60
+ return;
61
+ }
62
+ // Check the mirror — if quota is still exhausted, shift the timer
63
+ const updated = mirrorStore.readAll()?.[provider];
64
+ if (updated?.h5_resets_at_epoch) {
65
+ const stillExhausted = Date.now() < updated.h5_resets_at_epoch;
66
+ if (stillExhausted) {
67
+ // console.log(
68
+ // `[auto-quota-resume] ${provider} still exhausted at scheduled time.` +
69
+ // ` Re-scheduling...`,
70
+ // );
71
+ scheduleAutoResume(provider, machine, mirrorStore);
72
+ return;
73
+ }
74
+ }
75
+ const result = await machine.transition("running");
76
+ if (result.success) {
77
+ // console.log(
78
+ // `[auto-quota-resume] Job ${jobId} auto-resumed after ${provider} 5h quota reset.`,
79
+ // );
80
+ }
81
+ else {
82
+ console.error(`[auto-quota-resume] Auto-resume failed for ${jobId}: ${result.error}`);
83
+ }
84
+ }, delayMs);
85
+ activeTimers.set(jobId, { timeout, resumeAt });
86
+ return resumeAtIso;
87
+ }
88
+ /**
89
+ * Cancel the auto-resume timer for a job.
90
+ * Call this when the job is manually resumed or cancelled.
91
+ */
92
+ export function cancelAutoResume(jobId) {
93
+ const existing = activeTimers.get(jobId);
94
+ if (existing) {
95
+ clearTimeout(existing.timeout);
96
+ activeTimers.delete(jobId);
97
+ }
98
+ }
99
+ /**
100
+ * Get the scheduled resume time for a job, or null if not scheduled.
101
+ */
102
+ export function getScheduledResume(jobId) {
103
+ return activeTimers.get(jobId)?.resumeAt ?? null;
104
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Shared Blackboard — RFC-0011
3
+ *
4
+ * Durable file-based coordination so agents communicate without the human
5
+ * acting as message bus.
6
+ *
7
+ * Layout:
8
+ * harness/blackboard/
9
+ * status.json
10
+ * next_action.json
11
+ * tasks.json
12
+ * agent_registry.json
13
+ * locks/
14
+ * reports/
15
+ * context/
16
+ * events.jsonl
17
+ */
18
+ import { writeJson, readJson, appendJsonl, ensureUsageDir } from "../cli.ts";
19
+ // @ts-expect-error - Bun has built-in Node.js types
20
+ import { join } from "node:path";
21
+ export class SharedBlackboard {
22
+ jobDir;
23
+ record = null;
24
+ constructor(jobId, rootDir) {
25
+ this.jobDir = join(rootDir, "jobs", jobId, "blackboard");
26
+ }
27
+ /**
28
+ * Initialize a new blackboard for a job
29
+ */
30
+ init(jobId, taskGraph) {
31
+ const now = new Date().toISOString();
32
+ this.record = {
33
+ jobId,
34
+ status: "created",
35
+ nextAction: undefined,
36
+ tasks: taskGraph,
37
+ agentRegistry: { agents: {} },
38
+ reports: {},
39
+ locks: {},
40
+ updatedAt: now,
41
+ };
42
+ this.save();
43
+ }
44
+ /**
45
+ * Load blackboard from disk
46
+ */
47
+ load() {
48
+ const path = join(this.jobDir, "status.json");
49
+ this.record = readJson(path);
50
+ return this.record;
51
+ }
52
+ /**
53
+ * Save blackboard to disk
54
+ */
55
+ save() {
56
+ if (!this.record)
57
+ return;
58
+ this.record.updatedAt = new Date().toISOString();
59
+ ensureUsageDir();
60
+ const path = join(this.jobDir, "status.json");
61
+ writeJson(path, this.record);
62
+ }
63
+ /**
64
+ * Update job status
65
+ */
66
+ updateStatus(status) {
67
+ if (!this.record)
68
+ return;
69
+ this.record.status = status;
70
+ this.save();
71
+ this.appendEvent("StatusUpdated", { status });
72
+ }
73
+ /**
74
+ * Set the next action for agents to pick up
75
+ */
76
+ setNextAction(action) {
77
+ if (!this.record)
78
+ return;
79
+ this.record.nextAction = action;
80
+ this.save();
81
+ this.appendEvent("NextActionUpdated", {
82
+ taskId: action.taskId,
83
+ agentId: action.agentId,
84
+ priority: action.priority,
85
+ });
86
+ }
87
+ /**
88
+ * Clear the next action (after agent picks it up)
89
+ */
90
+ clearNextAction() {
91
+ if (!this.record)
92
+ return;
93
+ this.record.nextAction = undefined;
94
+ this.save();
95
+ }
96
+ /**
97
+ * Register an agent
98
+ */
99
+ registerAgent(agentId, name, provider, model) {
100
+ if (!this.record)
101
+ return;
102
+ this.record.agentRegistry.agents[agentId] = {
103
+ id: agentId,
104
+ name,
105
+ provider,
106
+ model,
107
+ status: "idle",
108
+ startedAt: new Date().toISOString(),
109
+ };
110
+ this.save();
111
+ }
112
+ /**
113
+ * Update agent status
114
+ */
115
+ updateAgentStatus(agentId, status, currentTaskId) {
116
+ if (!this.record)
117
+ return;
118
+ const agent = this.record.agentRegistry.agents[agentId];
119
+ if (!agent)
120
+ return;
121
+ agent.status = status;
122
+ agent.currentTaskId = currentTaskId;
123
+ agent.lastHeartbeat = new Date().toISOString();
124
+ this.save();
125
+ }
126
+ /**
127
+ * Unregister an agent
128
+ */
129
+ unregisterAgent(agentId) {
130
+ if (!this.record)
131
+ return;
132
+ delete this.record.agentRegistry.agents[agentId];
133
+ this.save();
134
+ }
135
+ /**
136
+ * Write an agent report
137
+ */
138
+ writeReport(report) {
139
+ if (!this.record)
140
+ return;
141
+ this.record.reports[report.agentId] = report;
142
+ this.save();
143
+ this.appendEvent("AgentReportWritten", {
144
+ agentId: report.agentId,
145
+ taskId: report.taskId,
146
+ status: report.status,
147
+ });
148
+ }
149
+ /**
150
+ * Acquire a lock on a task
151
+ */
152
+ acquireLock(taskId, agentId) {
153
+ if (!this.record)
154
+ return false;
155
+ if (this.record.locks[taskId]) {
156
+ return false; // Already locked
157
+ }
158
+ this.record.locks[taskId] = {
159
+ taskId,
160
+ agentId,
161
+ acquiredAt: new Date().toISOString(),
162
+ };
163
+ this.save();
164
+ this.appendEvent("LockAcquired", { taskId, agentId });
165
+ return true;
166
+ }
167
+ /**
168
+ * Release a lock on a task
169
+ */
170
+ releaseLock(taskId, agentId) {
171
+ if (!this.record)
172
+ return false;
173
+ const lock = this.record.locks[taskId];
174
+ if (!lock || lock.agentId !== agentId) {
175
+ return false; // Not locked by this agent
176
+ }
177
+ delete this.record.locks[taskId];
178
+ this.save();
179
+ this.appendEvent("LockReleased", { taskId, agentId });
180
+ return true;
181
+ }
182
+ /**
183
+ * Check if a task is locked
184
+ */
185
+ isLocked(taskId) {
186
+ return !!this.record?.locks[taskId];
187
+ }
188
+ /**
189
+ * Get lock info for a task
190
+ */
191
+ getLock(taskId) {
192
+ return this.record?.locks[taskId] ?? null;
193
+ }
194
+ /**
195
+ * Get the current record
196
+ */
197
+ getRecord() {
198
+ return this.record;
199
+ }
200
+ /**
201
+ * Get active agents
202
+ */
203
+ getActiveAgents() {
204
+ if (!this.record)
205
+ return [];
206
+ return Object.values(this.record.agentRegistry.agents);
207
+ }
208
+ /**
209
+ * Check for stale agents (no heartbeat in N minutes)
210
+ */
211
+ getStaleAgents(maxAgeMinutes = 10) {
212
+ if (!this.record)
213
+ return [];
214
+ const cutoff = Date.now() - maxAgeMinutes * 60 * 1000;
215
+ return Object.values(this.record.agentRegistry.agents).filter((a) => {
216
+ if (!a.lastHeartbeat)
217
+ return false;
218
+ return Date.parse(a.lastHeartbeat) < cutoff;
219
+ });
220
+ }
221
+ /**
222
+ * Append an event to the event log
223
+ */
224
+ appendEvent(type, data) {
225
+ if (!this.record)
226
+ return;
227
+ const event = {
228
+ ts: new Date().toISOString(),
229
+ jobId: this.record.jobId,
230
+ type,
231
+ message: `Blackboard event: ${type}`,
232
+ data,
233
+ };
234
+ const path = join(this.jobDir, "events.jsonl");
235
+ ensureUsageDir();
236
+ appendJsonl(path, event);
237
+ }
238
+ /**
239
+ * Export full blackboard state
240
+ */
241
+ export() {
242
+ return JSON.stringify(this.record, null, 2);
243
+ }
244
+ /**
245
+ * Get blackboard directory path
246
+ */
247
+ getPath() {
248
+ return this.jobDir;
249
+ }
250
+ }
251
+ /**
252
+ * Create and initialize a blackboard for a job
253
+ */
254
+ export function createBlackboard(jobId, rootDir, taskGraph) {
255
+ const blackboard = new SharedBlackboard(jobId, rootDir);
256
+ blackboard.init(jobId, taskGraph);
257
+ return blackboard;
258
+ }