pi-harness-runtime 0.2.0 → 0.3.1
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 +133 -158
- package/harness/agent-handoff.ts +189 -0
- package/harness/blackboard.ts +291 -0
- package/harness/context-window-manager.ts +210 -0
- package/harness/e2e/playwright-runner.ts +252 -0
- package/harness/e2e/test-engine.ts +402 -0
- package/harness/job-state-machine.ts +363 -0
- package/harness/loop-runtime.ts +337 -0
- package/harness/master-planner.ts +327 -0
- package/harness/project-detector/detector.ts +328 -0
- package/harness/repair-engine.ts +340 -0
- package/harness/task-graph.ts +336 -0
- package/index.ts +294 -2
- package/package.json +6 -3
- package/packages/checkpoint/README.md +3 -0
- package/packages/checkpoint/src/checkpoint-manager.ts +38 -0
- package/packages/provider-router/src/provider-router.ts +42 -0
- package/packages/providers/README.md +3 -0
- package/packages/providers/adapters.ts +261 -0
- package/packages/quota-manager/README.md +3 -0
- package/packages/quota-manager/quota-manager.ts +328 -0
- package/packages/runtime/README.md +3 -0
- package/packages/scheduler/README.md +3 -0
- package/packages/scheduler/src/scheduler.ts +50 -0
- package/packages/shared-context/README.md +3 -0
- package/packages/shared-context/src/shared-context.ts +42 -0
- package/packages/tui/README.md +3 -0
- package/packages/types/src/runtime-types.ts +439 -0
- package/packages/worktree/README.md +3 -0
- package/packages/worktree/worktree.ts +293 -0
- package/skills/harness-runtime/SKILL.md +186 -72
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quota Manager — RFC-0003
|
|
3
|
+
*
|
|
4
|
+
* Collects quota signals from API responses, provider status,
|
|
5
|
+
* Playwright, and local estimates. Produces provider availability state.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
QuotaSignal,
|
|
10
|
+
QuotaState,
|
|
11
|
+
} from "../../packages/types/src/runtime-types.ts";
|
|
12
|
+
|
|
13
|
+
export interface QuotaSignalInput {
|
|
14
|
+
provider: string;
|
|
15
|
+
source: "api_response" | "provider_status" | "playwright" | "local_estimate";
|
|
16
|
+
windowType: "5h" | "daily" | "weekly" | "monthly";
|
|
17
|
+
usedPct?: number;
|
|
18
|
+
exhausted?: boolean;
|
|
19
|
+
resetsAt?: string;
|
|
20
|
+
retryAfterMs?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class QuotaManager {
|
|
24
|
+
private signals: Map<string, QuotaSignal[]> = new Map();
|
|
25
|
+
private readonly staleThresholdMs: number;
|
|
26
|
+
|
|
27
|
+
constructor(staleThresholdMs: number = 30 * 60 * 1000) {
|
|
28
|
+
this.staleThresholdMs = staleThresholdMs;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Record a quota signal
|
|
33
|
+
*/
|
|
34
|
+
recordSignal(input: QuotaSignalInput): void {
|
|
35
|
+
const signal: QuotaSignal = {
|
|
36
|
+
provider: input.provider,
|
|
37
|
+
windowType: input.windowType,
|
|
38
|
+
usedPct: input.usedPct ?? 0,
|
|
39
|
+
remainingPct: 100 - (input.usedPct ?? 0),
|
|
40
|
+
exhausted: input.exhausted ?? false,
|
|
41
|
+
source: input.source,
|
|
42
|
+
capturedAt: new Date().toISOString(),
|
|
43
|
+
resetsAt: input.resetsAt,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const key = `${input.provider}:${input.windowType}`;
|
|
47
|
+
const existing = this.signals.get(key) ?? [];
|
|
48
|
+
this.signals.set(key, [...existing, signal]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get the latest signal for a provider
|
|
53
|
+
*/
|
|
54
|
+
getLatestSignal(
|
|
55
|
+
provider: string,
|
|
56
|
+
windowType?: "5h" | "daily" | "weekly" | "monthly",
|
|
57
|
+
): QuotaSignal | null {
|
|
58
|
+
const key = windowType ? `${provider}:${windowType}` : provider;
|
|
59
|
+
|
|
60
|
+
if (windowType) {
|
|
61
|
+
const signals = this.signals.get(key) ?? [];
|
|
62
|
+
return (
|
|
63
|
+
signals.sort(
|
|
64
|
+
(a, b) => Date.parse(b.capturedAt) - Date.parse(a.capturedAt),
|
|
65
|
+
)[0] ?? null
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Return most recent across all window types
|
|
70
|
+
let latest: QuotaSignal | null = null;
|
|
71
|
+
let latestTs = 0;
|
|
72
|
+
|
|
73
|
+
for (const [k, sigs] of this.signals.entries()) {
|
|
74
|
+
if (k.startsWith(`${provider}:`)) {
|
|
75
|
+
const mostRecent = sigs.sort(
|
|
76
|
+
(a, b) => Date.parse(b.capturedAt) - Date.parse(a.capturedAt),
|
|
77
|
+
)[0];
|
|
78
|
+
if (mostRecent) {
|
|
79
|
+
const ts = Date.parse(mostRecent.capturedAt);
|
|
80
|
+
if (ts > latestTs) {
|
|
81
|
+
latestTs = ts;
|
|
82
|
+
latest = mostRecent;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return latest;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get quota state for a provider
|
|
93
|
+
*/
|
|
94
|
+
getProviderState(provider: string): QuotaState {
|
|
95
|
+
const signals = this.getSignals(provider);
|
|
96
|
+
const exhausted = signals.some(
|
|
97
|
+
(s) => s.exhausted || (s.usedPct !== undefined && s.usedPct >= 100),
|
|
98
|
+
);
|
|
99
|
+
const limited = signals.some(
|
|
100
|
+
(s) => s.usedPct !== undefined && s.usedPct >= 90,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// Find next reset time
|
|
104
|
+
let nextAvailableAt: string | undefined;
|
|
105
|
+
for (const signal of signals) {
|
|
106
|
+
if (signal.resetsAt) {
|
|
107
|
+
if (
|
|
108
|
+
!nextAvailableAt ||
|
|
109
|
+
Date.parse(signal.resetsAt) < Date.parse(nextAvailableAt)
|
|
110
|
+
) {
|
|
111
|
+
nextAvailableAt = signal.resetsAt;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
provider,
|
|
118
|
+
available: !exhausted && !limited,
|
|
119
|
+
limited,
|
|
120
|
+
exhausted,
|
|
121
|
+
signals,
|
|
122
|
+
nextAvailableAt,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Get signals for a provider
|
|
128
|
+
*/
|
|
129
|
+
getSignals(provider: string): QuotaSignal[] {
|
|
130
|
+
const result: QuotaSignal[] = [];
|
|
131
|
+
const cutoff = Date.now() - this.staleThresholdMs;
|
|
132
|
+
|
|
133
|
+
for (const [key, sigs] of this.signals.entries()) {
|
|
134
|
+
if (key.startsWith(`${provider}:`)) {
|
|
135
|
+
for (const signal of sigs) {
|
|
136
|
+
if (Date.parse(signal.capturedAt) >= cutoff) {
|
|
137
|
+
result.push(signal);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Check if provider is available
|
|
148
|
+
*/
|
|
149
|
+
isAvailable(provider: string): boolean {
|
|
150
|
+
return this.getProviderState(provider).available;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Check if provider is exhausted
|
|
155
|
+
*/
|
|
156
|
+
isExhausted(provider: string): boolean {
|
|
157
|
+
return this.getProviderState(provider).exhausted;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Get time until provider is available again
|
|
162
|
+
*/
|
|
163
|
+
getWaitTime(provider: string): number | null {
|
|
164
|
+
const state = this.getProviderState(provider);
|
|
165
|
+
if (!state.nextAvailableAt) return null;
|
|
166
|
+
|
|
167
|
+
const waitMs = Date.parse(state.nextAvailableAt) - Date.now();
|
|
168
|
+
return waitMs > 0 ? waitMs : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get best available provider from a list
|
|
173
|
+
*/
|
|
174
|
+
selectBestProvider(providers: string[]): string | null {
|
|
175
|
+
const available = providers.filter((p) => this.isAvailable(p));
|
|
176
|
+
|
|
177
|
+
if (available.length === 0) return null;
|
|
178
|
+
|
|
179
|
+
// Sort by remaining quota (prefer those with more remaining)
|
|
180
|
+
available.sort((a, b) => {
|
|
181
|
+
const signalA = this.getLatestSignal(a);
|
|
182
|
+
const signalB = this.getLatestSignal(b);
|
|
183
|
+
const remA = signalA?.remainingPct ?? 100;
|
|
184
|
+
const remB = signalB?.remainingPct ?? 100;
|
|
185
|
+
return remB - remA;
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return available[0];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Clear stale signals
|
|
193
|
+
*/
|
|
194
|
+
clearStale(): void {
|
|
195
|
+
const cutoff = Date.now() - this.staleThresholdMs;
|
|
196
|
+
|
|
197
|
+
for (const [key, sigs] of this.signals.entries()) {
|
|
198
|
+
const fresh = sigs.filter((s) => Date.parse(s.capturedAt) >= cutoff);
|
|
199
|
+
if (fresh.length === 0) {
|
|
200
|
+
this.signals.delete(key);
|
|
201
|
+
} else {
|
|
202
|
+
this.signals.set(key, fresh);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Generate quota report
|
|
209
|
+
*/
|
|
210
|
+
generateReport(providers: string[]): string {
|
|
211
|
+
const lines = ["Quota Status Report", "=".repeat(40), ""];
|
|
212
|
+
|
|
213
|
+
for (const provider of providers) {
|
|
214
|
+
const state = this.getProviderState(provider);
|
|
215
|
+
const latest = this.getLatestSignal(provider);
|
|
216
|
+
|
|
217
|
+
const status = state.exhausted
|
|
218
|
+
? "EXHAUSTED"
|
|
219
|
+
: state.limited
|
|
220
|
+
? "LIMITED"
|
|
221
|
+
: "AVAILABLE";
|
|
222
|
+
|
|
223
|
+
lines.push(`${provider}: ${status}`);
|
|
224
|
+
|
|
225
|
+
if (latest) {
|
|
226
|
+
lines.push(` Latest: ${latest.usedPct ?? 0}% used`);
|
|
227
|
+
if (latest.resetsAt) {
|
|
228
|
+
const wait = Date.parse(latest.resetsAt) - Date.now();
|
|
229
|
+
if (wait > 0) {
|
|
230
|
+
lines.push(` Resets: in ${Math.ceil(wait / 60000)} minutes`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
lines.push(` Source: ${latest.source}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
lines.push("");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return lines.join("\n");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Parse quota signal from MiniMax error
|
|
245
|
+
*/
|
|
246
|
+
export function parseMiniMaxError(error: unknown): QuotaSignal | null {
|
|
247
|
+
const e = error as Record<string, unknown>;
|
|
248
|
+
const msg = String(e.message ?? e.error ?? "");
|
|
249
|
+
|
|
250
|
+
// MiniMax quota error: "Error code: 2056 - Rate limit exceeded. Retry after..."
|
|
251
|
+
if (
|
|
252
|
+
msg.includes("2056") ||
|
|
253
|
+
msg.includes("quota") ||
|
|
254
|
+
msg.includes("rate limit")
|
|
255
|
+
) {
|
|
256
|
+
// Try to extract retry time
|
|
257
|
+
const retryMatch = msg.match(/retry after (\d+) (seconds?|minutes?)/i);
|
|
258
|
+
let retryAfterMs: number | undefined;
|
|
259
|
+
|
|
260
|
+
if (retryMatch) {
|
|
261
|
+
const value = parseInt(retryMatch[1], 10);
|
|
262
|
+
const unit = retryMatch[2].toLowerCase();
|
|
263
|
+
retryAfterMs = unit.startsWith("minute")
|
|
264
|
+
? value * 60 * 1000
|
|
265
|
+
: value * 1000;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
provider: "minimax",
|
|
270
|
+
windowType: "5h",
|
|
271
|
+
usedPct: 100,
|
|
272
|
+
remainingPct: 0,
|
|
273
|
+
exhausted: true,
|
|
274
|
+
source: "api_response",
|
|
275
|
+
capturedAt: new Date().toISOString(),
|
|
276
|
+
retryAfterMs,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Parse quota signal from OpenAI error
|
|
285
|
+
*/
|
|
286
|
+
export function parseOpenAIError(error: unknown): QuotaSignal | null {
|
|
287
|
+
const e = error as Record<string, unknown>;
|
|
288
|
+
const code = String(e.code ?? "");
|
|
289
|
+
const msg = String(e.message ?? e.error ?? "");
|
|
290
|
+
|
|
291
|
+
if (code === "insufficient_quota" || code === "context_length_exceeded") {
|
|
292
|
+
return {
|
|
293
|
+
provider: "openai",
|
|
294
|
+
windowType: "daily",
|
|
295
|
+
usedPct: 100,
|
|
296
|
+
remainingPct: 0,
|
|
297
|
+
exhausted: true,
|
|
298
|
+
source: "api_response",
|
|
299
|
+
capturedAt: new Date().toISOString(),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (msg.includes("429")) {
|
|
304
|
+
const retryMatch = msg.match(/retry after (\d+) (seconds?|minutes?)/i);
|
|
305
|
+
let retryAfterMs: number | undefined;
|
|
306
|
+
|
|
307
|
+
if (retryMatch) {
|
|
308
|
+
const value = parseInt(retryMatch[1], 10);
|
|
309
|
+
const unit = retryMatch[2].toLowerCase();
|
|
310
|
+
retryAfterMs = unit.startsWith("minute")
|
|
311
|
+
? value * 60 * 1000
|
|
312
|
+
: value * 1000;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
provider: "openai",
|
|
317
|
+
windowType: "5h",
|
|
318
|
+
usedPct: 100,
|
|
319
|
+
remainingPct: 0,
|
|
320
|
+
exhausted: true,
|
|
321
|
+
source: "api_response",
|
|
322
|
+
capturedAt: new Date().toISOString(),
|
|
323
|
+
retryAfterMs,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface ScheduledJob {
|
|
5
|
+
jobId: string;
|
|
6
|
+
status: "scheduled" | "cancelled";
|
|
7
|
+
reason: string;
|
|
8
|
+
resumeAt: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class JsonRuntimeScheduler {
|
|
12
|
+
constructor(private readonly rootDir: string) {}
|
|
13
|
+
|
|
14
|
+
private schedulePath(): string {
|
|
15
|
+
return join(this.rootDir, "schedule.json");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async readAll(): Promise<ScheduledJob[]> {
|
|
19
|
+
try {
|
|
20
|
+
const text = await readFile(this.schedulePath(), "utf-8");
|
|
21
|
+
return JSON.parse(text) as ScheduledJob[];
|
|
22
|
+
} catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async writeAll(jobs: ScheduledJob[]): Promise<void> {
|
|
28
|
+
await mkdir(this.rootDir, { recursive: true });
|
|
29
|
+
await writeFile(this.schedulePath(), JSON.stringify(jobs, null, 2) + "\n", "utf-8");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async scheduleResume(jobId: string, resumeAt: string, reason: string): Promise<void> {
|
|
33
|
+
const jobs = (await this.readAll()).filter((j) => j.jobId !== jobId);
|
|
34
|
+
jobs.push({ jobId, status: "scheduled", resumeAt, reason });
|
|
35
|
+
await this.writeAll(jobs);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async dueJobs(now: Date = new Date()): Promise<string[]> {
|
|
39
|
+
const nowMs = now.getTime();
|
|
40
|
+
return (await this.readAll())
|
|
41
|
+
.filter((j) => j.status === "scheduled")
|
|
42
|
+
.filter((j) => Date.parse(j.resumeAt) <= nowMs)
|
|
43
|
+
.map((j) => j.jobId);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async cancel(jobId: string): Promise<void> {
|
|
47
|
+
const jobs = await this.readAll();
|
|
48
|
+
await this.writeAll(jobs.map((j) => j.jobId === jobId ? { ...j, status: "cancelled" } : j));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export class FileSharedContextStore {
|
|
5
|
+
constructor(private readonly rootDir: string) {}
|
|
6
|
+
|
|
7
|
+
private jobPath(jobId: string, file: string): string {
|
|
8
|
+
return join(this.rootDir, "jobs", jobId, file);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
private async write(jobId: string, file: string, text: string): Promise<void> {
|
|
12
|
+
const path = this.jobPath(jobId, file);
|
|
13
|
+
await mkdir(dirname(path), { recursive: true });
|
|
14
|
+
await writeFile(path, text.endsWith("\n") ? text : text + "\n", "utf-8");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
private async read(jobId: string, file: string): Promise<string | null> {
|
|
18
|
+
try {
|
|
19
|
+
return await readFile(this.jobPath(jobId, file), "utf-8");
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async writeRequirement(jobId: string, text: string): Promise<void> {
|
|
26
|
+
await this.write(jobId, "requirement.md", text);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async readRequirement(jobId: string): Promise<string | null> {
|
|
30
|
+
return await this.read(jobId, "requirement.md");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async writeResumePrompt(jobId: string, text: string): Promise<void> {
|
|
34
|
+
await this.write(jobId, "resume_prompt.md", text);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async appendDecision(jobId: string, text: string): Promise<void> {
|
|
38
|
+
const path = this.jobPath(jobId, "decisions.md");
|
|
39
|
+
await mkdir(dirname(path), { recursive: true });
|
|
40
|
+
await appendFile(path, `\n## ${new Date().toISOString()}\n\n${text}\n`, "utf-8");
|
|
41
|
+
}
|
|
42
|
+
}
|