@matthewfl/pi-contemplator 0.1.1 → 0.1.3

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.3",
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",
@@ -41,7 +41,7 @@
41
41
  "typecheck": "tsc --noEmit",
42
42
  "test": "npm run test:unit && npm run test:e2e",
43
43
  "test:unit": "vitest run",
44
- "test:e2e": "node tests/e2e/rpc-contemplator.mjs && node tests/e2e/rpc-summarizer.mjs && node tests/e2e/rpc-delivery.mjs && node tests/e2e/rpc-restore-review.mjs && node tests/e2e/rpc-compaction.mjs && node tests/e2e/rpc-compaction-resilience.mjs && node tests/e2e/rpc-memory-edges.mjs && node tests/e2e/rpc-routing-isolation.mjs"
44
+ "test:e2e": "node tests/e2e/rpc-contemplator.mjs && node tests/e2e/rpc-summarizer.mjs && node tests/e2e/rpc-delivery.mjs && node tests/e2e/rpc-restore-review.mjs && node tests/e2e/rpc-compaction.mjs && node tests/e2e/rpc-compaction-resilience.mjs && node tests/e2e/rpc-memory-edges.mjs && node tests/e2e/rpc-observer-length.mjs && node tests/e2e/rpc-routing-isolation.mjs"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "@earendil-works/pi-agent-core": "*",
@@ -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: () => {
@@ -211,14 +216,19 @@ ${conversation}`;
211
216
  const loop = args.agentLoop ?? agentLoop;
212
217
  const history: AgentMessage[] = [];
213
218
  let terminalFailure: { stopReason: string; errorMessage?: string } | undefined;
214
- const runInvocation = async (prompt: Message): Promise<void> => {
219
+ let lengthRetryAttempted = false;
220
+ const runInvocation = async (prompt: Message, afterLength = false): Promise<void> => {
215
221
  const context: AgentContext = {
216
222
  systemPrompt: OBSERVER_SYSTEM,
217
223
  messages: history.slice(),
218
224
  tools: [recordObservations as AgentTool<any>, doneTool],
219
225
  };
220
- const stream = loop([prompt], context, baseConfig, signal, streamSimple);
226
+ const invocationConfig: AgentLoopConfig = afterLength && reasoning
227
+ ? { ...baseConfig, reasoning: "minimal" }
228
+ : baseConfig;
229
+ const stream = loop([prompt], context, invocationConfig, signal, streamSimple);
221
230
  for await (const event of stream) {
231
+ args.onProgress?.();
222
232
  logAgentStreamError("observer", event);
223
233
  const message = (event as { message?: { role?: string; stopReason?: string; errorMessage?: string } }).message;
224
234
  if (message?.role === "assistant" && ["error", "aborted", "length"].includes(message.stopReason ?? "")) {
@@ -237,6 +247,23 @@ ${conversation}`;
237
247
  };
238
248
 
239
249
  await runInvocation(initialPrompt);
