ai-foreman 1.7.6 → 1.7.7
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/README.md +16 -1
- package/dist/activity.d.ts +3 -0
- package/dist/activity.js +29 -4
- package/dist/adapters/claude.d.ts +33 -2
- package/dist/adapters/claude.js +208 -17
- package/dist/adapters/codex.d.ts +11 -1
- package/dist/adapters/codex.js +195 -20
- package/dist/adapters/recovering.d.ts +8 -2
- package/dist/adapters/recovering.js +23 -3
- package/dist/adapters/sessionFailure.d.ts +18 -0
- package/dist/adapters/sessionFailure.js +51 -0
- package/dist/adapters/types.d.ts +52 -9
- package/dist/agentRun.d.ts +4 -1
- package/dist/agentRun.js +96 -9
- package/dist/branch/currentGuard.d.ts +34 -0
- package/dist/branch/currentGuard.js +65 -0
- package/dist/branch/planner.d.ts +1 -1
- package/dist/branch/planner.js +2 -7
- package/dist/branch/prefix.d.ts +8 -0
- package/dist/branch/prefix.js +26 -0
- package/dist/branch/resume.d.ts +2 -0
- package/dist/branch/resume.js +8 -0
- package/dist/branch/runner.d.ts +14 -2
- package/dist/branch/runner.js +77 -17
- package/dist/buildRuns.d.ts +13 -4
- package/dist/buildRuns.js +85 -19
- package/dist/cli/handoffs.d.ts +2 -0
- package/dist/cli/handoffs.js +74 -0
- package/dist/cli/start.js +940 -42
- package/dist/cli/tickets.js +199 -3
- package/dist/continuity.d.ts +86 -0
- package/dist/continuity.js +365 -0
- package/dist/foreman.d.ts +5 -1
- package/dist/foreman.js +41 -6
- package/dist/handoffs.d.ts +69 -0
- package/dist/handoffs.js +326 -0
- package/dist/log.d.ts +1 -1
- package/dist/permissions/policy.d.ts +5 -1
- package/dist/permissions/policy.js +48 -1
- package/dist/qaReview.d.ts +6 -6
- package/dist/qaReview.js +20 -14
- package/dist/sessionAvailability.d.ts +15 -0
- package/dist/sessionAvailability.js +57 -0
- package/dist/sessionIdentity.d.ts +33 -0
- package/dist/sessionIdentity.js +132 -0
- package/dist/sessionLifecycle.d.ts +102 -3
- package/dist/sessionLifecycle.js +578 -20
- package/dist/statusReporter.d.ts +58 -8
- package/dist/statusReporter.js +210 -14
- package/dist/ticketPlanning.js +8 -0
- package/dist/ticketPopulation.js +88 -12
- package/dist/tickets/commands.js +20 -12
- package/dist/tickets/groupReset.d.ts +25 -0
- package/dist/tickets/groupReset.js +279 -0
- package/dist/tickets/groups.d.ts +36 -0
- package/dist/tickets/groups.js +68 -0
- package/dist/tickets/importer.js +27 -3
- package/dist/tickets/recommendations.js +31 -7
- package/dist/tickets/reset.d.ts +1 -0
- package/dist/tickets/reset.js +1 -0
- package/dist/tickets/setupConfig.d.ts +1 -0
- package/dist/tickets/setupConfig.js +10 -2
- package/dist/tickets/stateDb.d.ts +34 -0
- package/dist/tickets/stateDb.js +207 -0
- package/dist/tickets/ticketLoader.js +5 -2
- package/dist/tickets/validate.js +4 -0
- package/dist/workflowDb.d.ts +99 -2
- package/dist/workflowDb.js +434 -8
- package/package.json +31 -3
package/dist/sessionLifecycle.js
CHANGED
|
@@ -1,27 +1,112 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { SessionUnavailableError, sessionUnavailableErrorFromFailure } from "./adapters/sessionFailure.js";
|
|
1
3
|
import { sanitizeDiagnostics } from "./runtimeReadiness.js";
|
|
4
|
+
import { WorkflowDb } from "./workflowDb.js";
|
|
5
|
+
import { providerSessionKey } from "./sessionIdentity.js";
|
|
6
|
+
export class RoleSessionValidationError extends Error {
|
|
7
|
+
role;
|
|
8
|
+
availability;
|
|
9
|
+
constructor(role, availability) {
|
|
10
|
+
super(`exact ${role} session is ${availability.status} (${availability.reason ?? "validation failed"}): ${availability.detail ?? "no provider detail"}`);
|
|
11
|
+
this.role = role;
|
|
12
|
+
this.availability = availability;
|
|
13
|
+
this.name = "RoleSessionValidationError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
2
16
|
/** One lifecycle implementation shared by interviews, builds, QA, and recovery. */
|
|
3
17
|
export class RoleSessionController {
|
|
4
18
|
options;
|
|
5
19
|
adapter;
|
|
6
20
|
workSessions = 0;
|
|
21
|
+
thresholdController;
|
|
7
22
|
constructor(options) {
|
|
8
23
|
this.options = options;
|
|
9
24
|
}
|
|
10
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Production facade: the role owner authorizes scoped adapters and validated
|
|
27
|
+
* successors while the threshold controller remains delegated policy and
|
|
28
|
+
* accounting. Compatibility callers may still construct the threshold
|
|
29
|
+
* controller directly.
|
|
30
|
+
*/
|
|
31
|
+
static managed(options) {
|
|
32
|
+
const controller = new RoleSessionController({
|
|
33
|
+
role: options.role,
|
|
34
|
+
settings: options.initialSettings,
|
|
35
|
+
create: async () => { throw new Error("managed role sessions create adapters only through validated lifecycle callbacks"); },
|
|
36
|
+
});
|
|
37
|
+
const settingsBoundary = options.settingsBoundary
|
|
38
|
+
? async (input) => {
|
|
39
|
+
const predecessor = controller.captureManagedIdentity(input.adapter);
|
|
40
|
+
const successor = await options.settingsBoundary(input);
|
|
41
|
+
return controller.acceptManagedTransition(predecessor, successor, "settings");
|
|
42
|
+
}
|
|
43
|
+
: undefined;
|
|
44
|
+
const handoff = options.handoff
|
|
45
|
+
? async (input) => {
|
|
46
|
+
const predecessor = controller.captureManagedIdentity(input.adapter);
|
|
47
|
+
const successor = await options.handoff(input);
|
|
48
|
+
return controller.acceptManagedTransition(predecessor, successor, "handoff");
|
|
49
|
+
}
|
|
50
|
+
: undefined;
|
|
51
|
+
controller.thresholdController = new ThresholdCompactionController({ ...options, settingsBoundary, handoff });
|
|
52
|
+
return controller;
|
|
53
|
+
}
|
|
54
|
+
async atSafeBoundary(adapter, frozenAction) {
|
|
55
|
+
if (!this.thresholdController)
|
|
56
|
+
throw new Error("this role session controller does not own threshold lifecycle policy");
|
|
57
|
+
await this.adoptManagedAdapter(adapter);
|
|
58
|
+
const result = await this.thresholdController.atSafeBoundary(adapter, frozenAction);
|
|
59
|
+
this.adapter = result.adapter;
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
async atWorkSessionBoundary(adapter, frozenAction, strategy) {
|
|
63
|
+
if (!this.thresholdController)
|
|
64
|
+
throw new Error("this role session controller does not own threshold lifecycle policy");
|
|
65
|
+
await this.adoptManagedAdapter(adapter);
|
|
66
|
+
const result = await this.thresholdController.atWorkSessionBoundary(adapter, frozenAction, strategy);
|
|
67
|
+
this.adapter = result.adapter;
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
effectiveSettings() {
|
|
71
|
+
return this.thresholdController?.effectiveSettings() ?? this.options.settings;
|
|
72
|
+
}
|
|
73
|
+
effectiveThreshold() {
|
|
74
|
+
if (!this.thresholdController)
|
|
75
|
+
return this.options.settings.auto_compact_threshold_percent ?? 50;
|
|
76
|
+
return this.thresholdController.effectiveThreshold();
|
|
77
|
+
}
|
|
78
|
+
async next(durableHandoff, nextSettings) {
|
|
11
79
|
this.workSessions += 1;
|
|
12
80
|
if (!this.adapter) {
|
|
13
81
|
if (nextSettings)
|
|
14
82
|
this.options.settings = nextSettings;
|
|
15
|
-
|
|
83
|
+
if (this.options.initialSessionId && !this.options.initialSessionRef) {
|
|
84
|
+
throw new RoleSessionValidationError(this.options.role, {
|
|
85
|
+
version: 1, status: "unknown", checkedAt: (this.options.now?.() ?? new Date()).toISOString(),
|
|
86
|
+
reason: "legacy-unscoped", detail: "a raw provider session ID has no verifiable location scope",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
this.adapter = await this.options.create(this.options.settings, this.options.initialSessionRef?.sessionId ?? this.options.initialSessionId, this.options.initialSessionRef);
|
|
90
|
+
if (this.options.initialSessionRef) {
|
|
91
|
+
const availability = await (this.adapter.validateSession?.() ?? Promise.resolve({
|
|
92
|
+
version: 1, status: "unknown", checkedAt: (this.options.now?.() ?? new Date()).toISOString(),
|
|
93
|
+
reason: "legacy-unscoped", detail: "provider adapter does not expose exact-session validation", sessionRef: this.options.initialSessionRef,
|
|
94
|
+
}));
|
|
95
|
+
if (availability.status !== "available" || !availability.sessionRef) {
|
|
96
|
+
await this.adapter.close().catch(() => { });
|
|
97
|
+
this.adapter = undefined;
|
|
98
|
+
throw new RoleSessionValidationError(this.options.role, availability);
|
|
99
|
+
}
|
|
100
|
+
this.adapter.adoptSessionRef?.(availability.sessionRef);
|
|
101
|
+
}
|
|
16
102
|
return this.boundary("initial", "started a workflow-scoped provider conversation");
|
|
17
103
|
}
|
|
18
104
|
if (nextSettings) {
|
|
19
105
|
const current = this.options.settings;
|
|
20
106
|
if (nextSettings.make !== current.make) {
|
|
21
|
-
await this.adapter.close();
|
|
22
107
|
this.options.settings = nextSettings;
|
|
23
|
-
|
|
24
|
-
return this.boundary("settings-fallback",
|
|
108
|
+
const validated = await this.moveFresh(durableHandoff, `provider changed from ${current.make} to ${nextSettings.make}`);
|
|
109
|
+
return this.boundary("settings-fallback", `${validated ? "validated cumulative handoff" : "fresh compatibility fallback"} after provider changed from ${current.make} to ${nextSettings.make}`);
|
|
25
110
|
}
|
|
26
111
|
const changed = nextSettings.model !== current.model || nextSettings.reasoning !== current.reasoning || nextSettings.fast !== current.fast;
|
|
27
112
|
this.options.settings = nextSettings;
|
|
@@ -30,27 +115,26 @@ export class RoleSessionController {
|
|
|
30
115
|
const switched = this.adapter.switchSettings
|
|
31
116
|
? await this.adapter.switchSettings({ model: nextSettings.model === "default" ? undefined : nextSettings.model, effort: effort(nextSettings.reasoning), fast: nextSettings.fast })
|
|
32
117
|
: { ok: false, error: "provider adapter does not support an in-conversation settings switch" };
|
|
118
|
+
if (switched.failure?.category === "session-unavailable")
|
|
119
|
+
throw sessionUnavailableErrorFromFailure(switched.failure);
|
|
33
120
|
if (!switched.ok) {
|
|
34
|
-
await this.adapter.close();
|
|
35
|
-
this.adapter = await this.options.create(nextSettings);
|
|
36
121
|
const detail = sanitize(switched.error ?? "settings switch failed");
|
|
37
|
-
const
|
|
38
|
-
|
|
122
|
+
const validated = await this.moveFresh(durableHandoff, `settings switch failed: ${detail}`);
|
|
123
|
+
const issue = this.issue("session_model_switch_failure", detail, validated ? "The role continued through a validated cumulative handoff." : "A validated handoff callback was unavailable; inspect continuity before proceeding.");
|
|
124
|
+
return this.boundary("settings-fallback", `settings switch failed (${detail}); ${validated ? "continued through a validated fresh successor" : "used the compatibility fresh fallback"}`, issue);
|
|
39
125
|
}
|
|
40
126
|
this.emit("settings-continued", "provider accepted the same-conversation settings switch");
|
|
41
127
|
}
|
|
42
128
|
}
|
|
43
129
|
if (this.options.settings.session_strategy === "fresh") {
|
|
44
|
-
await this.
|
|
45
|
-
|
|
46
|
-
const result = this.boundary("fresh", "started a fresh conversation with a durable handoff");
|
|
130
|
+
const validated = await this.moveFresh(durableHandoff, "ordinary session_strategy=fresh boundary");
|
|
131
|
+
const result = this.boundary("fresh", validated ? "transferred to a validated fresh successor with the durable handoff" : "started a compatibility fresh conversation without host validation");
|
|
47
132
|
return result;
|
|
48
133
|
}
|
|
49
134
|
if (!this.adapter.sessionId()) {
|
|
50
|
-
await this.
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
const result = this.boundary("missing", "continuity unavailable; started fresh from the durable handoff", issue);
|
|
135
|
+
const validated = await this.moveFresh(durableHandoff, "exact provider session identity is unavailable");
|
|
136
|
+
const issue = this.issue("session_missing", "exact provider session is unavailable", validated ? "Continued through a validated cumulative handoff." : "Validated handoff support is unavailable; inspect continuity before proceeding.");
|
|
137
|
+
const result = this.boundary("missing", validated ? "exact session unavailable; transferred to a validated fresh successor" : "exact session unavailable; used the compatibility fresh fallback", issue);
|
|
54
138
|
return result;
|
|
55
139
|
}
|
|
56
140
|
let firstError = "";
|
|
@@ -59,26 +143,132 @@ export class RoleSessionController {
|
|
|
59
143
|
const compacted = this.adapter.compact
|
|
60
144
|
? await this.adapter.compact()
|
|
61
145
|
: { ok: false, error: "provider adapter does not expose native compaction" };
|
|
146
|
+
if (compacted.failure?.category === "session-unavailable")
|
|
147
|
+
throw sessionUnavailableErrorFromFailure(compacted.failure);
|
|
62
148
|
if (compacted.ok)
|
|
63
149
|
return this.boundary("compacted", attempt === 1 ? "compacted and continued the exact conversation" : "compaction retry succeeded; continued the exact conversation");
|
|
64
150
|
firstError = sanitize(compacted.error ?? "provider reported compaction failure");
|
|
65
151
|
}
|
|
66
152
|
catch (error) {
|
|
153
|
+
if (error instanceof SessionUnavailableError)
|
|
154
|
+
throw error;
|
|
67
155
|
firstError = sanitize(error);
|
|
68
156
|
}
|
|
69
157
|
if (attempt === 1)
|
|
70
158
|
this.emit("compaction-retry", `compaction failed (${firstError}); retrying once`);
|
|
71
159
|
}
|
|
72
|
-
await this.
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
const result = this.boundary("compaction-fallback", `compaction failed twice (${firstError}); started fresh and continuity was lost`, issue);
|
|
160
|
+
const validated = await this.moveFresh(durableHandoff, `native compaction failed twice: ${firstError}`);
|
|
161
|
+
const issue = this.issue("session_compaction_failure", firstError, validated ? "Inspect provider health; the role continued through a validated cumulative handoff." : "Validated handoff support is unavailable; inspect continuity before proceeding.");
|
|
162
|
+
const result = this.boundary("compaction-fallback", `compaction failed twice (${firstError}); ${validated ? "continued through a validated fresh successor" : "used the compatibility fresh fallback"}`, issue);
|
|
76
163
|
return result;
|
|
77
164
|
}
|
|
78
165
|
current() { return this.adapter; }
|
|
79
166
|
count() { return this.workSessions; }
|
|
80
167
|
async usage() { return this.adapter?.contextUsage?.(); }
|
|
81
168
|
async close() { await this.adapter?.close(); this.adapter = undefined; }
|
|
169
|
+
async moveFresh(durableHandoff, reason) {
|
|
170
|
+
const predecessor = this.adapter;
|
|
171
|
+
if (this.options.handoff) {
|
|
172
|
+
const successor = await this.options.handoff({ predecessor, durableHandoff, reason, settings: this.options.settings });
|
|
173
|
+
const predecessorRef = predecessor.sessionRef?.();
|
|
174
|
+
const successorRef = successor.sessionRef?.();
|
|
175
|
+
if (predecessorRef && (!successorRef || providerSessionKey(predecessorRef) === providerSessionKey(successorRef))) {
|
|
176
|
+
if (successor !== predecessor)
|
|
177
|
+
await successor.close().catch(() => { });
|
|
178
|
+
throw new Error("validated handoff did not return a genuinely fresh location-scoped successor");
|
|
179
|
+
}
|
|
180
|
+
this.adapter = successor;
|
|
181
|
+
if (successor !== predecessor)
|
|
182
|
+
await predecessor.close().catch(() => { });
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
await predecessor.close();
|
|
186
|
+
this.adapter = await this.options.create(this.options.settings);
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
async adoptManagedAdapter(adapter) {
|
|
190
|
+
const sessionId = adapter.sessionId();
|
|
191
|
+
let ref = adapter.sessionRef?.();
|
|
192
|
+
if (sessionId && !ref) {
|
|
193
|
+
throw new RoleSessionValidationError(this.options.role, {
|
|
194
|
+
version: 1,
|
|
195
|
+
status: "unknown",
|
|
196
|
+
checkedAt: (this.options.now?.() ?? new Date()).toISOString(),
|
|
197
|
+
reason: "legacy-unscoped",
|
|
198
|
+
detail: "a live provider session ID has no location-scoped reference",
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
if (ref?.role !== undefined && ref.role !== this.options.role) {
|
|
202
|
+
throw new RoleSessionValidationError(this.options.role, {
|
|
203
|
+
version: 1,
|
|
204
|
+
status: "unavailable",
|
|
205
|
+
checkedAt: (this.options.now?.() ?? new Date()).toISOString(),
|
|
206
|
+
reason: "role-mismatch",
|
|
207
|
+
detail: `provider session belongs to role ${ref.role}, not ${this.options.role}`,
|
|
208
|
+
sessionRef: ref,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (ref?.source === "legacy-inferred" && !ref.validatedAt) {
|
|
212
|
+
const availability = await (adapter.validateSession?.() ?? Promise.resolve({
|
|
213
|
+
version: 1,
|
|
214
|
+
status: "unknown",
|
|
215
|
+
checkedAt: (this.options.now?.() ?? new Date()).toISOString(),
|
|
216
|
+
reason: "legacy-unscoped",
|
|
217
|
+
detail: "provider adapter does not expose exact-session validation",
|
|
218
|
+
sessionRef: ref,
|
|
219
|
+
}));
|
|
220
|
+
if (availability.status !== "available" || !availability.sessionRef)
|
|
221
|
+
throw new RoleSessionValidationError(this.options.role, availability);
|
|
222
|
+
ref = availability.sessionRef;
|
|
223
|
+
adapter.adoptSessionRef?.(ref);
|
|
224
|
+
}
|
|
225
|
+
this.adapter = adapter;
|
|
226
|
+
}
|
|
227
|
+
captureManagedIdentity(adapter) {
|
|
228
|
+
const sessionRef = adapter.sessionRef?.();
|
|
229
|
+
return {
|
|
230
|
+
...(adapter.sessionId() ? { sessionId: adapter.sessionId() } : {}),
|
|
231
|
+
...(sessionRef ? { sessionRef, sessionKey: providerSessionKey(sessionRef) } : {}),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
acceptManagedTransition(predecessor, successor, kind) {
|
|
235
|
+
const successorRef = successor.sessionRef?.();
|
|
236
|
+
const successorId = successor.sessionId();
|
|
237
|
+
if (!successorId || !successorRef) {
|
|
238
|
+
throw new RoleSessionValidationError(this.options.role, {
|
|
239
|
+
version: 1,
|
|
240
|
+
status: "unknown",
|
|
241
|
+
checkedAt: (this.options.now?.() ?? new Date()).toISOString(),
|
|
242
|
+
reason: "legacy-unscoped",
|
|
243
|
+
detail: `${kind} transition did not return a location-scoped successor`,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
if (successorRef.role !== this.options.role) {
|
|
247
|
+
throw new RoleSessionValidationError(this.options.role, {
|
|
248
|
+
version: 1,
|
|
249
|
+
status: "unavailable",
|
|
250
|
+
checkedAt: (this.options.now?.() ?? new Date()).toISOString(),
|
|
251
|
+
reason: "role-mismatch",
|
|
252
|
+
detail: `${kind} successor belongs to role ${successorRef.role}`,
|
|
253
|
+
sessionRef: successorRef,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
const successorKey = providerSessionKey(successorRef);
|
|
257
|
+
const changed = predecessor.sessionKey !== successorKey;
|
|
258
|
+
if (kind === "handoff" && !changed)
|
|
259
|
+
throw new Error("validated handoff reused the predecessor's scoped provider session");
|
|
260
|
+
if (changed) {
|
|
261
|
+
if (!successorRef.validatedAt)
|
|
262
|
+
throw new Error(`${kind} successor was not validated before adoption`);
|
|
263
|
+
}
|
|
264
|
+
if (changed && predecessor.sessionRef) {
|
|
265
|
+
if (successorRef.generation <= predecessor.sessionRef.generation) {
|
|
266
|
+
throw new Error(`${kind} successor generation did not advance after validated acceptance`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
this.adapter = successor;
|
|
270
|
+
return successor;
|
|
271
|
+
}
|
|
82
272
|
boundary(kind, message, issue) {
|
|
83
273
|
const transition = this.emit(kind, message);
|
|
84
274
|
return { adapter: this.adapter, transition, issue };
|
|
@@ -96,9 +286,377 @@ export class RoleSessionController {
|
|
|
96
286
|
};
|
|
97
287
|
}
|
|
98
288
|
}
|
|
289
|
+
export class ContextCapabilityError extends Error {
|
|
290
|
+
runId;
|
|
291
|
+
role;
|
|
292
|
+
constructor(runId, role, message) {
|
|
293
|
+
super(`${message}; new ${role} work is paused because truthful context occupancy is required for run ${runId}`);
|
|
294
|
+
this.runId = runId;
|
|
295
|
+
this.role = role;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/** Threshold-triggered native compaction at host-owned safe boundaries. */
|
|
299
|
+
export class ThresholdCompactionController {
|
|
300
|
+
options;
|
|
301
|
+
settings;
|
|
302
|
+
armed = true;
|
|
303
|
+
crossing = 0;
|
|
304
|
+
seenSessions = new Set();
|
|
305
|
+
runOnlyThreshold;
|
|
306
|
+
bootstrapScheduled = false;
|
|
307
|
+
bootstrapRecoveryDepth = 0;
|
|
308
|
+
historicalCountUncertain;
|
|
309
|
+
constructor(options) {
|
|
310
|
+
this.options = options;
|
|
311
|
+
this.settings = normalizeContextSettings(options.initialSettings);
|
|
312
|
+
this.historicalCountUncertain = Boolean(options.historicalCountUncertain);
|
|
313
|
+
}
|
|
314
|
+
async atSafeBoundary(adapter, frozenAction) {
|
|
315
|
+
adapter = await this.adoptSettings(adapter, frozenAction);
|
|
316
|
+
if (!adapter.sessionId() && !this.bootstrapScheduled) {
|
|
317
|
+
this.bootstrapScheduled = true;
|
|
318
|
+
const sample = {
|
|
319
|
+
version: 1, runId: this.options.runId, role: this.options.role, provider: adapter.agent,
|
|
320
|
+
model: this.settings.model, observedAt: this.now().toISOString(), source: "provider-query", freshness: "measuring",
|
|
321
|
+
settingsRevision: this.settings.settings_revision, compactionCount: 0, handoffGeneration: this.handoffGeneration(),
|
|
322
|
+
};
|
|
323
|
+
const db = new WorkflowDb(this.options.projectDir);
|
|
324
|
+
try {
|
|
325
|
+
db.recordContextSample(sample);
|
|
326
|
+
}
|
|
327
|
+
finally {
|
|
328
|
+
db.close();
|
|
329
|
+
}
|
|
330
|
+
return { adapter, action: "continued", sample, effectiveThreshold: this.threshold(), compactionCount: 0 };
|
|
331
|
+
}
|
|
332
|
+
let sample = await this.measure(adapter, "provider-query");
|
|
333
|
+
const threshold = this.threshold();
|
|
334
|
+
let session = adapter.sessionRef?.() ?? adapter.sessionId();
|
|
335
|
+
let sessionKey = adapterSessionKey(adapter);
|
|
336
|
+
const firstObservedSession = Boolean(sessionKey && !this.seenSessions.has(sessionKey));
|
|
337
|
+
if (sessionKey)
|
|
338
|
+
this.seenSessions.add(sessionKey);
|
|
339
|
+
const db = new WorkflowDb(this.options.projectDir);
|
|
340
|
+
let count = 0;
|
|
341
|
+
try {
|
|
342
|
+
db.recordContextSample(sample);
|
|
343
|
+
count = db.successfulCompactionCount(this.options.runId, this.options.role, session);
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
db.close();
|
|
347
|
+
}
|
|
348
|
+
if ((sample.percentage ?? -1) < threshold) {
|
|
349
|
+
this.armed = true;
|
|
350
|
+
return { adapter, action: "below-threshold", sample, effectiveThreshold: threshold, compactionCount: count };
|
|
351
|
+
}
|
|
352
|
+
if (!this.armed)
|
|
353
|
+
return { adapter, action: "continued", sample, effectiveThreshold: threshold, compactionCount: count };
|
|
354
|
+
this.armed = false;
|
|
355
|
+
this.crossing += 1;
|
|
356
|
+
let freshBootstrap = firstObservedSession;
|
|
357
|
+
// A settings command published while the action settled is newer intent.
|
|
358
|
+
const revisionBeforeSettle = this.settings.settings_revision;
|
|
359
|
+
const sessionBeforeSettle = adapterSessionKey(adapter);
|
|
360
|
+
adapter = await this.adoptSettings(adapter, frozenAction);
|
|
361
|
+
if (this.settings.settings_revision !== revisionBeforeSettle || adapterSessionKey(adapter) !== sessionBeforeSettle) {
|
|
362
|
+
sample = await this.measure(adapter, "provider-query");
|
|
363
|
+
session = adapter.sessionRef?.() ?? adapter.sessionId();
|
|
364
|
+
sessionKey = adapterSessionKey(adapter);
|
|
365
|
+
freshBootstrap = Boolean(sessionKey && !this.seenSessions.has(sessionKey));
|
|
366
|
+
if (sessionKey)
|
|
367
|
+
this.seenSessions.add(sessionKey);
|
|
368
|
+
const refreshed = new WorkflowDb(this.options.projectDir);
|
|
369
|
+
try {
|
|
370
|
+
refreshed.recordContextSample(sample);
|
|
371
|
+
count = refreshed.successfulCompactionCount(this.options.runId, this.options.role, adapter.sessionRef?.() ?? adapter.sessionId());
|
|
372
|
+
}
|
|
373
|
+
finally {
|
|
374
|
+
refreshed.close();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const revisedThreshold = this.threshold();
|
|
378
|
+
if ((sample.percentage ?? -1) < revisedThreshold) {
|
|
379
|
+
this.armed = true;
|
|
380
|
+
return { adapter, action: "continued", sample, effectiveThreshold: revisedThreshold, compactionCount: count };
|
|
381
|
+
}
|
|
382
|
+
const crossingKey = `${sessionKey ?? "missing"}:${this.crossing}:${revisedThreshold}`;
|
|
383
|
+
return this.compactSession(adapter, sample, count, frozenAction, crossingKey, true, freshBootstrap);
|
|
384
|
+
}
|
|
385
|
+
/** Apply the configured ordinary work-session boundary without bypassing handoff or compaction accounting. */
|
|
386
|
+
async atWorkSessionBoundary(adapter, frozenAction, strategy = this.settings.session_strategy) {
|
|
387
|
+
adapter = await this.adoptSettings(adapter, frozenAction);
|
|
388
|
+
const sample = await this.measure(adapter, "provider-query");
|
|
389
|
+
const db = new WorkflowDb(this.options.projectDir);
|
|
390
|
+
let count = 0;
|
|
391
|
+
try {
|
|
392
|
+
db.recordContextSample(sample);
|
|
393
|
+
count = db.successfulCompactionCount(this.options.runId, this.options.role, adapter.sessionRef?.() ?? adapter.sessionId());
|
|
394
|
+
}
|
|
395
|
+
finally {
|
|
396
|
+
db.close();
|
|
397
|
+
}
|
|
398
|
+
if (strategy === "fresh") {
|
|
399
|
+
return this.performHandoff(adapter, sample, count, frozenAction, "ordinary session_strategy=fresh boundary requires a validated fresh successor");
|
|
400
|
+
}
|
|
401
|
+
const crossingKey = `${adapterSessionKey(adapter) ?? "missing"}:ordinary:${count + 1}:${sha(frozenAction).slice(0, 20)}`;
|
|
402
|
+
return this.compactSession(adapter, sample, count, frozenAction, crossingKey, false, false);
|
|
403
|
+
}
|
|
404
|
+
effectiveSettings() { return this.settings; }
|
|
405
|
+
effectiveThreshold() { return this.threshold(); }
|
|
406
|
+
async compactSession(adapter, sample, count, frozenAction, crossingKey, thresholdCrossing, freshBootstrap) {
|
|
407
|
+
const threshold = this.threshold();
|
|
408
|
+
const sessionId = adapter.sessionId();
|
|
409
|
+
const sessionRef = adapter.sessionRef?.();
|
|
410
|
+
const sessionKey = sessionRef ? providerSessionKey(sessionRef) : undefined;
|
|
411
|
+
if (this.historicalCountUncertain)
|
|
412
|
+
return this.performHandoff(adapter, sample, count, frozenAction, "historical compaction count is uncertain; handing off rather than risking the configured maximum");
|
|
413
|
+
if (!sessionId)
|
|
414
|
+
return this.performHandoff(adapter, sample, count, frozenAction, "the provider session identity is unavailable");
|
|
415
|
+
if (!sessionRef)
|
|
416
|
+
return this.performHandoff(adapter, sample, count, frozenAction, "the provider session has no validated location-scoped identity");
|
|
417
|
+
if (count >= (this.settings.compact_maximum ?? 10))
|
|
418
|
+
return this.performHandoff(adapter, sample, count, frozenAction, `compact maximum ${this.settings.compact_maximum ?? 10} reached`);
|
|
419
|
+
if (!adapter.compact)
|
|
420
|
+
return this.performHandoff(adapter, sample, count, frozenAction, "native compaction is unavailable for this provider session");
|
|
421
|
+
const idempotencyKey = `compact:${this.options.runId}:${this.options.role}:${sha(crossingKey).slice(0, 24)}`;
|
|
422
|
+
const attemptDb = new WorkflowDb(this.options.projectDir);
|
|
423
|
+
const prior = attemptDb.startCompactionAttempt({ idempotencyKey, runId: this.options.runId, role: this.options.role, providerSessionId: sessionId, sessionRef, sessionKey, crossingKey, beforeSample: sample });
|
|
424
|
+
attemptDb.close();
|
|
425
|
+
if (prior.status === "succeeded" && prior.afterSample) {
|
|
426
|
+
const replayDb = new WorkflowDb(this.options.projectDir);
|
|
427
|
+
try {
|
|
428
|
+
count = replayDb.successfulCompactionCount(this.options.runId, this.options.role, sessionRef ?? sessionId);
|
|
429
|
+
}
|
|
430
|
+
finally {
|
|
431
|
+
replayDb.close();
|
|
432
|
+
}
|
|
433
|
+
return { adapter, action: "compacted", sample: prior.afterSample, effectiveThreshold: threshold, compactionCount: count };
|
|
434
|
+
}
|
|
435
|
+
this.options.report?.({ kind: "compacting", detail: thresholdCrossing
|
|
436
|
+
? `context ${sample.percentage?.toFixed(1)}% reached ${threshold}%`
|
|
437
|
+
: `ordinary compact session boundary (${count}/${this.settings.compact_maximum ?? 10})`, sample });
|
|
438
|
+
let compacted;
|
|
439
|
+
try {
|
|
440
|
+
compacted = await adapter.compact();
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
compacted = error instanceof SessionUnavailableError
|
|
444
|
+
? { ok: false, error: error.message, failure: error.failure }
|
|
445
|
+
: { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
446
|
+
}
|
|
447
|
+
if (compacted.failure?.category === "session-unavailable") {
|
|
448
|
+
const failedDb = new WorkflowDb(this.options.projectDir);
|
|
449
|
+
try {
|
|
450
|
+
failedDb.finishCompactionAttempt(idempotencyKey, { ok: false, error: compacted.error ?? compacted.failure.diagnostics });
|
|
451
|
+
failedDb.appendContinuityEvent({
|
|
452
|
+
runId: this.options.runId,
|
|
453
|
+
role: "host",
|
|
454
|
+
kind: "session_unavailable",
|
|
455
|
+
payload: {
|
|
456
|
+
role: this.options.role,
|
|
457
|
+
operation: "native-compaction",
|
|
458
|
+
phase: compacted.failure.phase,
|
|
459
|
+
dispatchState: compacted.failure.dispatchState ?? "unknown",
|
|
460
|
+
reason: compacted.failure.availability?.reason,
|
|
461
|
+
detail: compacted.failure.diagnostics,
|
|
462
|
+
},
|
|
463
|
+
authoritativeStateRevision: failedDb.continuityHead(this.options.runId, this.options.role)?.authoritativeStateRevision ?? this.settings.settings_revision,
|
|
464
|
+
sessionRef,
|
|
465
|
+
});
|
|
466
|
+
if (compacted.failure.dispatchState === "unknown")
|
|
467
|
+
failedDb.setContinuityHeadState(this.options.runId, this.options.role, "degraded");
|
|
468
|
+
}
|
|
469
|
+
finally {
|
|
470
|
+
failedDb.close();
|
|
471
|
+
}
|
|
472
|
+
throw sessionUnavailableErrorFromFailure(compacted.failure);
|
|
473
|
+
}
|
|
474
|
+
if (!compacted.ok) {
|
|
475
|
+
const failedDb = new WorkflowDb(this.options.projectDir);
|
|
476
|
+
try {
|
|
477
|
+
failedDb.finishCompactionAttempt(idempotencyKey, { ok: false, error: compacted.error ?? "provider did not explicitly complete compaction" });
|
|
478
|
+
}
|
|
479
|
+
finally {
|
|
480
|
+
failedDb.close();
|
|
481
|
+
}
|
|
482
|
+
return this.performHandoff(adapter, sample, count, frozenAction, `native compaction failed: ${sanitize(compacted.error ?? "no explicit completion")}`);
|
|
483
|
+
}
|
|
484
|
+
let after;
|
|
485
|
+
try {
|
|
486
|
+
after = await this.measure(adapter, "post-compact");
|
|
487
|
+
}
|
|
488
|
+
catch (error) {
|
|
489
|
+
const failedDb = new WorkflowDb(this.options.projectDir);
|
|
490
|
+
try {
|
|
491
|
+
failedDb.finishCompactionAttempt(idempotencyKey, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
492
|
+
}
|
|
493
|
+
finally {
|
|
494
|
+
failedDb.close();
|
|
495
|
+
}
|
|
496
|
+
return this.performHandoff(adapter, sample, count, frozenAction, "provider reported compaction but no authoritative post-compact occupancy sample was available");
|
|
497
|
+
}
|
|
498
|
+
const completedDb = new WorkflowDb(this.options.projectDir);
|
|
499
|
+
try {
|
|
500
|
+
completedDb.recordContextSample(after);
|
|
501
|
+
completedDb.finishCompactionAttempt(idempotencyKey, { ok: true, afterSample: after });
|
|
502
|
+
count = completedDb.successfulCompactionCount(this.options.runId, this.options.role, sessionRef ?? sessionId);
|
|
503
|
+
}
|
|
504
|
+
finally {
|
|
505
|
+
completedDb.close();
|
|
506
|
+
}
|
|
507
|
+
this.options.report?.({ kind: "compacted", detail: `context reduced to ${after.percentage?.toFixed(1)}%`, sample: after });
|
|
508
|
+
if (freshBootstrap && (after.percentage ?? -1) >= threshold) {
|
|
509
|
+
const adjusted = Math.min(99, Math.ceil((after.percentage ?? 0) + 10));
|
|
510
|
+
if (adjusted <= (after.percentage ?? 0))
|
|
511
|
+
throw new ContextCapabilityError(this.options.runId, this.options.role, `fresh bootstrap remained at ${after.percentage?.toFixed(1)}% after compaction and no safe run-only threshold exists`);
|
|
512
|
+
this.runOnlyThreshold = adjusted;
|
|
513
|
+
this.options.report?.({ kind: "threshold-adjusted", detail: `fresh bootstrap remained high; run-only threshold is now ${adjusted}%`, sample: after });
|
|
514
|
+
}
|
|
515
|
+
this.armed = thresholdCrossing ? (after.percentage ?? 100) < this.threshold() : true;
|
|
516
|
+
return { adapter, action: "compacted", sample: after, effectiveThreshold: this.threshold(), compactionCount: count };
|
|
517
|
+
}
|
|
518
|
+
async performHandoff(adapter, sample, count, frozenAction, reason) {
|
|
519
|
+
if (!this.options.handoff)
|
|
520
|
+
throw new ContextCapabilityError(this.options.runId, this.options.role, `${reason}; validated handoff is unavailable`);
|
|
521
|
+
this.options.report?.({ kind: "handoff", detail: reason, sample });
|
|
522
|
+
const successor = await this.options.handoff({ reason, adapter, sample, settings: this.settings, compactionCount: count, frozenAction });
|
|
523
|
+
const post = await this.measure(successor, "provider-query");
|
|
524
|
+
this.historicalCountUncertain = false;
|
|
525
|
+
const successorSessionId = successor.sessionId();
|
|
526
|
+
const successorSessionKey = adapterSessionKey(successor);
|
|
527
|
+
if (successorSessionKey)
|
|
528
|
+
this.seenSessions.add(successorSessionKey);
|
|
529
|
+
const db = new WorkflowDb(this.options.projectDir);
|
|
530
|
+
try {
|
|
531
|
+
db.recordContextSample(post);
|
|
532
|
+
}
|
|
533
|
+
finally {
|
|
534
|
+
db.close();
|
|
535
|
+
}
|
|
536
|
+
if ((post.percentage ?? 100) >= this.threshold()) {
|
|
537
|
+
if (this.bootstrapRecoveryDepth >= 1) {
|
|
538
|
+
throw new ContextCapabilityError(this.options.runId, this.options.role, `fresh successor bootstrap remained at ${post.percentage?.toFixed(1)}% after compaction-failure recovery`);
|
|
539
|
+
}
|
|
540
|
+
this.bootstrapRecoveryDepth += 1;
|
|
541
|
+
try {
|
|
542
|
+
const bootstrapped = await this.compactSession(successor, post, 0, frozenAction, `${successorSessionKey ?? successorSessionId ?? "missing"}:fresh-bootstrap:${sha(frozenAction).slice(0, 20)}`, true, true);
|
|
543
|
+
return { ...bootstrapped, action: "handed-off" };
|
|
544
|
+
}
|
|
545
|
+
finally {
|
|
546
|
+
this.bootstrapRecoveryDepth -= 1;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
this.armed = true;
|
|
550
|
+
return { adapter: successor, action: "handed-off", sample: post, effectiveThreshold: this.threshold(), compactionCount: 0 };
|
|
551
|
+
}
|
|
552
|
+
async adoptSettings(adapter, frozenAction) {
|
|
553
|
+
const candidate = this.options.readSettings?.();
|
|
554
|
+
if (!candidate || candidate.settings_revision <= this.settings.settings_revision)
|
|
555
|
+
return adapter;
|
|
556
|
+
const next = normalizeContextSettings(candidate);
|
|
557
|
+
const providerControlsChanged = next.make !== this.settings.make
|
|
558
|
+
|| next.model !== this.settings.model
|
|
559
|
+
|| next.reasoning !== this.settings.reasoning
|
|
560
|
+
|| next.fast !== this.settings.fast;
|
|
561
|
+
let adoptedAdapter = adapter;
|
|
562
|
+
if (providerControlsChanged) {
|
|
563
|
+
if (this.options.settingsBoundary) {
|
|
564
|
+
adoptedAdapter = await this.options.settingsBoundary({ adapter, current: this.settings, next, frozenAction });
|
|
565
|
+
}
|
|
566
|
+
else if (next.make === this.settings.make && adapter.switchSettings) {
|
|
567
|
+
const switched = await adapter.switchSettings({ model: next.model === "default" ? undefined : next.model, effort: effort(next.reasoning), fast: next.fast });
|
|
568
|
+
if (switched.failure?.category === "session-unavailable")
|
|
569
|
+
throw sessionUnavailableErrorFromFailure(switched.failure);
|
|
570
|
+
if (!switched.ok)
|
|
571
|
+
throw new ContextCapabilityError(this.options.runId, this.options.role, `settings revision ${next.settings_revision} could not be applied safely: ${sanitize(switched.error ?? "provider rejected the settings switch")}`);
|
|
572
|
+
}
|
|
573
|
+
else {
|
|
574
|
+
throw new ContextCapabilityError(this.options.runId, this.options.role, `settings revision ${next.settings_revision} requires a validated provider/model handoff, but no settings boundary is available`);
|
|
575
|
+
}
|
|
576
|
+
if (adoptedAdapter.agent !== next.make) {
|
|
577
|
+
throw new ContextCapabilityError(this.options.runId, this.options.role, `settings revision ${next.settings_revision} requested ${next.make}, but the adopted session reports ${adoptedAdapter.agent}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
this.settings = next;
|
|
581
|
+
this.runOnlyThreshold = undefined;
|
|
582
|
+
const db = new WorkflowDb(this.options.projectDir);
|
|
583
|
+
try {
|
|
584
|
+
const ref = adoptedAdapter.sessionRef?.();
|
|
585
|
+
db.acknowledgeSettings({ runId: this.options.runId, role: this.options.role, providerSessionId: adoptedAdapter.sessionId(), ...(ref ? { sessionRef: ref, sessionKey: providerSessionKey(ref) } : {}), revision: next.settings_revision, acknowledgedAt: this.now().toISOString() });
|
|
586
|
+
}
|
|
587
|
+
finally {
|
|
588
|
+
db.close();
|
|
589
|
+
}
|
|
590
|
+
this.options.settingsAdopted?.(next, adoptedAdapter);
|
|
591
|
+
this.options.report?.({ kind: "settings-adopted", detail: `adopted settings revision ${next.settings_revision}` });
|
|
592
|
+
return adoptedAdapter;
|
|
593
|
+
}
|
|
594
|
+
async measure(adapter, source) {
|
|
595
|
+
const attempts = Math.max(1, this.options.contextRetries ?? 3);
|
|
596
|
+
let usage;
|
|
597
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
598
|
+
try {
|
|
599
|
+
usage = await adapter.contextUsage?.();
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
usage = undefined;
|
|
603
|
+
}
|
|
604
|
+
if (usage && Number.isFinite(usage.used) && (usage.percentage !== undefined || usage.maximum !== undefined))
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
if (!usage || !Number.isFinite(usage.used))
|
|
608
|
+
throw new ContextCapabilityError(this.options.runId, this.options.role, `truthful context occupancy was unavailable after ${attempts} provider attempt(s)`);
|
|
609
|
+
const percentage = usage.percentage ?? (usage.maximum ? usage.used / usage.maximum * 100 : undefined);
|
|
610
|
+
if (percentage === undefined || !Number.isFinite(percentage))
|
|
611
|
+
throw new ContextCapabilityError(this.options.runId, this.options.role, "provider context occupancy lacks a usable percentage and maximum");
|
|
612
|
+
const ref = adapter.sessionRef?.();
|
|
613
|
+
return {
|
|
614
|
+
version: 1, runId: this.options.runId, role: this.options.role, provider: adapter.agent,
|
|
615
|
+
providerSessionId: adapter.sessionId(), ...(ref ? { sessionRef: ref, sessionKey: providerSessionKey(ref) } : {}), model: this.settings.model, observedAt: usage.observedAt ?? this.now().toISOString(),
|
|
616
|
+
source, freshness: "fresh", used: usage.used, maximum: usage.maximum, percentage,
|
|
617
|
+
settingsRevision: this.settings.settings_revision,
|
|
618
|
+
compactionCount: adapter.sessionId() ? this.compactionCount(ref ?? adapter.sessionId()) : 0,
|
|
619
|
+
handoffGeneration: this.handoffGeneration(),
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
compactionCount(session) {
|
|
623
|
+
const db = new WorkflowDb(this.options.projectDir);
|
|
624
|
+
try {
|
|
625
|
+
return db.successfulCompactionCount(this.options.runId, this.options.role, session);
|
|
626
|
+
}
|
|
627
|
+
finally {
|
|
628
|
+
db.close();
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
handoffGeneration() {
|
|
632
|
+
const db = new WorkflowDb(this.options.projectDir);
|
|
633
|
+
try {
|
|
634
|
+
return db.handoffs(this.options.runId).at(-1)?.generation ?? 0;
|
|
635
|
+
}
|
|
636
|
+
finally {
|
|
637
|
+
db.close();
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
threshold() { return this.runOnlyThreshold ?? this.settings.auto_compact_threshold_percent ?? 50; }
|
|
641
|
+
now() { return this.options.now?.() ?? new Date(); }
|
|
642
|
+
}
|
|
643
|
+
function normalizeContextSettings(settings) {
|
|
644
|
+
const threshold = settings.auto_compact_threshold_percent ?? 50;
|
|
645
|
+
const maximum = settings.compact_maximum ?? 10;
|
|
646
|
+
if (!Number.isInteger(threshold) || threshold < 1 || threshold > 99)
|
|
647
|
+
throw new Error("auto_compact_threshold_percent must be an integer from 1 to 99");
|
|
648
|
+
if (!Number.isSafeInteger(maximum) || maximum < 1)
|
|
649
|
+
throw new Error("compact_maximum must be a positive safe integer");
|
|
650
|
+
return { ...settings, display_session_cost: settings.display_session_cost ?? false, auto_compact_threshold_percent: threshold, compact_maximum: maximum };
|
|
651
|
+
}
|
|
652
|
+
function sha(value) { return createHash("sha256").update(value).digest("hex"); }
|
|
99
653
|
function effort(value) {
|
|
100
654
|
return ["low", "medium", "high", "xhigh"].includes(value) ? value : undefined;
|
|
101
655
|
}
|
|
102
656
|
function sanitize(error) {
|
|
103
657
|
return sanitizeDiagnostics(error instanceof Error ? error.message : String(error)).slice(0, 1000);
|
|
104
658
|
}
|
|
659
|
+
function adapterSessionKey(adapter) {
|
|
660
|
+
const ref = adapter.sessionRef?.();
|
|
661
|
+
return ref ? providerSessionKey(ref) : undefined;
|
|
662
|
+
}
|