@matthewfl/pi-contemplator 0.1.1 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matthewfl/pi-contemplator",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A Pi extension that keeps long-running agentic sessions on track with background memory, contemplation, and structural review.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -5,7 +5,7 @@ import { streamSimple } from "@earendil-works/pi-ai/compat";
5
5
  import { generateSummaryWithUsage } from "@earendil-works/pi-coding-agent";
6
6
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
7
7
  import { Box, Text } from "@earendil-works/pi-tui";
8
- import { agentActiveTimeMs, assistantOutputTokens, assistantToolCallCount, fullProjection, isReviewRequestEntry, isReviewResultEntry, OM_AGENT_ACTIVITY, OM_REVIEWER_MESSAGE, OM_REVIEWER_NOTICE, OM_REVIEWER_STATE, OM_REVIEW_REQUEST, OM_REVIEW_RESULT, recallMemorySources, type Entry, type ReviewResult, type StructuralReviewRequest } from "../../session-ledger/index.js";
8
+ import { agentActiveTimeMs, assistantOutputTokens, assistantToolCallCount, fullProjection, isReviewRequestEntry, isReviewResultEntry, OM_AGENT_ACTIVITY, OM_REVIEWER_MESSAGE, OM_REVIEWER_NOTICE, OM_REVIEWER_STATE, OM_REVIEW_REQUEST, OM_REVIEW_RESULT, rawTokensSinceObservationCoverage, recallMemorySources, type Entry, type ReviewResult, type StructuralReviewRequest } from "../../session-ledger/index.js";
9
9
  import { hashId } from "../../ids.js";
10
10
  import { createSearchMemoriesAgentTool } from "../../tools/search-memories.js";
11
11
  import { createRecallAgentTool } from "../../tools/recall-observation.js";
@@ -17,6 +17,7 @@ import { forceRequiredToolPayload, requiredToolChoice } from "../../required-too
17
17
  import { memoryReferenceIds } from "../../memory-citations.js";
18
18
  import { buildContemplatorSystemPrompt } from "./prompts.js";
19
19
  import { runStructuralReview } from "../reviewer/agent.js";
20
+ import { createWorkerStallWatchdog } from "../../worker-watchdog.js";
20
21
 
