@quantmind-br/pi-goal-x 0.0.0-stage → 0.35.0
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/LICENSE +21 -0
- package/README.md +131 -2
- package/assets/badge-dark.svg +18 -0
- package/assets/badge-light.svg +18 -0
- package/docs/advanced-usage.md +106 -0
- package/docs/agent-flow-design.md +306 -0
- package/docs/agentic-runtime-prd.md +773 -0
- package/docs/architecture.md +368 -0
- package/docs/controls.md +140 -0
- package/docs/pi-autoresearch-survey.md +50 -0
- package/docs/pi-goal-x-unified-dashboard-plan.md +1667 -0
- package/docs/prompt-caching.md +31 -0
- package/docs/unified-dashboard.md +286 -0
- package/extensions/auditor-selector.ts +74 -0
- package/extensions/goal-accounting.ts +107 -0
- package/extensions/goal-activity.ts +202 -0
- package/extensions/goal-auditor.ts +480 -0
- package/extensions/goal-commands.ts +866 -0
- package/extensions/goal-compaction.ts +197 -0
- package/extensions/goal-completion.ts +173 -0
- package/extensions/goal-context.ts +12 -0
- package/extensions/goal-contract.ts +58 -0
- package/extensions/goal-controls.ts +202 -0
- package/extensions/goal-core-tools.ts +549 -0
- package/extensions/goal-core.ts +78 -0
- package/extensions/goal-detail.ts +65 -0
- package/extensions/goal-dialog-controls.ts +31 -0
- package/extensions/goal-draft.ts +244 -0
- package/extensions/goal-drafting.ts +535 -0
- package/extensions/goal-events.ts +692 -0
- package/extensions/goal-format.ts +376 -0
- package/extensions/goal-interruption.ts +53 -0
- package/extensions/goal-ledger-index.ts +68 -0
- package/extensions/goal-ledger.ts +1007 -0
- package/extensions/goal-live-retention.ts +219 -0
- package/extensions/goal-oracle.ts +346 -0
- package/extensions/goal-policy.ts +370 -0
- package/extensions/goal-pool.ts +103 -0
- package/extensions/goal-prompt-cache.ts +80 -0
- package/extensions/goal-questionnaire.ts +1073 -0
- package/extensions/goal-record.ts +388 -0
- package/extensions/goal-recovery.ts +256 -0
- package/extensions/goal-runtime.ts +277 -0
- package/extensions/goal-scheduler-state.ts +100 -0
- package/extensions/goal-scheduler.ts +436 -0
- package/extensions/goal-service.ts +843 -0
- package/extensions/goal-session-health.ts +130 -0
- package/extensions/goal-session-safety.ts +65 -0
- package/extensions/goal-settings.ts +1370 -0
- package/extensions/goal-state.ts +1069 -0
- package/extensions/goal-status.ts +305 -0
- package/extensions/goal-task-confirmation.ts +174 -0
- package/extensions/goal-task-count.ts +59 -0
- package/extensions/goal-task-derive.ts +46 -0
- package/extensions/goal-task-index.ts +57 -0
- package/extensions/goal-task-tools.ts +595 -0
- package/extensions/goal-tool-inputs.ts +102 -0
- package/extensions/goal-tool-names.ts +73 -0
- package/extensions/goal-tools.ts +16 -0
- package/extensions/goal-widget.ts +455 -0
- package/extensions/goal.ts +42 -0
- package/extensions/network-error-backoff.ts +55 -0
- package/extensions/prompts/goal-prompts.ts +321 -0
- package/extensions/storage/atomic-rename.ts +52 -0
- package/extensions/storage/goal-files.ts +695 -0
- package/extensions/storage/goal-lock.ts +131 -0
- package/extensions/storage/goal-root.ts +95 -0
- package/extensions/widgets/auditor-dashboard-model.ts +174 -0
- package/extensions/widgets/dialog-scaffold.ts +26 -0
- package/extensions/widgets/goal-controls-dialog.ts +329 -0
- package/extensions/widgets/goal-dashboard-model.ts +530 -0
- package/extensions/widgets/goal-dashboard-renderer.ts +674 -0
- package/extensions/widgets/goal-escape-dialog.ts +148 -0
- package/extensions/widgets/goal-notifications.ts +9 -0
- package/extensions/widgets/goal-widget.ts +723 -0
- package/extensions/widgets/task-list-overlay.ts +409 -0
- package/extensions/widgets/text-cache.ts +30 -0
- package/package.json +117 -4
- package/scripts/recover-session-checkpoints.mjs +289 -0
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
import { goalStorageContext, type GoalStorageContext } from "./storage/goal-root.ts";
|
|
2
|
+
import { commandHint, commandInstructions } from "./goal-controls.ts";
|
|
3
|
+
import { cacheGoalHistory } from "./goal-prompt-cache.ts";
|
|
4
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import {
|
|
6
|
+
GOAL_EVENT_ENTRY,
|
|
7
|
+
assistantTurnTokens,
|
|
8
|
+
extractGoalIdFromInjectedMessage,
|
|
9
|
+
goalEventMessageId,
|
|
10
|
+
hasAbortedAssistantMessage,
|
|
11
|
+
hasErrorAssistantMessage,
|
|
12
|
+
hasNetworkErrorAssistantMessage,
|
|
13
|
+
isAbortedAssistantMessage,
|
|
14
|
+
isErrorAssistantMessage,
|
|
15
|
+
isMeaningfulProgressToolCall,
|
|
16
|
+
} from "./goal-format.ts";
|
|
17
|
+
import { buildCompactionSummary, buildPostCompactionGoalDelta } from "./goal-compaction.ts";
|
|
18
|
+
import { latestAuditorResultForGoal, readGoalLedger, goalRuntimeEvents, invalidateGoalLedgerCache } from "./goal-ledger.ts";
|
|
19
|
+
import { shouldArmPostCompactReminder, shouldInjectPostCompactReminder } from "./goal-policy.ts";
|
|
20
|
+
import { formatTokenValue } from "./goal-core.ts";
|
|
21
|
+
import { loadGoalSettings, invalidateGoalSettingsCache } from "./goal-settings.ts";
|
|
22
|
+
import { modelBudgetLine, budgetRemaining } from "./goal-accounting.ts";
|
|
23
|
+
import { asRecord, nowIso, type AssistantMessageLike, type GoalRecord } from "./goal-record.ts";
|
|
24
|
+
import { goalSelectorLabel, otherOpenGoalCount } from "./goal-pool.ts";
|
|
25
|
+
import { invalidateGoalPoolCache } from "./storage/goal-files.ts";
|
|
26
|
+
import { checkpointTriggerPrompt } from "./prompts/goal-prompts.ts";
|
|
27
|
+
import { consumeOracleFollowupMarker, hasPendingOracleAdviceForFocusedGoal } from "./goal-oracle.ts";
|
|
28
|
+
import { LiveTailRetention } from "./goal-live-retention.ts";
|
|
29
|
+
import {
|
|
30
|
+
goalPromptParts,
|
|
31
|
+
staleContinuationPrompt,
|
|
32
|
+
unfocusedOpenGoalsPrompt,
|
|
33
|
+
untrustedObjectiveBlock,
|
|
34
|
+
} from "./prompts/goal-prompts.ts";
|
|
35
|
+
import { hasActiveDraft, rehydrateDraft } from "./goal-drafting.ts";
|
|
36
|
+
import { FIVE_GOAL_TOOLS } from "./goal-tool-names.ts";
|
|
37
|
+
import { syncTerminalInputPause } from "./goal-widget.ts";
|
|
38
|
+
import type { GoalCore } from "./goal-state.ts";
|
|
39
|
+
import { filterGoalSessionContext, isStaleExtensionContextError } from "./goal-session-safety.ts";
|
|
40
|
+
import { registerGoalContext } from "./goal-context.ts";
|
|
41
|
+
import type { GoalMutationOutcome } from "./goal-service.ts";
|
|
42
|
+
|
|
43
|
+
/** Normalize checkpoints independently: deleting old markers shifts the cached prefix. */
|
|
44
|
+
export function compactGoalCheckpointContext(
|
|
45
|
+
messages: readonly unknown[],
|
|
46
|
+
_currentGoal: GoalRecord | null,
|
|
47
|
+
): unknown[] | null {
|
|
48
|
+
let output: unknown[] | null = null;
|
|
49
|
+
for (let i = 0; i < messages.length; i++) {
|
|
50
|
+
const message = messages[i] as { customType?: string; details?: unknown; content?: unknown };
|
|
51
|
+
const id = goalEventMessageId(message);
|
|
52
|
+
if (id === null) { output?.push(message); continue; }
|
|
53
|
+
output ??= messages.slice(0, i);
|
|
54
|
+
output.push({ ...message, content: checkpointTriggerPrompt(id), display: false,
|
|
55
|
+
details: { version: 2, kind: "checkpoint", goalId: id } });
|
|
56
|
+
}
|
|
57
|
+
return output;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The goal extension's lifecycle event handlers (context, turn_start,
|
|
62
|
+
* tool_call, tool_execution_end, turn_end, message_end, session_start,
|
|
63
|
+
* session_before_compact, session_compact, session_tree, before_agent_start,
|
|
64
|
+
* agent_end, agent_settled, session_shutdown). All state flows through the
|
|
65
|
+
* GoalCore.
|
|
66
|
+
*/
|
|
67
|
+
export function registerGoalEvents(core: GoalCore): void {
|
|
68
|
+
const { pi } = core;
|
|
69
|
+
// Transients are keyed per session: the context hook of session B must never
|
|
70
|
+
// overwrite the set that session A's provider request will consume.
|
|
71
|
+
const liveRetention = new LiveTailRetention();
|
|
72
|
+
const sessionKeyFor = (ctx?: ExtensionContext): string =>
|
|
73
|
+
ctx?.sessionManager?.getSessionId?.() ? JSON.stringify([ctx.sessionManager.getSessionId(), ctx.model?.provider, ctx.model?.id]) : "unidentified";
|
|
74
|
+
type SessionOwner = {ctx: ExtensionContext; manager: ExtensionContext["sessionManager"]; storage: GoalStorageContext; keys: Set<string>};
|
|
75
|
+
let session: SessionOwner | undefined;
|
|
76
|
+
const contexts = new WeakMap<object, SessionOwner>();
|
|
77
|
+
const leases = new Map<string, SessionOwner>();
|
|
78
|
+
pi.on("before_provider_request", (event, ctx) => {
|
|
79
|
+
if (ctx && contexts.has(ctx) && contexts.get(ctx) !== session) return;
|
|
80
|
+
try {
|
|
81
|
+
if (session && ctx && ctx.sessionManager !== session.manager) return;
|
|
82
|
+
return cacheGoalHistory(event.payload, liveRetention.contents(sessionKeyFor(ctx)));
|
|
83
|
+
}
|
|
84
|
+
catch (error) { if (!isStaleExtensionContextError(error)) throw error; }
|
|
85
|
+
});
|
|
86
|
+
let continuationAfterSettleFor: string | null = null;
|
|
87
|
+
let networkErrorRecoveryAfterSettleFor: string | null = null;
|
|
88
|
+
|
|
89
|
+
registerGoalContext(pi, (input, ctx) => {
|
|
90
|
+
const filtered = filterGoalSessionContext(input);
|
|
91
|
+
const messages = compactGoalCheckpointContext(filtered ?? input, core.state.goal) ?? filtered;
|
|
92
|
+
const base = messages ?? input;
|
|
93
|
+
if (contexts.has(ctx) && contexts.get(ctx) !== session) return messages;
|
|
94
|
+
try {if (session && ctx.sessionManager !== session.manager) return messages;}
|
|
95
|
+
catch (error) {if (isStaleExtensionContextError(error)) return messages; throw error;}
|
|
96
|
+
// Reassert startup rejection only for the current host's provider boundary.
|
|
97
|
+
if (core.scheduler.isDenied()) ctx.abort?.();
|
|
98
|
+
if (session) contexts.set(ctx, session);
|
|
99
|
+
const sessionKey = sessionKeyFor(ctx);
|
|
100
|
+
if (session) {
|
|
101
|
+
leases.set(sessionKey, session);
|
|
102
|
+
session.keys.add(sessionKey);
|
|
103
|
+
if (leases.size > 16) leases.delete(leases.keys().next().value!);
|
|
104
|
+
if (session.keys.size > 16) session.keys.delete(session.keys.values().next().value!);
|
|
105
|
+
}
|
|
106
|
+
if (sessionKey === "unidentified") liveRetention.clear(sessionKey);
|
|
107
|
+
const parts = currentGoalContextParts(ctx);
|
|
108
|
+
if (parts) {
|
|
109
|
+
// Retain previously sent tails verbatim at their anchors so this
|
|
110
|
+
// request extends the previous one instead of displacing its tail.
|
|
111
|
+
// Only genuinely new state/counters content is appended.
|
|
112
|
+
const { messages: retained } = liveRetention.apply(sessionKey, base, { state: parts.state, counters: parts.counters });
|
|
113
|
+
return retained;
|
|
114
|
+
}
|
|
115
|
+
liveRetention.clear(sessionKey);
|
|
116
|
+
return messages;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
pi.on("agent_start", async (_event, ctx) => { core.scheduler.begin(ctx); });
|
|
120
|
+
pi.on("message_start", async (event, ctx) => { core.scheduler.message(ctx, event.message); });
|
|
121
|
+
pi.on("turn_start", async (_event, ctx) => {
|
|
122
|
+
if (core.scheduler.isDenied()) return;
|
|
123
|
+
core.scheduler.turn(ctx);
|
|
124
|
+
// Per-turn flag resets (#4 + C9 fix).
|
|
125
|
+
core.advanceTurnSeq();
|
|
126
|
+
core.goalWorkToolCalledThisTurn = false;
|
|
127
|
+
core.beginAccounting();
|
|
128
|
+
core.goalService.beginTurn(ctx, core.focusedGoalId); // P1-3 transaction buffer
|
|
129
|
+
core.touchGoalActivity(); // F5
|
|
130
|
+
core.updateUI(ctx);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// #4 + C9 fix + Phase 5 C3: gate in-turn tool calls based on lifecycle state.
|
|
134
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
135
|
+
const stoppedGoalId = core.currentTurnStoppedGoalId();
|
|
136
|
+
if (core.scheduler.isDenied()) return { block: true, reason: `Stale goal dispatch; use ${commandHint("goal-resume", core.loadedCommands)}.` };
|
|
137
|
+
// Post-stop in-turn block: after update_goal / set_goal_tasks (or a user
|
|
138
|
+
// lifecycle command) fires in this turn, block all subsequent tool calls
|
|
139
|
+
// except read-only inspection.
|
|
140
|
+
if (stoppedGoalId !== null && core.runtime.isStaleCheckpointBlocked(event.toolName)) {
|
|
141
|
+
return {
|
|
142
|
+
block: true,
|
|
143
|
+
reason: `The goal was already stopped earlier in this turn (goalId=${stoppedGoalId}). ` +
|
|
144
|
+
`Do not call more tools; end the turn with a brief summary and yield to the user.`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
// Stale checkpoint guard: if the turn was triggered by a queued continuation
|
|
148
|
+
// for a goal that is no longer active/autoContinue, block work tools.
|
|
149
|
+
const checkpointGoalId = core.runtime.getCheckpointGoalId();
|
|
150
|
+
if (checkpointGoalId !== null && !core.isActionableContinuationGoal(checkpointGoalId) && core.isStaleCheckpointBlockedToolCall(event.toolName)) {
|
|
151
|
+
// Block the tool call with a stale-checkpoint message.
|
|
152
|
+
return {
|
|
153
|
+
block: true,
|
|
154
|
+
reason: `Cannot call ${event.toolName}: the goal checkpoint that triggered this turn is no longer active. ` +
|
|
155
|
+
`Goal ${checkpointGoalId} has been paused, cleared, or replaced. ` +
|
|
156
|
+
`End the turn with a brief summary and yield to the user.`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
// Keep declarations cached; block unconfirmed goal operations at execution.
|
|
160
|
+
if (hasActiveDraft(core) && (FIVE_GOAL_TOOLS as readonly string[]).includes(event.toolName)) {
|
|
161
|
+
return {
|
|
162
|
+
block: true,
|
|
163
|
+
reason: commandInstructions("A goal draft is active. Finish it with propose_goal_draft or ask the user to run /goal-cancel.", core.loadedCommands),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
// A proposal that confirmed or cancelled the draft must not let later calls
|
|
167
|
+
// in the same assistant message act on (or recreate) the unconfirmed goal.
|
|
168
|
+
if (core.runtime.isDraftResolvedThisTurn() && (FIVE_GOAL_TOOLS as readonly string[]).includes(event.toolName)) {
|
|
169
|
+
return {
|
|
170
|
+
block: true,
|
|
171
|
+
reason: "The goal draft was resolved earlier in this turn. End the turn with a brief summary and yield to the user.",
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
// Track Oracle work attempts; this does not authorize scheduling.
|
|
175
|
+
if (isMeaningfulProgressToolCall(event.toolName, event.input)) {
|
|
176
|
+
core.goalWorkToolCalledThisTurn = true;
|
|
177
|
+
// Goal mutations are measured by content changes at settlement. A
|
|
178
|
+
// ready declaration or an unchanged task update is only bookkeeping.
|
|
179
|
+
if (event.toolName !== "update_goal" && event.toolName !== "update_goal_task") core.scheduler.recordWork();
|
|
180
|
+
// Issue #26: record a meaningful work attempt against armed Oracle
|
|
181
|
+
// advice. get_goal / echo-only reads are excluded upstream by
|
|
182
|
+
// isMeaningfulProgressToolCall.
|
|
183
|
+
const focusedId = core.focusedGoalId;
|
|
184
|
+
if (focusedId && hasPendingOracleAdviceForFocusedGoal(focusedId)) {
|
|
185
|
+
const armed = consumeOracleFollowupMarker(focusedId);
|
|
186
|
+
if (armed) {
|
|
187
|
+
try {
|
|
188
|
+
core.goalService.appendEvents(ctx, [{
|
|
189
|
+
type: "oracle_followup_attempted",
|
|
190
|
+
goalId: armed.goalId,
|
|
191
|
+
fingerprint: armed.fingerprint,
|
|
192
|
+
adviceId: armed.adviceId,
|
|
193
|
+
firstToolName: event.toolName,
|
|
194
|
+
at: nowIso(),
|
|
195
|
+
}]);
|
|
196
|
+
} catch { /* best-effort ledger append */ }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
pi.on("tool_execution_end", async (_event, ctx) => {
|
|
204
|
+
if (core.scheduler.isDenied()) return;
|
|
205
|
+
core.touchGoalActivity(); // F5
|
|
206
|
+
core.accountProgress(ctx);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
pi.on("turn_end", async (event, ctx) => {
|
|
210
|
+
if (core.scheduler.isDenied()) return;
|
|
211
|
+
const message = event.message as AssistantMessageLike;
|
|
212
|
+
const tokens = assistantTurnTokens(message);
|
|
213
|
+
core.touchGoalActivity(); // F5
|
|
214
|
+
core.accountProgress(ctx, { completedTurnTokens: tokens });
|
|
215
|
+
core.interruption.finishTurn(ctx);
|
|
216
|
+
|
|
217
|
+
if (isAbortedAssistantMessage(message)) {
|
|
218
|
+
// Pause only on a genuine user abort (signal fired). A provider- or
|
|
219
|
+
// transport-side abort without the signal routes into recovery via
|
|
220
|
+
// agent_end instead of stranding the goal.
|
|
221
|
+
if (ctx.signal?.aborted && !core.interruption.ignoreAbort() && !core.interruption.isHeld()) core.pauseActiveGoal(ctx);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
// Provider failures are not completed work: do not turn one failed turn
|
|
225
|
+
// into an unbounded auto-continue retry storm. Keep the display
|
|
226
|
+
// reconciled (and accounting already ran above), but never queue a
|
|
227
|
+
// continuation for an error turn (danim47c pattern).
|
|
228
|
+
if (isErrorAssistantMessage(message)) {
|
|
229
|
+
core.refreshGoalDisplayFromDisk(ctx);
|
|
230
|
+
core.updateUI(ctx);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
core.refreshGoalDisplayFromDisk(ctx);
|
|
234
|
+
|
|
235
|
+
// Archive a goal that was marked complete but whose archival was deferred
|
|
236
|
+
// so the agent could see/recognize the audit result first.
|
|
237
|
+
// This runs after the agent's turn ends — the agent has now seen the result.
|
|
238
|
+
if (core.state.goal?.status === "complete" && !core.state.goal?.archivedPath) {
|
|
239
|
+
const completedGoal = core.state.goal;
|
|
240
|
+
let archiveResult: GoalMutationOutcome;
|
|
241
|
+
try {
|
|
242
|
+
archiveResult = core.goalService.apply(ctx, {
|
|
243
|
+
reconcile: false,
|
|
244
|
+
archive: true,
|
|
245
|
+
commitFocused: false,
|
|
246
|
+
mutate: () => completedGoal,
|
|
247
|
+
ledger: (written) => [{
|
|
248
|
+
type: "goal_completed",
|
|
249
|
+
goalId: completedGoal.id,
|
|
250
|
+
archivePath: written.archivedPath,
|
|
251
|
+
at: nowIso(),
|
|
252
|
+
}],
|
|
253
|
+
});
|
|
254
|
+
} catch (err) {
|
|
255
|
+
// The archive write throws on failure (e.g. unwritable archived
|
|
256
|
+
// directory); surface it as a typed outcome (follow-up Stage 3).
|
|
257
|
+
archiveResult = { ok: false, message: err instanceof Error ? err.message : String(err) };
|
|
258
|
+
}
|
|
259
|
+
if (archiveResult.ok) {
|
|
260
|
+
core.goalsById.delete(completedGoal.id);
|
|
261
|
+
core.assignFocusedGoalId(null);
|
|
262
|
+
core.appendFocusEntry(null, "completed");
|
|
263
|
+
// §16.6: append the dedicated goal_archived event (the completion
|
|
264
|
+
// transaction keeps goal_completed for compatibility) and emit the
|
|
265
|
+
// real archive path.
|
|
266
|
+
try {
|
|
267
|
+
core.goalService.appendEvents(ctx, [{
|
|
268
|
+
type: "goal_archived",
|
|
269
|
+
goalId: completedGoal.id,
|
|
270
|
+
archivePath: archiveResult.goal?.archivedPath ?? "",
|
|
271
|
+
at: nowIso(),
|
|
272
|
+
}]);
|
|
273
|
+
} catch {
|
|
274
|
+
// Best-effort; the archive itself already succeeded.
|
|
275
|
+
}
|
|
276
|
+
const path = archiveResult.goal?.archivedPath ?? "";
|
|
277
|
+
ctx.ui.notify(path ? `Goal archived.\nFile: ${path}` : "Goal archived.", "info");
|
|
278
|
+
} else {
|
|
279
|
+
// §16.6 failure behavior: never claim success, keep the complete
|
|
280
|
+
// record recoverable at its active path, and write a diagnostic
|
|
281
|
+
// ledger event when possible.
|
|
282
|
+
const remainingPath = completedGoal.activePath ?? "(unknown)";
|
|
283
|
+
ctx.ui.notify(`Failed to archive completed goal: ${archiveResult.message}. The complete record remains at ${remainingPath}.`, "warning");
|
|
284
|
+
try {
|
|
285
|
+
core.goalService.appendEvents(ctx, [{
|
|
286
|
+
type: "goal_archive_failed",
|
|
287
|
+
goalId: completedGoal.id,
|
|
288
|
+
message: archiveResult.message ?? "archive write failed",
|
|
289
|
+
at: nowIso(),
|
|
290
|
+
}]);
|
|
291
|
+
} catch {
|
|
292
|
+
// Diagnostic write is best-effort.
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
core.updateUI(ctx);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
core.goalService.endTurn(ctx); // P1-3: single flush (lock + write + ledger batch)
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
pi.on("message_end", async (event, ctx) => {
|
|
302
|
+
// Signal-aware: see turn_end — only user aborts pause; provider-side
|
|
303
|
+
// aborts are handled by agent_end's recovery path.
|
|
304
|
+
if (!core.scheduler.isDenied() && isAbortedAssistantMessage(event.message) && ctx.signal?.aborted && !core.interruption.ignoreAbort() && !core.interruption.isHeld()) core.pauseActiveGoal(ctx);
|
|
305
|
+
const raw = asRecord(event.message);
|
|
306
|
+
if (raw?.role === "custom" && raw.customType === GOAL_EVENT_ENTRY && raw.display !== false) {
|
|
307
|
+
return { message: { ...event.message, display: false } as typeof event.message };
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
/** Detach first; every local cleanup runs even when host or persistence throws. */
|
|
312
|
+
function teardown(ctx: ExtensionContext): void {
|
|
313
|
+
const outgoing = session;
|
|
314
|
+
session = undefined;
|
|
315
|
+
core.sessionOwner = null;
|
|
316
|
+
let failure: unknown;
|
|
317
|
+
const run = (action: () => void) => {
|
|
318
|
+
try {action();} catch (error) {if (!isStaleExtensionContextError(error)) failure ??= error;}
|
|
319
|
+
};
|
|
320
|
+
run(() => core.invalidateSession());
|
|
321
|
+
continuationAfterSettleFor = null;
|
|
322
|
+
networkErrorRecoveryAfterSettleFor = null;
|
|
323
|
+
run(() => core.interruption.settled());
|
|
324
|
+
run(() => core.interruption.reset());
|
|
325
|
+
run(() => core.auditMessages.clear());
|
|
326
|
+
run(() => core.scheduler.shutdown());
|
|
327
|
+
if (outgoing) run(() => core.accountProgress(outgoing.ctx));
|
|
328
|
+
run(() => core.goalService.endSession(outgoing?.storage));
|
|
329
|
+
run(() => core.clearContinuationState());
|
|
330
|
+
run(() => core.clearActiveAccounting());
|
|
331
|
+
run(() => core.stopAuditAnimation());
|
|
332
|
+
run(() => core.clearAuditResult());
|
|
333
|
+
core.auditProgress = null;
|
|
334
|
+
core.goalModalDepth = 0;
|
|
335
|
+
const unsubscribe = core.terminalInputUnsubscribe;
|
|
336
|
+
core.terminalInputUnsubscribe = null;
|
|
337
|
+
run(() => unsubscribe?.());
|
|
338
|
+
if (outgoing && core.state.goal) run(() => {core.goalService.persist(outgoing.storage);});
|
|
339
|
+
run(() => core.clearGoalWidget(outgoing?.ctx ?? ctx));
|
|
340
|
+
if (outgoing) for (const key of outgoing.keys) {
|
|
341
|
+
if (leases.get(key) !== outgoing) continue;
|
|
342
|
+
liveRetention.clear(key);
|
|
343
|
+
leases.delete(key);
|
|
344
|
+
}
|
|
345
|
+
if (failure) throw failure;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function startSession(event: {reason?: string}, ctx: ExtensionContext): Promise<void> {
|
|
349
|
+
teardown(ctx);
|
|
350
|
+
try {
|
|
351
|
+
const owner: SessionOwner = {ctx, manager: ctx.sessionManager, storage: goalStorageContext(ctx), keys: new Set()};
|
|
352
|
+
session = owner;
|
|
353
|
+
core.sessionOwner = owner;
|
|
354
|
+
contexts.set(ctx, owner);
|
|
355
|
+
invalidateGoalSettingsCache();
|
|
356
|
+
invalidateGoalPoolCache();
|
|
357
|
+
invalidateGoalLedgerCache();
|
|
358
|
+
await core.loadState(ctx);
|
|
359
|
+
if (session !== owner) return;
|
|
360
|
+
if (ctx.hasUI) ctx.ui.setStatus("goal-interrupt", undefined);
|
|
361
|
+
if (core.controlWarnings?.length) ctx.ui.notify(`Goal command settings were not applied: ${core.controlWarnings.join(" ")} Open /goal-settings controls to correct them.`, "warning");
|
|
362
|
+
rehydrateDraft(core, ctx);
|
|
363
|
+
syncTerminalInputPause(core, ctx);
|
|
364
|
+
if (event.reason === "resume" && !core.state.goal && !core.hasExplicitSessionFocus && otherOpenGoalCount(core.goalsById, null) > 1 && ctx.hasUI) {
|
|
365
|
+
// Prompt the user to pick which open goal to focus (mirrors /goal-focus).
|
|
366
|
+
const open = core.openGoals();
|
|
367
|
+
const labels = open.map((item) => goalSelectorLabel(item, core.focusedGoalId));
|
|
368
|
+
const byLabel = new Map(labels.map((label, index) => [label, open[index]?.id]));
|
|
369
|
+
core.enterGoalModal();
|
|
370
|
+
try {
|
|
371
|
+
const selected = await ctx.ui.select("Focus open goal", labels);
|
|
372
|
+
const selectedId = selected ? byLabel.get(selected) : undefined;
|
|
373
|
+
if (selectedId && session === owner) {
|
|
374
|
+
core.setFocusedGoalId(selectedId, ctx, "selected");
|
|
375
|
+
core.armFocusedContinuation(ctx);
|
|
376
|
+
}
|
|
377
|
+
} finally {
|
|
378
|
+
if (session === owner) core.exitGoalModal();
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (session !== owner) return;
|
|
382
|
+
// Codex behavior: prompt before reactivating a paused goal on resume.
|
|
383
|
+
if (event.reason === "resume" && core.state.goal?.status === "paused" && ctx.hasUI) {
|
|
384
|
+
const current = core.state.goal;
|
|
385
|
+
const shouldResume = await ctx.ui.confirm("Resume paused goal?", `Goal: ${current.objective}`);
|
|
386
|
+
if (shouldResume && session === owner) {
|
|
387
|
+
core.scheduler.resume(ctx);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (session !== owner) return;
|
|
391
|
+
core.beginAccounting();
|
|
392
|
+
core.scheduler.restore(ctx);
|
|
393
|
+
} catch (error) {if (!isStaleExtensionContextError(error)) throw error;}
|
|
394
|
+
}
|
|
395
|
+
pi.on("session_start", startSession);
|
|
396
|
+
|
|
397
|
+
pi.on("session_before_compact", async (_event, ctx) => {
|
|
398
|
+
core.accountProgress(ctx);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
pi.on("session_compact", async (_event, ctx) => {
|
|
402
|
+
core.goalService.flushTurn(ctx); // P1-3: persist any buffered transaction before reload
|
|
403
|
+
if (core.state.goal) core.persist(ctx);
|
|
404
|
+
core.beginAccounting();
|
|
405
|
+
// Arm a deterministic compaction summary for the next agent turn.
|
|
406
|
+
// This replaces the generic reminder with artifact-backed state.
|
|
407
|
+
if (shouldArmPostCompactReminder(core.state.goal)) {
|
|
408
|
+
core.runtime.armPostCompactReminder();
|
|
409
|
+
}
|
|
410
|
+
core.queueContinuation(ctx, true);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
pi.on("session_compact_failed", async (event, ctx) => {
|
|
414
|
+
// `session_before_compact` already charged progress to the goal, so
|
|
415
|
+
// flush the buffered transaction and persist first. Otherwise the goal
|
|
416
|
+
// record and the ledger can disagree about a compaction that never
|
|
417
|
+
// happened.
|
|
418
|
+
core.goalService.flushTurn(ctx);
|
|
419
|
+
if (core.state.goal) core.persist(ctx);
|
|
420
|
+
if (event.aborted || !core.state.goal) return;
|
|
421
|
+
const detail = event.errorMessage ? `: ${event.errorMessage}` : "";
|
|
422
|
+
ctx.ui.notify(
|
|
423
|
+
`Goal context compaction failed (${event.reason})${detail}. The goal stays active; retry with /compact or keep working.`,
|
|
424
|
+
"warning",
|
|
425
|
+
);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
pi.on("session_tree", (_event, ctx) => startSession({}, ctx));
|
|
429
|
+
|
|
430
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
431
|
+
core.advanceTurnSeq();
|
|
432
|
+
core.installGoalToolProfile(!loadGoalSettings(ctx.cwd).disableTasks);
|
|
433
|
+
const incomingGoalId = event.messageType === "custom"
|
|
434
|
+
? goalEventMessageId(event.message) : extractGoalIdFromInjectedMessage(event.prompt ?? "");
|
|
435
|
+
if (event.messageType !== "custom" && !incomingGoalId) core.interruption.continueWithUser(ctx);
|
|
436
|
+
|
|
437
|
+
// If this turn was triggered by a hidden goal checkpoint that no longer
|
|
438
|
+
// matches the active goal, abort the whole turn instead of letting the
|
|
439
|
+
// model act on a stale instruction.
|
|
440
|
+
if (incomingGoalId !== null) {
|
|
441
|
+
// Reconcile from disk to pick up any external state changes before
|
|
442
|
+
// evaluating whether the checkpoint is actionable.
|
|
443
|
+
core.reconcileFocusedGoalFromDisk(ctx);
|
|
444
|
+
core.runtime.setCheckpoint(incomingGoalId);
|
|
445
|
+
// This can be the hidden checkpoint dispatched by the network-error
|
|
446
|
+
// timer. Clear ordinary continuation bookkeeping but retain the
|
|
447
|
+
// consecutive recovery count for a later failed retry.
|
|
448
|
+
core.clearContinuationState(false);
|
|
449
|
+
if (!core.isActionableContinuationGoal(incomingGoalId)) {
|
|
450
|
+
try {
|
|
451
|
+
core.scheduler.rejectCheckpoint(ctx);
|
|
452
|
+
} catch {}
|
|
453
|
+
core.updateUI(ctx);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
core.runtime.setCheckpoint(null);
|
|
457
|
+
} else {
|
|
458
|
+
// A user-driven turn — clear any queued continuation so we don't
|
|
459
|
+
// double-fire after the user's own message returns. Also reset the
|
|
460
|
+
// autoContinue nudge state so the user always gets a fresh chain.
|
|
461
|
+
core.runtime.setCheckpoint(null);
|
|
462
|
+
core.clearContinuationState();
|
|
463
|
+
networkErrorRecoveryAfterSettleFor = null;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
core.reconcileFocusedGoalFromDisk(ctx);
|
|
467
|
+
core.runningGoalId = core.state.goal?.status === "active" ? core.state.goal.id : null;
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
/** Request-only state: no live counters, focus, or reminders enter the system prefix. */
|
|
471
|
+
/** Request-only goal state split into a rarely-changing policy block and per-turn counters. Other branches are stable-only. */
|
|
472
|
+
function currentGoalContextParts(ctx: ExtensionContext): { state: string; counters?: string } | undefined {
|
|
473
|
+
const checkpoint = core.runtime.getCheckpointGoalId();
|
|
474
|
+
if (checkpoint !== null && (core.scheduler.isDenied() || !core.isActionableContinuationGoal(checkpoint))) {
|
|
475
|
+
return { state: staleContinuationPrompt(checkpoint, core.state.goal) };
|
|
476
|
+
}
|
|
477
|
+
// Several prompt enrichments may need the same ledger snapshot. Keep one
|
|
478
|
+
// local read for this hook instead of repeatedly traversing the cached
|
|
479
|
+
// ledger when rejection and post-compaction steering overlap.
|
|
480
|
+
let promptLedger: ReturnType<typeof readGoalLedger> | undefined;
|
|
481
|
+
const getPromptLedger = () => promptLedger ??= { events: core.state.goal ? goalRuntimeEvents(ctx, core.state.goal.id) : [], malformed: 0 };
|
|
482
|
+
|
|
483
|
+
if (!core.state.goal) {
|
|
484
|
+
// Issue #72: hideUnfocusedPrompt opts out of the model-facing
|
|
485
|
+
// unfocused reminder only. The stale-checkpoint guard above and
|
|
486
|
+
// all focused-goal context below are unaffected.
|
|
487
|
+
if (loadGoalSettings(ctx.cwd).hideUnfocusedPrompt === true) return;
|
|
488
|
+
const openCount = otherOpenGoalCount(core.goalsById, null);
|
|
489
|
+
if (openCount > 0) {
|
|
490
|
+
return { state: commandInstructions(unfocusedOpenGoalsPrompt(openCount), core.loadedCommands) };
|
|
491
|
+
}
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (core.state.goal.status === "complete") return;
|
|
495
|
+
if (core.state.goal.status === "paused") {
|
|
496
|
+
const current = core.state.goal;
|
|
497
|
+
const pauseExtras: string[] = [];
|
|
498
|
+
if (current.stopReason === "agent") {
|
|
499
|
+
pauseExtras.push("");
|
|
500
|
+
pauseExtras.push(`Pause reason: ${current.pauseReason ?? "(unknown)"}`);
|
|
501
|
+
if (current.pauseSuggestedAction) pauseExtras.push(`Suggested action: ${current.pauseSuggestedAction}`);
|
|
502
|
+
}
|
|
503
|
+
// Inject durable auditor feedback if available
|
|
504
|
+
let auditorExtra = "";
|
|
505
|
+
try {
|
|
506
|
+
const ledger = getPromptLedger();
|
|
507
|
+
const auditorResult = latestAuditorResultForGoal(ledger.events, current.id);
|
|
508
|
+
if (auditorResult && auditorResult.verdict === "disapproved") {
|
|
509
|
+
auditorExtra = `\n\n[AUDITOR REJECTION] An independent auditor previously rejected a completion request for this goal. Reason: ${auditorResult.report.slice(0, 300)}\nAddress the auditor's objections before requesting completion again.`;
|
|
510
|
+
}
|
|
511
|
+
} catch {
|
|
512
|
+
// Ledger read failure should not break the prompt
|
|
513
|
+
}
|
|
514
|
+
return { state: `[PI GOAL PAUSED goalId=${current.id}]\n${untrustedObjectiveBlock(current)}${pauseExtras.join("\n")}${auditorExtra}\n\nThe goal is paused. Do not autonomously continue substantive work unless the user resumes it with ${commandHint("goal-resume", core.loadedCommands)}. If the user explicitly asks to finish the paused goal and the objective is already satisfied based on available evidence, you may call update_goal({status: "complete"}). To abandon a goal, the user runs ${commandHint("goal-clear", core.loadedCommands)}. Do not report the goal blocked in response to a pause.` };
|
|
515
|
+
}
|
|
516
|
+
// Token-budget-limited goals get one-time wrap-up steering: summarize,
|
|
517
|
+
// do not start new substantive work, never claim completion unless real.
|
|
518
|
+
if (core.state.goal?.status === "budget_limited") {
|
|
519
|
+
const limitedGoal = core.state.goal;
|
|
520
|
+
const budgetText = modelBudgetLine(limitedGoal);
|
|
521
|
+
// E4: surface the remaining-vs-overshoot fact in the wrap-up steering.
|
|
522
|
+
const remaining = budgetRemaining(limitedGoal);
|
|
523
|
+
const balanceText = typeof remaining === "number"
|
|
524
|
+
? remaining < 0
|
|
525
|
+
? ` — ${formatTokenValue(-remaining)} over the budget`
|
|
526
|
+
: ` — ${formatTokenValue(remaining)} remaining`
|
|
527
|
+
: "";
|
|
528
|
+
const reminder = core.runtime.consumePostBudgetReminder()
|
|
529
|
+
? `\n\n[TOKEN BUDGET REACHED goalId=${limitedGoal.id}]\nThe goal's lifetime token-spending cap (not context capacity) has been reached${budgetText ? ` (${budgetText}${balanceText})` : ""}. Wrap up the current work in one final response: summarize what was accomplished and what remains, do not start new substantive work, and do not claim the goal is complete unless it actually is. To continue, the user can use ${commandHint("goal-tweak", core.loadedCommands)} to raise or remove the token budget.`
|
|
530
|
+
: "";
|
|
531
|
+
return { state: `[PI GOAL BUDGET LIMITED goalId=${limitedGoal.id}]\n${untrustedObjectiveBlock(limitedGoal)}${budgetText ? `\n${budgetText}` : ""}${reminder}` };
|
|
532
|
+
}
|
|
533
|
+
if (core.state.goal.status === "blocked") {
|
|
534
|
+
const blocked = core.state.goal;
|
|
535
|
+
return { state: `[PI GOAL BLOCKED goalId=${blocked.id}]\n${untrustedObjectiveBlock(blocked)}\nBlocker: ${blocked.pauseReason ?? "unspecified"}\nThe goal is blocked; the user must run ${commandHint("goal-resume", core.loadedCommands)} before goal work continues.` };
|
|
536
|
+
}
|
|
537
|
+
const activeGoal = core.state.goal;
|
|
538
|
+
const settings = loadGoalSettings(ctx.cwd);
|
|
539
|
+
const { state: policy, counters } = goalPromptParts(activeGoal, { ...settings, commands: core.loadedCommands }, ctx.getContextUsage?.());
|
|
540
|
+
// Rare transitional steering rides in the stable block; per-turn usage and
|
|
541
|
+
// scheduling counters stay in the volatile block so retention grows slowly.
|
|
542
|
+
let state = policy;
|
|
543
|
+
// F5: [GOAL STALLED] steering note when the detector fired.
|
|
544
|
+
const stalledNote = core.checkStall(ctx);
|
|
545
|
+
if (stalledNote) state += stalledNote;
|
|
546
|
+
// Inject durable auditor feedback if the latest result was a rejection
|
|
547
|
+
try {
|
|
548
|
+
const ledger = getPromptLedger();
|
|
549
|
+
const auditorResult = latestAuditorResultForGoal(ledger.events, activeGoal.id);
|
|
550
|
+
if (auditorResult && auditorResult.verdict === "disapproved" && ledger.events.some((e) => e.type === "completion_requested" && e.goalId === activeGoal.id)) {
|
|
551
|
+
state = `${state}\n\n[AUDITOR REJECTION goalId=${activeGoal.id}]\nAn independent auditor previously rejected a completion request for this goal. Reason: ${auditorResult.report.slice(0, 300)}\nAddress the auditor's objections before requesting completion again.`;
|
|
552
|
+
}
|
|
553
|
+
} catch {
|
|
554
|
+
// Ledger read failure should not break the prompt
|
|
555
|
+
}
|
|
556
|
+
if (core.runtime.isPostCompactReminderPending() && shouldInjectPostCompactReminder({ pending: true, goal: activeGoal })) {
|
|
557
|
+
core.runtime.clearPostCompactReminder();
|
|
558
|
+
// PR E §62: post-compaction DELTA — the current goal block already
|
|
559
|
+
// carries objective/policy/task gate/contract; inject only what
|
|
560
|
+
// compaction may have lost. Falls back to a generic note on ledger
|
|
561
|
+
// read failure.
|
|
562
|
+
try {
|
|
563
|
+
const ledger = getPromptLedger();
|
|
564
|
+
const otherOpenCount = core.openGoals().filter((g) => g.id !== activeGoal.id).length;
|
|
565
|
+
const delta = buildPostCompactionGoalDelta({ goal: activeGoal, ledgerEvents: ledger.events, otherOpenCount });
|
|
566
|
+
state = `${state}\n\n${delta}`;
|
|
567
|
+
} catch {
|
|
568
|
+
state = `${state}\n\n[POST-COMPACTION RESYNC goalId=${core.state.goal.id}]\nThe conversation was just compacted. Re-read the objective and continue from the actual artifacts/state; do not rely on memory of the prior chat.`;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return { state, counters };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
575
|
+
const endedGoalId = core.runningGoalId;
|
|
576
|
+
core.runningGoalId = null;
|
|
577
|
+
continuationAfterSettleFor = null;
|
|
578
|
+
networkErrorRecoveryAfterSettleFor = null;
|
|
579
|
+
if (core.scheduler.isDenied()) return;
|
|
580
|
+
|
|
581
|
+
// Account for any tokens from aborted in-flight assistant messages so
|
|
582
|
+
// they are not silently lost (but charge them to the original goal).
|
|
583
|
+
const abortedTokens = event.messages
|
|
584
|
+
.filter(isAbortedAssistantMessage)
|
|
585
|
+
.reduce((sum, message) => sum + assistantTurnTokens(message), 0);
|
|
586
|
+
if (abortedTokens > 0 && endedGoalId && core.state.goal?.id === endedGoalId) {
|
|
587
|
+
core.accountProgress(ctx, { completedTurnTokens: abortedTokens });
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Keep any prior recovery attempt while Pi finishes its own automatic
|
|
591
|
+
// retries. A user-driven path resets it through the default argument.
|
|
592
|
+
core.runtime.clearContinuationState(false);
|
|
593
|
+
if (!core.state.goal || core.state.goal.status !== "active" || !core.state.goal.autoContinue) return;
|
|
594
|
+
if (endedGoalId && core.state.goal.id !== endedGoalId) return;
|
|
595
|
+
if (!core.reconcileFocusedGoalFromDisk(ctx)) return;
|
|
596
|
+
// A genuine user abort pauses the goal. An assistant message with
|
|
597
|
+
// stopReason "aborted" WITHOUT a user abort signal is a provider- or
|
|
598
|
+
// transport-side termination (e.g. after Pi exhausts its retries) —
|
|
599
|
+
// pausing there stranded goals during outages, so it routes into the
|
|
600
|
+
// same bounded recovery as classified transient errors instead.
|
|
601
|
+
if (core.interruption.isHeld() || core.interruption.ignoreAbort()) return;
|
|
602
|
+
if (ctx.signal?.aborted) {
|
|
603
|
+
if (!core.interruption.ignoreAbort() && !core.interruption.isHeld()) core.pauseActiveGoal(ctx);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
// Provider failures are not completed work: persist and refresh the
|
|
607
|
+
// display, but never queue a continuation for a run whose messages
|
|
608
|
+
// include an assistant error (danim47c pattern).
|
|
609
|
+
if (hasNetworkErrorAssistantMessage(event.messages) || hasAbortedAssistantMessage(event.messages)) {
|
|
610
|
+
core.persist(ctx);
|
|
611
|
+
core.updateUI(ctx);
|
|
612
|
+
networkErrorRecoveryAfterSettleFor = core.state.goal.id;
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
if (hasErrorAssistantMessage(event.messages)) {
|
|
616
|
+
core.persist(ctx);
|
|
617
|
+
core.updateUI(ctx);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
core.runtime.clearNetworkErrorBackoff();
|
|
621
|
+
core.persist(ctx);
|
|
622
|
+
core.updateUI(ctx);
|
|
623
|
+
// agent_end runs before pi finishes retries, compaction, terminating-tool
|
|
624
|
+
// settlement, and queued messages. Starting the continuation timer here
|
|
625
|
+
// can poll a stale busy context. agent_settled is the first point where
|
|
626
|
+
// Pi guarantees no automatic work remains.
|
|
627
|
+
continuationAfterSettleFor = core.state.goal.id;
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
pi.on("agent_settled", async (event, ctx) => {
|
|
631
|
+
if (!core.auditMessages.flush(ctx, pi)) return;
|
|
632
|
+
const goalId = continuationAfterSettleFor;
|
|
633
|
+
continuationAfterSettleFor = null;
|
|
634
|
+
const networkErrorGoalId = networkErrorRecoveryAfterSettleFor;
|
|
635
|
+
networkErrorRecoveryAfterSettleFor = null;
|
|
636
|
+
if (core.scheduler.isDenied()) {
|
|
637
|
+
core.runningGoalId = null;
|
|
638
|
+
core.runtime.setCheckpoint(null);
|
|
639
|
+
core.scheduler.settled(ctx, false);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
const resumedInterruptedTurn = core.interruption.ignoreAbort() && !core.interruption.isHeld();
|
|
643
|
+
// Startup cancellation may leave no assistant message or request signal.
|
|
644
|
+
if (event.aborted) {
|
|
645
|
+
const abortedGoalId = goalId ?? networkErrorGoalId ?? core.runningGoalId;
|
|
646
|
+
core.runningGoalId = null;
|
|
647
|
+
if (abortedGoalId && !core.interruption.ignoreAbort(abortedGoalId) && !core.interruption.isHeld(abortedGoalId) && core.state.goal?.id === abortedGoalId && core.state.goal.status === "active") core.pauseActiveGoal(ctx);
|
|
648
|
+
core.scheduler.settled(ctx, false);
|
|
649
|
+
core.interruption.settled();
|
|
650
|
+
if (resumedInterruptedTurn) core.scheduler.schedule(ctx);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
core.interruption.settled();
|
|
654
|
+
core.scheduler.settled(ctx, !!goalId && core.isActionableContinuationGoal(goalId));
|
|
655
|
+
if (resumedInterruptedTurn) core.scheduler.schedule(ctx);
|
|
656
|
+
if (goalId && core.isActionableContinuationGoal(goalId)) return;
|
|
657
|
+
if (!networkErrorGoalId || !core.isActionableContinuationGoal(networkErrorGoalId)) return;
|
|
658
|
+
const recovery = loadGoalSettings(ctx.cwd).networkRecovery;
|
|
659
|
+
const policy = recovery
|
|
660
|
+
? { maxAttempts: recovery.maxAttempts, maxDelayMs: recovery.maxDelayMs }
|
|
661
|
+
: undefined;
|
|
662
|
+
const plan = core.runtime.scheduleNetworkErrorRetry(ctx, core.state.goal!, policy);
|
|
663
|
+
if (plan) {
|
|
664
|
+
const cap = plan.maxAttempts > 0 ? `/${plan.maxAttempts}` : ", unbounded";
|
|
665
|
+
ctx.ui.notify(
|
|
666
|
+
`Provider network error. Retrying the goal in ${Math.round(plan.delayMs / 1000)}s (recovery ${plan.attempt}${cap}).`,
|
|
667
|
+
"warning",
|
|
668
|
+
);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
// Only reachable under a configured bounded cap (maxAttempts > 0).
|
|
672
|
+
ctx.ui.notify(
|
|
673
|
+
"Provider network errors persisted after all recovery attempts. The goal remains active; resume it when the provider is healthy.",
|
|
674
|
+
"warning",
|
|
675
|
+
);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
679
|
+
// A delayed shutdown of a previously observed owner cannot touch its successor.
|
|
680
|
+
if (contexts.has(ctx) && contexts.get(ctx) !== session) return;
|
|
681
|
+
try {if (session && ctx.sessionManager !== session.manager) return;}
|
|
682
|
+
catch (error) {
|
|
683
|
+
if (!isStaleExtensionContextError(error)) throw error;
|
|
684
|
+
if (session && contexts.get(ctx) !== session) {
|
|
685
|
+
// An unseen stale callback cannot retire an owner whose host is still live.
|
|
686
|
+
try {session.ctx.sessionManager; return;}
|
|
687
|
+
catch (ownerError) {if (!isStaleExtensionContextError(ownerError)) throw ownerError;}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
teardown(ctx);
|
|
691
|
+
});
|
|
692
|
+
}
|