@pinet/agent-goal 0.2.6
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 +130 -0
- package/dist/dashboard.d.ts +3 -0
- package/dist/dashboard.js +42 -0
- package/dist/domain.d.ts +199 -0
- package/dist/domain.js +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +337 -0
- package/dist/memory-storage.d.ts +25 -0
- package/dist/memory-storage.js +164 -0
- package/dist/pi-evaluator.d.ts +8 -0
- package/dist/pi-evaluator.js +74 -0
- package/dist/progress.d.ts +18 -0
- package/dist/progress.js +34 -0
- package/dist/runtime.d.ts +49 -0
- package/dist/runtime.js +629 -0
- package/dist/sqlite-storage.d.ts +23 -0
- package/dist/sqlite-storage.js +420 -0
- package/dist/wake-scheduler.d.ts +10 -0
- package/dist/wake-scheduler.js +34 -0
- package/package.json +53 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { AgentGoal, GoalBudget, GoalContinuation, GoalContinuationClaim, GoalEvaluator, GoalEventSink, GoalProgress, GoalRetryPolicy, GoalStatus, GoalStorage, GoalTerminalCandidate, GoalTerminalCandidateRecord, GoalWakeScheduler } from "./domain.js";
|
|
2
|
+
export interface GoalRuntimeOptions {
|
|
3
|
+
defaultBudget?: GoalBudget;
|
|
4
|
+
retryPolicy?: GoalRetryPolicy;
|
|
5
|
+
eventSink?: GoalEventSink;
|
|
6
|
+
claimTtlMs?: number;
|
|
7
|
+
delay?: (milliseconds: number) => Promise<void>;
|
|
8
|
+
evaluationInterval?: number;
|
|
9
|
+
wakeScheduler?: GoalWakeScheduler;
|
|
10
|
+
}
|
|
11
|
+
export declare class GoalRuntime {
|
|
12
|
+
private readonly storage;
|
|
13
|
+
private readonly evaluator;
|
|
14
|
+
private readonly continuation;
|
|
15
|
+
private readonly now;
|
|
16
|
+
private readonly evaluatingScopes;
|
|
17
|
+
private readonly recoveringScopes;
|
|
18
|
+
private readonly budget;
|
|
19
|
+
private readonly retryPolicy;
|
|
20
|
+
private readonly eventSink?;
|
|
21
|
+
private readonly claimTtlMs;
|
|
22
|
+
private readonly delay;
|
|
23
|
+
private readonly evaluationInterval;
|
|
24
|
+
private readonly wakeScheduler;
|
|
25
|
+
private closed;
|
|
26
|
+
constructor(storage: GoalStorage, evaluator: GoalEvaluator, continuation: GoalContinuation, now?: () => Date, options?: GoalRuntimeOptions);
|
|
27
|
+
close(closeStorage?: boolean): void;
|
|
28
|
+
get(scopeId: string): Promise<AgentGoal | undefined>;
|
|
29
|
+
getContinuationClaim(scopeId: string): Promise<GoalContinuationClaim | undefined>;
|
|
30
|
+
getTerminalCandidate(scopeId: string): Promise<GoalTerminalCandidateRecord | undefined>;
|
|
31
|
+
create(scopeId: string, objective: string, budget?: GoalBudget): Promise<AgentGoal>;
|
|
32
|
+
setStatus(scopeId: string, status: Extract<GoalStatus, "active" | "paused" | "complete">): Promise<AgentGoal>;
|
|
33
|
+
clear(scopeId: string): Promise<boolean>;
|
|
34
|
+
requestTerminalCandidate(scopeId: string, candidate: GoalTerminalCandidate): Promise<GoalTerminalCandidateRecord>;
|
|
35
|
+
start(scopeId: string, reason?: string): Promise<void>;
|
|
36
|
+
acknowledgeContinuation(scopeId: string): Promise<void>;
|
|
37
|
+
recover(scopeId: string): Promise<void>;
|
|
38
|
+
settle(scopeId: string, progress: GoalProgress): Promise<void>;
|
|
39
|
+
private processPendingEvaluation;
|
|
40
|
+
private continueWithClaim;
|
|
41
|
+
private runContinuationClaim;
|
|
42
|
+
private blockAfterRetryExhaustion;
|
|
43
|
+
private budgetExhausted;
|
|
44
|
+
private markBudgetLimited;
|
|
45
|
+
private scheduleRecovery;
|
|
46
|
+
private retryDelay;
|
|
47
|
+
private record;
|
|
48
|
+
private requireGoal;
|
|
49
|
+
}
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { TimerGoalWakeScheduler } from "./wake-scheduler.js";
|
|
3
|
+
const DEFAULT_BUDGET = { maxIterations: 25 };
|
|
4
|
+
const DEFAULT_RETRY_POLICY = {
|
|
5
|
+
maxAttempts: 3,
|
|
6
|
+
baseDelayMs: 250,
|
|
7
|
+
maxDelayMs: 2_000,
|
|
8
|
+
};
|
|
9
|
+
export class GoalRuntime {
|
|
10
|
+
storage;
|
|
11
|
+
evaluator;
|
|
12
|
+
continuation;
|
|
13
|
+
now;
|
|
14
|
+
evaluatingScopes = new Set();
|
|
15
|
+
recoveringScopes = new Set();
|
|
16
|
+
budget;
|
|
17
|
+
retryPolicy;
|
|
18
|
+
eventSink;
|
|
19
|
+
claimTtlMs;
|
|
20
|
+
delay;
|
|
21
|
+
evaluationInterval;
|
|
22
|
+
wakeScheduler;
|
|
23
|
+
closed = false;
|
|
24
|
+
constructor(storage, evaluator, continuation, now = () => new Date(), options = {}) {
|
|
25
|
+
this.storage = storage;
|
|
26
|
+
this.evaluator = evaluator;
|
|
27
|
+
this.continuation = continuation;
|
|
28
|
+
this.now = now;
|
|
29
|
+
this.budget = options.defaultBudget ?? DEFAULT_BUDGET;
|
|
30
|
+
this.retryPolicy = options.retryPolicy ?? DEFAULT_RETRY_POLICY;
|
|
31
|
+
this.eventSink = options.eventSink;
|
|
32
|
+
this.claimTtlMs = options.claimTtlMs ?? 5 * 60_000;
|
|
33
|
+
this.evaluationInterval = options.evaluationInterval ?? 0;
|
|
34
|
+
this.wakeScheduler =
|
|
35
|
+
options.wakeScheduler ?? new TimerGoalWakeScheduler(() => this.now().getTime());
|
|
36
|
+
if (!Number.isInteger(this.evaluationInterval) || this.evaluationInterval < 0) {
|
|
37
|
+
throw new Error("Goal evaluationInterval must be a non-negative integer");
|
|
38
|
+
}
|
|
39
|
+
this.delay =
|
|
40
|
+
options.delay ??
|
|
41
|
+
((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
|
42
|
+
}
|
|
43
|
+
close(closeStorage = true) {
|
|
44
|
+
this.closed = true;
|
|
45
|
+
this.wakeScheduler.close();
|
|
46
|
+
if (closeStorage)
|
|
47
|
+
this.storage.close();
|
|
48
|
+
}
|
|
49
|
+
async get(scopeId) {
|
|
50
|
+
return this.storage.get(scopeId);
|
|
51
|
+
}
|
|
52
|
+
async getContinuationClaim(scopeId) {
|
|
53
|
+
return this.storage.getContinuationClaim(scopeId);
|
|
54
|
+
}
|
|
55
|
+
async getTerminalCandidate(scopeId) {
|
|
56
|
+
return this.storage.getTerminalCandidate(scopeId);
|
|
57
|
+
}
|
|
58
|
+
async create(scopeId, objective, budget = this.budget) {
|
|
59
|
+
const trimmedObjective = objective.trim();
|
|
60
|
+
if (!trimmedObjective)
|
|
61
|
+
throw new Error("Goal objective cannot be empty");
|
|
62
|
+
if (!Number.isInteger(budget.maxIterations) || budget.maxIterations <= 0) {
|
|
63
|
+
throw new Error("Goal maxIterations must be a positive integer");
|
|
64
|
+
}
|
|
65
|
+
if (budget.maxTokens !== undefined &&
|
|
66
|
+
(!Number.isFinite(budget.maxTokens) || budget.maxTokens <= 0)) {
|
|
67
|
+
throw new Error("Goal maxTokens must be a positive finite number");
|
|
68
|
+
}
|
|
69
|
+
if (budget.maxRuntimeMs !== undefined &&
|
|
70
|
+
(!Number.isFinite(budget.maxRuntimeMs) || budget.maxRuntimeMs <= 0)) {
|
|
71
|
+
throw new Error("Goal maxRuntimeMs must be a positive finite number");
|
|
72
|
+
}
|
|
73
|
+
if (await this.storage.get(scopeId)) {
|
|
74
|
+
throw new Error("This session already has a goal; clear it before creating another");
|
|
75
|
+
}
|
|
76
|
+
const timestamp = this.now().toISOString();
|
|
77
|
+
const goal = {
|
|
78
|
+
id: randomUUID(),
|
|
79
|
+
scopeId,
|
|
80
|
+
objective: trimmedObjective,
|
|
81
|
+
status: "active",
|
|
82
|
+
budget: { ...budget },
|
|
83
|
+
usage: { iterations: 0, tokens: 0 },
|
|
84
|
+
version: 1,
|
|
85
|
+
createdAt: timestamp,
|
|
86
|
+
updatedAt: timestamp,
|
|
87
|
+
};
|
|
88
|
+
await this.storage.create(goal);
|
|
89
|
+
await this.record({ type: "goal.created", goal });
|
|
90
|
+
return goal;
|
|
91
|
+
}
|
|
92
|
+
async setStatus(scopeId, status) {
|
|
93
|
+
const current = await this.requireGoal(scopeId);
|
|
94
|
+
const transitionAllowed = (status === "active" && (current.status === "paused" || current.status === "blocked")) ||
|
|
95
|
+
(status === "paused" && current.status === "active") ||
|
|
96
|
+
(status === "complete" && current.status !== "complete");
|
|
97
|
+
if (!transitionAllowed)
|
|
98
|
+
throw new Error(`Cannot change a ${current.status} goal to ${status}`);
|
|
99
|
+
const next = {
|
|
100
|
+
...current,
|
|
101
|
+
status,
|
|
102
|
+
blockedReason: undefined,
|
|
103
|
+
version: current.version + 1,
|
|
104
|
+
updatedAt: this.now().toISOString(),
|
|
105
|
+
};
|
|
106
|
+
if (!(await this.storage.replace(next, current.version))) {
|
|
107
|
+
throw new Error("Goal changed while its status was being updated; retry the command");
|
|
108
|
+
}
|
|
109
|
+
const pending = await this.storage.getPendingEvaluation(scopeId);
|
|
110
|
+
if (pending)
|
|
111
|
+
await this.storage.deletePendingEvaluation(scopeId, pending.evaluationId);
|
|
112
|
+
const candidate = await this.storage.getTerminalCandidate(scopeId);
|
|
113
|
+
if (candidate)
|
|
114
|
+
await this.storage.deleteTerminalCandidate(scopeId, candidate.candidateId);
|
|
115
|
+
const claim = await this.storage.getContinuationClaim(scopeId);
|
|
116
|
+
if (claim)
|
|
117
|
+
await this.storage.deleteContinuationClaim(scopeId, claim.claimId);
|
|
118
|
+
this.wakeScheduler.cancel(scopeId);
|
|
119
|
+
await this.record({ type: "goal.status_changed", goal: next, previousStatus: current.status });
|
|
120
|
+
return next;
|
|
121
|
+
}
|
|
122
|
+
async clear(scopeId) {
|
|
123
|
+
const current = await this.storage.get(scopeId);
|
|
124
|
+
if (!current)
|
|
125
|
+
return false;
|
|
126
|
+
const deleted = await this.storage.delete(scopeId, current.version);
|
|
127
|
+
if (deleted) {
|
|
128
|
+
this.wakeScheduler.cancel(scopeId);
|
|
129
|
+
await this.record({ type: "goal.cleared", goal: current });
|
|
130
|
+
}
|
|
131
|
+
return deleted;
|
|
132
|
+
}
|
|
133
|
+
async requestTerminalCandidate(scopeId, candidate) {
|
|
134
|
+
const goal = await this.requireGoal(scopeId);
|
|
135
|
+
if (goal.status !== "active") {
|
|
136
|
+
throw new Error(`Cannot request a terminal decision for a ${goal.status} goal`);
|
|
137
|
+
}
|
|
138
|
+
const reason = candidate.reason.trim();
|
|
139
|
+
if (!reason)
|
|
140
|
+
throw new Error("A terminal goal candidate requires a concrete reason");
|
|
141
|
+
const record = {
|
|
142
|
+
...candidate,
|
|
143
|
+
reason,
|
|
144
|
+
scopeId,
|
|
145
|
+
goalId: goal.id,
|
|
146
|
+
goalVersion: goal.version,
|
|
147
|
+
candidateId: randomUUID(),
|
|
148
|
+
createdAt: this.now().toISOString(),
|
|
149
|
+
};
|
|
150
|
+
if (!(await this.storage.putTerminalCandidate(record))) {
|
|
151
|
+
throw new Error("Goal changed while its terminal candidate was being recorded; retry");
|
|
152
|
+
}
|
|
153
|
+
await this.record({ type: "goal.terminal_candidate_requested", goal, candidate: record });
|
|
154
|
+
return record;
|
|
155
|
+
}
|
|
156
|
+
async start(scopeId, reason = "Begin working toward the new goal.") {
|
|
157
|
+
const goal = await this.requireGoal(scopeId);
|
|
158
|
+
if (goal.status !== "active")
|
|
159
|
+
throw new Error(`Cannot start a ${goal.status} goal`);
|
|
160
|
+
if (this.budgetExhausted(goal)) {
|
|
161
|
+
await this.markBudgetLimited(goal);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
await this.continueWithClaim(goal, reason);
|
|
165
|
+
}
|
|
166
|
+
async acknowledgeContinuation(scopeId) {
|
|
167
|
+
const claim = await this.storage.getContinuationClaim(scopeId);
|
|
168
|
+
if (claim)
|
|
169
|
+
await this.storage.deleteContinuationClaim(scopeId, claim.claimId);
|
|
170
|
+
this.wakeScheduler.cancel(scopeId);
|
|
171
|
+
}
|
|
172
|
+
async recover(scopeId) {
|
|
173
|
+
if (this.closed || this.recoveringScopes.has(scopeId))
|
|
174
|
+
return;
|
|
175
|
+
this.recoveringScopes.add(scopeId);
|
|
176
|
+
try {
|
|
177
|
+
if (await this.storage.getPendingEvaluation(scopeId))
|
|
178
|
+
await this.processPendingEvaluation(scopeId);
|
|
179
|
+
const goal = await this.storage.get(scopeId);
|
|
180
|
+
if (!goal || goal.status !== "active")
|
|
181
|
+
return;
|
|
182
|
+
if (this.budgetExhausted(goal)) {
|
|
183
|
+
await this.markBudgetLimited(goal);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
let claim = await this.storage.getContinuationClaim(scopeId);
|
|
187
|
+
const now = this.now().getTime();
|
|
188
|
+
if (claim && (claim.goalId !== goal.id || claim.goalVersion !== goal.version)) {
|
|
189
|
+
await this.storage.deleteContinuationClaim(scopeId, claim.claimId);
|
|
190
|
+
claim = undefined;
|
|
191
|
+
}
|
|
192
|
+
if (claim) {
|
|
193
|
+
const claimIsLive = Date.parse(claim.expiresAt) > now;
|
|
194
|
+
if (claimIsLive && claim.state === "started") {
|
|
195
|
+
this.scheduleRecovery(scopeId, claim.expiresAt);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (claimIsLive && claim.state === "deferred" && Date.parse(claim.availableAt) > now) {
|
|
199
|
+
this.scheduleRecovery(scopeId, claim.availableAt);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
await this.record({ type: "goal.recovered", goal });
|
|
203
|
+
await this.runContinuationClaim(goal, claim);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
await this.record({ type: "goal.recovered", goal });
|
|
207
|
+
await this.continueWithClaim(goal, "Resume the persisted active goal.");
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
this.recoveringScopes.delete(scopeId);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async settle(scopeId, progress) {
|
|
214
|
+
const settlementId = randomUUID();
|
|
215
|
+
const ownsEvaluation = !this.evaluatingScopes.has(scopeId);
|
|
216
|
+
if (ownsEvaluation)
|
|
217
|
+
this.evaluatingScopes.add(scopeId);
|
|
218
|
+
try {
|
|
219
|
+
const goal = await this.storage.get(scopeId);
|
|
220
|
+
if (!goal || goal.status !== "active")
|
|
221
|
+
return;
|
|
222
|
+
await this.acknowledgeContinuation(scopeId);
|
|
223
|
+
let durableCandidate = await this.storage.getTerminalCandidate(scopeId);
|
|
224
|
+
if (durableCandidate &&
|
|
225
|
+
(durableCandidate.goalId !== goal.id || durableCandidate.goalVersion !== goal.version)) {
|
|
226
|
+
await this.storage.deleteTerminalCandidate(scopeId, durableCandidate.candidateId);
|
|
227
|
+
durableCandidate = undefined;
|
|
228
|
+
}
|
|
229
|
+
const timestamp = this.now().toISOString();
|
|
230
|
+
const pending = {
|
|
231
|
+
scopeId,
|
|
232
|
+
goalId: goal.id,
|
|
233
|
+
goalVersion: goal.version,
|
|
234
|
+
evaluationId: settlementId,
|
|
235
|
+
iterationsDelta: 1,
|
|
236
|
+
progress: {
|
|
237
|
+
...progress,
|
|
238
|
+
tokenDelta: Math.max(0, progress.tokenDelta ?? 0),
|
|
239
|
+
terminalCandidate: progress.terminalCandidate ??
|
|
240
|
+
(durableCandidate
|
|
241
|
+
? { outcome: durableCandidate.outcome, reason: durableCandidate.reason }
|
|
242
|
+
: undefined),
|
|
243
|
+
},
|
|
244
|
+
attempt: 0,
|
|
245
|
+
availableAt: timestamp,
|
|
246
|
+
createdAt: timestamp,
|
|
247
|
+
updatedAt: timestamp,
|
|
248
|
+
};
|
|
249
|
+
if (!(await this.storage.appendPendingEvaluation(pending)))
|
|
250
|
+
return;
|
|
251
|
+
if (durableCandidate) {
|
|
252
|
+
await this.storage.deleteTerminalCandidate(scopeId, durableCandidate.candidateId);
|
|
253
|
+
}
|
|
254
|
+
if (!ownsEvaluation)
|
|
255
|
+
return;
|
|
256
|
+
await this.processPendingEvaluation(scopeId, true);
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
if (ownsEvaluation) {
|
|
260
|
+
this.evaluatingScopes.delete(scopeId);
|
|
261
|
+
if (!this.closed && (await this.storage.getPendingEvaluation(scopeId))) {
|
|
262
|
+
await this.processPendingEvaluation(scopeId);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async processPendingEvaluation(scopeId, ownsEvaluation = false) {
|
|
268
|
+
if (!ownsEvaluation) {
|
|
269
|
+
if (this.evaluatingScopes.has(scopeId))
|
|
270
|
+
return;
|
|
271
|
+
this.evaluatingScopes.add(scopeId);
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
while (true) {
|
|
275
|
+
const pending = await this.storage.getPendingEvaluation(scopeId);
|
|
276
|
+
if (!pending)
|
|
277
|
+
return;
|
|
278
|
+
const goal = await this.storage.get(scopeId);
|
|
279
|
+
if (!goal || goal.status !== "active") {
|
|
280
|
+
await this.storage.deletePendingEvaluation(scopeId, pending.evaluationId);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (goal.lastEvaluation?.id === pending.evaluationId) {
|
|
284
|
+
await this.storage.deletePendingEvaluation(scopeId, pending.evaluationId);
|
|
285
|
+
if (goal.lastEvaluation.outcome === "continue" && !this.budgetExhausted(goal)) {
|
|
286
|
+
await this.continueWithClaim(goal, goal.lastEvaluation.reason);
|
|
287
|
+
}
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (goal.id !== pending.goalId || goal.version !== pending.goalVersion) {
|
|
291
|
+
await this.storage.deletePendingEvaluation(scopeId, pending.evaluationId);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const waitMs = Date.parse(pending.availableAt) - this.now().getTime();
|
|
295
|
+
if (waitMs > 0)
|
|
296
|
+
await this.delay(waitMs);
|
|
297
|
+
const accountedUsage = {
|
|
298
|
+
iterations: goal.usage.iterations + pending.iterationsDelta,
|
|
299
|
+
tokens: goal.usage.tokens + (pending.progress.tokenDelta ?? 0),
|
|
300
|
+
};
|
|
301
|
+
const projectedGoal = { ...goal, usage: accountedUsage };
|
|
302
|
+
const evaluatorRequired = pending.progress.terminalCandidate !== undefined ||
|
|
303
|
+
this.budgetExhausted(projectedGoal) ||
|
|
304
|
+
(this.evaluationInterval > 0 &&
|
|
305
|
+
Math.floor(accountedUsage.iterations / this.evaluationInterval) >
|
|
306
|
+
Math.floor(goal.usage.iterations / this.evaluationInterval));
|
|
307
|
+
let evaluation = {
|
|
308
|
+
outcome: "continue",
|
|
309
|
+
reason: "The worker stopped without requesting a terminal goal decision.",
|
|
310
|
+
};
|
|
311
|
+
try {
|
|
312
|
+
if (evaluatorRequired) {
|
|
313
|
+
evaluation = await this.evaluator.evaluate(goal, pending.progress);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
if (this.closed)
|
|
318
|
+
return;
|
|
319
|
+
const latestPending = await this.storage.getPendingEvaluation(scopeId);
|
|
320
|
+
if (!latestPending || latestPending.evaluationId !== pending.evaluationId)
|
|
321
|
+
continue;
|
|
322
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
323
|
+
const attempt = pending.attempt + 1;
|
|
324
|
+
if (attempt >= this.retryPolicy.maxAttempts) {
|
|
325
|
+
const blockedAt = this.now().toISOString();
|
|
326
|
+
const reason = `evaluator failed after ${attempt} attempts: ${failure.message}`;
|
|
327
|
+
const blocked = {
|
|
328
|
+
...goal,
|
|
329
|
+
status: "blocked",
|
|
330
|
+
blockedReason: reason,
|
|
331
|
+
usage: accountedUsage,
|
|
332
|
+
lastSettledAt: blockedAt,
|
|
333
|
+
lastEvaluation: {
|
|
334
|
+
id: pending.evaluationId,
|
|
335
|
+
outcome: "blocked",
|
|
336
|
+
reason,
|
|
337
|
+
at: blockedAt,
|
|
338
|
+
},
|
|
339
|
+
version: goal.version + 1,
|
|
340
|
+
updatedAt: blockedAt,
|
|
341
|
+
};
|
|
342
|
+
if (!(await this.storage.commitEvaluation(blocked, goal.version, pending.evaluationId))) {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
await this.record({
|
|
346
|
+
type: "goal.progress_accounted",
|
|
347
|
+
goal: blocked,
|
|
348
|
+
tokenDelta: pending.progress.tokenDelta ?? 0,
|
|
349
|
+
});
|
|
350
|
+
await this.record({
|
|
351
|
+
type: "goal.retry_exhausted",
|
|
352
|
+
scopeId,
|
|
353
|
+
goalId: goal.id,
|
|
354
|
+
operation: "evaluator",
|
|
355
|
+
attempt,
|
|
356
|
+
error: failure.message,
|
|
357
|
+
});
|
|
358
|
+
await this.record({
|
|
359
|
+
type: "goal.status_changed",
|
|
360
|
+
goal: blocked,
|
|
361
|
+
previousStatus: goal.status,
|
|
362
|
+
});
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const retryAt = new Date(this.now().getTime() + this.retryDelay(attempt)).toISOString();
|
|
366
|
+
const retry = {
|
|
367
|
+
...pending,
|
|
368
|
+
attempt,
|
|
369
|
+
availableAt: retryAt,
|
|
370
|
+
lastError: failure.message,
|
|
371
|
+
updatedAt: this.now().toISOString(),
|
|
372
|
+
};
|
|
373
|
+
if (!(await this.storage.replacePendingEvaluation(retry, pending.evaluationId))) {
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
await this.record({
|
|
377
|
+
type: "goal.retry_scheduled",
|
|
378
|
+
scopeId,
|
|
379
|
+
goalId: goal.id,
|
|
380
|
+
operation: "evaluator",
|
|
381
|
+
attempt,
|
|
382
|
+
availableAt: retryAt,
|
|
383
|
+
error: failure.message,
|
|
384
|
+
});
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (this.closed)
|
|
388
|
+
return;
|
|
389
|
+
const latestPending = await this.storage.getPendingEvaluation(scopeId);
|
|
390
|
+
if (!latestPending || latestPending.evaluationId !== pending.evaluationId)
|
|
391
|
+
continue;
|
|
392
|
+
const evaluatedAt = this.now().toISOString();
|
|
393
|
+
const evaluated = {
|
|
394
|
+
...goal,
|
|
395
|
+
status: evaluation.outcome === "continue" ? "active" : evaluation.outcome,
|
|
396
|
+
blockedReason: evaluation.outcome === "blocked" ? evaluation.reason : undefined,
|
|
397
|
+
usage: accountedUsage,
|
|
398
|
+
lastSettledAt: evaluatedAt,
|
|
399
|
+
lastEvaluation: { id: pending.evaluationId, ...evaluation, at: evaluatedAt },
|
|
400
|
+
version: goal.version + 1,
|
|
401
|
+
updatedAt: evaluatedAt,
|
|
402
|
+
};
|
|
403
|
+
const next = evaluation.outcome === "continue" && this.budgetExhausted(evaluated)
|
|
404
|
+
? {
|
|
405
|
+
...evaluated,
|
|
406
|
+
status: "budget_limited",
|
|
407
|
+
blockedReason: "Goal continuation budget exhausted",
|
|
408
|
+
}
|
|
409
|
+
: evaluated;
|
|
410
|
+
if (!(await this.storage.commitEvaluation(next, goal.version, pending.evaluationId))) {
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
await this.record({
|
|
414
|
+
type: "goal.progress_accounted",
|
|
415
|
+
goal: next,
|
|
416
|
+
tokenDelta: pending.progress.tokenDelta ?? 0,
|
|
417
|
+
});
|
|
418
|
+
if (evaluatorRequired) {
|
|
419
|
+
await this.record({ type: "goal.evaluated", goal: next, evaluation });
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
await this.record({ type: "goal.auto_continued", goal: next });
|
|
423
|
+
}
|
|
424
|
+
if (next.status === "active")
|
|
425
|
+
await this.continueWithClaim(next, evaluation.reason);
|
|
426
|
+
else
|
|
427
|
+
await this.record({
|
|
428
|
+
type: "goal.status_changed",
|
|
429
|
+
goal: next,
|
|
430
|
+
previousStatus: goal.status,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
finally {
|
|
435
|
+
if (!ownsEvaluation) {
|
|
436
|
+
this.evaluatingScopes.delete(scopeId);
|
|
437
|
+
if (!this.closed && (await this.storage.getPendingEvaluation(scopeId))) {
|
|
438
|
+
await this.processPendingEvaluation(scopeId);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
async continueWithClaim(goal, reason) {
|
|
444
|
+
const existingClaim = await this.storage.getContinuationClaim(goal.scopeId);
|
|
445
|
+
if (existingClaim) {
|
|
446
|
+
if (existingClaim.goalId === goal.id && existingClaim.goalVersion === goal.version)
|
|
447
|
+
return;
|
|
448
|
+
await this.storage.deleteContinuationClaim(goal.scopeId, existingClaim.claimId);
|
|
449
|
+
}
|
|
450
|
+
const timestamp = this.now().toISOString();
|
|
451
|
+
const claim = {
|
|
452
|
+
scopeId: goal.scopeId,
|
|
453
|
+
goalId: goal.id,
|
|
454
|
+
goalVersion: goal.version,
|
|
455
|
+
claimId: randomUUID(),
|
|
456
|
+
state: "claimed",
|
|
457
|
+
reason,
|
|
458
|
+
attempt: 0,
|
|
459
|
+
availableAt: timestamp,
|
|
460
|
+
expiresAt: new Date(this.now().getTime() + this.claimTtlMs).toISOString(),
|
|
461
|
+
createdAt: timestamp,
|
|
462
|
+
updatedAt: timestamp,
|
|
463
|
+
};
|
|
464
|
+
if (!(await this.storage.createContinuationClaim(claim)))
|
|
465
|
+
return;
|
|
466
|
+
await this.record({ type: "goal.continuation_claimed", goal, claim });
|
|
467
|
+
await this.runContinuationClaim(goal, claim);
|
|
468
|
+
}
|
|
469
|
+
async runContinuationClaim(goal, claim) {
|
|
470
|
+
for (let attempt = claim.attempt + 1; attempt <= this.retryPolicy.maxAttempts; attempt += 1) {
|
|
471
|
+
const currentGoal = await this.storage.get(goal.scopeId);
|
|
472
|
+
const currentClaim = await this.storage.getContinuationClaim(goal.scopeId);
|
|
473
|
+
if (!currentGoal ||
|
|
474
|
+
currentGoal.id !== goal.id ||
|
|
475
|
+
currentGoal.version !== goal.version ||
|
|
476
|
+
currentGoal.status !== "active" ||
|
|
477
|
+
currentClaim?.claimId !== claim.claimId) {
|
|
478
|
+
if (currentClaim?.claimId === claim.claimId) {
|
|
479
|
+
await this.storage.deleteContinuationClaim(goal.scopeId, claim.claimId);
|
|
480
|
+
}
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const waitMs = Date.parse(claim.availableAt) - this.now().getTime();
|
|
484
|
+
if (waitMs > 0)
|
|
485
|
+
await this.delay(waitMs);
|
|
486
|
+
claim.attempt = attempt;
|
|
487
|
+
let result;
|
|
488
|
+
try {
|
|
489
|
+
result = await this.continuation.continueIfIdle(goal, {
|
|
490
|
+
claimId: claim.claimId,
|
|
491
|
+
idempotencyKey: `${goal.id}:${goal.version}`,
|
|
492
|
+
expectedGoalVersion: goal.version,
|
|
493
|
+
reason: claim.reason,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
catch (error) {
|
|
497
|
+
result = {
|
|
498
|
+
status: "unavailable",
|
|
499
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
if (this.closed)
|
|
503
|
+
return;
|
|
504
|
+
if (result.status === "started") {
|
|
505
|
+
claim.state = "started";
|
|
506
|
+
claim.lastError = undefined;
|
|
507
|
+
claim.updatedAt = this.now().toISOString();
|
|
508
|
+
if (!(await this.storage.replaceContinuationClaim(claim, claim.claimId)))
|
|
509
|
+
return;
|
|
510
|
+
this.scheduleRecovery(goal.scopeId, claim.expiresAt);
|
|
511
|
+
await this.record({ type: "goal.continuation_started", goal, claim });
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const retryDelay = result.retryAfterMs ?? this.retryDelay(attempt);
|
|
515
|
+
claim.state = "deferred";
|
|
516
|
+
claim.lastError = result.reason;
|
|
517
|
+
claim.availableAt = new Date(this.now().getTime() + retryDelay).toISOString();
|
|
518
|
+
claim.updatedAt = this.now().toISOString();
|
|
519
|
+
if (!(await this.storage.replaceContinuationClaim(claim, claim.claimId)))
|
|
520
|
+
return;
|
|
521
|
+
if (result.status === "busy") {
|
|
522
|
+
this.scheduleRecovery(goal.scopeId, claim.availableAt);
|
|
523
|
+
await this.record({ type: "goal.continuation_deferred", goal, claim });
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
if (attempt < this.retryPolicy.maxAttempts) {
|
|
527
|
+
await this.record({
|
|
528
|
+
type: "goal.retry_scheduled",
|
|
529
|
+
scopeId: goal.scopeId,
|
|
530
|
+
goalId: goal.id,
|
|
531
|
+
operation: "continuation",
|
|
532
|
+
attempt,
|
|
533
|
+
availableAt: claim.availableAt,
|
|
534
|
+
error: result.reason,
|
|
535
|
+
claimId: claim.claimId,
|
|
536
|
+
});
|
|
537
|
+
await this.delay(retryDelay);
|
|
538
|
+
claim.state = "claimed";
|
|
539
|
+
claim.updatedAt = this.now().toISOString();
|
|
540
|
+
if (!(await this.storage.replaceContinuationClaim(claim, claim.claimId)))
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
await this.blockAfterRetryExhaustion(goal, "continuation", new Error(claim.lastError ?? "Continuation unavailable"), claim.attempt, claim.claimId);
|
|
545
|
+
}
|
|
546
|
+
async blockAfterRetryExhaustion(goal, operation, error, attempt, claimId) {
|
|
547
|
+
const current = await this.storage.get(goal.scopeId);
|
|
548
|
+
if (!current ||
|
|
549
|
+
current.id !== goal.id ||
|
|
550
|
+
current.version !== goal.version ||
|
|
551
|
+
current.status !== "active") {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (claimId) {
|
|
555
|
+
const claim = await this.storage.getContinuationClaim(goal.scopeId);
|
|
556
|
+
if (claim?.claimId !== claimId)
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const next = {
|
|
560
|
+
...current,
|
|
561
|
+
status: "blocked",
|
|
562
|
+
blockedReason: `${operation} failed after ${attempt} attempts: ${error.message}`,
|
|
563
|
+
version: current.version + 1,
|
|
564
|
+
updatedAt: this.now().toISOString(),
|
|
565
|
+
};
|
|
566
|
+
if (!(await this.storage.replace(next, current.version)))
|
|
567
|
+
return;
|
|
568
|
+
if (claimId)
|
|
569
|
+
await this.storage.deleteContinuationClaim(goal.scopeId, claimId);
|
|
570
|
+
this.wakeScheduler.cancel(goal.scopeId);
|
|
571
|
+
await this.record({
|
|
572
|
+
type: "goal.retry_exhausted",
|
|
573
|
+
scopeId: goal.scopeId,
|
|
574
|
+
goalId: goal.id,
|
|
575
|
+
operation,
|
|
576
|
+
attempt,
|
|
577
|
+
error: error.message,
|
|
578
|
+
claimId,
|
|
579
|
+
});
|
|
580
|
+
await this.record({ type: "goal.status_changed", goal: next, previousStatus: current.status });
|
|
581
|
+
}
|
|
582
|
+
budgetExhausted(goal) {
|
|
583
|
+
return (goal.usage.iterations >= goal.budget.maxIterations ||
|
|
584
|
+
(goal.budget.maxTokens !== undefined && goal.usage.tokens >= goal.budget.maxTokens) ||
|
|
585
|
+
(goal.budget.maxRuntimeMs !== undefined &&
|
|
586
|
+
this.now().getTime() - Date.parse(goal.createdAt) >= goal.budget.maxRuntimeMs));
|
|
587
|
+
}
|
|
588
|
+
async markBudgetLimited(goal) {
|
|
589
|
+
const next = {
|
|
590
|
+
...goal,
|
|
591
|
+
status: "budget_limited",
|
|
592
|
+
blockedReason: "Goal continuation budget exhausted",
|
|
593
|
+
version: goal.version + 1,
|
|
594
|
+
updatedAt: this.now().toISOString(),
|
|
595
|
+
};
|
|
596
|
+
if (await this.storage.replace(next, goal.version)) {
|
|
597
|
+
this.wakeScheduler.cancel(goal.scopeId);
|
|
598
|
+
await this.record({ type: "goal.status_changed", goal: next, previousStatus: goal.status });
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
scheduleRecovery(scopeId, wakeAt) {
|
|
602
|
+
if (this.closed)
|
|
603
|
+
return;
|
|
604
|
+
this.wakeScheduler.schedule(scopeId, wakeAt, () => {
|
|
605
|
+
void this.recover(scopeId).catch((error) => this.record({
|
|
606
|
+
type: "goal.error",
|
|
607
|
+
operation: "scheduled recovery",
|
|
608
|
+
error: error instanceof Error ? error.message : String(error),
|
|
609
|
+
}));
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
retryDelay(attempt) {
|
|
613
|
+
return Math.min(this.retryPolicy.baseDelayMs * 2 ** (attempt - 1), this.retryPolicy.maxDelayMs);
|
|
614
|
+
}
|
|
615
|
+
async record(event) {
|
|
616
|
+
try {
|
|
617
|
+
await this.eventSink?.record(event);
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
// Event sinks are observational and must not own goal lifecycle progress.
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
async requireGoal(scopeId) {
|
|
624
|
+
const goal = await this.storage.get(scopeId);
|
|
625
|
+
if (!goal)
|
|
626
|
+
throw new Error("This session has no goal");
|
|
627
|
+
return goal;
|
|
628
|
+
}
|
|
629
|
+
}
|