21
22
  interface PendingUpdate {
22
23
  observations: string[];
@@ -213,6 +214,10 @@ export class Contemplator {
213
214
  private history: AgentMessage[] = [];
214
215
  private pending: PendingUpdate | undefined;
215
216
  private running = false;
217
+ /** Invalidates stale/hard-timed-out flush finalizers across session changes. */
218
+ private flushEpoch = 0;
219
+ /** Bounds retries of one poisoned memory update so future updates can run. */
220
+ private consecutiveFlushFailures = 0;
216
221
  private seenObservationIds = new Set<string>();
217
222
  private seenSummaryIds = new Set<string>();
218
223
  private seenReviewIds = new Set<string>();
@@ -263,6 +268,9 @@ export class Contemplator {
263
268
  });
264
269
  this.pi.on("session_start", (event: any, ctx: ExtensionContext) => {
265
270
  this.sessionGeneration++;
271
+ this.flushEpoch++;
272
+ this.running = false;
273
+ this.consecutiveFlushFailures = 0;
266
274
  const generation = this.sessionGeneration;
267
275
  this.agentActiveSince = undefined;
268
276
  // AgentSession preserves its steering queue across extension reloads. An
@@ -278,12 +286,18 @@ export class Contemplator {
278
286
  });
279
287
  this.pi.on("session_tree", (_event: any, ctx: ExtensionContext) => {
280
288
  this.sessionGeneration++;
289
+ this.flushEpoch++;
290
+ this.running = false;
291
+ this.consecutiveFlushFailures = 0;
281
292
  this.agentActiveSince = undefined;
282
293
  // Pending steering messages remain queued while navigating the tree.
283
294
  this.restore(ctx, true, true);
284
295
  });
285
296
  this.pi.on("session_shutdown", () => {
286
297
  this.sessionGeneration++;
298
+ this.flushEpoch++;
299
+ this.running = false;
300
+ this.consecutiveFlushFailures = 0;
287
301
  this.agentActiveSince = undefined;
288
302
  this.history = [];
289
303
  this.pending = undefined;
@@ -555,6 +569,23 @@ export class Contemplator {
555
569
  return;
556
570
  }
557
571
  const branchEntries = ctx.sessionManager.getBranch() as Entry[];
572
+ const observerBacklogTokens = rawTokensSinceObservationCoverage(branchEntries);
573
+ if (
574
+ observerBacklogTokens >= this.runtime.config.observeAfterTokens &&
575
+ (this.runtime.consolidationInFlight || this.runtime.lastObserverError === undefined)
576
+ ) {
577
+ // A catch-up observer pipeline may append several partial batches while it
578
+ // drains old source. Do not let primary-agent checkpoints feed those
579
+ // fragments to the contemplator one at a time. The pipeline emits one
580
+ // memory update after the backlog falls below the observer trigger.
581
+ this.publishState("observer");
582
+ debugLog("contemplator.waiting", {
583
+ reason: "observer_backlog",
584
+ observerBacklogTokens,
585
+ observeAfterTokens: this.runtime.config.observeAfterTokens,
586
+ });
587
+ return;
588
+ }
558
589
  const projection = fullProjection(branchEntries);
559
590
  const observations = projection.observations.map((item) => `[${item.id}] ${item.content}`);
560
591
  const summaries = projection.summaries.map((item) => `[${item.id}] ${item.content}`);
@@ -637,6 +668,7 @@ export class Contemplator {
637
668
  this.pending = undefined;
638
669
  const turnsBeforeRun = this.turnsSinceRun;
639
670
  const sessionGeneration = this.sessionGeneration;
671
+ const flushEpoch = ++this.flushEpoch;
640
672
  this.running = true;
641
673
  this.turnsSinceRun = 0;
642
674
  const startedAt = Date.now();
@@ -644,6 +676,7 @@ export class Contemplator {
644
676
  let failureMessage: string | undefined;
645
677
  let workerNotified = false;
646
678
  let promptPersisted = false;
679
+ let workerWatchdog: ReturnType<typeof createWorkerStallWatchdog> | undefined;
647
680
  this.publishState("running", { lastStartedAt: startedAt, lastError: undefined });
648
681
  debugLog("contemplator.start", {
649
682
  newObservationCount: update.observations.length,
@@ -664,16 +697,22 @@ export class Contemplator {
664
697
  failureMessage = resolved.reason;
665
698
  debugLog("contemplator.model_unavailable", { reason: resolved.reason });
666
699
  if (sessionGeneration === this.sessionGeneration) {
667
- const pending = this.pending as PendingUpdate | undefined;
668
- this.pending = {
669
- observations: mergeMemoryLines(pending?.observations ?? [], update.observations),
670
- summaries: mergeMemoryLines(pending?.summaries ?? [], update.summaries),
671
- reviews: mergeMemoryLines(pending?.reviews ?? [], update.reviews),
672
- mainAgentOutputTokens: update.mainAgentOutputTokens,
673
- mainAgentToolCalls: update.mainAgentToolCalls,
674
- mainAgentActiveTimeMs: update.mainAgentActiveTimeMs,
675
- };
676
- this.turnsSinceRun = turnsBeforeRun;
700
+ this.consecutiveFlushFailures++;
701
+ if (this.consecutiveFlushFailures < 2) {
702
+ const pending = this.pending as PendingUpdate | undefined;
703
+ this.pending = {
704
+ observations: mergeMemoryLines(pending?.observations ?? [], update.observations),
705
+ summaries: mergeMemoryLines(pending?.summaries ?? [], update.summaries),
706
+ reviews: mergeMemoryLines(pending?.reviews ?? [], update.reviews),
707
+ mainAgentOutputTokens: update.mainAgentOutputTokens,
708
+ mainAgentToolCalls: update.mainAgentToolCalls,
709
+ mainAgentActiveTimeMs: update.mainAgentActiveTimeMs,
710
+ };
711
+ } else {
712
+ debugLog("contemplator.poisoned_update_released", { reason: resolved.reason, observationCount: update.observations.length, summaryCount: update.summaries.length, reviewCount: update.reviews.length });
713
+ this.consecutiveFlushFailures = 0;
714
+ }
715
+ this.turnsSinceRun = 0; // Back off until fresh primary responses arrive; never retry every checkpoint.
677
716
  }
678
717
  return;
679
718
  }
@@ -687,6 +726,7 @@ export class Contemplator {
687
726
  modelId: selectedModel.id,
688
727
  contextWindow: selectedModel.contextWindow,
689
728
  });
729
+ workerWatchdog = createWorkerStallWatchdog("contemplator");
690
730
  if (this.runtime.config.showWorkerNotifications && ctx.hasUI) {
691
731
  ctx.ui?.notify("pi-contemplator: contemplator running", "info");
692
732
  workerNotified = true;
@@ -774,9 +814,15 @@ export class Contemplator {
774
814
  // while individual provider APIs also support required/any. Preserve the
775
815
  // runtime hint and final-payload enforcement without weakening base types.
776
816
  if (invocation > 1) (invocationConfig as any).toolChoice = requiredToolChoice(api);
777
- const stream = agentLoop([nextPrompt], context, invocationConfig, undefined, streamSimple);
778
- for await (const event of stream) logAgentStreamError("contemplator", event);
779
- const result = await stream.result();
817
+ workerWatchdog.progress();
818
+ const stream = agentLoop([nextPrompt], context, invocationConfig, workerWatchdog.signal, streamSimple);
819
+ const result = await workerWatchdog.race((async () => {
820
+ for await (const event of stream) {
821
+ workerWatchdog!.progress();
822
+ logAgentStreamError("contemplator", event);
823
+ }
824
+ return stream.result();
825
+ })());
780
826
  // agentLoop returns its input prompt as the first new message. We already
781
827
  // added nextPrompt above, so do not duplicate each update in the durable
782
828
  // contemplator history or in a subsequent retry's context.
@@ -840,25 +886,37 @@ export class Contemplator {
840
886
  });
841
887
  }
842
888
  }
843
- if (sessionGeneration === this.sessionGeneration) await this.compactHistory(resolved.model as Model<any>, resolved.apiKey, resolved.headers, sessionGeneration);
889
+ if (sessionGeneration === this.sessionGeneration) {
890
+ workerWatchdog.progress();
891
+ await workerWatchdog.race(this.compactHistory(resolved.model as Model<any>, resolved.apiKey, resolved.headers, sessionGeneration, flushEpoch));
892
+ }
844
893
  } catch (error) {
845
894
  failed = true;
846
895
  failureMessage = error instanceof Error ? error.message : String(error);
847
896
  debugLog("contemplator.error", { errorMessage: failureMessage });
848
897
  if (sessionGeneration === this.sessionGeneration && !promptPersisted) {
849
- const pending = this.pending as PendingUpdate | undefined;
850
- this.pending = {
851
- observations: mergeMemoryLines(pending?.observations ?? [], update.observations),
852
- summaries: mergeMemoryLines(pending?.summaries ?? [], update.summaries),
853
- reviews: mergeMemoryLines(pending?.reviews ?? [], update.reviews),
854
- mainAgentOutputTokens: update.mainAgentOutputTokens,
855
- mainAgentToolCalls: update.mainAgentToolCalls,
856
- mainAgentActiveTimeMs: update.mainAgentActiveTimeMs,
857
- };
858
- this.turnsSinceRun = turnsBeforeRun;
898
+ this.consecutiveFlushFailures++;
899
+ if (this.consecutiveFlushFailures < 2) {
900
+ const pending = this.pending as PendingUpdate | undefined;
901
+ this.pending = {
902
+ observations: mergeMemoryLines(pending?.observations ?? [], update.observations),
903
+ summaries: mergeMemoryLines(pending?.summaries ?? [], update.summaries),
904
+ reviews: mergeMemoryLines(pending?.reviews ?? [], update.reviews),
905
+ mainAgentOutputTokens: update.mainAgentOutputTokens,
906
+ mainAgentToolCalls: update.mainAgentToolCalls,
907
+ mainAgentActiveTimeMs: update.mainAgentActiveTimeMs,
908
+ };
909
+ } else {
910
+ debugLog("contemplator.poisoned_update_released", { reason: failureMessage, observationCount: update.observations.length, summaryCount: update.summaries.length, reviewCount: update.reviews.length });
911
+ this.consecutiveFlushFailures = 0;
912
+ }
913
+ this.turnsSinceRun = 0; // Back off until fresh primary responses arrive; never retry every checkpoint.
859
914
  }
860
915
  } finally {
916
+ workerWatchdog?.dispose();
917
+ if (flushEpoch !== this.flushEpoch) return;
861
918
  this.running = false;
919
+ if (!failed) this.consecutiveFlushFailures = 0;
862
920
  const pendingHasEnoughMemories = this.pending !== undefined && (
863
921
  this.pending.reviews.length > 0 ||
864
922
  this.pending.observations.length >= this.runtime.config.contemplatorMinNewObservations ||
@@ -951,15 +1009,20 @@ export class Contemplator {
951
1009
  const session = this.reviewerSessions.get(request.id) ?? { scope: request.scope, history: options.history ?? [], messageEntryIds: [], foldedEntryIds: new Set() };
952
1010
  this.reviewerSessions.set(request.id, session);
953
1011
  const task = this.runtime.launchReviewTask(ctx, async () => {
1012
+ const watchdog = createWorkerStallWatchdog("structural reviewer");
1013
+ let acceptsMessages = true;
954
1014
  try {
955
1015
  debugLog("reviewer.started", { reviewRequestId: request.id, scope: request.scope, resumed: session.history.length > 0 });
956
- const result = await runStructuralReview({
1016
+ const result = await watchdog.race(runStructuralReview({
957
1017
  request, model, apiKey, headers,
1018
+ signal: watchdog.signal,
1019
+ onProgress: watchdog.progress,
958
1020
  getBranch: () => ctx.sessionManager.getBranch() as Entry[],
959
1021
  recordUsage: (usage) => this.runtime.recordAgentUsage(usage),
960
1022
  history: session.history,
961
1023
  onMessages: (messages) => {
962
- if (sessionGeneration !== this.sessionGeneration || !this.reviewIsPending(ctx, request.id)) return;
1024
+ watchdog.progress();
1025
+ if (!acceptsMessages || sessionGeneration !== this.sessionGeneration || !this.reviewIsPending(ctx, request.id)) return;
963
1026
  for (const message of messages) {
964
1027
  session.history.push(message);
965
1028
  const entryId = this.appendEntryWithId(ctx, OM_REVIEWER_MESSAGE, { version: 1, reviewRequestId: request.id, scope: request.scope, message }, request.id);
@@ -969,7 +1032,7 @@ export class Contemplator {
969
1032
  }
970
1033
  }
971
1034
  },
972
- });
1035
+ }));
973
1036
  if (sessionGeneration !== this.sessionGeneration) {
974
1037
  debugLog("reviewer.failed", { reviewRequestId: request.id, reason: "session_changed" });
975
1038
  return;
@@ -997,6 +1060,8 @@ export class Contemplator {
997
1060
  }
998
1061
  this.runtime.notifyMemoryUpdate(ctx);
999
1062
  } finally {
1063
+ acceptsMessages = false;
1064
+ watchdog.dispose();
1000
1065
  this.inFlightReviewIds.delete(request.id);
1001
1066
  if (key) this.inFlightReviewKeys.delete(key);
1002
1067
  }
@@ -1080,7 +1145,7 @@ export class Contemplator {
1080
1145
  return own?.id;
1081
1146
  }
1082
1147
 
1083
- private async compactHistory(model: Model<any>, apiKey: string, headers: Record<string, string> | undefined, sessionGeneration: number): Promise<void> {
1148
+ private async compactHistory(model: Model<any>, apiKey: string, headers: Record<string, string> | undefined, sessionGeneration: number, flushEpoch: number): Promise<void> {
1084
1149
  const serializedLength = this.history.reduce((total, message) => total + JSON.stringify(message).length, 0);
1085
1150
  if (this.history.length < 12 || serializedLength < 60_000) return;
1086
1151
  const previousMessageCount = this.history.length;
@@ -1091,7 +1156,7 @@ export class Contemplator {
1091
1156
  const history = this.history.slice();
1092
1157
  const summaryWithUsage = await generateSummaryWithUsage(history as AgentMessage[], model, 4_000, apiKey, headers);
1093
1158
  this.runtime.recordAgentUsage(summaryWithUsage.usage);
1094
- if (sessionGeneration !== this.sessionGeneration) {
1159
+ if (sessionGeneration !== this.sessionGeneration || flushEpoch !== this.flushEpoch) {
1095
1160
  debugLog("contemplator.compaction_stale", { reason: "session_or_branch_changed" });
1096
1161
  return;
1097
1162
  }
@@ -5,7 +5,7 @@ import { streamSimple } from "@earendil-works/pi-ai/compat";
5
5
  import type { Static } from "typebox";
6
6
  import { hashId } from "../../ids.js";
7
7
  import { logAgentStreamError } from "../stream-errors.js";
8
- import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
8
+ import { OBSERVER_AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
9
9
  import { OBSERVER_SYSTEM } from "./prompts.js";
10
10
  import { nowTimestamp, truncateRecordContent } from "../../serialize.js";
11
11
  import type { Observation, Relevance, Retention } from "../../session-ledger/index.js";
@@ -25,6 +25,7 @@ interface RunObserverArgs {
25
25
  maxTurns?: number;
26
26
  thinkingLevel?: ModelThinkingLevel;
27
27
  recordUsage?: (usage: LlmUsageInput) => void;
28
+ onProgress?: () => void;
28
29
  }
29
30
 
30
31
  const RelevanceSchema = Type.Union([
@@ -182,7 +183,11 @@ ${joinOrEmpty(priorObservations)}
182
183
  Compress the following new conversation chunk into observations by calling record_observations one or more times. Do not restate facts already present in current summaries or current observations. Prefer inline conversation timestamps when assigning times; fall back to the current local time above only if no message timestamp applies. When the chunk is fully covered, call done alone. If the chunk contains no useful new information, call done without calling record_observations.
183
184
 
184
185
  NEW CONVERSATION CHUNK:
185
- ${conversation}`;
186
+ ${conversation}
187
+
188
+ END NEW CONVERSATION CHUNK
189
+
190
+ IMPORTANT: Now call record_observations to record the useful new observations from this conversation chunk. When the chunk is covered, call done; if there are no useful observations, call done without recording any.`;
186
191
 
187
192
  const initialPrompt: Message = {
188
193
  role: "user",
@@ -198,7 +203,7 @@ ${conversation}`;
198
203
  model,
199
204
  apiKey,
200
205
  headers,
201
- maxTokens: boundedMaxTokens(model, AGENT_LOOP_MAX_TOKENS),
206
+ maxTokens: boundedMaxTokens(model, OBSERVER_AGENT_LOOP_MAX_TOKENS),
202
207
  convertToLlm: (msgs) => msgs as Message[],
203
208
  toolExecution: "sequential",
204
209
  shouldStopAfterTurn: () => {
@@ -219,6 +224,7 @@ ${conversation}`;
219
224
  };
220
225
  const stream = loop([prompt], context, baseConfig, signal, streamSimple);
221
226
  for await (const event of stream) {
227
+ args.onProgress?.();
222
228
  logAgentStreamError("observer", event);
223
229
  const message = (event as { message?: { role?: string; stopReason?: string; errorMessage?: string } }).message;
224
230
  if (message?.role === "assistant" && ["error", "aborted", "length"].includes(message.stopReason ?? "")) {
@@ -246,19 +252,16 @@ ${conversation}`;
246
252
  await runInvocation(reminder);
247
253
  }
248
254
 
249
- // Accepted observations remain useful even if the model neglected the final
250
- // confirmation. Zero-observation coverage is advanced only by an explicit
251
- // done call; failures, truncation, malformed records, and repeated prose do
252
- // not silently discard the source chunk.
255
+ // `done` is a behavioral aid and terminal shortcut, not a transaction gate.
256
+ // Accepted observations commit even if it was omitted. A second prose-only
257
+ // zero-observation stop is also a valid empty result after the reminder;
258
+ // actual stream failures, truncation, and malformed records still throw.
253
259
  if (accumulated.size === 0 && terminalFailure) {
254
260
  throw new ObserverStreamError(terminalFailure.stopReason, terminalFailure.errorMessage);
255
261
  }
256
262
  if (accumulated.size === 0 && rejectedTotal > 0) {
257
263
  throw new ObserverStreamError("invalid_observations", `${rejectedTotal} proposed observation${rejectedTotal === 1 ? " was" : "s were"} rejected`);
258
264
  }
259
- if (accumulated.size === 0 && !doneCalled) {
260
- throw new ObserverStreamError("incomplete", "observer stopped twice without recording observations or calling done");
261
- }
262
265
  if (accumulated.size === 0) return undefined;
263
266
  return Array.from(accumulated.values());
264
267
  }
@@ -30,6 +30,7 @@ export interface RunStructuralReviewArgs {
30
30
  recordUsage?: (usage: LlmUsageInput) => void;
31
31
  /** Receives the reviewer's assistant output for durable debug/view rendering. */
32
32
  onMessages?: (messages: AgentMessage[]) => void;
33
+ onProgress?: () => void;
33
34
  /** Previously persisted reviewer transcript; a non-empty history resumes work. */
34
35
  history?: AgentMessage[];
35
36
  }
@@ -177,7 +178,10 @@ export async function runStructuralReview(args: RunStructuralReviewArgs): Promis
177
178
  history.push(promptMessage);
178
179
  args.onMessages?.([promptMessage]);
179
180
  const stream = loop([prompt], context, config, args.signal, budgetedStreamSimple);
180
- for await (const event of stream) logAgentStreamError("reviewer", event);
181
+ for await (const event of stream) {
182
+ args.onProgress?.();
183
+ logAgentStreamError("reviewer", event);
184
+ }
181
185
  const newMessages = await stream.result();
182
186
  history.push(...newMessages);
183
187
  args.onMessages?.(newMessages);
@@ -63,11 +63,13 @@ function liveStateLine(state: ContemplatorRunState): string {
63
63
  const timing = `Last start: ${state.lastStartedAt === undefined ? "not run this launch" : new Date(state.lastStartedAt).toISOString()} · Last end: ${state.lastCompletedAt === undefined ? "not completed this launch" : new Date(state.lastCompletedAt).toISOString()}`;
64
64
  const error = state.lastError ? `\nLast error: ${state.lastError}` : "";
65
65
  if (state.running) return `LIVE · running for ${Math.max(0, Math.floor((Date.now() - (state.lastStartedAt ?? Date.now())) / 60_000))}m · ${pending}\n${timing}${error}`;
66
- const reason = state.waitingFor === "memories"
67
- ? "waiting for memory threshold"
68
- : state.waitingFor === "responses"
69
- ? "waiting for response spacing"
70
- : state.waitingFor === "ready"
66
+ const reason = state.waitingFor === "observer"
67
+ ? "waiting for observer backlog"
68
+ : state.waitingFor === "memories"
69
+ ? "waiting for memory threshold"
70
+ : state.waitingFor === "responses"
71
+ ? "waiting for response spacing"
72
+ : state.waitingFor === "ready"
71
73
  ? "ready to launch"
72
74
  : state.waitingFor === "disabled"
73
75
  ? "disabled"
@@ -42,6 +42,7 @@ function formatRunAge(timestamp: number): string {
42
42
 
43
43
  function contemplatorWaitingLabel(waitingFor: Runtime["contemplatorState"]["waitingFor"]): string {
44
44
  switch (waitingFor) {
45
+ case "observer": return "waiting for observer backlog";
45
46
  case "memories": return "waiting for memory threshold";
46
47
  case "responses": return "waiting for response spacing";
47
48
  case "ready": return "ready to launch";
@@ -9,6 +9,7 @@ import {
9
9
  } from "./compaction-resume.js";
10
10
 
11
11
  const COMPACTION_STATUS_KEY = "observational-memory-compaction";
12
+ export const COMPACTION_CALLBACK_TIMEOUT_MS = 30 * 60_000;
12
13
  type CompactionOrigin = "agent-requested" | "length-stop" | "proactive";
13
14
 
14
15
  type TriggerOptions = {
@@ -37,6 +38,7 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
37
38
  const { origin, resume, threshold, shortContinuationPrompt } = options;
38
39
  const hasUI = ctx.hasUI;
39
40
  const ui = ctx.ui;
41
+ const generation = runtime.getContextGeneration?.() ?? 0;
40
42
 
41
43
  runtime.compactInFlight = true;
42
44
  runtime.compactOrigin = origin;
@@ -74,23 +76,39 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
74
76
  );
75
77
  }
76
78
  if (origin === "agent-requested") runtime.compactContinuationPrompt = undefined;
77
- ctx.compact({
78
- onComplete: () => {
79
- runtime.compactInFlight = false;
80
- runtime.compactOrigin = undefined;
81
- if (hasUI && !resume) ui?.setStatus?.(COMPACTION_STATUS_KEY, undefined);
82
- if (resume) resumeAfterCompaction(pi, runtime, { hasUI, ui }, false, shortContinuationPrompt);
83
- },
84
- onError: (error: { message: string }) => {
85
- runtime.compactInFlight = false;
86
- runtime.compactOrigin = undefined;
87
- if (hasUI) ui?.setStatus?.(COMPACTION_STATUS_KEY, undefined);
88
- if (error.message !== "Compaction cancelled" && hasUI) {
89
- ui?.notify(`pi-contemplator: ${error.message}`, "error");
90
- }
91
- if (resume) resumeAfterCompaction(pi, runtime, { hasUI, ui }, true, shortContinuationPrompt);
92
- },
93
- });
79
+ let settled = false;
80
+ const callbackTimeout = setTimeout(() => {
81
+ if (settled || generation !== (runtime.getContextGeneration?.() ?? 0)) return;
82
+ settled = true;
83
+ runtime.compactInFlight = false;
84
+ runtime.compactOrigin = undefined;
85
+ if (hasUI) {
86
+ ui?.setStatus?.(COMPACTION_STATUS_KEY, undefined);
87
+ ui?.notify("pi-contemplator: compaction callback timed out; releasing the compaction lock", "error");
88
+ }
89
+ if (resume) resumeAfterCompaction(pi, runtime, { hasUI, ui }, true, shortContinuationPrompt);
90
+ }, COMPACTION_CALLBACK_TIMEOUT_MS);
91
+ (callbackTimeout as ReturnType<typeof setTimeout> & { unref?: () => void }).unref?.();
92
+ const settle = (failed: boolean, error?: { message: string }) => {
93
+ if (settled || generation !== (runtime.getContextGeneration?.() ?? 0)) return;
94
+ settled = true;
95
+ clearTimeout(callbackTimeout);
96
+ runtime.compactInFlight = false;
97
+ runtime.compactOrigin = undefined;
98
+ if (hasUI && (failed || !resume)) ui?.setStatus?.(COMPACTION_STATUS_KEY, undefined);
99
+ if (failed && error?.message !== "Compaction cancelled" && hasUI) ui?.notify(`pi-contemplator: ${error?.message ?? "compaction failed"}`, "error");
100
+ if (resume) resumeAfterCompaction(pi, runtime, { hasUI, ui }, failed, shortContinuationPrompt);
101
+ };
102
+ try {
103
+ ctx.compact({
104
+ onComplete: () => settle(false),
105
+ onError: (error: { message: string }) => settle(true, error),
106
+ });
107
+ } catch (error) {
108
+ settled = true;
109
+ clearTimeout(callbackTimeout);
110
+ throw error;
111
+ }
94
112
  } catch (error) {
95
113
  runtime.compactInFlight = false;
96
114
  if (origin === "agent-requested") runtime.compactContinuationPrompt = undefined;
@@ -4,6 +4,7 @@ import { runObserver } from "../agents/observer/agent.js";
4
4
  import { debugLog, withDebugLogContext } from "../debug-log.js";
5
5
  import { resolveObserverChunkMaxTokens } from "../config.js";
6
6
  import type { ResolveResult, Runtime } from "../runtime.js";
7
+ import { createWorkerStallWatchdog } from "../worker-watchdog.js";
7
8
  import { serializeSourceAddressedBranchEntries } from "../serialize.js";
8
9
  import {
9
10
  OM_SUMMARIZER_COMMIT,
@@ -43,28 +44,10 @@ export const SUMMARIZER_STALL_TIMEOUT_MS = 15 * 60_000;
43
44
  export function createSummarizerStallWatchdog(
44
45
  timeoutMs: number,
45
46
  onStall: (signal: AbortSignal) => void,
46
- ): { signal: AbortSignal; progress: () => void; dispose: () => void } {
47
- const controller = new AbortController();
48
- let timer: ReturnType<typeof setTimeout> | undefined;
49
- const progress = () => {
50
- if (controller.signal.aborted) return;
51
- if (timer !== undefined) clearTimeout(timer);
52
- timer = setTimeout(() => {
53
- timer = undefined;
54
- controller.abort(new Error(`summarizer produced no progress for ${Math.round(timeoutMs / 60_000)} minutes`));
55
- onStall(controller.signal);
56
- }, timeoutMs);
57
- (timer as ReturnType<typeof setTimeout> & { unref?: () => void }).unref?.();
58
- };
59
- progress();
60
- return {
61
- signal: controller.signal,
62
- progress,
63
- dispose: () => {
64
- if (timer !== undefined) clearTimeout(timer);
65
- timer = undefined;
66
- },
67
- };
47
+ ) {
48
+ let watchdog!: ReturnType<typeof createWorkerStallWatchdog>;
49
+ watchdog = createWorkerStallWatchdog("summarizer", timeoutMs, () => onStall(watchdog.signal));
50
+ return watchdog;
68
51
  }
69
52
 
70
53
  function sourceEntriesAfter(entries: Entry[], index: number): Entry[] {
@@ -96,6 +79,7 @@ function makeModelResolver(runtime: Runtime, ctx: ConsolidationCtx): (stage: "ob
96
79
  runtime.resolveFailureNotified = false;
97
80
  return cached;
98
81
  }
82
+ runtime.lastObserverError = cached.reason;
99
83
  debugLog(`${stage}.model_unavailable`, { reason: cached.reason });
100
84
  if (!runtime.resolveFailureNotified && ctx.hasUI && ctx.ui) {
101
85
  ctx.ui.notify(`pi-contemplator: ${stage} skipped — ${cached.reason}`, "warning");
@@ -109,6 +93,14 @@ export function registerConsolidationTrigger(pi: ExtensionAPI, runtime: Runtime)
109
93
  const launch = (_event: unknown, ctx: ConsolidationCtx) => {
110
94
  maybeLaunchConsolidation(pi, runtime, ctx);
111
95
  };
96
+ pi.on("session_start", (event, ctx) => {
97
+ launch(event, ctx);
98
+ syncAndScheduleSummarizer(pi, runtime, ctx as ConsolidationCtx);
99
+ });
100
+ pi.on("session_tree", (event, ctx) => {
101
+ launch(event, ctx);
102
+ syncAndScheduleSummarizer(pi, runtime, ctx as ConsolidationCtx);
103
+ });
112
104
  pi.on("agent_start", (event, ctx) => {
113
105
  launch(event, ctx);
114
106
  syncAndScheduleSummarizer(pi, runtime, ctx as ConsolidationCtx);
@@ -119,8 +111,10 @@ export function registerConsolidationTrigger(pi: ExtensionAPI, runtime: Runtime)
119
111
  });
120
112
  runtime.setAgentActivityListener((ctx) => {
121
113
  runtime.ensureConfig(ctx.cwd);
122
- // Token pools are re-evaluated at every primary-agent progress checkpoint;
123
- // idle wall-clock time has no scheduling meaning.
114
+ // Long autonomous turns may run for hours without agent_start/turn_end.
115
+ // Re-evaluate both source coverage and memory pressure at every primary
116
+ // progress checkpoint so neither worker can silently stop for the turn.
117
+ maybeLaunchConsolidation(pi, runtime, ctx as ConsolidationCtx);
124
118
  scheduleSummarizer(pi, runtime, ctx as ConsolidationCtx);
125
119
  });
126
120
  runtime.setSettingsUpdateListener((ctx, settings) => {
@@ -166,7 +160,7 @@ function maybeLaunchConsolidation(pi: ExtensionAPI, runtime: Runtime, ctx: Conso
166
160
  };
167
161
 
168
162
  const sessionMetadata = debugSessionMetadata(ctx);
169
- void runtime.launchConsolidationTask(ctx, async () => withDebugLogContext({
163
+ const task = runtime.launchConsolidationTask(ctx, async () => withDebugLogContext({
170
164
  enabled: runtime.config.debugLog === true,
171
165
  cwd: ctx.cwd,
172
166
  ...sessionMetadata,
@@ -174,6 +168,13 @@ function maybeLaunchConsolidation(pi: ExtensionAPI, runtime: Runtime, ctx: Conso
174
168
  }, async () => {
175
169
  await runConsolidationPipeline(pi, runtime, consolidationCtx);
176
170
  }));
171
+ // launchTrackedTask releases its lock before this continuation. If observer
172
+ // setup failed before coverage could move, wake the contemplator once in
173
+ // degraded mode rather than leaving all advisory work gated forever. Future
174
+ // primary activity still retries the observer.
175
+ void task.then(() => {
176
+ if (runtime.lastObserverError) runtime.notifyMemoryUpdate(ctx);
177
+ });
177
178
  }
178
179
 
179
180
  export function launchCompactionObserver(
@@ -314,7 +315,9 @@ export function scheduleSummarizer(pi: ExtensionAPI, runtime: Runtime, ctx: Cons
314
315
  }, async () => {
315
316
  let stalled = false;
316
317
  let successfullyCompleted = false;
318
+ let modelRunAttempted = false;
317
319
  let disposeStallWatchdog = () => {};
320
+ let acceptsSummarizerMessages = true;
318
321
  const startedAt = Date.now();
319
322
  runtime.lastSummarizerStartedAt = startedAt;
320
323
  runtime.lastSummarizerRun = { startedAt, status: "running", messages: [] };
@@ -334,7 +337,8 @@ export function scheduleSummarizer(pi: ExtensionAPI, runtime: Runtime, ctx: Cons
334
337
  if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(`pi-contemplator: ${reason}; cancelling and leaving the backlog eligible for retry`, "warning");
335
338
  });
336
339
  disposeStallWatchdog = watchdog.dispose;
337
- const result = await runSummarizer({
340
+ modelRunAttempted = true;
341
+ const result = await watchdog.race(runSummarizer({
338
342
  signal: watchdog.signal,
339
343
  model: resolved.model as any,
340
344
  apiKey: resolved.apiKey,
@@ -348,9 +352,9 @@ export function scheduleSummarizer(pi: ExtensionAPI, runtime: Runtime, ctx: Cons
348
352
  recordUsage: (usage) => runtime.recordAgentUsage(usage),
349
353
  onMessages: (messages) => {
350
354
  watchdog.progress();
351
- if (generation === runtime.getContextGeneration()) runtime.lastSummarizerRun = { startedAt, status: "running", messages: messages.slice() };
355
+ if (acceptsSummarizerMessages && generation === runtime.getContextGeneration()) runtime.lastSummarizerRun = { startedAt, status: "running", messages: messages.slice() };
352
356
  },
353
- });
357
+ }));
354
358
  if (generation !== runtime.getContextGeneration()) return;
355
359
  if (!result.completed) {
356
360
  runtime.lastSummarizerRun = stalled
@@ -377,6 +381,7 @@ export function scheduleSummarizer(pi: ExtensionAPI, runtime: Runtime, ctx: Cons
377
381
  }
378
382
  throw error;
379
383
  } finally {
384
+ acceptsSummarizerMessages = false;
380
385
  disposeStallWatchdog();
381
386
  if (generation === runtime.getContextGeneration()) {
382
387
  const completedAt = Date.now();
@@ -385,7 +390,11 @@ export function scheduleSummarizer(pi: ExtensionAPI, runtime: Runtime, ctx: Cons
385
390
  const postRunPools = currentMemoryPools(runtime, ctx.sessionManager.getBranch() as Entry[]);
386
391
  const target = runtime.config.oldMemoryPoolTargetTokens;
387
392
  runtime.summarizerNextTriggerTokens = summarizerTriggerAfterRun(
388
- successfullyCompleted,
393
+ // A failed/no-progress model pass must not be retried with the
394
+ // identical pool at every primary-agent checkpoint. Require fresh
395
+ // old-memory growth before trying another (potentially different)
396
+ // sample. Model-resolution failures spend no tokens and stay eligible.
397
+ successfullyCompleted || modelRunAttempted,
389
398
  runtime.summarizerNextTriggerTokens,
390
399
  target,
391
400
  postRunPools.oldTokens,
@@ -464,18 +473,41 @@ async function runObserverStage(
464
473
  priorObservations: priorObservations.length,
465
474
  });
466
475
 
467
- const observations = await runObserver({
468
- model: resolved.model as any,
469
- apiKey: resolved.apiKey,
470
- headers: resolved.headers,
471
- priorSummaries,
472
- priorObservations,
473
- chunk,
474
- allowedSourceEntryIds: sourceEntryIds,
475
- maxTurns: runtime.config.agentMaxTurns,
476
- thinkingLevel: runtime.config.model?.thinking ?? "low",
477
- recordUsage: (usage) => runtime.recordAgentUsage(usage),
478
- });
476
+ let observations;
477
+ let failedMessage: string | undefined;
478
+ const observerWatchdog = createWorkerStallWatchdog("observer");
479
+ try {
480
+ observations = await observerWatchdog.race(runObserver({
481
+ model: resolved.model as any,
482
+ apiKey: resolved.apiKey,
483
+ headers: resolved.headers,
484
+ priorSummaries,
485
+ priorObservations,
486
+ chunk,
487
+ allowedSourceEntryIds: sourceEntryIds,
488
+ maxTurns: runtime.config.agentMaxTurns,
489
+ thinkingLevel: runtime.config.model?.thinking ?? "low",
490
+ recordUsage: (usage) => runtime.recordAgentUsage(usage),
491
+ onProgress: observerWatchdog.progress,
492
+ signal: observerWatchdog.signal,
493
+ }));
494
+ } catch (error) {
495
+ // A permanently pathological old chunk must not pin every newer source
496
+ // entry and block the contemplator forever. Accepted observations are
497
+ // already returned normally by runObserver; only a zero-progress failure
498
+ // reaches here. Record the failure visibly, then advance this bounded
499
+ // range with an empty coverage marker so catch-up can continue.
500
+ failedMessage = runtime.recordConsolidationStageError(ctx, "observer", error);
501
+ debugLog("observer.failed_chunk_advanced", {
502
+ failedMessage,
503
+ coversUpToId,
504
+ sourceEntryIds,
505
+ chunkTokens,
506
+ });
507
+ observations = undefined;
508
+ } finally {
509
+ observerWatchdog.dispose();
510
+ }
479
511
  if (options.contextGeneration !== undefined && options.contextGeneration !== runtime.getContextGeneration()) {
480
512
  debugLog("observer.stale", { reason: "session_or_branch_changed" });
481
513
  return "abort";
@@ -497,18 +529,18 @@ async function runObserverStage(
497
529
  const accepted = observations ?? [];
498
530
  const data = buildObservationsRecordedData(accepted, effectiveCoversUpToId);
499
531
  if (!data) return "continue";
500
- debugLog(accepted.length > 0 ? "observer.records" : "observer.coverage_only", {
532
+ debugLog(failedMessage ? "observer.failed_coverage" : accepted.length > 0 ? "observer.records" : "observer.coverage_only", {
501
533
  count: accepted.length,
502
534
  observationTokens: accepted.reduce((sum, observation) => sum + observation.tokenCount, 0),
503
535
  coversUpToId: effectiveCoversUpToId,
536
+ ...(failedMessage ? { failedMessage } : {}),
504
537
  });
505
- // A clean zero-observation verdict is still successful coverage. Persist an
506
- // empty batch so the next bounded pass starts after this chunk instead of
507
- // retrying the same low-information source forever. Failures throw above and
508
- // therefore never reach this coverage commit.
538
+ // A clean zero-observation verdict and a zero-progress failed chunk both use
539
+ // an empty coverage marker. This prevents low-information or pathological old
540
+ // source from pinning the entire observer/contemplator pipeline forever.
509
541
  appendEntry(pi, OM_OBSERVATIONS_RECORDED, data);
510
542
  debugLog("observer.appended", { count: accepted.length, coversUpToId: effectiveCoversUpToId });
511
- if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(
543
+ if (!failedMessage && shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(
512
544
  accepted.length > 0
513
545
  ? `pi-contemplator: ${accepted.length} observation${accepted.length === 1 ? "" : "s"} recorded`
514
546
  : "pi-contemplator: observer found no new information; processed chunk marked covered",
@@ -2,6 +2,9 @@ import type { Model } from "@earendil-works/pi-ai";
2
2
 
3
3
  export const AGENT_LOOP_MAX_TOKENS = 32_000;
4
4
 
5
+ /** Observer output allowance for difficult, high-volume source chunks. */
6
+ export const OBSERVER_AGENT_LOOP_MAX_TOKENS = 160_000;
7
+
5
8
  /**
6
9
  * Lifetime output-token budget for one structural review request.
7
10
  * Persisted reviewer transcripts carry usage across keep-going iterations,
package/src/runtime.ts CHANGED
@@ -72,7 +72,7 @@ export interface ContemplatorRunState {
72
72
  pendingReviews: number;
73
73
  /** Completed primary-model responses since the previous contemplator run. */
74
74
  responsesSinceRun: number;
75
- waitingFor: "disabled" | "passive" | "memories" | "responses" | "ready" | "running" | "idle";
75
+ waitingFor: "disabled" | "passive" | "observer" | "memories" | "responses" | "ready" | "running" | "idle";
76
76
  lastStartedAt?: number;
77
77
  lastCompletedAt?: number;
78
78
  lastError?: string;
@@ -254,6 +254,16 @@ export class Runtime {
254
254
  // against a different branch and would otherwise leak across sessions
255
255
  // (e.g. a request made in session A compacting session B's branch, or a
256
256
  // never-cleared compactInFlight bricking all future compactions).
257
+ // Detach stale background tasks immediately. Their promise finalizers are
258
+ // identity-guarded below, so an old session can never retain or later clear
259
+ // a lock owned by the new session.
260
+ this.consolidationInFlight = false;
261
+ this.consolidationPromise = null;
262
+ this.consolidationPhase = undefined;
263
+ this.summarizerInFlight = false;
264
+ this.summarizerPromise = null;
265
+ this.reviewInFlight = false;
266
+ this.reviewPromise = null;
257
267
  this.compactInFlight = false;
258
268
  this.compactRequested = false;
259
269
  this.compactContinuationPrompt = undefined;
@@ -336,9 +346,10 @@ export class Runtime {
336
346
  this.consolidationPhase = undefined;
337
347
  this.lastObserverError = undefined;
338
348
  const promise = this.launchTrackedTask(ctx, "consolidation", work, () => {
349
+ if (this.consolidationPromise !== promise) return;
339
350
  this.consolidationInFlight = false;
340
351
  this.consolidationPhase = undefined;
341
- if (this.consolidationPromise === promise) this.consolidationPromise = null;
352
+ this.consolidationPromise = null;
342
353
  });
343
354
  this.consolidationPromise = promise;
344
355
  return promise;
@@ -351,9 +362,10 @@ export class Runtime {
351
362
  this.summarizerInFlight = true;
352
363
  this.lastSummarizerError = undefined;
353
364
  const promise = this.launchTrackedTask(ctx, "summarizer", work, (error) => {
365
+ if (this.summarizerPromise !== promise) return;
354
366
  this.summarizerInFlight = false;
355
367
  this.lastSummarizerError = error;
356
- if (this.summarizerPromise === promise) this.summarizerPromise = null;
368
+ this.summarizerPromise = null;
357
369
  });
358
370
  this.summarizerPromise = promise;
359
371
  return promise;
@@ -365,8 +377,9 @@ export class Runtime {
365
377
  if (this.reviewInFlight) return undefined;
366
378
  this.reviewInFlight = true;
367
379
  const promise = this.launchTrackedTask(ctx, "structural review", work, () => {
380
+ if (this.reviewPromise !== promise) return;
368
381
  this.reviewInFlight = false;
369
- if (this.reviewPromise === promise) this.reviewPromise = null;
382
+ this.reviewPromise = null;
370
383
  });
371
384
  this.reviewPromise = promise;
372
385
  return promise;
@@ -0,0 +1,63 @@
1
+ export const BACKGROUND_WORKER_STALL_TIMEOUT_MS = 15 * 60_000;
2
+ export const BACKGROUND_WORKER_MAX_RUNTIME_MS = 6 * 60 * 60_000;
3
+
4
+ /**
5
+ * No-progress watchdog for background model workers.
6
+ *
7
+ * Aborting the provider is the cooperative path. `race()` is the hard liveness
8
+ * boundary: even a provider that ignores AbortSignal cannot retain a worker's
9
+ * single-flight lock forever. The abandoned promise has handlers installed by
10
+ * Promise.race, so a later rejection is not unhandled.
11
+ */
12
+ export function createWorkerStallWatchdog(
13
+ label: string,
14
+ timeoutMs = BACKGROUND_WORKER_STALL_TIMEOUT_MS,
15
+ onStall?: (error: Error) => void,
16
+ maxRuntimeMs = BACKGROUND_WORKER_MAX_RUNTIME_MS,
17
+ ): {
18
+ signal: AbortSignal;
19
+ progress: () => void;
20
+ race: <T>(work: Promise<T>) => Promise<T>;
21
+ dispose: () => void;
22
+ } {
23
+ const controller = new AbortController();
24
+ let timer: ReturnType<typeof setTimeout> | undefined;
25
+ let maxRuntimeTimer: ReturnType<typeof setTimeout> | undefined;
26
+ let rejectStall!: (error: Error) => void;
27
+ let settled = false;
28
+ const stalled = new Promise<never>((_resolve, reject) => { rejectStall = reject; });
29
+ // Some callers use only the cooperative AbortSignal. Keep the hard-boundary
30
+ // promise observed even before/without a race() call.
31
+ void stalled.catch(() => {});
32
+ const trip = (error: Error) => {
33
+ if (settled || controller.signal.aborted) return;
34
+ controller.abort(error);
35
+ onStall?.(error);
36
+ rejectStall(error);
37
+ };
38
+ maxRuntimeTimer = setTimeout(() => trip(new Error(`${label} exceeded its ${Math.round(maxRuntimeMs / 3_600_000)}-hour runtime ceiling`)), maxRuntimeMs);
39
+ (maxRuntimeTimer as ReturnType<typeof setTimeout> & { unref?: () => void }).unref?.();
40
+ const progress = () => {
41
+ if (settled || controller.signal.aborted) return;
42
+ if (timer !== undefined) clearTimeout(timer);
43
+ timer = setTimeout(() => {
44
+ timer = undefined;
45
+ if (settled) return;
46
+ trip(new Error(`${label} produced no progress for ${Math.round(timeoutMs / 60_000)} minutes`));
47
+ }, timeoutMs);
48
+ (timer as ReturnType<typeof setTimeout> & { unref?: () => void }).unref?.();
49
+ };
50
+ progress();
51
+ return {
52
+ signal: controller.signal,
53
+ progress,
54
+ race: async <T>(work: Promise<T>) => Promise.race([work, stalled]),
55
+ dispose: () => {
56
+ settled = true;
57
+ if (timer !== undefined) clearTimeout(timer);
58
+ if (maxRuntimeTimer !== undefined) clearTimeout(maxRuntimeTimer);
59
+ timer = undefined;
60
+ maxRuntimeTimer = undefined;
61
+ },
62
+ };
63
+ }