@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 +1 -1
- package/src/agents/contemplator/agent.ts +95 -30
- package/src/agents/observer/agent.ts +13 -10
- package/src/agents/reviewer/agent.ts +5 -1
- package/src/commands/contemplator-view.ts +7 -5
- package/src/commands/status.ts +1 -0
- package/src/hooks/compaction-trigger.ts +35 -17
- package/src/hooks/consolidation-trigger.ts +79 -47
- package/src/model-budget.ts +3 -0
- package/src/runtime.ts +17 -4
- package/src/worker-watchdog.ts +63 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@matthewfl/pi-contemplator",
|
|
3
|
-
"version": "0.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
|
-
|
|
668
|
-
this.
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
const result = await
|
|
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)
|
|
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
|
-
|
|
850
|
-
this.
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
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
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
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)
|
|
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 === "
|
|
67
|
-
? "waiting for
|
|
68
|
-
: state.waitingFor === "
|
|
69
|
-
? "waiting for
|
|
70
|
-
: state.waitingFor === "
|
|
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"
|
package/src/commands/status.ts
CHANGED
|
@@ -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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
//
|
|
123
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
|
506
|
-
// empty
|
|
507
|
-
//
|
|
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",
|
package/src/model-budget.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|