pi-harness-runtime 0.3.2-beta.2 → 0.4.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,105 @@
1
+ /**
2
+ * Harness Runtime — Core Module Index
3
+ *
4
+ * Re-exports all harness components for easy importing.
5
+ */
6
+
7
+ // Re-export types from the types package
8
+ export type {
9
+ JobStatus,
10
+ TaskStatus,
11
+ RuntimeCheckpoint,
12
+ RuntimeTask,
13
+ RuntimeEvent,
14
+ TaskGraph,
15
+ TaskNode,
16
+ RepairTask,
17
+ FailureType,
18
+ AttemptedFix,
19
+ RetryPolicy,
20
+ QuotaSignal,
21
+ QuotaState,
22
+ ProviderConfig,
23
+ ProviderCapability,
24
+ ProviderRequest,
25
+ ProviderResponse,
26
+ E2EScenario,
27
+ E2EStep,
28
+ E2EResult,
29
+ E2EReport,
30
+ } from "../packages/types/src/runtime-types.js";
31
+
32
+ // State Machine
33
+ export { JobStateMachine } from "./job-state-machine.js";
34
+ export type {
35
+ StateTransition,
36
+ TransitionResult,
37
+ } from "./job-state-machine.js";
38
+ export type { CheckpointManager } from "./job-state-machine.js";
39
+
40
+ // Task Graph
41
+ export { TaskGraphManager } from "./task-graph.js";
42
+ export type { TaskGraphOptions } from "./task-graph.js";
43
+
44
+ // Loop Runtime
45
+ export { LoopRuntime } from "./loop-runtime.js";
46
+ export type { LoopResult } from "./loop-runtime.js";
47
+
48
+ // Repair Engine
49
+ export { RepairEngine } from "./repair-engine.js";
50
+ export type { RepairResult } from "./repair-engine.js";
51
+
52
+ // Master Planner
53
+ export { MasterPlanner } from "./master-planner.js";
54
+ export type { PlanResult } from "./master-planner.js";
55
+
56
+ // Context Window Manager
57
+ export { ContextWindowManager } from "./context-window-manager.js";
58
+
59
+ // Blackboard
60
+ export { createBlackboard, SharedBlackboard } from "./blackboard.js";
61
+
62
+ // Agent Handoff
63
+ export { AgentHandoffProtocol } from "./agent-handoff.js";
64
+
65
+ // Auto Compact (RFC-0019)
66
+ export { AutoCompactEngine } from "./auto-compact.js";
67
+ export type {
68
+ CompactionEvent,
69
+ CompactionConfig,
70
+ ContinuePrompt,
71
+ } from "./auto-compact.js";
72
+
73
+ // Output Limit Handler (RFC-0020)
74
+ export { OutputLimitHandler } from "./output-limit-handler.js";
75
+ export type {
76
+ OutputLimitConfig,
77
+ OutputLimitEvent,
78
+ ExpectedOutput,
79
+ } from "./output-limit-handler.js";
80
+
81
+ // Partial Recovery (RFC-0021)
82
+ export { PartialRecovery } from "./partial-recovery.js";
83
+ export type {
84
+ PartialResponse,
85
+ RecoveryStatus,
86
+ MergeOptions,
87
+ } from "./partial-recovery.js";
88
+
89
+ // Notification Events (RFC-0022)
90
+ export {
91
+ HarnessNotificationEvents,
92
+ createNotificationConfigFromEnv,
93
+ } from "./notification-events.js";
94
+
95
+ // E2E Testing
96
+ export { E2ETestEngine } from "./e2e/test-engine.js";
97
+ export { PlaywrightE2ERunner } from "./e2e/playwright-runner.js";
98
+ export { MiniMaxQuotaScraper } from "./e2e/playwright-runner.js";
99
+ export type {
100
+ E2ERunner,
101
+ PlaywrightRunnerConfig,
102
+ } from "./e2e/playwright-runner.js";
103
+
104
+ // Project Detector
105
+ export { ProjectDetector } from "./project-detector/detector.js";
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Harness Notification Events — RFC-0022
3
+ *
4
+ * Integrates NotificationCenter with the harness runtime.
5
+ * Sends mobile alerts for all runtime events.
6
+ *
7
+ * Events:
8
+ * JobStarted, TaskCompleted, TaskFailed, QuotaPaused,
9
+ * ResumeScheduled, ContextCompacted, OutputLimitContinued,
10
+ * E2EFailed, HumanReviewNeeded, ReadyForClient, JobCancelled, Error
11
+ */
12
+
13
+ import { NotificationCenter } from "../packages/notification/notification-center.js";
14
+ import type {
15
+ NotificationConfig,
16
+ NotificationEvent,
17
+ NotificationContext,
18
+ } from "../packages/notification/types.js";
19
+ import type { JobStateMachine } from "./job-state-machine.js";
20
+ import type { TaskGraphManager } from "./task-graph.js";
21
+
22
+ export interface NotificationEventsConfig {
23
+ jobId: string;
24
+ requirement: string;
25
+ notificationConfig?: NotificationConfig;
26
+ // Optional: path to config file for persistent settings
27
+ configPath?: string;
28
+ }
29
+
30
+ export class HarnessNotificationEvents {
31
+ private center: NotificationCenter;
32
+ private config: NotificationEventsConfig;
33
+ private machine: JobStateMachine | null = null;
34
+ private graph: TaskGraphManager | null = null;
35
+
36
+ constructor(config: NotificationEventsConfig) {
37
+ this.config = config;
38
+ this.center = new NotificationCenter(config.notificationConfig);
39
+ }
40
+
41
+ /**
42
+ * Initialize the notification center
43
+ */
44
+ async initialize(): Promise<void> {
45
+ await this.center.initialize();
46
+ }
47
+
48
+ /**
49
+ * Attach to job state machine to receive events
50
+ */
51
+ attachToMachine(machine: JobStateMachine): void {
52
+ this.machine = machine;
53
+ }
54
+
55
+ /**
56
+ * Attach to task graph to get task info
57
+ */
58
+ attachToGraph(graph: TaskGraphManager): void {
59
+ this.graph = graph;
60
+ }
61
+
62
+ /**
63
+ * Check if notifications are configured
64
+ */
65
+ hasChannels(): boolean {
66
+ return this.center.hasChannels();
67
+ }
68
+
69
+ /**
70
+ * List configured channels
71
+ */
72
+ listChannels(): string[] {
73
+ return this.center.listChannels();
74
+ }
75
+
76
+ // ─── Event Emitters ────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Emit JobStarted event
80
+ */
81
+ async emitJobStarted(): Promise<void> {
82
+ await this.emit("JobStarted");
83
+ }
84
+
85
+ /**
86
+ * Emit TaskCompleted event
87
+ */
88
+ async emitTaskCompleted(taskId: string, taskTitle?: string): Promise<void> {
89
+ await this.emit("TaskCompleted", { taskId, taskTitle });
90
+ }
91
+
92
+ /**
93
+ * Emit TaskFailed event
94
+ */
95
+ async emitTaskFailed(
96
+ taskId: string,
97
+ taskTitle?: string,
98
+ error?: string,
99
+ ): Promise<void> {
100
+ await this.emit("TaskFailed", { taskId, taskTitle, error });
101
+ }
102
+
103
+ /**
104
+ * Emit QuotaPaused event
105
+ */
106
+ async emitQuotaPaused(resumeAt?: string): Promise<void> {
107
+ await this.emit("QuotaPaused", {
108
+ error: resumeAt ? `Resumes at ${resumeAt}` : undefined,
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Emit ResumeScheduled event
114
+ */
115
+ async emitResumeScheduled(resumeAt: string): Promise<void> {
116
+ await this.emit("ResumeScheduled", { error: resumeAt });
117
+ }
118
+
119
+ /**
120
+ * Emit ContextCompacted event
121
+ */
122
+ async emitContextCompacted(tokensCompacted?: number): Promise<void> {
123
+ await this.emit("ContextCompacted", {
124
+ error: tokensCompacted
125
+ ? `Compacted ${tokensCompacted.toLocaleString()} tokens`
126
+ : undefined,
127
+ });
128
+ }
129
+
130
+ /**
131
+ * Emit OutputLimitContinued event
132
+ */
133
+ async emitOutputLimitContinued(attempt: number): Promise<void> {
134
+ await this.emit("OutputLimitContinued", {
135
+ error: `Attempt ${attempt}`,
136
+ });
137
+ }
138
+
139
+ /**
140
+ * Emit E2EFailed event
141
+ */
142
+ async emitE2EFailed(scenarioId?: string): Promise<void> {
143
+ await this.emit("E2EFailed", {
144
+ error: scenarioId ? `Scenario: ${scenarioId}` : undefined,
145
+ });
146
+ }
147
+
148
+ /**
149
+ * Emit HumanReviewNeeded event
150
+ */
151
+ async emitHumanReviewNeeded(taskId: string, reason: string): Promise<void> {
152
+ await this.emit("HumanReviewNeeded", { taskId, error: reason });
153
+ }
154
+
155
+ /**
156
+ * Emit ReadyForClient event
157
+ */
158
+ async emitReadyForClient(): Promise<void> {
159
+ await this.emit("ReadyForClient");
160
+ }
161
+
162
+ /**
163
+ * Emit JobCancelled event
164
+ */
165
+ async emitJobCancelled(reason?: string): Promise<void> {
166
+ await this.emit("JobCancelled", { error: reason });
167
+ }
168
+
169
+ /**
170
+ * Emit Error event
171
+ */
172
+ async emitError(error: string): Promise<void> {
173
+ await this.emit("Error", { error });
174
+ }
175
+
176
+ // ─── State Machine Event Listeners ─────────────────────────────────
177
+
178
+ /**
179
+ * Wire up with JobStateMachine to emit events on transitions
180
+ */
181
+ wireWithStateMachine(machine: JobStateMachine): void {
182
+ this.machine = machine;
183
+
184
+ // Listen to state machine events via polling or callback
185
+ // This would need the state machine to emit events
186
+ }
187
+
188
+ /**
189
+ * Check current state and emit appropriate events
190
+ */
191
+ async checkAndEmitStateChange(
192
+ oldState: string,
193
+ newState: string,
194
+ ): Promise<void> {
195
+ // Map state machine states to notification events
196
+ const stateEventMap: Record<string, NotificationEvent> = {
197
+ paused_quota: "QuotaPaused",
198
+ waiting_human: "HumanReviewNeeded",
199
+ ready_for_client: "ReadyForClient",
200
+ cancelled: "JobCancelled",
201
+ planning: "JobStarted",
202
+ };
203
+
204
+ const event = stateEventMap[newState];
205
+ if (event) {
206
+ await this.emit(event);
207
+ }
208
+ }
209
+
210
+ // ─── Private Methods ───────────────────────────────────────────────
211
+
212
+ private async emit(
213
+ event: NotificationEvent,
214
+ extra?: Partial<NotificationContext>,
215
+ ): Promise<void> {
216
+ if (!this.center.hasChannels()) {
217
+ return; // No channels configured, skip
218
+ }
219
+
220
+ const context: NotificationContext = {
221
+ jobId: this.config.jobId,
222
+ requirement: this.config.requirement,
223
+ ...extra,
224
+ };
225
+
226
+ try {
227
+ const results = await this.center.notify(event, context);
228
+
229
+ // Log results (but don't fail if notification fails)
230
+ for (const result of results) {
231
+ if (!result.success) {
232
+ console.warn(
233
+ `[NotificationEvents] Failed to send ${event} to ${result.channel}: ${result.error}`,
234
+ );
235
+ }
236
+ }
237
+ } catch (error) {
238
+ // Never crash the runtime due to notification failure
239
+ console.error(`[NotificationEvents] Notification error: ${error}`);
240
+ }
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Create notification config from environment variables
246
+ */
247
+ export function createNotificationConfigFromEnv():
248
+ | NotificationConfig
249
+ | undefined {
250
+ const channels: NotificationConfig["channels"] = [];
251
+
252
+ // Telegram
253
+ if (process.env.TELEGRAM_BOT_TOKEN && process.env.TELEGRAM_CHAT_ID) {
254
+ channels.push({
255
+ id: "telegram",
256
+ type: "telegram",
257
+ enabled: true,
258
+ config: {
259
+ botToken: process.env.TELEGRAM_BOT_TOKEN,
260
+ chatId: process.env.TELEGRAM_CHAT_ID,
261
+ },
262
+ });
263
+ }
264
+
265
+ // Ntfy
266
+ if (process.env.NTFY_TOPIC) {
267
+ channels.push({
268
+ id: "ntfy",
269
+ type: "ntfy",
270
+ enabled: true,
271
+ config: {
272
+ server: process.env.NTFY_SERVER ?? "https://ntfy.sh",
273
+ topic: process.env.NTFY_TOPIC,
274
+ authToken: process.env.NTFY_TOKEN,
275
+ },
276
+ });
277
+ }
278
+
279
+ // Webhook
280
+ if (process.env.NOTIFICATION_WEBHOOK_URL) {
281
+ channels.push({
282
+ id: "webhook",
283
+ type: "webhook",
284
+ enabled: true,
285
+ config: {
286
+ url: process.env.NOTIFICATION_WEBHOOK_URL,
287
+ method:
288
+ (process.env.NOTIFICATION_WEBHOOK_METHOD as "POST" | "PUT") ?? "POST",
289
+ authToken: process.env.NOTIFICATION_WEBHOOK_TOKEN,
290
+ },
291
+ });
292
+ }
293
+
294
+ if (channels.length === 0) {
295
+ return undefined;
296
+ }
297
+
298
+ return { channels, enabled: true };
299
+ }
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Output Token Limit Handler — RFC-0020
3
+ *
4
+ * Handles model responses that stop because the maximum output token limit was reached.
5
+ * Coordinates with AutoCompactEngine (RFC-0019) and PartialRecovery (RFC-0021).
6
+ *
7
+ * Classification Matrix:
8
+ * | Failure Type | Runtime Action |
9
+ * |--------------------|--------------------------|
10
+ * | Quota exhausted | pause until reset |
11
+ * | Context full | compact and resume |
12
+ * | Output token limit | continue same task |
13
+ * | Unknown error | retry or escalate |
14
+ */
15
+
16
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { homedir } from "node:os";
19
+ import type { CompactionEvent } from "./auto-compact.js";
20
+
21
+ export interface OutputLimitConfig {
22
+ jobId: string;
23
+ taskId: string;
24
+ rootDir?: string;
25
+ maxContinueAttempts?: number;
26
+ continuationBackoffMs?: number;
27
+ requireExpectedOutputValidation?: boolean;
28
+ }
29
+
30
+ export interface OutputLimitEvent {
31
+ timestamp: string;
32
+ jobId: string;
33
+ taskId: string;
34
+ reason: string;
35
+ partialContent: string;
36
+ finishReason: "length" | "stop" | "content_filter" | "error";
37
+ attempts: number;
38
+ continueSucceeded?: boolean;
39
+ }
40
+
41
+ export interface ExpectedOutput {
42
+ description: string;
43
+ validate?: (content: string) => boolean;
44
+ }
45
+
46
+ const OUTPUT_LIMIT_PATTERNS = [
47
+ /reached the maximum output token limit/i,
48
+ /output token limit/i,
49
+ /response may be incomplete/i,
50
+ /stop_reason.*length/i,
51
+ /finish_reason.*length/i,
52
+ /max_tokens.*exceeded/i,
53
+ /completion.*truncated/i,
54
+ /model.*stopped.*token/i,
55
+ ];
56
+
57
+ export class OutputLimitHandler {
58
+ private readonly rootDir: string;
59
+ private readonly jobId: string;
60
+ private readonly taskId: string;
61
+ private readonly maxAttempts: number;
62
+ private readonly backoffMs: number;
63
+ private readonly requireValidation: boolean;
64
+ private attempts = 0;
65
+ private partials: string[] = [];
66
+
67
+ constructor(config: OutputLimitConfig) {
68
+ this.rootDir =
69
+ config.rootDir ??
70
+ join(homedir(), ".pi", "harness", config.jobId, "partial", config.taskId);
71
+ this.jobId = config.jobId;
72
+ this.taskId = config.taskId;
73
+ this.maxAttempts = config.maxContinueAttempts ?? 5;
74
+ this.backoffMs = config.continuationBackoffMs ?? 1000;
75
+ this.requireValidation = config.requireExpectedOutputValidation ?? true;
76
+ this.ensureDir();
77
+ }
78
+
79
+ /**
80
+ * Detect if an error or response indicates output limit was reached
81
+ */
82
+ detectOutputLimit(
83
+ error: unknown,
84
+ response?: { finishReason?: string },
85
+ ): boolean {
86
+ // Check error message
87
+ if (error) {
88
+ const errorStr = String(error).toLowerCase();
89
+ for (const pattern of OUTPUT_LIMIT_PATTERNS) {
90
+ if (pattern.test(errorStr)) {
91
+ return true;
92
+ }
93
+ }
94
+ }
95
+
96
+ // Check finish reason
97
+ if (response?.finishReason === "length") {
98
+ return true;
99
+ }
100
+
101
+ return false;
102
+ }
103
+
104
+ /**
105
+ * Classify the type of failure for proper handling
106
+ */
107
+ classifyFailure(
108
+ error: unknown,
109
+ response?: { finishReason?: string; content?: string },
110
+ ): "quota_exhausted" | "context_full" | "output_limit" | "unknown" {
111
+ const errorStr = String(error ?? "").toLowerCase();
112
+
113
+ // Check for quota exhaustion
114
+ if (
115
+ /error.*quota/i.test(errorStr) ||
116
+ /error.*2056/i.test(errorStr) ||
117
+ /error.*insufficient_quota/i.test(errorStr)
118
+ ) {
119
+ return "quota_exhausted";
120
+ }
121
+
122
+ // Check for context/sequence length
123
+ if (
124
+ /error.*context.*length/i.test(errorStr) ||
125
+ /error.*too many tokens/i.test(errorStr) ||
126
+ /error.*maximum context/i.test(errorStr)
127
+ ) {
128
+ return "context_full";
129
+ }
130
+
131
+ // Check for output token limit
132
+ if (
133
+ this.detectOutputLimit(error, response) ||
134
+ response?.finishReason === "length"
135
+ ) {
136
+ return "output_limit";
137
+ }
138
+
139
+ return "unknown";
140
+ }
141
+
142
+ /**
143
+ * Handle output limit - save partial and prepare continuation
144
+ */
145
+ async handleOutputLimit(
146
+ partialContent: string,
147
+ finishReason: string = "length",
148
+ ): Promise<OutputLimitEvent> {
149
+ this.attempts++;
150
+ const timestamp = new Date().toISOString();
151
+
152
+ // Save partial response
153
+ const partialPath = this.savePartial(partialContent, this.attempts);
154
+ this.partials.push(partialPath);
155
+
156
+ // Create event
157
+ const event: OutputLimitEvent = {
158
+ timestamp,
159
+ jobId: this.jobId,
160
+ taskId: this.taskId,
161
+ reason: "output_token_limit",
162
+ partialContent,
163
+ finishReason: finishReason as OutputLimitEvent["finishReason"],
164
+ attempts: this.attempts,
165
+ };
166
+
167
+ // Save event
168
+ this.saveEvent(event);
169
+
170
+ // Wait with backoff before continuing
171
+ await this.backoff();
172
+
173
+ return event;
174
+ }
175
+
176
+ /**
177
+ * Check if we should continue (respects max attempts)
178
+ */
179
+ shouldContinue(): boolean {
180
+ return this.attempts < this.maxAttempts;
181
+ }
182
+
183
+ /**
184
+ * Get current attempt count
185
+ */
186
+ getAttempts(): number {
187
+ return this.attempts;
188
+ }
189
+
190
+ /**
191
+ * Get all partial responses
192
+ */
193
+ getPartials(): string[] {
194
+ return [...this.partials];
195
+ }
196
+
197
+ /**
198
+ * Merge partial responses
199
+ */
200
+ mergePartials(): string {
201
+ const merged: string[] = [];
202
+
203
+ for (const partialPath of this.partials) {
204
+ if (existsSync(partialPath)) {
205
+ const content = readFileSync(partialPath, "utf-8");
206
+ merged.push(content);
207
+ }
208
+ }
209
+
210
+ // v0.1: simple concatenation as markdown sections
211
+ return merged
212
+ .map((p, i) => `## Partial ${i + 1}\n\n${p}`)
213
+ .join("\n\n---\n\n");
214
+ }
215
+
216
+ /**
217
+ * Build continue message for the next turn
218
+ */
219
+ buildContinueMessage(additionalContext?: string): string {
220
+ const merged = this.mergePartials();
221
+
222
+ const lines = [
223
+ "# Continue From Partial Response",
224
+ "",
225
+ "The previous response was truncated due to output token limit.",
226
+ "",
227
+ "## Merged Partial Content",
228
+ "",
229
+ merged,
230
+ ];
231
+
232
+ if (additionalContext) {
233
+ lines.push("", "## Additional Context");
234
+ lines.push("", additionalContext);
235
+ }
236
+
237
+ lines.push("", "## Instructions");
238
+ lines.push("");
239
+ lines.push("1. Review the partial content above");
240
+ lines.push("2. Continue from where the response was truncated");
241
+ lines.push("3. Do not repeat content that already appears above");
242
+ lines.push(
243
+ `4. This is attempt ${this.attempts + 1} of ${this.maxAttempts}`,
244
+ );
245
+
246
+ return lines.join("\n");
247
+ }
248
+
249
+ /**
250
+ * Validate expected output if configured
251
+ */
252
+ validateOutput(content: string, expected: ExpectedOutput): boolean {
253
+ if (!this.requireValidation) {
254
+ return true;
255
+ }
256
+
257
+ if (expected.validate) {
258
+ return expected.validate(content);
259
+ }
260
+
261
+ // Basic validation: content should be longer than partial
262
+ return content.length > this.getPartials()[0]?.length ?? 0;
263
+ }
264
+
265
+ /**
266
+ * Reset for a new task
267
+ */
268
+ reset(): void {
269
+ this.attempts = 0;
270
+ this.partials = [];
271
+ }
272
+
273
+ /**
274
+ * Check if escalation is needed (max attempts reached)
275
+ */
276
+ shouldEscalate(): boolean {
277
+ return this.attempts >= this.maxAttempts;
278
+ }
279
+
280
+ // ─── Private Methods ────────────────────────────────────────────────
281
+
282
+ private ensureDir(): void {
283
+ if (!existsSync(this.rootDir)) {
284
+ mkdirSync(this.rootDir, { recursive: true });
285
+ }
286
+ }
287
+
288
+ private savePartial(content: string, attempt: number): string {
289
+ const filename = `partial_${String(attempt).padStart(3, "0")}.md`;
290
+ const path = join(this.rootDir, filename);
291
+ writeFileSync(path, content, "utf-8");
292
+ return path;
293
+ }
294
+
295
+ private saveEvent(event: OutputLimitEvent): void {
296
+ const eventsPath = join(this.rootDir, "events.jsonl");
297
+ writeFileSync(eventsPath, JSON.stringify(event) + "\n", "utf-8");
298
+
299
+ // Also save recovery status
300
+ const statusPath = join(this.rootDir, "recovery_status.json");
301
+ const status = {
302
+ taskId: this.taskId,
303
+ status: this.shouldContinue() ? "continuing" : "escalated",
304
+ partials: this.partials.map((p) => p.split("/").pop()),
305
+ mergedOutput: "merged.md",
306
+ attempts: this.attempts,
307
+ lastError: event.reason,
308
+ };
309
+ writeFileSync(statusPath, JSON.stringify(status, null, 2) + "\n", "utf-8");
310
+ }
311
+
312
+ private async backoff(): Promise<void> {
313
+ const delay = this.backoffMs * 2 ** (this.attempts - 1);
314
+ await new Promise((resolve) => setTimeout(resolve, delay));
315
+ }
316
+ }