killeros 2.0.18 → 2.0.19

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/killeros/goals.ts CHANGED
@@ -7,9 +7,9 @@ import { Text } from "@earendil-works/pi-tui";
7
7
  import { Type } from "typebox";
8
8
  import { BoundedText } from "./bounded-text.ts";
9
9
  import { formatTime, formatTokens } from "./display.ts";
10
- import { reportError } from "./errors.ts";
10
+ import { hasErrorCode, reportError } from "./errors.ts";
11
11
  import { resolvePersonalInstructions } from "./personal-instructions.ts";
12
- import type { GoalBlockerAudit, GoalFileBaseline, GoalFileVerification, GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
12
+ import type { GoalBlockerAudit, GoalFileBaseline, GoalFileVerification, GoalRuntime, GoalState, GoalStateCommon, GoalStatus, InitRuntime } from "./runtime.ts";
13
13
  import { safeTerminalText } from "./safe-terminal-text.ts";
14
14
 
15
15
  const GOAL_ENTRY_TYPE = "killeros-goal";
@@ -69,26 +69,30 @@ function finiteNonNegative(value: unknown): value is number {
69
69
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
70
70
  }
71
71
 
72
+ function isUnknownRecord(value: unknown): value is Record<string, unknown> {
73
+ return typeof value === "object" && value !== null && !Array.isArray(value);
74
+ }
75
+
72
76
  function isGoalFileBaseline(value: unknown): value is GoalFileBaseline {
73
- if (!value || typeof value !== "object") return false;
74
- const candidate = value as { exists?: unknown; size?: unknown; mtimeMs?: unknown; contentHash?: unknown };
75
- if (candidate.exists === false) return candidate.size === undefined && candidate.mtimeMs === undefined && candidate.contentHash === undefined;
76
- return candidate.exists === true
77
- && finiteNonNegative(candidate.size)
78
- && finiteNonNegative(candidate.mtimeMs)
79
- && (candidate.contentHash === undefined
80
- || candidate.contentHash === null
81
- || typeof candidate.contentHash === "string" && /^[a-f0-9]{64}$/u.test(candidate.contentHash));
77
+ if (!isUnknownRecord(value)) return false;
78
+ if (value.exists === false) {
79
+ return value.size === undefined && value.mtimeMs === undefined && value.contentHash === undefined;
80
+ }
81
+ return value.exists === true
82
+ && finiteNonNegative(value.size)
83
+ && finiteNonNegative(value.mtimeMs)
84
+ && (value.contentHash === undefined
85
+ || value.contentHash === null
86
+ || typeof value.contentHash === "string" && /^[a-f0-9]{64}$/u.test(value.contentHash));
82
87
  }
83
88
 
84
89
  function isGoalFileVerification(value: unknown): value is GoalFileVerification {
85
- if (!value || typeof value !== "object") return false;
86
- const candidate = value as Partial<GoalFileVerification>;
87
- return candidate.kind === "file"
88
- && typeof candidate.path === "string"
89
- && candidate.path === candidate.path.trim()
90
- && isAbsoluteFilePath(candidate.path)
91
- && isGoalFileBaseline(candidate.baseline);
90
+ return isUnknownRecord(value)
91
+ && value.kind === "file"
92
+ && typeof value.path === "string"
93
+ && value.path === value.path.trim()
94
+ && isAbsoluteFilePath(value.path)
95
+ && isGoalFileBaseline(value.baseline);
92
96
  }
93
97
 
94
98
  function isAbsoluteFilePath(value: string): boolean {
@@ -119,7 +123,7 @@ function captureGoalFileBaseline(filePath: string): GoalFileBaseline {
119
123
  try {
120
124
  artifact = lstatSync(filePath);
121
125
  } catch (error) {
122
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return { exists: false };
126
+ if (hasErrorCode(error, "ENOENT")) return { exists: false };
123
127
  throw error;
124
128
  }
125
129
  const baseline = { exists: true as const, size: artifact.size, mtimeMs: artifact.mtimeMs };
@@ -175,56 +179,98 @@ function verifyGoalDeliverable(verification: GoalFileVerification): void {
175
179
  }
176
180
 
177
181
  function isGoalBlockerAudit(value: unknown, turns: number, status: GoalStatus): value is GoalBlockerAudit {
178
- if (!value || typeof value !== "object") return false;
179
- const candidate = value as Partial<GoalBlockerAudit>;
180
- if (typeof candidate.key !== "string"
181
- || !/^[a-z0-9][a-z0-9._-]{0,119}$/u.test(candidate.key)
182
- || !Number.isInteger(candidate.streak) || (candidate.streak ?? 0) < 1 || (candidate.streak ?? 0) > 3
183
- || !Number.isInteger(candidate.lastTurn) || (candidate.lastTurn ?? 0) < 1 || (candidate.lastTurn ?? 0) > turns) return false;
182
+ if (!isUnknownRecord(value)
183
+ || typeof value.key !== "string"
184
+ || !/^[a-z0-9][a-z0-9._-]{0,119}$/u.test(value.key)
185
+ || typeof value.streak !== "number" || !Number.isInteger(value.streak) || value.streak < 1 || value.streak > 3
186
+ || typeof value.lastTurn !== "number" || !Number.isInteger(value.lastTurn) || value.lastTurn < 1 || value.lastTurn > turns) {
187
+ return false;
188
+ }
184
189
  if (status === "complete") return false;
185
- return status === "blocked" ? candidate.streak === 3 : candidate.streak! < 3;
190
+ return status === "blocked" ? value.streak === 3 : value.streak < 3;
186
191
  }
187
192
 
188
193
  function parseGoalState(value: unknown): GoalState | undefined {
189
- if (!value || typeof value !== "object") return undefined;
190
- const candidate = value as Partial<GoalState>;
191
- if (candidate.version !== GOAL_VERSION
192
- || !Number.isInteger(candidate.revision) || (candidate.revision ?? 0) < 1
193
- || typeof candidate.objective !== "string" || !candidate.objective.trim()
194
- || [...candidate.objective].length > GOAL_OBJECTIVE_LIMIT
195
- || !isGoalStatus(candidate.status)
196
- || !finiteNonNegative(candidate.createdAt)
197
- || !finiteNonNegative(candidate.updatedAt)
198
- || !finiteNonNegative(candidate.activeMilliseconds)
199
- || !Number.isInteger(candidate.turns) || (candidate.turns ?? -1) < 0
200
- || candidate.blockedAuditStartTurn !== undefined
201
- && (!Number.isInteger(candidate.blockedAuditStartTurn) || candidate.blockedAuditStartTurn < 0 || candidate.blockedAuditStartTurn > candidate.turns!)
202
- || !finiteNonNegative(candidate.baselineTokens)
203
- || candidate.activeStartedAt !== undefined && !finiteNonNegative(candidate.activeStartedAt)
204
- || candidate.result !== undefined && typeof candidate.result !== "string"
205
- || candidate.verification !== undefined && !isGoalFileVerification(candidate.verification)
206
- || candidate.blockerAudit !== undefined && !isGoalBlockerAudit(candidate.blockerAudit, candidate.turns!, candidate.status)
207
- || candidate.resumeAfterManualCompaction !== undefined && candidate.resumeAfterManualCompaction !== true
208
- || candidate.resumeAfterManualCompaction === true && candidate.status !== "paused") {
194
+ if (!isUnknownRecord(value)) return undefined;
195
+ const {
196
+ version,
197
+ revision,
198
+ objective,
199
+ status,
200
+ createdAt,
201
+ updatedAt,
202
+ activeMilliseconds,
203
+ activeStartedAt,
204
+ turns,
205
+ blockedAuditStartTurn,
206
+ baselineTokens,
207
+ result,
208
+ resumeAfterManualCompaction,
209
+ blockerAudit,
210
+ verification,
211
+ } = value;
212
+ if (version !== GOAL_VERSION
213
+ || typeof revision !== "number" || !Number.isInteger(revision) || revision < 1
214
+ || typeof objective !== "string" || !objective.trim() || [...objective].length > GOAL_OBJECTIVE_LIMIT
215
+ || !isGoalStatus(status)
216
+ || !finiteNonNegative(createdAt)
217
+ || !finiteNonNegative(updatedAt)
218
+ || !finiteNonNegative(activeMilliseconds)
219
+ || typeof turns !== "number" || !Number.isInteger(turns) || turns < 0
220
+ || blockedAuditStartTurn !== undefined
221
+ && (typeof blockedAuditStartTurn !== "number" || !Number.isInteger(blockedAuditStartTurn)
222
+ || blockedAuditStartTurn < 0 || blockedAuditStartTurn > turns)
223
+ || !finiteNonNegative(baselineTokens)
224
+ || result !== undefined && typeof result !== "string"
225
+ || verification !== undefined && !isGoalFileVerification(verification)
226
+ || resumeAfterManualCompaction !== undefined && resumeAfterManualCompaction !== true
227
+ || blockerAudit !== undefined && !isGoalBlockerAudit(blockerAudit, turns, status)) {
209
228
  return undefined;
210
229
  }
211
- return {
230
+
231
+ const common: GoalStateCommon = {
212
232
  version: GOAL_VERSION,
213
- revision: candidate.revision!,
214
- objective: candidate.objective.trim(),
215
- status: candidate.status,
216
- createdAt: candidate.createdAt,
217
- updatedAt: candidate.updatedAt,
218
- activeMilliseconds: candidate.activeMilliseconds,
219
- activeStartedAt: candidate.activeStartedAt,
220
- turns: candidate.turns!,
221
- blockedAuditStartTurn: candidate.blockedAuditStartTurn ?? 0,
222
- baselineTokens: candidate.baselineTokens,
223
- result: candidate.result,
224
- resumeAfterManualCompaction: candidate.resumeAfterManualCompaction,
225
- blockerAudit: candidate.blockerAudit,
226
- verification: candidate.verification,
233
+ revision,
234
+ objective: objective.trim(),
235
+ createdAt,
236
+ updatedAt,
237
+ activeMilliseconds,
238
+ turns,
239
+ blockedAuditStartTurn: blockedAuditStartTurn ?? 0,
240
+ baselineTokens,
241
+ ...(verification === undefined ? {} : { verification }),
227
242
  };
243
+ switch (status) {
244
+ case "active":
245
+ if (!finiteNonNegative(activeStartedAt) || resumeAfterManualCompaction !== undefined) return undefined;
246
+ return {
247
+ ...common,
248
+ status,
249
+ activeStartedAt,
250
+ ...(result === undefined ? {} : { result }),
251
+ ...(blockerAudit === undefined ? {} : { blockerAudit }),
252
+ };
253
+ case "paused":
254
+ if (activeStartedAt !== undefined) return undefined;
255
+ return {
256
+ ...common,
257
+ status,
258
+ ...(result === undefined ? {} : { result }),
259
+ ...(blockerAudit === undefined ? {} : { blockerAudit }),
260
+ ...(resumeAfterManualCompaction === undefined ? {} : { resumeAfterManualCompaction }),
261
+ };
262
+ case "blocked":
263
+ if (activeStartedAt !== undefined || resumeAfterManualCompaction !== undefined || typeof result !== "string") return undefined;
264
+ return { ...common, status, result, ...(blockerAudit === undefined ? {} : { blockerAudit }) };
265
+ case "complete":
266
+ if (activeStartedAt !== undefined || resumeAfterManualCompaction !== undefined
267
+ || typeof result !== "string" || blockerAudit !== undefined) return undefined;
268
+ return { ...common, status, result };
269
+ default: {
270
+ const exhaustive: never = status;
271
+ return exhaustive;
272
+ }
273
+ }
228
274
  }
229
275
 
230
276
  function goalBranchEntries(ctx: ExtensionContext): ReturnType<ExtensionContext["sessionManager"]["getEntries"]> {
@@ -240,37 +286,60 @@ function restoreGoalState(ctx: ExtensionContext): RestoredGoalState {
240
286
  for (let index = entries.length - 1; index >= 0; index -= 1) {
241
287
  const entry = entries[index];
242
288
  if (entry?.type !== "custom" || entry.customType !== GOAL_ENTRY_TYPE) continue;
243
- const data = entry.data as Partial<GoalEntryData> | undefined;
244
- if (!data || data.version !== GOAL_VERSION || data.state === null) {
289
+ const data: unknown = entry.data;
290
+ if (!isUnknownRecord(data) || data.version !== GOAL_VERSION || data.state === null) {
245
291
  return { state: undefined };
246
292
  }
247
- const restored = parseGoalState(data.state);
293
+ // v2.0.18 shutdown checkpoints stopped active clocks by omitting activeStartedAt.
294
+ const savedState = data.event === "checkpoint"
295
+ && isUnknownRecord(data.state)
296
+ && data.state.status === "active"
297
+ && data.state.activeStartedAt === undefined
298
+ ? { ...data.state, activeStartedAt: Date.now() }
299
+ : data.state;
300
+ const restored = parseGoalState(savedState);
248
301
  if (!restored) return { state: undefined };
249
- const state = restored.status === "active"
250
- ? { ...restored, activeStartedAt: Date.now() }
251
- : { ...restored, activeStartedAt: undefined };
252
- if (state.status === "paused") state.resumeAfterManualCompaction = undefined;
253
- return { state };
302
+ if (restored.status === "active") {
303
+ return { state: { ...restored, activeStartedAt: Date.now() } };
304
+ }
305
+ if (restored.status === "paused") {
306
+ const { resumeAfterManualCompaction: _resume, ...state } = restored;
307
+ return { state };
308
+ }
309
+ return { state: restored };
254
310
  }
255
311
  return { state: undefined };
256
312
  }
257
313
 
258
314
  export function goalElapsedMilliseconds(state: GoalState, now = Date.now()): number {
259
- const activeInterval = state.status === "active" && state.activeStartedAt !== undefined
315
+ const activeInterval = state.status === "active"
260
316
  ? Math.max(0, now - state.activeStartedAt)
261
317
  : 0;
262
318
  return state.activeMilliseconds + activeInterval;
263
319
  }
264
320
 
265
- function stopGoalClock(state: GoalState, now: number): GoalState {
266
- if (state.status !== "active" || state.activeStartedAt === undefined) return state;
321
+ function commonGoalState(state: GoalState): GoalStateCommon {
267
322
  return {
268
- ...state,
269
- activeMilliseconds: state.activeMilliseconds + Math.max(0, now - state.activeStartedAt),
270
- activeStartedAt: undefined,
323
+ version: state.version,
324
+ revision: state.revision,
325
+ objective: state.objective,
326
+ createdAt: state.createdAt,
327
+ updatedAt: state.updatedAt,
328
+ activeMilliseconds: state.activeMilliseconds,
329
+ turns: state.turns,
330
+ blockedAuditStartTurn: state.blockedAuditStartTurn,
331
+ baselineTokens: state.baselineTokens,
332
+ ...(state.verification === undefined ? {} : { verification: state.verification }),
271
333
  };
272
334
  }
273
335
 
336
+ function stopGoalClock(state: GoalState, now: number): GoalStateCommon {
337
+ const common = commonGoalState(state);
338
+ return state.status === "active"
339
+ ? { ...common, activeMilliseconds: common.activeMilliseconds + Math.max(0, now - state.activeStartedAt) }
340
+ : common;
341
+ }
342
+
274
343
  function sumGoalTokens(ctx: ExtensionContext): number {
275
344
  let total = 0;
276
345
  for (const entry of goalBranchEntries(ctx)) {
@@ -303,6 +372,7 @@ function persistGoalState(
303
372
  state: GoalState | undefined,
304
373
  ): void {
305
374
  const data: GoalEntryData = { version: GOAL_VERSION, event, state: state ?? null };
375
+ runtime.automaticCompaction = undefined;
306
376
  pi.appendEntry(GOAL_ENTRY_TYPE, data);
307
377
  runtime.state = state;
308
378
  syncGoalUpdateTool(pi, runtime);
@@ -322,17 +392,40 @@ function transitionGoal(
322
392
  if (!current) throw new Error("No goal is set");
323
393
  const now = Date.now();
324
394
  const stopped = stopGoalClock(current, now);
325
- const next: GoalState = {
395
+ const common: GoalStateCommon = {
326
396
  ...stopped,
327
397
  revision: stopped.revision + 1,
328
- status,
329
398
  updatedAt: now,
330
- activeStartedAt: status === "active" ? now : undefined,
331
399
  blockedAuditStartTurn: options.resetBlockedAudit ? stopped.turns : stopped.blockedAuditStartTurn,
332
- blockerAudit: options.resetBlockedAudit ? undefined : options.blockerAudit ?? stopped.blockerAudit,
333
- result,
334
- resumeAfterManualCompaction: options.resumeAfterManualCompaction,
335
400
  };
401
+ const blockerAudit = options.resetBlockedAudit ? undefined : options.blockerAudit ?? current.blockerAudit;
402
+ let next: GoalState;
403
+ switch (status) {
404
+ case "active":
405
+ next = { ...common, status, activeStartedAt: now, ...(blockerAudit === undefined ? {} : { blockerAudit }) };
406
+ break;
407
+ case "paused":
408
+ next = {
409
+ ...common,
410
+ status,
411
+ ...(result === undefined ? {} : { result }),
412
+ ...(blockerAudit === undefined ? {} : { blockerAudit }),
413
+ ...(options.resumeAfterManualCompaction === undefined ? {} : { resumeAfterManualCompaction: true }),
414
+ };
415
+ break;
416
+ case "blocked":
417
+ if (result === undefined) throw new Error("A blocked goal requires a result");
418
+ next = { ...common, status, result, ...(blockerAudit === undefined ? {} : { blockerAudit }) };
419
+ break;
420
+ case "complete":
421
+ if (result === undefined) throw new Error("A complete goal requires a result");
422
+ next = { ...common, status, result };
423
+ break;
424
+ default: {
425
+ const exhaustive: never = status;
426
+ return exhaustive;
427
+ }
428
+ }
336
429
  persistGoalState(pi, runtime, event, next);
337
430
  if (status !== "active") {
338
431
  runtime.continuationScheduled = false;
@@ -399,11 +492,12 @@ export function pauseGoalAfterFailure(
399
492
  try {
400
493
  transitionGoal(pi, runtime, "error", "paused", safeReason);
401
494
  } catch {
402
- runtime.state = runtime.state ? {
403
- ...stopGoalClock(runtime.state, Date.now()),
495
+ const current = runtime.state;
496
+ runtime.state = current ? {
497
+ ...stopGoalClock(current, Date.now()),
404
498
  status: "paused",
405
499
  result: safeReason,
406
- resumeAfterManualCompaction: undefined,
500
+ ...(current.blockerAudit === undefined ? {} : { blockerAudit: current.blockerAudit }),
407
501
  } : undefined;
408
502
  syncGoalUpdateTool(pi, runtime);
409
503
  runtime.persistenceRetryNeeded = true;
@@ -427,11 +521,13 @@ function pauseGoalForPossibleManualCompaction(
427
521
  resumeAfterManualCompaction: true,
428
522
  });
429
523
  } catch {
430
- runtime.state = runtime.state ? {
431
- ...stopGoalClock(runtime.state, Date.now()),
524
+ const current = runtime.state;
525
+ runtime.state = current ? {
526
+ ...stopGoalClock(current, Date.now()),
432
527
  status: "paused",
433
528
  result: safeReason,
434
529
  resumeAfterManualCompaction: true,
530
+ ...(current.blockerAudit === undefined ? {} : { blockerAudit: current.blockerAudit }),
435
531
  } : undefined;
436
532
  syncGoalUpdateTool(pi, runtime);
437
533
  runtime.persistenceRetryNeeded = true;
@@ -472,7 +568,7 @@ function beginGoalTurn(
472
568
  pi: ExtensionAPI,
473
569
  runtime: GoalRuntime,
474
570
  ctx: ExtensionContext,
475
- current: GoalState,
571
+ current: Extract<GoalState, { status: "active" }>,
476
572
  ): GoalState | undefined {
477
573
  const now = Date.now();
478
574
  const next: GoalState = {
@@ -480,8 +576,6 @@ function beginGoalTurn(
480
576
  revision: current.revision + 1,
481
577
  turns: current.turns + 1,
482
578
  updatedAt: now,
483
- activeStartedAt: current.activeStartedAt ?? now,
484
- resumeAfterManualCompaction: undefined,
485
579
  };
486
580
  try {
487
581
  persistGoalState(pi, runtime, "turn", next);
@@ -530,38 +624,81 @@ function scheduleGoalContinuation(
530
624
  }
531
625
  }
532
626
 
533
- function completeAutomaticCompaction(
627
+ /** Resumes the revision paused by automatic compaction after both host callbacks settle. */
628
+ function finalizeAutomaticCompaction(
534
629
  pi: ExtensionAPI,
535
630
  runtime: GoalRuntime,
536
631
  initState: InitRuntime,
537
632
  ctx: ExtensionContext,
538
633
  ): void {
539
- if (runtime.automaticCompaction === undefined) return;
540
- if (runtime.state?.status !== "active" || initState.active) {
541
- runtime.automaticCompaction = undefined;
634
+ const recovery = runtime.automaticCompaction;
635
+ if (!recovery?.compactionSucceeded || !recovery.turnSettled) return;
636
+ runtime.automaticCompaction = undefined;
637
+ if (runtime.state?.status !== "paused"
638
+ || runtime.state.revision !== recovery.pausedRevision
639
+ || initState.active) return;
640
+ try {
641
+ transitionGoal(pi, runtime, "resume", "active", undefined, { resetBlockedAudit: true });
642
+ } catch (error) {
643
+ runtime.persistenceRetryNeeded = true;
644
+ reportError(ctx, "Automatic compaction succeeded, but the goal could not be resumed", error);
542
645
  return;
543
646
  }
544
- runtime.automaticCompaction = undefined;
647
+ runtime.continuationScheduled = false;
545
648
  setImmediate(() => scheduleGoalContinuation(pi, runtime, initState, ctx));
546
649
  }
547
650
 
651
+ /** Records Pi's successful compaction callback and attempts guarded recovery. */
652
+ function completeAutomaticCompaction(
653
+ pi: ExtensionAPI,
654
+ runtime: GoalRuntime,
655
+ initState: InitRuntime,
656
+ ctx: ExtensionContext,
657
+ ): void {
658
+ if (!runtime.automaticCompaction) return;
659
+ runtime.automaticCompaction.compactionSucceeded = true;
660
+ finalizeAutomaticCompaction(pi, runtime, initState, ctx);
661
+ }
662
+
663
+ /** Consumes automatic recovery and records its failure on the eligible paused goal. */
664
+ function stopAutomaticCompactionRecovery(
665
+ pi: ExtensionAPI,
666
+ runtime: GoalRuntime,
667
+ ctx: ExtensionContext,
668
+ reason: string,
669
+ ): void {
670
+ const recovery = runtime.automaticCompaction;
671
+ runtime.automaticCompaction = undefined;
672
+ const safeReason = safeTerminalText(reason);
673
+ if (runtime.state?.status !== "paused" || runtime.state.revision !== recovery?.pausedRevision) return;
674
+ try {
675
+ transitionGoal(pi, runtime, "error", "paused", safeReason);
676
+ } catch {
677
+ runtime.state = { ...runtime.state, result: safeReason };
678
+ runtime.persistenceRetryNeeded = true;
679
+ runtime.requestRender?.();
680
+ }
681
+ ctx.ui.notify(
682
+ `Goal paused: ${safeReason}\nAutomatic continuation is stopped. Run /goal resume after resolving the compaction problem.`,
683
+ "error",
684
+ );
685
+ }
686
+
687
+ /** Leaves the goal paused when Pi rejects automatic compaction. */
548
688
  function failAutomaticCompaction(
549
689
  pi: ExtensionAPI,
550
690
  runtime: GoalRuntime,
551
691
  ctx: ExtensionContext,
552
692
  error: unknown,
553
693
  ): void {
554
- if (runtime.automaticCompaction === undefined) return;
555
- runtime.automaticCompaction = undefined;
556
- if (runtime.state?.status !== "active") return;
557
694
  const reason = error instanceof Error ? error.message : String(error);
558
- pauseGoalAfterFailure(
559
- pi,
560
- runtime,
561
- ctx,
562
- `automatic compaction failed: ${reason}`,
563
- "Automatic continuation is stopped. Run /goal resume after resolving the compaction problem.",
564
- );
695
+ if (!runtime.automaticCompaction) {
696
+ if (runtime.persistenceRetryNeeded) {
697
+ ctx.ui.notify(`Automatic compaction did not start: ${safeTerminalText(reason)}`, "error");
698
+ }
699
+ return;
700
+ }
701
+ stopAutomaticCompactionRecovery(pi, runtime, ctx, `automatic compaction failed: ${reason}`);
565
702
  }
566
703
 
567
704
  function goalInstructions(state: GoalState, heading: string): string {
@@ -732,7 +869,11 @@ export function registerGoal(
732
869
  const checkpoint: GoalState = {
733
870
  ...stopGoalClock(runtime.state, now),
734
871
  revision: runtime.state.revision + 1,
872
+ status: "active",
735
873
  updatedAt: now,
874
+ activeStartedAt: now,
875
+ ...(runtime.state.result === undefined ? {} : { result: runtime.state.result }),
876
+ ...(runtime.state.blockerAudit === undefined ? {} : { blockerAudit: runtime.state.blockerAudit }),
736
877
  };
737
878
  try {
738
879
  persistGoalState(pi, runtime, "checkpoint", checkpoint);
@@ -846,16 +987,18 @@ export function registerGoal(
846
987
  return;
847
988
  }
848
989
  if (runtime.state.status === "paused") {
849
- if (!runtime.persistenceRetryNeeded && runtime.state.resumeAfterManualCompaction !== true) {
990
+ if (!runtime.persistenceRetryNeeded
991
+ && runtime.state.resumeAfterManualCompaction !== true
992
+ && runtime.automaticCompaction === undefined) {
850
993
  ctx.ui.notify("Goal is already paused", "info");
851
994
  return;
852
995
  }
853
996
  const now = Date.now();
997
+ const { resumeAfterManualCompaction: _resume, ...paused } = runtime.state;
854
998
  const checkpoint: GoalState = {
855
- ...runtime.state,
856
- revision: runtime.state.revision + 1,
999
+ ...paused,
1000
+ revision: paused.revision + 1,
857
1001
  updatedAt: now,
858
- resumeAfterManualCompaction: undefined,
859
1002
  };
860
1003
  try {
861
1004
  persistGoalState(pi, runtime, "pause", checkpoint);
@@ -972,18 +1115,17 @@ export function registerGoal(
972
1115
  }
973
1116
  const now = Date.now();
974
1117
  const current = stopGoalClock(runtime.state, now);
1118
+ const { verification: _previousVerification, ...currentWithoutVerification } = current;
1119
+ const verification = inferGoalVerification(objective);
975
1120
  const next: GoalState = {
976
- ...current,
1121
+ ...currentWithoutVerification,
977
1122
  revision: current.revision + 1,
978
1123
  objective,
979
1124
  status: "active",
980
1125
  updatedAt: now,
981
1126
  activeStartedAt: now,
982
1127
  blockedAuditStartTurn: current.turns,
983
- blockerAudit: undefined,
984
- verification: inferGoalVerification(objective),
985
- result: undefined,
986
- resumeAfterManualCompaction: undefined,
1128
+ ...(verification === undefined ? {} : { verification }),
987
1129
  };
988
1130
  try {
989
1131
  persistGoalState(pi, runtime, "edit", next);
@@ -1113,6 +1255,32 @@ export function registerGoalSettlement(
1113
1255
  runtime.goalTurnInFlight = false;
1114
1256
  runtime.agentEndObserved = false;
1115
1257
  runtime.continuationScheduled = false;
1258
+
1259
+ if (runtime.automaticCompaction) {
1260
+ const stopReason = runtime.lastStopReason;
1261
+ const error = safeTerminalText(runtime.lastError ?? "");
1262
+ runtime.lastStopReason = undefined;
1263
+ runtime.lastError = undefined;
1264
+ const expectedInterruption = stopReason === "aborted"
1265
+ || stopReason === "error" && error === "This operation was aborted";
1266
+ if (!wasGoalTurn || !agentEndObserved) {
1267
+ stopAutomaticCompactionRecovery(pi, runtime, ctx, "the goal turn ended without an agent result");
1268
+ return;
1269
+ }
1270
+ if ((stopReason === "error" || stopReason === "aborted") && !expectedInterruption) {
1271
+ stopAutomaticCompactionRecovery(
1272
+ pi,
1273
+ runtime,
1274
+ ctx,
1275
+ error || "the agent turn failed",
1276
+ );
1277
+ return;
1278
+ }
1279
+ runtime.automaticCompaction.turnSettled = true;
1280
+ finalizeAutomaticCompaction(pi, runtime, initState, ctx);
1281
+ return;
1282
+ }
1283
+
1116
1284
  if (!wasGoalTurn || runtime.state?.status !== "active" || initState.active) {
1117
1285
  if (continuationWasScheduled && runtime.state?.status === "active" && !initState.active) {
1118
1286
  pauseGoalAfterFailure(pi, runtime, ctx, "the goal continuation ended before an agent turn started");
@@ -1122,7 +1290,6 @@ export function registerGoalSettlement(
1122
1290
  return;
1123
1291
  }
1124
1292
  if (!agentEndObserved) {
1125
- if (runtime.automaticCompaction !== undefined) return;
1126
1293
  pauseGoalAfterFailure(pi, runtime, ctx, "the goal turn ended without an agent result");
1127
1294
  return;
1128
1295
  }
@@ -1130,7 +1297,6 @@ export function registerGoalSettlement(
1130
1297
  const reason = runtime.lastError || "the agent turn was aborted";
1131
1298
  runtime.lastStopReason = undefined;
1132
1299
  runtime.lastError = undefined;
1133
- if (runtime.automaticCompaction !== undefined) return;
1134
1300
  pauseGoalForPossibleManualCompaction(pi, runtime, ctx, reason);
1135
1301
  return;
1136
1302
  }
@@ -1152,13 +1318,40 @@ export function registerGoalSettlement(
1152
1318
  recoverGoalAfterManualCompaction(pi, runtime, initState, ctx);
1153
1319
  });
1154
1320
 
1321
+ const resetAutomaticRecovery = (): void => { runtime.automaticCompaction = undefined; };
1322
+ pi.on("session_before_switch", resetAutomaticRecovery);
1323
+ pi.on("session_before_fork", resetAutomaticRecovery);
1324
+
1155
1325
  return {
1156
1326
  isActive: (ctx: ExtensionContext): boolean => isGoalModeSupported(ctx)
1157
1327
  && isSavedSession(ctx)
1158
1328
  && runtime.state?.status === "active"
1159
1329
  && !initState.active,
1160
1330
  onRequested: (): void => {
1161
- if (runtime.state?.status === "active") runtime.automaticCompaction = "pending";
1331
+ if (runtime.state?.status !== "active") return;
1332
+ try {
1333
+ const paused = transitionGoal(pi, runtime, "pause", "paused");
1334
+ runtime.automaticCompaction = {
1335
+ pausedRevision: paused.revision,
1336
+ compactionSucceeded: false,
1337
+ turnSettled: false,
1338
+ };
1339
+ } catch (error) {
1340
+ const current = runtime.state;
1341
+ const reason = safeTerminalText(`automatic compaction pause could not be saved: ${error instanceof Error ? error.message : String(error)}`);
1342
+ runtime.state = current ? {
1343
+ ...stopGoalClock(current, Date.now()),
1344
+ status: "paused",
1345
+ result: reason,
1346
+ ...(current.blockerAudit === undefined ? {} : { blockerAudit: current.blockerAudit }),
1347
+ } : undefined;
1348
+ syncGoalUpdateTool(pi, runtime);
1349
+ runtime.persistenceRetryNeeded = true;
1350
+ runtime.continuationScheduled = false;
1351
+ runtime.automaticCompaction = undefined;
1352
+ runtime.requestRender?.();
1353
+ throw error;
1354
+ }
1162
1355
  },
1163
1356
  onCompleted: (ctx: ExtensionContext): void => completeAutomaticCompaction(pi, runtime, initState, ctx),
1164
1357
  onFailed: (ctx: ExtensionContext, error: unknown): void => failAutomaticCompaction(pi, runtime, ctx, error),