killeros 2.0.18 → 2.0.20

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,96 @@ function scheduleGoalContinuation(
530
624
  }
531
625
  }
532
626
 
533
- function completeAutomaticCompaction(
627
+ /** Resumes the revision paused by automatic compaction once both host callbacks settle and Pi reports an outcome. */
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 || recovery.outcome === "pending" || !recovery.turnSettled) return;
636
+ const skipped = recovery.outcome === "skipped";
637
+ runtime.automaticCompaction = undefined;
638
+ if (runtime.state?.status !== "paused"
639
+ || runtime.state.revision !== recovery.pausedRevision
640
+ || initState.active) return;
641
+ try {
642
+ transitionGoal(pi, runtime, "resume", "active", undefined, { resetBlockedAudit: true });
643
+ } catch (error) {
644
+ runtime.persistenceRetryNeeded = true;
645
+ reportError(ctx, skipped
646
+ ? "Automatic compaction was skipped, but the goal could not be resumed"
647
+ : "Automatic compaction succeeded, but the goal could not be resumed", error);
542
648
  return;
543
649
  }
544
- runtime.automaticCompaction = undefined;
650
+ runtime.continuationScheduled = false;
545
651
  setImmediate(() => scheduleGoalContinuation(pi, runtime, initState, ctx));
546
652
  }
547
653
 
654
+ /** Records Pi's successful compaction callback and attempts guarded recovery. */
655
+ function completeAutomaticCompaction(
656
+ pi: ExtensionAPI,
657
+ runtime: GoalRuntime,
658
+ initState: InitRuntime,
659
+ ctx: ExtensionContext,
660
+ ): void {
661
+ if (!runtime.automaticCompaction) return;
662
+ runtime.automaticCompaction.outcome = "completed";
663
+ finalizeAutomaticCompaction(pi, runtime, initState, ctx);
664
+ }
665
+
666
+ /** Records Pi's expected session-too-small rejection and resumes without claiming compaction succeeded. */
667
+ function skipAutomaticCompaction(
668
+ pi: ExtensionAPI,
669
+ runtime: GoalRuntime,
670
+ initState: InitRuntime,
671
+ ctx: ExtensionContext,
672
+ ): void {
673
+ if (!runtime.automaticCompaction) return;
674
+ runtime.automaticCompaction.outcome = "skipped";
675
+ finalizeAutomaticCompaction(pi, runtime, initState, ctx);
676
+ }
677
+
678
+ /** Consumes automatic recovery and records its failure on the eligible paused goal. */
679
+ function stopAutomaticCompactionRecovery(
680
+ pi: ExtensionAPI,
681
+ runtime: GoalRuntime,
682
+ ctx: ExtensionContext,
683
+ reason: string,
684
+ ): void {
685
+ const recovery = runtime.automaticCompaction;
686
+ runtime.automaticCompaction = undefined;
687
+ const safeReason = safeTerminalText(reason);
688
+ if (runtime.state?.status !== "paused" || runtime.state.revision !== recovery?.pausedRevision) return;
689
+ try {
690
+ transitionGoal(pi, runtime, "error", "paused", safeReason);
691
+ } catch {
692
+ runtime.state = { ...runtime.state, result: safeReason };
693
+ runtime.persistenceRetryNeeded = true;
694
+ runtime.requestRender?.();
695
+ }
696
+ ctx.ui.notify(
697
+ `Goal paused: ${safeReason}\nAutomatic continuation is stopped. Run /goal resume after resolving the compaction problem.`,
698
+ "error",
699
+ );
700
+ }
701
+
702
+ /** Leaves the goal paused when Pi rejects automatic compaction. */
548
703
  function failAutomaticCompaction(
549
704
  pi: ExtensionAPI,
550
705
  runtime: GoalRuntime,
551
706
  ctx: ExtensionContext,
552
707
  error: unknown,
553
708
  ): void {
554
- if (runtime.automaticCompaction === undefined) return;
555
- runtime.automaticCompaction = undefined;
556
- if (runtime.state?.status !== "active") return;
557
709
  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
- );
710
+ if (!runtime.automaticCompaction) {
711
+ if (runtime.persistenceRetryNeeded) {
712
+ ctx.ui.notify(`Automatic compaction did not start: ${safeTerminalText(reason)}`, "error");
713
+ }
714
+ return;
715
+ }
716
+ stopAutomaticCompactionRecovery(pi, runtime, ctx, `automatic compaction failed: ${reason}`);
565
717
  }