250
+ if (accumulated.size === 0 && terminalFailure?.stopReason === "length") {
251
+ lengthRetryAttempted = true;
252
+ // A provider can impose a lower output ceiling than the advertised model
253
+ // maximum. agentLoop stops on `length` when no tool call was completed; it
254
+ // does not automatically send a continuation request. Preserve the partial
255
+ // response so the model can continue from work it already performed rather
256
+ // than paying to reproduce it, then append a short tool-focused instruction
257
+ // and reduce reasoning to minimal. A second length stop fails forward at the
258
+ // bounded-chunk level.
259
+ terminalFailure = undefined;
260
+ const retryPrompt: Message = {
261
+ role: "user",
262
+ content: [{ type: "text", text: "IMPORTANT: The previous response reached the provider output limit before recording anything. Continue from the work already above and call record_observations now instead of spending another response budget analyzing." }],
263
+ timestamp: Date.now(),
264
+ };
265
+ await runInvocation(retryPrompt, true);
266
+ }
240
267
  if (accumulated.size === 0 && !doneCalled && !terminalFailure && rejectedTotal === 0) {
241
268
  const reminder: Message = {
242
269
  role: "user",
@@ -246,19 +273,19 @@ ${conversation}`;
246
273
  await runInvocation(reminder);
247
274
  }
248
275
 
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.
276
+ // `done` is a behavioral aid and terminal shortcut, not a transaction gate.
277
+ // Accepted observations commit even if it was omitted. A second prose-only
278
+ // zero-observation stop is also a valid empty result after the reminder;
279
+ // actual stream failures, truncation, and malformed records still throw.
253
280
  if (accumulated.size === 0 && terminalFailure) {
254
- throw new ObserverStreamError(terminalFailure.stopReason, terminalFailure.errorMessage);
281
+ const detail = terminalFailure.stopReason === "length" && lengthRetryAttempted
282
+ ? `provider reached the output limit twice without recording an observation (effective max output request: ${baseConfig.maxTokens} tokens)`
283
+ : terminalFailure.errorMessage;
284
+ throw new ObserverStreamError(terminalFailure.stopReason, detail);
255
285
  }
256
286
  if (accumulated.size === 0 && rejectedTotal > 0) {
257
287
  throw new ObserverStreamError("invalid_observations", `${rejectedTotal} proposed observation${rejectedTotal === 1 ? " was" : "s were"} rejected`);
258
288
  }
259
- if (accumulated.size === 0 && !doneCalled) {
260
- throw new ObserverStreamError("incomplete", "observer stopped twice without recording observations or calling done");
261
- }
262
289
  if (accumulated.size === 0) return undefined;
263
290
  return Array.from(accumulated.values());
264
291
  }
@@ -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,8 @@ 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";
8
+ import { boundedMaxTokens, OBSERVER_AGENT_LOOP_MAX_TOKENS } from "../model-budget.js";
7
9
  import { serializeSourceAddressedBranchEntries } from "../serialize.js";
8
10
  import {
9
11
  OM_SUMMARIZER_COMMIT,
@@ -43,28 +45,10 @@ export const SUMMARIZER_STALL_TIMEOUT_MS = 15 * 60_000;
43
45
  export function createSummarizerStallWatchdog(
44
46
  timeoutMs: number,
45
47
  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
- };
48
+ ) {
49
+ let watchdog!: ReturnType<typeof createWorkerStallWatchdog>;
50
+ watchdog = createWorkerStallWatchdog("summarizer", timeoutMs, () => onStall(watchdog.signal));
51
+ return watchdog;
68
52
  }
69
53
 
70
54
  function sourceEntriesAfter(entries: Entry[], index: number): Entry[] {
@@ -96,6 +80,7 @@ function makeModelResolver(runtime: Runtime, ctx: ConsolidationCtx): (stage: "ob
96
80
  runtime.resolveFailureNotified = false;
97
81
  return cached;
98
82
  }
83
+ runtime.lastObserverError = cached.reason;
99
84
  debugLog(`${stage}.model_unavailable`, { reason: cached.reason });
100
85
  if (!runtime.resolveFailureNotified && ctx.hasUI && ctx.ui) {
101
86
  ctx.ui.notify(`pi-contemplator: ${stage} skipped — ${cached.reason}`, "warning");
@@ -109,6 +94,14 @@ export function registerConsolidationTrigger(pi: ExtensionAPI, runtime: Runtime)
109
94
  const launch = (_event: unknown, ctx: ConsolidationCtx) => {
110
95
  maybeLaunchConsolidation(pi, runtime, ctx);
111
96
  };
97
+ pi.on("session_start", (event, ctx) => {
98
+ launch(event, ctx);
99
+ syncAndScheduleSummarizer(pi, runtime, ctx as ConsolidationCtx);
100
+ });
101
+ pi.on("session_tree", (event, ctx) => {
102
+ launch(event, ctx);
103
+ syncAndScheduleSummarizer(pi, runtime, ctx as ConsolidationCtx);
104
+ });
112
105
  pi.on("agent_start", (event, ctx) => {
113
106
  launch(event, ctx);
114
107
  syncAndScheduleSummarizer(pi, runtime, ctx as ConsolidationCtx);
@@ -119,8 +112,10 @@ export function registerConsolidationTrigger(pi: ExtensionAPI, runtime: Runtime)
119
112
  });
120
113
  runtime.setAgentActivityListener((ctx) => {
121
114
  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.
115
+ // Long autonomous turns may run for hours without agent_start/turn_end.
116
+ // Re-evaluate both source coverage and memory pressure at every primary
117
+ // progress checkpoint so neither worker can silently stop for the turn.
118
+ maybeLaunchConsolidation(pi, runtime, ctx as ConsolidationCtx);
124
119
  scheduleSummarizer(pi, runtime, ctx as ConsolidationCtx);
125
120
  });
126
121
  runtime.setSettingsUpdateListener((ctx, settings) => {
@@ -166,7 +161,7 @@ function maybeLaunchConsolidation(pi: ExtensionAPI, runtime: Runtime, ctx: Conso
166
161
  };
167
162
 
168
163
  const sessionMetadata = debugSessionMetadata(ctx);
169
- void runtime.launchConsolidationTask(ctx, async () => withDebugLogContext({
164
+ const task = runtime.launchConsolidationTask(ctx, async () => withDebugLogContext({
170
165
  enabled: runtime.config.debugLog === true,
171
166
  cwd: ctx.cwd,
172
167
  ...sessionMetadata,
@@ -174,6 +169,13 @@ function maybeLaunchConsolidation(pi: ExtensionAPI, runtime: Runtime, ctx: Conso
174
169
  }, async () => {
175
170
  await runConsolidationPipeline(pi, runtime, consolidationCtx);
176
171
  }));
172
+ // launchTrackedTask releases its lock before this continuation. If observer
173
+ // setup failed before coverage could move, wake the contemplator once in
174
+ // degraded mode rather than leaving all advisory work gated forever. Future
175
+ // primary activity still retries the observer.
176
+ void task.then(() => {
177
+ if (runtime.lastObserverError) runtime.notifyMemoryUpdate(ctx);
178
+ });
177
179
  }
178
180
 
179
181
  export function launchCompactionObserver(
@@ -314,7 +316,9 @@ export function scheduleSummarizer(pi: ExtensionAPI, runtime: Runtime, ctx: Cons
314
316
  }, async () => {
315
317
  let stalled = false;
316
318
  let successfullyCompleted = false;
319
+ let modelRunAttempted = false;
317
320
  let disposeStallWatchdog = () => {};
321
+ let acceptsSummarizerMessages = true;
318
322
  const startedAt = Date.now();
319
323
  runtime.lastSummarizerStartedAt = startedAt;
320
324
  runtime.lastSummarizerRun = { startedAt, status: "running", messages: [] };
@@ -334,7 +338,8 @@ export function scheduleSummarizer(pi: ExtensionAPI, runtime: Runtime, ctx: Cons
334
338
  if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(`pi-contemplator: ${reason}; cancelling and leaving the backlog eligible for retry`, "warning");
335
339
  });
336
340
  disposeStallWatchdog = watchdog.dispose;
337
- const result = await runSummarizer({
341
+ modelRunAttempted = true;
342
+ const result = await watchdog.race(runSummarizer({
338
343
  signal: watchdog.signal,
339
344
  model: resolved.model as any,
340
345
  apiKey: resolved.apiKey,
@@ -348,9 +353,9 @@ export function scheduleSummarizer(pi: ExtensionAPI, runtime: Runtime, ctx: Cons
348
353
  recordUsage: (usage) => runtime.recordAgentUsage(usage),
349
354
  onMessages: (messages) => {
350
355
  watchdog.progress();
351
- if (generation === runtime.getContextGeneration()) runtime.lastSummarizerRun = { startedAt, status: "running", messages: messages.slice() };
356
+ if (acceptsSummarizerMessages && generation === runtime.getContextGeneration()) runtime.lastSummarizerRun = { startedAt, status: "running", messages: messages.slice() };
352
357
  },
353
- });
358
+ }));
354
359
  if (generation !== runtime.getContextGeneration()) return;
355
360
  if (!result.completed) {
356
361
  runtime.lastSummarizerRun = stalled
@@ -377,6 +382,7 @@ export function scheduleSummarizer(pi: ExtensionAPI, runtime: Runtime, ctx: Cons
377
382
  }
378
383
  throw error;
379
384
  } finally {
385
+ acceptsSummarizerMessages = false;
380
386
  disposeStallWatchdog();
381
387
  if (generation === runtime.getContextGeneration()) {
382
388
  const completedAt = Date.now();
@@ -385,7 +391,11 @@ export function scheduleSummarizer(pi: ExtensionAPI, runtime: Runtime, ctx: Cons
385
391
  const postRunPools = currentMemoryPools(runtime, ctx.sessionManager.getBranch() as Entry[]);
386
392
  const target = runtime.config.oldMemoryPoolTargetTokens;
387
393
  runtime.summarizerNextTriggerTokens = summarizerTriggerAfterRun(
388
- successfullyCompleted,
394
+ // A failed/no-progress model pass must not be retried with the
395
+ // identical pool at every primary-agent checkpoint. Require fresh
396
+ // old-memory growth before trying another (potentially different)
397
+ // sample. Model-resolution failures spend no tokens and stay eligible.
398
+ successfullyCompleted || modelRunAttempted,
389
399
  runtime.summarizerNextTriggerTokens,
390
400
  target,
391
401
  postRunPools.oldTokens,
@@ -457,6 +467,9 @@ async function runObserverStage(
457
467
  debugLog("observer.start", {
458
468
  tokens,
459
469
  chunkTokens,
470
+ requestedMaxOutputTokens: OBSERVER_AGENT_LOOP_MAX_TOKENS,
471
+ effectiveMaxOutputTokens: boundedMaxTokens(resolved.model as any, OBSERVER_AGENT_LOOP_MAX_TOKENS),
472
+ advertisedModelMaxTokens: (resolved.model as { maxTokens?: number }).maxTokens,
460
473
  coversUpToId,
461
474
  sourceEntryIds,
462
475
  sourceEntryCount: sourceEntryIds.length,
@@ -464,18 +477,41 @@ async function runObserverStage(
464
477
  priorObservations: priorObservations.length,
465
478
  });
466
479
 
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
- });
480
+ let observations;
481
+ let failedMessage: string | undefined;
482
+ const observerWatchdog = createWorkerStallWatchdog("observer");
483
+ try {
484
+ observations = await observerWatchdog.race(runObserver({
485
+ model: resolved.model as any,
486
+ apiKey: resolved.apiKey,
487
+ headers: resolved.headers,
488
+ priorSummaries,
489
+ priorObservations,
490
+ chunk,
491
+ allowedSourceEntryIds: sourceEntryIds,
492
+ maxTurns: runtime.config.agentMaxTurns,
493
+ thinkingLevel: runtime.config.model?.thinking ?? "low",
494
+ recordUsage: (usage) => runtime.recordAgentUsage(usage),
495
+ onProgress: observerWatchdog.progress,
496
+ signal: observerWatchdog.signal,
497
+ }));
498
+ } catch (error) {
499
+ // A permanently pathological old chunk must not pin every newer source
500
+ // entry and block the contemplator forever. Accepted observations are
501
+ // already returned normally by runObserver; only a zero-progress failure
502
+ // reaches here. Record the failure visibly, then advance this bounded
503
+ // range with an empty coverage marker so catch-up can continue.
504
+ failedMessage = runtime.recordConsolidationStageError(ctx, "observer", error);
505
+ debugLog("observer.failed_chunk_advanced", {
506
+ failedMessage,
507
+ coversUpToId,
508
+ sourceEntryIds,
509
+ chunkTokens,
510
+ });
511
+ observations = undefined;
512
+ } finally {
513
+ observerWatchdog.dispose();
514
+ }
479
515
  if (options.contextGeneration !== undefined && options.contextGeneration !== runtime.getContextGeneration()) {
480
516
  debugLog("observer.stale", { reason: "session_or_branch_changed" });
481
517
  return "abort";
@@ -497,18 +533,18 @@ async function runObserverStage(
497
533
  const accepted = observations ?? [];
498
534
  const data = buildObservationsRecordedData(accepted, effectiveCoversUpToId);
499
535
  if (!data) return "continue";
500
- debugLog(accepted.length > 0 ? "observer.records" : "observer.coverage_only", {
536
+ debugLog(failedMessage ? "observer.failed_coverage" : accepted.length > 0 ? "observer.records" : "observer.coverage_only", {
501
537
  count: accepted.length,
502
538
  observationTokens: accepted.reduce((sum, observation) => sum + observation.tokenCount, 0),
503
539
  coversUpToId: effectiveCoversUpToId,
540
+ ...(failedMessage ? { failedMessage } : {}),
504
541
  });
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.
542
+ // A clean zero-observation verdict and a zero-progress failed chunk both use
543
+ // an empty coverage marker. This prevents low-information or pathological old
544
+ // source from pinning the entire observer/contemplator pipeline forever.
509
545
  appendEntry(pi, OM_OBSERVATIONS_RECORDED, data);
510
546
  debugLog("observer.appended", { count: accepted.length, coversUpToId: effectiveCoversUpToId });
511
- if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(
547
+ if (!failedMessage && shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(
512
548
  accepted.length > 0
513
549
  ? `pi-contemplator: ${accepted.length} observation${accepted.length === 1 ? "" : "s"} recorded`
514
550
  : "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
+ }