pi-long-task 0.3.12 → 0.3.13

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.
@@ -8,9 +8,11 @@ import {
8
8
  type GoalDiscoveryEntrypoint,
9
9
  type GoalDiscoveryRunner,
10
10
  } from "./goal_discovery.ts";
11
- import type { GoalLoopLimits, GoalLoopStatus } from "./goal_loop.ts";
11
+ import type { GoalIterationStatus, GoalLoopLimits, GoalLoopStatus } from "./goal_loop.ts";
12
12
  import {
13
+ cancelGoalLoop,
13
14
  createGoalLoopState,
15
+ failGoalLoop,
14
16
  goalLoopStopReason,
15
17
  startGoalIteration,
16
18
  type GoalLoopLimitInput,
@@ -145,8 +147,8 @@ export async function runGoalLoop(options: RunGoalLoopOptions): Promise<GoalLoop
145
147
  let goalSpecification: GoalSpecification | undefined = await store.tryLoadGoalSpecification();
146
148
 
147
149
  await store.saveState(state);
148
- await store.initializeResult(state);
149
- await store.appendNewTraceEvents(0, state);
150
+ await store.initializeResultIfMissing(state);
151
+ await store.appendNewTraceEvents(await store.durableTraceLength(), state);
150
152
  const publish = (phase: GoalLoopProgressPhase, message: string, extra: Partial<GoalLoopProgressUpdate> = {}) => {
151
153
  options.onProgress?.({
152
154
  message,
@@ -165,136 +167,201 @@ export async function runGoalLoop(options: RunGoalLoopOptions): Promise<GoalLoop
165
167
  tracePath: store.paths.tracePath,
166
168
  goalSpecPath: store.paths.goalSpecPath,
167
169
  discoveryDecision,
168
- workerCostTotal: accumulatedWorkerCost(executionResults, generationResults),
169
- reviewerCostTotal: accumulatedReviewerCost(reviewResults),
170
- totalCost: accumulatedWorkerCost(executionResults, generationResults) + accumulatedReviewerCost(reviewResults),
170
+ workerCostTotal: accumulatedWorkerCost(state),
171
+ reviewerCostTotal: accumulatedReviewerCost(state),
172
+ totalCost: accumulatedWorkerCost(state) + accumulatedReviewerCost(state),
171
173
  ...extra,
172
174
  });
173
175
  };
174
176
 
175
177
  publish("goal_start", `Starting goal loop: ${state.goal}`);
176
178
 
177
- if (!goalLoopStopReason(state, { now: now(), abortSignal: options.abortSignal })) {
178
- goalSpecification = await maybeRunGoalDiscovery({
179
- state,
180
- store,
181
- discoveryDecision,
182
- existingSpecification: goalSpecification,
183
- options,
184
- now,
185
- publish,
186
- });
187
- }
188
-
189
- while (state.status === "running") {
190
- const stopReason = goalLoopStopReason(state, { now: now(), abortSignal: options.abortSignal });
191
- if (stopReason) {
192
- const previousTraceLength = state.trace.length;
193
- state = startGoalIteration(state, { now: now(), abortSignal: options.abortSignal });
194
- await persistStateChange(store, previousTraceLength, state);
195
- break;
196
- }
197
-
198
- const nextIteration = state.currentIteration > 0 ? state.currentIteration : state.iterations.length + 1;
199
- publish("todo_generation_start", `Goal iteration ${nextIteration}: generating TODO markdown.`, {
200
- iteration: nextIteration,
201
- });
202
- const generation = await runGoalTodoGenerationLongTask({
203
- state,
204
- cwd: options.cwd,
205
- store,
206
- longTaskRunner: options.todoGenerationRunner,
207
- abortSignal: options.abortSignal,
208
- model: options.model,
209
- modelName: options.modelName,
210
- thinkingLevel: options.thinkingLevel,
211
- maxBashTimeoutMs: options.maxBashTimeoutMs,
212
- now,
213
- goalSpecification,
214
- });
215
- generationResults.push(generation);
216
- state = generation.state;
217
- publish("todo_generated", `Goal iteration ${state.currentIteration}: generated TODO markdown.`, {
218
- iteration: state.currentIteration,
219
- });
220
-
221
- try {
222
- publish(
223
- "todo_execution_start",
224
- `Goal iteration ${state.currentIteration}: running generated TODO as a long task.`,
225
- {
226
- iteration: state.currentIteration,
227
- },
228
- );
229
- const execution = await runGoalTodoExecutionLongTask({
179
+ try {
180
+ if (!goalLoopStopReason(state, { now: now(), abortSignal: options.abortSignal })) {
181
+ goalSpecification = await maybeRunGoalDiscovery({
230
182
  state,
231
- cwd: options.cwd,
232
183
  store,
233
- longTaskRunner: options.todoExecutionRunner,
234
- abortSignal: options.abortSignal,
235
- model: options.model,
236
- modelName: options.modelName,
237
- thinkingLevel: options.thinkingLevel,
238
- maxBashTimeoutMs: options.maxBashTimeoutMs,
239
- maxAttemptsPerTask: options.maxAttemptsPerTask,
240
- commit: options.commit,
184
+ discoveryDecision,
185
+ existingSpecification: goalSpecification,
186
+ options,
241
187
  now,
242
- onProgress: (update) => {
243
- publish("todo_execution_start", `Goal iteration ${state.currentIteration}: ${update.message}`, {
244
- iteration: state.currentIteration,
245
- workerStatus: update.status,
246
- childProgress: update,
247
- });
248
- options.onWorkerProgress?.(update);
249
- },
188
+ publish,
250
189
  });
251
- executionResults.push(execution);
252
- state = execution.state;
253
- publish(
254
- "todo_executed",
255
- `Goal iteration ${state.currentIteration}: worker finished with ${execution.childResult.status}.`,
256
- {
190
+ }
191
+
192
+ while (state.status === "running") {
193
+ const phaseNow = now();
194
+ const stopReason = goalLoopStopReason(state, { now: phaseNow, abortSignal: options.abortSignal });
195
+ if (stopReason) {
196
+ const previousTraceLength = state.trace.length;
197
+ state = startGoalIteration(state, { now: phaseNow, abortSignal: options.abortSignal });
198
+ await persistStateChange(store, previousTraceLength, state);
199
+ break;
200
+ }
201
+
202
+ const current = state.iterations.find((item) => item.iteration === state.currentIteration);
203
+ if (current && isActiveIterationPhase(current.status) && deadlineExpired(current.deadlineAt, phaseNow)) {
204
+ const previousTraceLength = state.trace.length;
205
+ state = failGoalLoop(state, `Goal iteration ${current.iteration} exceeded its iteration deadline.`, {
206
+ now: phaseNow,
207
+ status: "partial",
208
+ });
209
+ await persistStateChange(store, previousTraceLength, state);
210
+ break;
211
+ }
212
+
213
+ const currentStatus = current?.status;
214
+ if (!current || currentStatus === "reviewed_incomplete") {
215
+ const nextIteration = state.iterations.length + 1;
216
+ publish("todo_generation_start", `Goal iteration ${nextIteration}: generating TODO markdown.`, {
217
+ iteration: nextIteration,
218
+ });
219
+ const generation = await runGoalTodoGenerationLongTask({
220
+ state,
221
+ cwd: options.cwd,
222
+ store,
223
+ longTaskRunner: options.todoGenerationRunner,
224
+ abortSignal: options.abortSignal,
225
+ model: options.model,
226
+ modelName: options.modelName,
227
+ thinkingLevel: options.thinkingLevel,
228
+ maxBashTimeoutMs: options.maxBashTimeoutMs,
229
+ now,
230
+ goalSpecification,
231
+ });
232
+ generationResults.push(generation);
233
+ state = generation.state;
234
+ publish("todo_generated", `Goal iteration ${state.currentIteration}: generated TODO markdown.`, {
235
+ iteration: state.currentIteration,
236
+ });
237
+ continue;
238
+ }
239
+
240
+ if (currentStatus === "pending") {
241
+ publish("todo_generation_start", `Goal iteration ${current.iteration}: resuming TODO generation.`, {
242
+ iteration: current.iteration,
243
+ });
244
+ const generation = await runGoalTodoGenerationLongTask({
245
+ state,
246
+ cwd: options.cwd,
247
+ store,
248
+ longTaskRunner: options.todoGenerationRunner,
249
+ abortSignal: options.abortSignal,
250
+ model: options.model,
251
+ modelName: options.modelName,
252
+ thinkingLevel: options.thinkingLevel,
253
+ maxBashTimeoutMs: options.maxBashTimeoutMs,
254
+ now,
255
+ goalSpecification,
256
+ });
257
+ generationResults.push(generation);
258
+ state = generation.state;
259
+ publish("todo_generated", `Goal iteration ${state.currentIteration}: generated TODO markdown.`, {
257
260
  iteration: state.currentIteration,
258
- workerStatus: execution.childResult.status,
259
- },
260
- );
261
- } catch (error) {
262
- if (error instanceof GoalTodoExecutionError && error.state) {
263
- state = error.state;
264
- } else {
265
- throw new GoalLoopOrchestratorError(`Goal TODO execution failed: ${errorMessage(error)}`, {
266
- cause: error,
261
+ });
262
+ continue;
263
+ }
264
+
265
+ if (currentStatus === "todo_generated") {
266
+ publish("todo_execution_start", `Goal iteration ${current.iteration}: running generated TODO as a long task.`, {
267
+ iteration: current.iteration,
268
+ });
269
+ try {
270
+ const execution = await runGoalTodoExecutionLongTask({
271
+ state,
272
+ cwd: options.cwd,
273
+ store,
274
+ longTaskRunner: options.todoExecutionRunner,
275
+ abortSignal: options.abortSignal,
276
+ model: options.model,
277
+ modelName: options.modelName,
278
+ thinkingLevel: options.thinkingLevel,
279
+ maxBashTimeoutMs: options.maxBashTimeoutMs,
280
+ maxAttemptsPerTask: options.maxAttemptsPerTask,
281
+ commit: options.commit,
282
+ now,
283
+ onProgress: (update) => {
284
+ publish("todo_execution_start", `Goal iteration ${state.currentIteration}: ${update.message}`, {
285
+ iteration: state.currentIteration,
286
+ workerStatus: update.status,
287
+ childProgress: update,
288
+ });
289
+ options.onWorkerProgress?.(update);
290
+ },
291
+ });
292
+ executionResults.push(execution);
293
+ state = execution.state;
294
+ publish(
295
+ "todo_executed",
296
+ `Goal iteration ${state.currentIteration}: worker finished with ${execution.childResult.status}.`,
297
+ {
298
+ iteration: state.currentIteration,
299
+ workerStatus: execution.childResult.status,
300
+ },
301
+ );
302
+ } catch (error) {
303
+ if (error instanceof GoalTodoExecutionError && error.state) {
304
+ state = error.state;
305
+ } else {
306
+ throw error;
307
+ }
308
+ }
309
+ continue;
310
+ }
311
+
312
+ if (currentStatus === "todo_executed" || currentStatus === "failed") {
313
+ publish("review_start", `Goal iteration ${current.iteration}: reviewing goal completion.`, {
314
+ iteration: current.iteration,
315
+ });
316
+ const review = await runGoalReviewSession({
267
317
  state,
318
+ cwd: options.cwd,
319
+ store,
320
+ reviewerRunner: options.reviewerRunner,
321
+ abortSignal: options.abortSignal,
322
+ model: options.model,
323
+ modelName: options.modelName,
324
+ thinkingLevel: options.thinkingLevel,
325
+ now,
326
+ goalSpecification,
327
+ timeoutMs: remainingReviewTimeout(state, current.deadlineAt, now()),
268
328
  });
329
+ reviewResults.push(review);
330
+ state = review.state;
331
+ publish(
332
+ "reviewed",
333
+ `Goal iteration ${review.iteration.iteration}: reviewer decided ${review.reviewerResult.decision}.`,
334
+ {
335
+ iteration: review.iteration.iteration,
336
+ reviewerDecision: review.reviewerResult.decision,
337
+ remainingWork: review.reviewerResult.remainingWork,
338
+ },
339
+ );
340
+ continue;
269
341
  }
270
- }
271
342
 
272
- publish("review_start", `Goal iteration ${state.currentIteration}: reviewing goal completion.`, {
273
- iteration: state.currentIteration,
274
- });
275
- const review = await runGoalReviewSession({
276
- state,
277
- cwd: options.cwd,
278
- store,
279
- reviewerRunner: options.reviewerRunner,
280
- abortSignal: options.abortSignal,
281
- model: options.model,
282
- modelName: options.modelName,
283
- thinkingLevel: options.thinkingLevel,
284
- now,
285
- goalSpecification,
286
- });
287
- reviewResults.push(review);
288
- state = review.state;
289
- publish(
290
- "reviewed",
291
- `Goal iteration ${review.iteration.iteration}: reviewer decided ${review.reviewerResult.decision}.`,
292
- {
293
- iteration: review.iteration.iteration,
294
- reviewerDecision: review.reviewerResult.decision,
295
- remainingWork: review.reviewerResult.remainingWork,
296
- },
297
- );
343
+ throw new GoalLoopOrchestratorError(`Cannot resume goal iteration ${current.iteration} from ${currentStatus}.`, {
344
+ state,
345
+ });
346
+ }
347
+ } catch (error) {
348
+ const errorState = stateFromError(error);
349
+ if (errorState) {
350
+ state = errorState;
351
+ } else {
352
+ try {
353
+ state = await store.loadState();
354
+ } catch {
355
+ // Retain the latest in-memory state when no newer durable state is available.
356
+ }
357
+ }
358
+ if (state.status === "running") {
359
+ const previousTraceLength = state.trace.length;
360
+ state = options.abortSignal?.aborted
361
+ ? cancelGoalLoop(state, `Goal loop aborted: ${errorMessage(error)}`, { now: now() })
362
+ : failGoalLoop(state, `Goal loop failed: ${errorMessage(error)}`, { now: now() });
363
+ await persistStateChange(store, previousTraceLength, state);
364
+ }
298
365
  }
299
366
 
300
367
  publish("complete", `Goal loop ${state.status}: ${state.completion?.reason ?? "finished"}`);
@@ -365,18 +432,42 @@ async function persistStateChange(
365
432
  await store.appendNewTraceEvents(previousTraceLength, state);
366
433
  }
367
434
 
368
- function accumulatedWorkerCost(
369
- executionResults: GoalTodoExecutionResult[],
370
- generationResults: GoalTodoGenerationResult[],
371
- ): number {
372
- return sumFinite([
373
- ...executionResults.map((result) => result.childResult.workerCostTotal),
374
- ...generationResults.map((result) => result.childResult.workerCostTotal),
375
- ]);
435
+ function isActiveIterationPhase(status: GoalIterationStatus): boolean {
436
+ return status === "pending" || status === "todo_generated" || status === "todo_executed" || status === "failed";
437
+ }
438
+
439
+ function deadlineExpired(deadlineAt: string | undefined, now: Date): boolean {
440
+ return Boolean(deadlineAt && now.getTime() >= Date.parse(deadlineAt));
441
+ }
442
+
443
+ function remainingReviewTimeout(state: GoalLoopState, iterationDeadlineAt: string | undefined, now: Date): number {
444
+ const remaining = [
445
+ state.limits.reviewerTimeoutMs,
446
+ state.deadlineAt ? Date.parse(state.deadlineAt) - now.getTime() : Number.POSITIVE_INFINITY,
447
+ iterationDeadlineAt ? Date.parse(iterationDeadlineAt) - now.getTime() : Number.POSITIVE_INFINITY,
448
+ ].filter((value) => Number.isFinite(value));
449
+ return Math.max(1, Math.floor(Math.min(...remaining)));
450
+ }
451
+
452
+ function stateFromError(error: unknown): GoalLoopState | undefined {
453
+ if (typeof error !== "object" || error === null || !("state" in error)) {
454
+ return undefined;
455
+ }
456
+ const state = (error as { state?: unknown }).state;
457
+ return state && typeof state === "object" ? (state as GoalLoopState) : undefined;
458
+ }
459
+
460
+ function accumulatedWorkerCost(state: GoalLoopState): number {
461
+ return sumFinite(
462
+ state.iterations.flatMap((iteration) => [
463
+ iteration.generatedTodo?.generatorWorkerCostTotal,
464
+ iteration.workerResult?.workerCostTotal,
465
+ ]),
466
+ );
376
467
  }
377
468
 
378
- function accumulatedReviewerCost(reviewResults: GoalReviewResult[]): number {
379
- return sumFinite(reviewResults.map((result) => result.sessionResult.reviewerCostTotal));
469
+ function accumulatedReviewerCost(state: GoalLoopState): number {
470
+ return sumFinite(state.iterations.map((iteration) => iteration.reviewerResult?.reviewerCostTotal));
380
471
  }
381
472
 
382
473
  function sumFinite(values: Array<number | undefined>): number {
@@ -10,14 +10,14 @@ import {
10
10
  recordReviewerResult,
11
11
  } from "./goal_loop.ts";
12
12
  import { GoalStateStore } from "./goal_state.ts";
13
+ import { runGuardedSessionPrompt } from "./session_guard.ts";
13
14
  import { goalSpecificationToMarkdown, type GoalSpecification } from "./goal_spec.ts";
14
15
  import {
15
- assistantTextFromEvent,
16
16
  createIsolatedWorkerSession,
17
17
  DEFAULT_WORKER_TOOLS,
18
- lastAssistantTextFromMessages,
19
18
  workerUsageCostFromEvent,
20
19
  workerUsageCostFromStats,
20
+ workerUsageCostKeyFromEvent,
21
21
  type WorkerSessionFactory,
22
22
  } from "./worker_session.ts";
23
23
 
@@ -36,6 +36,7 @@ export interface GoalReviewOptions {
36
36
  now?: () => Date;
37
37
  sessionFactory?: WorkerSessionFactory;
38
38
  goalSpecification?: GoalSpecification;
39
+ timeoutMs?: number;
39
40
  }
40
41
 
41
42
  export interface GoalReviewResult {
@@ -121,7 +122,7 @@ export async function runGoalReviewSession(options: GoalReviewOptions): Promise<
121
122
  prompt: payload,
122
123
  cwd: path.resolve(options.cwd ?? process.cwd()),
123
124
  abortSignal: options.abortSignal,
124
- timeoutMs: state.limits.reviewerTimeoutMs,
125
+ timeoutMs: options.timeoutMs ?? state.limits.reviewerTimeoutMs,
125
126
  model: options.model,
126
127
  modelName: options.modelName,
127
128
  thinkingLevel: options.thinkingLevel,
@@ -149,6 +150,36 @@ export async function runGoalReviewSession(options: GoalReviewOptions): Promise<
149
150
  const rawReviewerOutput = sessionResult.assistantText;
150
151
  await writeFile(rawReviewPath, rawReviewerOutput, "utf8");
151
152
 
153
+ if (sessionResult.aborted) {
154
+ const reason = sessionResult.error ?? "Goal review was aborted.";
155
+ state = cancelGoalLoop(state, reason, { now: now() });
156
+ await persistStateChange(store, previousTraceLength, state);
157
+ await store.writeIterationSnapshot(currentIteration(state, iteration.iteration));
158
+ throw new GoalReviewError(reason, { state });
159
+ }
160
+ if (sessionResult.timedOut || sessionResult.error) {
161
+ const message = sessionResult.timedOut
162
+ ? `Reviewer session timed out: ${sessionResult.error ?? "time budget exceeded"}`
163
+ : `Reviewer session failed: ${sessionResult.error}`;
164
+ const failure = await recordReviewFailure({
165
+ state,
166
+ iteration,
167
+ store,
168
+ previousTraceLength,
169
+ payloadPath,
170
+ rawReviewPath,
171
+ message,
172
+ error: sessionResult.error ?? message,
173
+ rawReviewerOutput,
174
+ sessionResult,
175
+ now,
176
+ });
177
+ throw new GoalReviewError(failure.reviewerResult.summary, {
178
+ state: failure.state,
179
+ reviewerResult: failure.reviewerResult,
180
+ });
181
+ }
182
+
152
183
  let reviewerResult: GoalReviewerResultState;
153
184
  try {
154
185
  reviewerResult = {
@@ -322,33 +353,17 @@ function minimumIterationRemainingWork(state: GoalLoopState): string[] {
322
353
 
323
354
  export async function runGoalReviewerSession(options: GoalReviewerRunnerOptions): Promise<GoalReviewerSessionResult> {
324
355
  const sessionFactory = options.sessionFactory ?? createIsolatedWorkerSession;
325
- const events: unknown[] = [];
326
- let assistantText = "";
327
- let timedOut = false;
328
- let aborted = false;
329
- let error: string | undefined;
330
356
  let reviewerCostTotal = 0;
331
357
  let session: Awaited<ReturnType<typeof sessionFactory>>["session"] | undefined;
332
- let unsubscribe: (() => void) | undefined;
333
- let timeout: ReturnType<typeof setTimeout> | undefined;
334
-
335
- const abortSession = async (reason: string) => {
336
- if (!session || aborted) {
337
- return;
338
- }
339
- aborted = true;
340
- error = error ?? reason;
341
- await session.abort?.();
342
- };
343
- const abortListener = () => {
344
- void abortSession("reviewer session aborted by outer signal").catch((exc: unknown) => {
345
- error = error ?? errorMessage(exc);
346
- });
347
- };
358
+ const costsByMessage = new Map<string, number>();
348
359
 
349
360
  try {
350
361
  if (options.abortSignal?.aborted) {
351
- throw new Error("reviewer session aborted before start");
362
+ return {
363
+ assistantText: "",
364
+ aborted: true,
365
+ error: errorMessage(options.abortSignal.reason ?? "reviewer session aborted before start"),
366
+ };
352
367
  }
353
368
  const factoryResult = await sessionFactory({
354
369
  cwd: options.cwd,
@@ -358,55 +373,65 @@ export async function runGoalReviewerSession(options: GoalReviewerRunnerOptions)
358
373
  thinkingLevel: options.thinkingLevel,
359
374
  });
360
375
  session = factoryResult.session;
361
- unsubscribe = session.subscribe((event: unknown) => {
362
- events.push(event);
363
- const text = assistantTextFromEvent(event);
364
- if (text) {
365
- assistantText = text;
366
- }
367
- const cost = workerUsageCostFromEvent(event);
368
- if (cost !== undefined) {
369
- reviewerCostTotal = Math.max(reviewerCostTotal, cost);
370
- }
376
+ const promptResult = await runGuardedSessionPrompt({
377
+ session,
378
+ prompt: options.prompt,
379
+ abortSignal: options.abortSignal,
380
+ timeoutMs: options.timeoutMs,
381
+ gracefulShutdownMs: 0,
382
+ diagnostics: factoryResult.diagnostics,
383
+ dispose: false,
384
+ onEvent: (event) => {
385
+ const cost = workerUsageCostFromEvent(event);
386
+ if (cost === undefined) {
387
+ return;
388
+ }
389
+ const key = workerUsageCostKeyFromEvent(event);
390
+ if (key) {
391
+ costsByMessage.set(key, cost);
392
+ reviewerCostTotal = [...costsByMessage.values()].reduce((total, item) => total + item, 0);
393
+ } else {
394
+ reviewerCostTotal += cost;
395
+ }
396
+ },
371
397
  });
372
- options.abortSignal?.addEventListener("abort", abortListener, { once: true });
373
- if (options.timeoutMs > 0) {
374
- timeout = setTimeout(() => {
375
- timedOut = true;
376
- void abortSession(`reviewer exceeded ${options.timeoutMs}ms timeout`).catch((exc: unknown) => {
377
- error = error ?? errorMessage(exc);
378
- });
379
- }, options.timeoutMs);
398
+
399
+ try {
400
+ const stats = session.getSessionStats ? await session.getSessionStats() : undefined;
401
+ const statsCost = workerUsageCostFromStats(stats);
402
+ if (statsCost !== undefined) {
403
+ reviewerCostTotal = statsCost;
404
+ }
405
+ } catch {
406
+ // Event-based accounting remains available when session stats fail.
380
407
  }
381
- await session.prompt(options.prompt);
382
- assistantText = latestAssistantText(session, assistantText);
408
+
409
+ return {
410
+ assistantText: promptResult.assistantText,
411
+ reviewerSessionId: promptResult.sessionId,
412
+ reviewerSessionFile: promptResult.sessionFile,
413
+ reviewerCostTotal,
414
+ timedOut: promptResult.timedOut,
415
+ aborted: promptResult.aborted,
416
+ error: promptResult.error,
417
+ };
383
418
  } catch (exc) {
384
- error = error ?? errorMessage(exc);
419
+ return {
420
+ assistantText: "",
421
+ reviewerSessionId: session?.sessionId,
422
+ reviewerSessionFile: session?.sessionFile,
423
+ reviewerCostTotal,
424
+ error: errorMessage(exc),
425
+ };
385
426
  } finally {
386
- if (timeout) {
387
- clearTimeout(timeout);
388
- }
389
- options.abortSignal?.removeEventListener("abort", abortListener);
390
- unsubscribe?.();
391
427
  if (session) {
392
- assistantText = latestAssistantText(session, assistantText);
393
- const statsCost = session.getSessionStats ? workerUsageCostFromStats(await session.getSessionStats()) : undefined;
394
- if (statsCost !== undefined) {
395
- reviewerCostTotal = statsCost;
428
+ try {
429
+ await Promise.resolve(session.dispose?.());
430
+ } catch {
431
+ // Cleanup is best-effort and must not replace a useful reviewer outcome.
396
432
  }
397
- session.dispose?.();
398
433
  }
399
434
  }
400
-
401
- return {
402
- assistantText,
403
- reviewerSessionId: session?.sessionId,
404
- reviewerSessionFile: session?.sessionFile,
405
- reviewerCostTotal,
406
- timedOut,
407
- aborted: aborted || Boolean(options.abortSignal?.aborted),
408
- error,
409
- };
410
435
  }
411
436
 
412
437
  function currentReviewableIteration(state: GoalLoopState): GoalIterationState {
@@ -584,13 +609,6 @@ function defaultSummary(decision: GoalReviewerDecision): string {
584
609
  }
585
610
  }
586
611
 
587
- function latestAssistantText(
588
- session: { getLastAssistantText?: () => string | undefined; messages?: unknown[] },
589
- fallback: string,
590
- ): string {
591
- return session.getLastAssistantText?.() || lastAssistantTextFromMessages(session.messages) || fallback;
592
- }
593
-
594
612
  function markdownFence(value: string, language: string): string {
595
613
  const ticks = longestBacktickRun(value) + 1;
596
614
  const fence = "`".repeat(Math.max(3, ticks));
package/src/goal_state.ts CHANGED
@@ -97,6 +97,31 @@ export class GoalStateStore {
97
97
  }
98
98
  }
99
99
 
100
+ async initializeResultIfMissing(state: GoalLoopState): Promise<boolean> {
101
+ try {
102
+ await readFile(this.paths.resultPath, "utf8");
103
+ return false;
104
+ } catch (error) {
105
+ if (!isNodeErrnoException(error) || error.code !== "ENOENT") {
106
+ throw error;
107
+ }
108
+ }
109
+ await this.initializeResult(state);
110
+ return true;
111
+ }
112
+
113
+ async durableTraceLength(): Promise<number> {
114
+ try {
115
+ const text = await readFile(this.paths.tracePath, "utf8");
116
+ return text.split(/\r?\n/).filter((line) => line.trim()).length;
117
+ } catch (error) {
118
+ if (isNodeErrnoException(error) && error.code === "ENOENT") {
119
+ return 0;
120
+ }
121
+ throw error;
122
+ }
123
+ }
124
+
100
125
  async initializeResult(state: GoalLoopState): Promise<void> {
101
126
  validateGoalLoopState(state);
102
127
  await this.ensureRunDir();