566
718
 
567
719
  function goalInstructions(state: GoalState, heading: string): string {
@@ -732,7 +884,11 @@ export function registerGoal(
732
884
  const checkpoint: GoalState = {
733
885
  ...stopGoalClock(runtime.state, now),
734
886
  revision: runtime.state.revision + 1,
887
+ status: "active",
735
888
  updatedAt: now,
889
+ activeStartedAt: now,
890
+ ...(runtime.state.result === undefined ? {} : { result: runtime.state.result }),
891
+ ...(runtime.state.blockerAudit === undefined ? {} : { blockerAudit: runtime.state.blockerAudit }),
736
892
  };
737
893
  try {
738
894
  persistGoalState(pi, runtime, "checkpoint", checkpoint);
@@ -846,16 +1002,18 @@ export function registerGoal(
846
1002
  return;
847
1003
  }
848
1004
  if (runtime.state.status === "paused") {
849
- if (!runtime.persistenceRetryNeeded && runtime.state.resumeAfterManualCompaction !== true) {
1005
+ if (!runtime.persistenceRetryNeeded
1006
+ && runtime.state.resumeAfterManualCompaction !== true
1007
+ && runtime.automaticCompaction === undefined) {
850
1008
  ctx.ui.notify("Goal is already paused", "info");
851
1009
  return;
852
1010
  }
853
1011
  const now = Date.now();
1012
+ const { resumeAfterManualCompaction: _resume, ...paused } = runtime.state;
854
1013
  const checkpoint: GoalState = {
855
- ...runtime.state,
856
- revision: runtime.state.revision + 1,
1014
+ ...paused,
1015
+ revision: paused.revision + 1,
857
1016
  updatedAt: now,
858
- resumeAfterManualCompaction: undefined,
859
1017
  };
860
1018
  try {
861
1019
  persistGoalState(pi, runtime, "pause", checkpoint);
@@ -972,18 +1130,17 @@ export function registerGoal(
972
1130
  }
973
1131
  const now = Date.now();
974
1132
  const current = stopGoalClock(runtime.state, now);
1133
+ const { verification: _previousVerification, ...currentWithoutVerification } = current;
1134
+ const verification = inferGoalVerification(objective);
975
1135
  const next: GoalState = {
976
- ...current,
1136
+ ...currentWithoutVerification,
977
1137
  revision: current.revision + 1,
978
1138
  objective,
979
1139
  status: "active",
980
1140
  updatedAt: now,
981
1141
  activeStartedAt: now,
982
1142
  blockedAuditStartTurn: current.turns,
983
- blockerAudit: undefined,
984
- verification: inferGoalVerification(objective),
985
- result: undefined,
986
- resumeAfterManualCompaction: undefined,
1143
+ ...(verification === undefined ? {} : { verification }),
987
1144
  };
988
1145
  try {
989
1146
  persistGoalState(pi, runtime, "edit", next);
@@ -1105,6 +1262,7 @@ export function registerGoalSettlement(
1105
1262
  onRequested(): void;
1106
1263
  onCompleted(ctx: ExtensionContext): void;
1107
1264
  onFailed(ctx: ExtensionContext, error: unknown): void;
1265
+ onSkipped(ctx: ExtensionContext): void;
1108
1266
  } {
1109
1267
  pi.on("agent_settled", (_event, ctx) => {
1110
1268
  const wasGoalTurn = runtime.goalTurnInFlight;
@@ -1113,6 +1271,32 @@ export function registerGoalSettlement(
1113
1271
  runtime.goalTurnInFlight = false;
1114
1272
  runtime.agentEndObserved = false;
1115
1273
  runtime.continuationScheduled = false;
1274
+
1275
+ if (runtime.automaticCompaction) {
1276
+ const stopReason = runtime.lastStopReason;
1277
+ const error = safeTerminalText(runtime.lastError ?? "");
1278
+ runtime.lastStopReason = undefined;
1279
+ runtime.lastError = undefined;
1280
+ const expectedInterruption = stopReason === "aborted"
1281
+ || stopReason === "error" && error === "This operation was aborted";
1282
+ if (!wasGoalTurn || !agentEndObserved) {
1283
+ stopAutomaticCompactionRecovery(pi, runtime, ctx, "the goal turn ended without an agent result");
1284
+ return;
1285
+ }
1286
+ if ((stopReason === "error" || stopReason === "aborted") && !expectedInterruption) {
1287
+ stopAutomaticCompactionRecovery(
1288
+ pi,
1289
+ runtime,
1290
+ ctx,
1291
+ error || "the agent turn failed",
1292
+ );
1293
+ return;
1294
+ }
1295
+ runtime.automaticCompaction.turnSettled = true;
1296
+ finalizeAutomaticCompaction(pi, runtime, initState, ctx);
1297
+ return;
1298
+ }
1299
+
1116
1300
  if (!wasGoalTurn || runtime.state?.status !== "active" || initState.active) {
1117
1301
  if (continuationWasScheduled && runtime.state?.status === "active" && !initState.active) {
1118
1302
  pauseGoalAfterFailure(pi, runtime, ctx, "the goal continuation ended before an agent turn started");
@@ -1122,7 +1306,6 @@ export function registerGoalSettlement(
1122
1306
  return;
1123
1307
  }
1124
1308
  if (!agentEndObserved) {
1125
- if (runtime.automaticCompaction !== undefined) return;
1126
1309
  pauseGoalAfterFailure(pi, runtime, ctx, "the goal turn ended without an agent result");
1127
1310
  return;
1128
1311
  }
@@ -1130,7 +1313,6 @@ export function registerGoalSettlement(
1130
1313
  const reason = runtime.lastError || "the agent turn was aborted";
1131
1314
  runtime.lastStopReason = undefined;
1132
1315
  runtime.lastError = undefined;
1133
- if (runtime.automaticCompaction !== undefined) return;
1134
1316
  pauseGoalForPossibleManualCompaction(pi, runtime, ctx, reason);
1135
1317
  return;
1136
1318
  }
@@ -1152,15 +1334,43 @@ export function registerGoalSettlement(
1152
1334
  recoverGoalAfterManualCompaction(pi, runtime, initState, ctx);
1153
1335
  });
1154
1336
 
1337
+ const resetAutomaticRecovery = (): void => { runtime.automaticCompaction = undefined; };
1338
+ pi.on("session_before_switch", resetAutomaticRecovery);
1339
+ pi.on("session_before_fork", resetAutomaticRecovery);
1340
+
1155
1341
  return {
1156
1342
  isActive: (ctx: ExtensionContext): boolean => isGoalModeSupported(ctx)
1157
1343
  && isSavedSession(ctx)
1158
1344
  && runtime.state?.status === "active"
1159
1345
  && !initState.active,
1160
1346
  onRequested: (): void => {
1161
- if (runtime.state?.status === "active") runtime.automaticCompaction = "pending";
1347
+ if (runtime.state?.status !== "active") return;
1348
+ try {
1349
+ const paused = transitionGoal(pi, runtime, "pause", "paused");
1350
+ runtime.automaticCompaction = {
1351
+ pausedRevision: paused.revision,
1352
+ outcome: "pending",
1353
+ turnSettled: false,
1354
+ };
1355
+ } catch (error) {
1356
+ const current = runtime.state;
1357
+ const reason = safeTerminalText(`automatic compaction pause could not be saved: ${error instanceof Error ? error.message : String(error)}`);
1358
+ runtime.state = current ? {
1359
+ ...stopGoalClock(current, Date.now()),
1360
+ status: "paused",
1361
+ result: reason,
1362
+ ...(current.blockerAudit === undefined ? {} : { blockerAudit: current.blockerAudit }),
1363
+ } : undefined;
1364
+ syncGoalUpdateTool(pi, runtime);
1365
+ runtime.persistenceRetryNeeded = true;
1366
+ runtime.continuationScheduled = false;
1367
+ runtime.automaticCompaction = undefined;
1368
+ runtime.requestRender?.();
1369
+ throw error;
1370
+ }
1162
1371
  },
1163
1372
  onCompleted: (ctx: ExtensionContext): void => completeAutomaticCompaction(pi, runtime, initState, ctx),
1164
1373
  onFailed: (ctx: ExtensionContext, error: unknown): void => failAutomaticCompaction(pi, runtime, ctx, error),
1374
+ onSkipped: (ctx: ExtensionContext): void => skipAutomaticCompaction(pi, runtime, initState, ctx),
1165
1375
  };
1166
1376
  }