opencode-codex-memory 0.5.0 → 0.6.1
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 +23 -6
- package/dist/src/agent-health.d.ts +21 -0
- package/dist/src/agent-health.js +133 -0
- package/dist/src/capture.js +7 -28
- package/dist/src/host-client.d.ts +26 -2
- package/dist/src/host-client.js +86 -3
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +32 -8
- package/dist/src/llm.d.ts +8 -0
- package/dist/src/llm.js +169 -51
- package/dist/src/phase1.js +17 -3
- package/dist/src/phase2.d.ts +12 -0
- package/dist/src/phase2.js +66 -6
- package/dist/src/ratelimit.d.ts +18 -2
- package/dist/src/ratelimit.js +91 -10
- package/dist/src/store.d.ts +22 -6
- package/dist/src/store.js +75 -6
- package/dist/tools/control.js +38 -2
- package/dist/tools/memory.d.ts +2 -0
- package/dist/tools/memory.js +87 -19
- package/package.json +1 -1
package/dist/src/llm.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { memoryRoot } from "./paths.js";
|
|
4
|
-
import { hostSessionCreate, hostSessionDeletionConfirmed, hostSessionPrompt, hostStructuredOutput, } from "./host-client.js";
|
|
5
|
-
import { pluginShutdownSignal } from "./lifecycle.js";
|
|
4
|
+
import { hostListSessionsGlobal, hostSessionCreate, hostSessionDeletionConfirmed, hostSessionPrompt, hostStructuredOutput, ignoreLateRejection, pluginHttpGet, withHostTimeout, } from "./host-client.js";
|
|
5
|
+
import { isPluginShuttingDown, pluginShutdownSignal } from "./lifecycle.js";
|
|
6
|
+
import { SCAN_LIMIT } from "./store.js";
|
|
7
|
+
import { isProviderCapacityError, ProviderCapacityError } from "./ratelimit.js";
|
|
6
8
|
let inputRef = null;
|
|
9
|
+
let inputGeneration = 0;
|
|
7
10
|
export function setPluginInput(input) {
|
|
8
11
|
inputRef = input;
|
|
12
|
+
inputGeneration++;
|
|
9
13
|
configModels = null;
|
|
14
|
+
configModelsInFlight = null;
|
|
10
15
|
}
|
|
11
16
|
export function getPluginInput() {
|
|
12
17
|
return inputRef;
|
|
@@ -20,6 +25,25 @@ const SUBSESSION_LIST_TIMEOUT_MS = 5_000;
|
|
|
20
25
|
const SUBSESSION_ABORT_TIMEOUT_MS = 1_000;
|
|
21
26
|
const SUBSESSION_CONFIRM_TIMEOUT_MS = 1_000;
|
|
22
27
|
const SUBSESSION_DELETE_TIMEOUT_MS = 10_000;
|
|
28
|
+
const SUBSESSION_CREATE_TIMEOUT_MS = 10_000;
|
|
29
|
+
const CONFIG_GET_TIMEOUT_MS = 5_000;
|
|
30
|
+
const SUBSESSION_DELETE_CONCURRENCY = 8;
|
|
31
|
+
const SUBSESSION_DELETE_BATCH_TIMEOUT_MS = 30_000;
|
|
32
|
+
let createTimeoutMs = SUBSESSION_CREATE_TIMEOUT_MS;
|
|
33
|
+
let configGetTimeoutMs = CONFIG_GET_TIMEOUT_MS;
|
|
34
|
+
let staleDeleteBatchTimeoutMs = SUBSESSION_DELETE_BATCH_TIMEOUT_MS;
|
|
35
|
+
/** Test seam. */
|
|
36
|
+
export function setSubSessionCreateTimeoutForTest(ms) {
|
|
37
|
+
createTimeoutMs = ms ?? SUBSESSION_CREATE_TIMEOUT_MS;
|
|
38
|
+
}
|
|
39
|
+
/** Test seam. */
|
|
40
|
+
export function setConfigGetTimeoutForTest(ms) {
|
|
41
|
+
configGetTimeoutMs = ms ?? CONFIG_GET_TIMEOUT_MS;
|
|
42
|
+
}
|
|
43
|
+
/** Test seam. */
|
|
44
|
+
export function setStaleDeleteBatchTimeoutForTest(ms) {
|
|
45
|
+
staleDeleteBatchTimeoutMs = ms ?? SUBSESSION_DELETE_BATCH_TIMEOUT_MS;
|
|
46
|
+
}
|
|
23
47
|
export function isMemorySubSession(sessionId) {
|
|
24
48
|
return activeSubSessions.has(sessionId);
|
|
25
49
|
}
|
|
@@ -39,16 +63,20 @@ async function createSession(agent, title) {
|
|
|
39
63
|
const input = getPluginInput();
|
|
40
64
|
if (!input)
|
|
41
65
|
throw new Error("plugin input not initialized");
|
|
66
|
+
if (isPluginShuttingDown())
|
|
67
|
+
throw new SubagentCancelledError();
|
|
42
68
|
const directory = resolveSubSessionDirectory();
|
|
43
69
|
// directory is a query param (not body); without it the client inherits
|
|
44
70
|
// PluginInput.directory, which may be a deleted project path.
|
|
45
|
-
const
|
|
71
|
+
const controller = new AbortController();
|
|
72
|
+
const res = await withHostTimeout(hostSessionCreate(input.client, {
|
|
46
73
|
directory,
|
|
47
74
|
body: {
|
|
48
75
|
title: title ?? `codex-memory-${agent}`,
|
|
49
76
|
metadata: { [SUBSESSION_METADATA_KEY]: true },
|
|
50
77
|
},
|
|
51
|
-
|
|
78
|
+
signal: controller.signal,
|
|
79
|
+
}), createTimeoutMs, "session.create", controller);
|
|
52
80
|
if (!res.data)
|
|
53
81
|
throw new Error(`session create failed: ${JSON.stringify(res.error ?? {})}`);
|
|
54
82
|
const body = res.data;
|
|
@@ -66,23 +94,46 @@ async function createSession(agent, title) {
|
|
|
66
94
|
* plugin instance — opencode reloads plugins on config change.
|
|
67
95
|
*/
|
|
68
96
|
let configModels = null;
|
|
97
|
+
let configModelsInFlight = null;
|
|
69
98
|
async function getConfigModels() {
|
|
70
99
|
if (configModels)
|
|
71
100
|
return configModels;
|
|
101
|
+
if (configModelsInFlight)
|
|
102
|
+
return configModelsInFlight;
|
|
72
103
|
const input = getPluginInput();
|
|
73
104
|
if (!input)
|
|
74
105
|
return {};
|
|
106
|
+
const generation = inputGeneration;
|
|
107
|
+
const request = (async () => {
|
|
108
|
+
const controller = new AbortController();
|
|
109
|
+
try {
|
|
110
|
+
const res = await withHostTimeout(input.client.config.get({ signal: controller.signal }), configGetTimeoutMs, "config.get", controller);
|
|
111
|
+
if (res.error || !res.data || generation !== inputGeneration)
|
|
112
|
+
return {};
|
|
113
|
+
const resolved = { model: res.data.model, smallModel: res.data.small_model };
|
|
114
|
+
configModels = resolved;
|
|
115
|
+
return resolved;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// Config endpoint unavailable: leave models unset so the sub-agent runs
|
|
119
|
+
// on the session default. Do not cache failures: the next call can recover.
|
|
120
|
+
return {};
|
|
121
|
+
}
|
|
122
|
+
})();
|
|
123
|
+
configModelsInFlight = request;
|
|
75
124
|
try {
|
|
76
|
-
|
|
77
|
-
const cfg = res?.data;
|
|
78
|
-
configModels = { model: cfg?.model, smallModel: cfg?.small_model };
|
|
125
|
+
return await request;
|
|
79
126
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
configModels = {};
|
|
127
|
+
finally {
|
|
128
|
+
if (configModelsInFlight === request)
|
|
129
|
+
configModelsInFlight = null;
|
|
84
130
|
}
|
|
85
|
-
|
|
131
|
+
}
|
|
132
|
+
export async function resolveExtractionModel(configured) {
|
|
133
|
+
return configured ?? (await getConfigModels()).smallModel;
|
|
134
|
+
}
|
|
135
|
+
export async function resolveConsolidationModel(configured) {
|
|
136
|
+
return configured ?? (await getConfigModels()).model;
|
|
86
137
|
}
|
|
87
138
|
// extract_model / consolidation model strings are "providerID/modelID".
|
|
88
139
|
function parseModelRef(ref) {
|
|
@@ -163,9 +214,11 @@ async function runPrompt(sessionId, prompt, agent, opts = {}) {
|
|
|
163
214
|
...(opts.system ? { system: opts.system } : {}),
|
|
164
215
|
...(model ? { model } : {}),
|
|
165
216
|
...(opts.format ? { format: opts.format } : {}),
|
|
217
|
+
...(opts.variant ? { variant: opts.variant } : {}),
|
|
166
218
|
parts: [{ type: "text", text: prompt }],
|
|
167
219
|
},
|
|
168
220
|
});
|
|
221
|
+
ignoreLateRejection(promptPromise);
|
|
169
222
|
let timer;
|
|
170
223
|
let onAbort;
|
|
171
224
|
try {
|
|
@@ -187,12 +240,21 @@ async function runPrompt(sessionId, prompt, agent, opts = {}) {
|
|
|
187
240
|
timer = setTimeout(() => reject(new SubagentTimeoutError(timeoutMs)), timeoutMs);
|
|
188
241
|
}),
|
|
189
242
|
]);
|
|
190
|
-
if (!res.data)
|
|
191
|
-
|
|
243
|
+
if (!res.data) {
|
|
244
|
+
const message = `prompt failed: ${JSON.stringify(res.error ?? {})}`;
|
|
245
|
+
if (isProviderCapacityError(res.error))
|
|
246
|
+
throw new ProviderCapacityError(message);
|
|
247
|
+
throw new Error(message);
|
|
248
|
+
}
|
|
192
249
|
const promptError = res.data.info?.error;
|
|
193
250
|
if (promptError) {
|
|
194
251
|
const detail = promptError.data?.message;
|
|
195
|
-
|
|
252
|
+
const status = promptError.data?.statusCode;
|
|
253
|
+
const message = `sub-agent prompt failed${promptError.name ? ` (${promptError.name})` : ""}` +
|
|
254
|
+
`${detail ? `: ${detail}` : ""}${status !== undefined ? ` (HTTP ${status})` : ""}`;
|
|
255
|
+
if (isProviderCapacityError(promptError))
|
|
256
|
+
throw new ProviderCapacityError(message, status);
|
|
257
|
+
throw new Error(message);
|
|
196
258
|
}
|
|
197
259
|
return res.data;
|
|
198
260
|
}
|
|
@@ -263,7 +325,7 @@ export async function extractViaSubagent(sessionId, transcript, opts = {}) {
|
|
|
263
325
|
try {
|
|
264
326
|
const prompt = buildExtractionInput(sessionId, opts.cwd ?? "unknown", transcript);
|
|
265
327
|
// extract_model option > opencode small_model > session default.
|
|
266
|
-
const model = opts.model
|
|
328
|
+
const model = await resolveExtractionModel(opts.model);
|
|
267
329
|
const data = await runPrompt(subId, prompt, agent, {
|
|
268
330
|
// Mirrors the stage-1 job lease (1h): codex has no per-request timeout,
|
|
269
331
|
// and a near-600k-char transcript on a slow model can easily exceed a
|
|
@@ -276,6 +338,9 @@ export async function extractViaSubagent(sessionId, transcript, opts = {}) {
|
|
|
276
338
|
// call (toolChoice: required) — which is why memorize-extract must allow
|
|
277
339
|
// that one otherwise-denied tool.
|
|
278
340
|
format: { type: "json_schema", schema: EXTRACTION_SCHEMA },
|
|
341
|
+
// Codex extraction ReasoningEffort::Low. Host maps variant → reasoningEffort;
|
|
342
|
+
// missing variant on the model is a no-op.
|
|
343
|
+
variant: "low",
|
|
279
344
|
});
|
|
280
345
|
// The captured JSON lands on AssistantMessage.structured (schema
|
|
281
346
|
// v1/session.ts; absent from the generated SDK type — see host-client.ts).
|
|
@@ -306,8 +371,14 @@ export async function consolidateViaSubagent(memoryRoot, diffFileName, model, si
|
|
|
306
371
|
try {
|
|
307
372
|
const prompt = buildConsolidationPrompt(memoryRoot, diffFileName);
|
|
308
373
|
// consolidation_model option > opencode model (main) > session default.
|
|
309
|
-
const resolved =
|
|
310
|
-
await promptSession(subId, prompt, agent, {
|
|
374
|
+
const resolved = await resolveConsolidationModel(model);
|
|
375
|
+
await promptSession(subId, prompt, agent, {
|
|
376
|
+
model: resolved,
|
|
377
|
+
timeoutMs: CONSOLIDATION_TIMEOUT_MS,
|
|
378
|
+
signal,
|
|
379
|
+
// Codex consolidation ReasoningEffort::Medium.
|
|
380
|
+
variant: "medium",
|
|
381
|
+
});
|
|
311
382
|
}
|
|
312
383
|
catch (err) {
|
|
313
384
|
promptError = err;
|
|
@@ -331,46 +402,92 @@ export async function cleanupOldSubSessions(maxAgeMinutes = 90, timeoutMs = SUBS
|
|
|
331
402
|
const input = getPluginInput();
|
|
332
403
|
if (!input)
|
|
333
404
|
return;
|
|
334
|
-
|
|
405
|
+
if (!pluginHttpGet(input.client))
|
|
406
|
+
return;
|
|
407
|
+
const controller = new AbortController();
|
|
408
|
+
const staleSessionIds = [];
|
|
335
409
|
try {
|
|
336
|
-
if (typeof input.client?.session?.list !== "function")
|
|
337
|
-
return;
|
|
338
|
-
const res = await Promise.race([
|
|
339
|
-
input.client.session.list(),
|
|
340
|
-
new Promise((_, reject) => {
|
|
341
|
-
timer = setTimeout(() => reject(new Error(`session.list timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
342
|
-
}),
|
|
343
|
-
]);
|
|
344
|
-
if (!res.data)
|
|
345
|
-
return;
|
|
346
|
-
const list = res.data;
|
|
347
410
|
const cutoff = Date.now() - maxAgeMinutes * 60 * 1000;
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
if (
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
411
|
+
const deadline = Date.now() + timeoutMs;
|
|
412
|
+
const seen = new Set();
|
|
413
|
+
let cursor;
|
|
414
|
+
let pageLimit = SCAN_LIMIT;
|
|
415
|
+
while (true) {
|
|
416
|
+
const remaining = deadline - Date.now();
|
|
417
|
+
if (remaining <= 0)
|
|
418
|
+
return;
|
|
419
|
+
const res = await withHostTimeout(hostListSessionsGlobal(input.client, {
|
|
420
|
+
limit: pageLimit,
|
|
421
|
+
cursor,
|
|
422
|
+
search: "codex-memory-",
|
|
423
|
+
signal: controller.signal,
|
|
424
|
+
}), remaining, "experimental.session.list", controller);
|
|
425
|
+
if (res.error || !Array.isArray(res.data))
|
|
426
|
+
return;
|
|
427
|
+
const list = res.data;
|
|
428
|
+
let newSessionCount = 0;
|
|
429
|
+
for (const s of list) {
|
|
430
|
+
if (!s.id || seen.has(s.id))
|
|
431
|
+
continue;
|
|
432
|
+
seen.add(s.id);
|
|
433
|
+
newSessionCount++;
|
|
434
|
+
const pluginTitle = isPluginSubSessionTitle(s.title);
|
|
435
|
+
const owned = s.metadata?.[SUBSESSION_METADATA_KEY] === true && pluginTitle;
|
|
436
|
+
const legacy = s.metadata?.[SUBSESSION_METADATA_KEY] !== true && pluginTitle;
|
|
437
|
+
if (!owned && !legacy)
|
|
438
|
+
continue;
|
|
439
|
+
// Durable ownership requires marker + generated title; a legacy title
|
|
440
|
+
// alone can reseed the skip set but never authorizes deletion.
|
|
441
|
+
activeSubSessions.add(s.id);
|
|
442
|
+
if (!owned)
|
|
443
|
+
continue;
|
|
444
|
+
const created = s.time?.created ?? 0;
|
|
445
|
+
if (created && created < cutoff) {
|
|
446
|
+
staleSessionIds.push(s.id);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (list.length < pageLimit)
|
|
450
|
+
return;
|
|
451
|
+
const updates = list.map((s) => s.time?.updated).filter((updated) => typeof updated === "number" && Number.isFinite(updated) && updated >= 0);
|
|
452
|
+
if (updates.length === 0)
|
|
453
|
+
return;
|
|
454
|
+
// listGlobal uses `updated < cursor`. Add one millisecond so sessions
|
|
455
|
+
// tied at the page boundary remain visible, then dedupe repeated rows.
|
|
456
|
+
const nextCursor = updates.reduce((min, updated) => Math.min(min, updated), Infinity) + 1;
|
|
457
|
+
if (cursor !== undefined && (nextCursor >= cursor || newSessionCount === 0)) {
|
|
458
|
+
// A full page can consist entirely of the same timestamp. Increase the
|
|
459
|
+
// page size until unseen tied rows appear or the overall deadline wins.
|
|
460
|
+
pageLimit += SCAN_LIMIT;
|
|
360
461
|
continue;
|
|
361
|
-
const created = s.time?.created ?? 0;
|
|
362
|
-
if (created && created < cutoff) {
|
|
363
|
-
void deleteSession(s.id);
|
|
364
462
|
}
|
|
463
|
+
cursor = nextCursor;
|
|
464
|
+
pageLimit = SCAN_LIMIT;
|
|
365
465
|
}
|
|
366
466
|
}
|
|
367
467
|
catch {
|
|
368
468
|
// best effort only
|
|
369
469
|
}
|
|
370
470
|
finally {
|
|
371
|
-
|
|
471
|
+
void deleteStaleSubSessions(staleSessionIds, input, staleDeleteBatchTimeoutMs);
|
|
372
472
|
}
|
|
373
473
|
}
|
|
474
|
+
async function deleteStaleSubSessions(sessionIds, input, timeoutMs) {
|
|
475
|
+
let cursor = 0;
|
|
476
|
+
const deadline = Date.now() + timeoutMs;
|
|
477
|
+
const workers = Array.from({ length: Math.min(SUBSESSION_DELETE_CONCURRENCY, sessionIds.length) }, async () => {
|
|
478
|
+
while (cursor < sessionIds.length && Date.now() < deadline && !isPluginShuttingDown()) {
|
|
479
|
+
const id = sessionIds[cursor++];
|
|
480
|
+
try {
|
|
481
|
+
await deleteSession(id, input, Math.max(1, Math.min(SUBSESSION_DELETE_TIMEOUT_MS, deadline - Date.now())));
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
// deleteSession is best-effort; one unexpected failure must not stop
|
|
485
|
+
// the remaining stale-helper cleanup.
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
await Promise.all(workers);
|
|
490
|
+
}
|
|
374
491
|
function isPluginSubSessionTitle(title) {
|
|
375
492
|
return title === "codex-memory-consolidate" || /^codex-memory-extract-ses_[A-Za-z0-9]+$/.test(title ?? "");
|
|
376
493
|
}
|
|
@@ -383,20 +500,21 @@ function isPluginSubSessionTitle(title) {
|
|
|
383
500
|
* really gone?) and only governs ownership tracking, never the shutdown result:
|
|
384
501
|
* hosts without `session.get` would otherwise never report a clean shutdown.
|
|
385
502
|
*/
|
|
386
|
-
async function deleteSession(id) {
|
|
387
|
-
const input = getPluginInput();
|
|
503
|
+
async function deleteSession(id, input = getPluginInput(), timeoutMs = SUBSESSION_DELETE_TIMEOUT_MS) {
|
|
388
504
|
if (!input)
|
|
389
505
|
return false;
|
|
390
506
|
const controller = new AbortController();
|
|
391
507
|
let timer;
|
|
392
508
|
try {
|
|
509
|
+
const deletePromise = input.client.session.delete({ path: { id }, signal: controller.signal });
|
|
510
|
+
ignoreLateRejection(deletePromise);
|
|
393
511
|
const res = await Promise.race([
|
|
394
|
-
|
|
512
|
+
deletePromise,
|
|
395
513
|
new Promise((_, reject) => {
|
|
396
514
|
timer = setTimeout(() => {
|
|
397
515
|
controller.abort();
|
|
398
|
-
reject(new Error(`session.delete timed out after ${
|
|
399
|
-
},
|
|
516
|
+
reject(new Error(`session.delete timed out after ${timeoutMs}ms`));
|
|
517
|
+
}, timeoutMs);
|
|
400
518
|
timer.unref?.();
|
|
401
519
|
}),
|
|
402
520
|
]);
|
package/dist/src/phase1.js
CHANGED
|
@@ -3,7 +3,7 @@ import { loadTranscript, selectEligibleSessions } from "./capture.js";
|
|
|
3
3
|
import { redact, isMemoryExcludedFragment } from "./redact.js";
|
|
4
4
|
import { stripCitations } from "./citation.js";
|
|
5
5
|
import { extractViaSubagent, SubagentCancelledError } from "./llm.js";
|
|
6
|
-
import { checkRateLimit, markRateLimitUsed } from "./ratelimit.js";
|
|
6
|
+
import { checkRateLimit, isProviderCapacityBlocked, isProviderCapacityError, markRateLimitUsed, noteProviderCapacityExhausted, ProviderCapacityError, } from "./ratelimit.js";
|
|
7
7
|
import { isPluginShuttingDown } from "./lifecycle.js";
|
|
8
8
|
import { recordDiagnostic } from "./diagnostics.js";
|
|
9
9
|
export const DEFAULT_PHASE1_OPTIONS = {
|
|
@@ -26,7 +26,14 @@ export async function runPhase1(store, opts = DEFAULT_PHASE1_OPTIONS, rateLimitC
|
|
|
26
26
|
return;
|
|
27
27
|
// Prune first (no tokens), matching codex start.rs ordering before the gate.
|
|
28
28
|
store.pruneStage1Outputs(opts.maxUnusedDays ?? 30);
|
|
29
|
-
|
|
29
|
+
// Historical quota-exhausted jobs never get a newer watermark; reopen them
|
|
30
|
+
// before the gate so a later unblocked pass can claim them.
|
|
31
|
+
const requeued = store.requeueExhaustedProviderCapacityJobs();
|
|
32
|
+
if (requeued > 0) {
|
|
33
|
+
recordDiagnostic("info", "phase1", `requeued ${requeued} quota-exhausted job(s)`);
|
|
34
|
+
}
|
|
35
|
+
const extractModel = opts.extractModel;
|
|
36
|
+
const rl = await rateLimitCheck("phase1", extractModel);
|
|
30
37
|
if (!rl.ok) {
|
|
31
38
|
console.warn("[opencode-codex-memory] skipping phase1 due to rate limit:", rl.reason);
|
|
32
39
|
return;
|
|
@@ -54,6 +61,11 @@ export async function runPhase1(store, opts = DEFAULT_PHASE1_OPTIONS, rateLimitC
|
|
|
54
61
|
store.releaseStage1OnShutdown(sid, claim.ownershipToken);
|
|
55
62
|
return;
|
|
56
63
|
}
|
|
64
|
+
// Sibling jobs in this pass: do not call the model after quota was observed.
|
|
65
|
+
if (isProviderCapacityBlocked("phase1", extractModel)) {
|
|
66
|
+
store.markStage1Failed(sid, claim.ownershipToken, new ProviderCapacityError("provider capacity exhausted"));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
57
69
|
try {
|
|
58
70
|
const session = sessionById.get(sid);
|
|
59
71
|
const sourceUpdatedAt = session?.updated_at ?? Date.now();
|
|
@@ -74,7 +86,7 @@ export async function runPhase1(store, opts = DEFAULT_PHASE1_OPTIONS, rateLimitC
|
|
|
74
86
|
}
|
|
75
87
|
const result = await extractViaSubagent(sid, transcript, {
|
|
76
88
|
cwd: session?.directory ?? undefined,
|
|
77
|
-
model:
|
|
89
|
+
model: extractModel,
|
|
78
90
|
});
|
|
79
91
|
// Finalize after a completed model call even if dispose raced, so the
|
|
80
92
|
// claim does not sit `running` until lease expiry. The mark is token +
|
|
@@ -103,6 +115,8 @@ export async function runPhase1(store, opts = DEFAULT_PHASE1_OPTIONS, rateLimitC
|
|
|
103
115
|
store.releaseStage1OnShutdown(sid, claim.ownershipToken);
|
|
104
116
|
}
|
|
105
117
|
else {
|
|
118
|
+
if (isProviderCapacityError(err))
|
|
119
|
+
noteProviderCapacityExhausted("phase1", extractModel);
|
|
106
120
|
store.markStage1Failed(sid, claim.ownershipToken, err);
|
|
107
121
|
}
|
|
108
122
|
}
|
package/dist/src/phase2.d.ts
CHANGED
|
@@ -12,6 +12,18 @@ export interface Phase2Options {
|
|
|
12
12
|
heartbeatIntervalMs?: number;
|
|
13
13
|
}
|
|
14
14
|
export declare const DEFAULT_PHASE2_OPTIONS: Phase2Options;
|
|
15
|
+
/**
|
|
16
|
+
* Codex get_phase2_input_selection re-validates each row against the live
|
|
17
|
+
* threads table. We only drop a row on a confirmed 404 (same as session.deleted);
|
|
18
|
+
* timeouts / missing get / other errors keep the row.
|
|
19
|
+
*/
|
|
20
|
+
export declare function dropGonePhase2Inputs(store: MemoryStore, selected: ReturnType<MemoryStore["getPhase2InputSelection"]>): Promise<ReturnType<MemoryStore["getPhase2InputSelection"]>>;
|
|
21
|
+
/**
|
|
22
|
+
* Codex pages ranked candidates until it has `maxRaw` rows whose threads are
|
|
23
|
+
* still live. Our thread metadata lives in the host, so confirmed-gone rows
|
|
24
|
+
* are deleted and the ranking is queried again to backfill their slots.
|
|
25
|
+
*/
|
|
26
|
+
export declare function selectLivePhase2Inputs(store: MemoryStore, maxRaw: number, maxUnusedDays: number): Promise<ReturnType<MemoryStore["getPhase2InputSelection"]>>;
|
|
15
27
|
/** True while THIS process runs a consolidation (memory_reset refuses then). */
|
|
16
28
|
export declare function isPhase2InFlight(): boolean;
|
|
17
29
|
export declare function runPhase2(store: MemoryStore, opts?: Phase2Options): Promise<{
|
package/dist/src/phase2.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { checkRateLimit, isProviderCapacityError, noteProviderCapacityExhausted } from "./ratelimit.js";
|
|
1
2
|
import { ensureLayout, rebuildRawMemories, writeRolloutSummaries, pruneExtensionResources, writeWorkspaceDiff, validateConsolidationArtifacts, } from "./workspace.js";
|
|
2
3
|
import { ensureBaseline, captureWorkspaceDiff, resetBaseline, DIFF_ARTIFACT } from "./git-baseline.js";
|
|
3
|
-
import { consolidateViaSubagent, SubagentCancelledError, SubagentShutdownError } from "./llm.js";
|
|
4
|
+
import { consolidateViaSubagent, getPluginInput, SubagentCancelledError, SubagentShutdownError, } from "./llm.js";
|
|
5
|
+
import { hostSessionLiveness } from "./host-client.js";
|
|
4
6
|
import { invalidateCache } from "./source.js";
|
|
5
7
|
import { memoryRoot } from "./paths.js";
|
|
6
8
|
import { abortPhase2Consolidation, beginPhase2AbortScope, endPhase2AbortScope, isPluginShuttingDown, } from "./lifecycle.js";
|
|
@@ -13,6 +15,59 @@ export const DEFAULT_PHASE2_OPTIONS = {
|
|
|
13
15
|
};
|
|
14
16
|
// Export runs only after a successful phase 2 (fresh, validated artifacts) and
|
|
15
17
|
// must never fail the run — Codex's workspace is best-effort foreign territory.
|
|
18
|
+
const PHASE2_LIVE_CHECK_CONCURRENCY = 8;
|
|
19
|
+
/**
|
|
20
|
+
* Codex get_phase2_input_selection re-validates each row against the live
|
|
21
|
+
* threads table. We only drop a row on a confirmed 404 (same as session.deleted);
|
|
22
|
+
* timeouts / missing get / other errors keep the row.
|
|
23
|
+
*/
|
|
24
|
+
export async function dropGonePhase2Inputs(store, selected) {
|
|
25
|
+
const client = getPluginInput()?.client;
|
|
26
|
+
if (!client || selected.length === 0)
|
|
27
|
+
return selected;
|
|
28
|
+
const kept = [];
|
|
29
|
+
for (let i = 0; i < selected.length; i += PHASE2_LIVE_CHECK_CONCURRENCY) {
|
|
30
|
+
const chunk = selected.slice(i, i + PHASE2_LIVE_CHECK_CONCURRENCY);
|
|
31
|
+
const results = await Promise.all(chunk.map(async (out) => ({ out, live: await hostSessionLiveness(client, out.session_id) })));
|
|
32
|
+
for (const { out, live } of results) {
|
|
33
|
+
if (live === "gone")
|
|
34
|
+
store.deleteSessionMemory(out.session_id);
|
|
35
|
+
else
|
|
36
|
+
kept.push(out);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return kept;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Codex pages ranked candidates until it has `maxRaw` rows whose threads are
|
|
43
|
+
* still live. Our thread metadata lives in the host, so confirmed-gone rows
|
|
44
|
+
* are deleted and the ranking is queried again to backfill their slots.
|
|
45
|
+
*/
|
|
46
|
+
export async function selectLivePhase2Inputs(store, maxRaw, maxUnusedDays) {
|
|
47
|
+
if (maxRaw <= 0)
|
|
48
|
+
return [];
|
|
49
|
+
const selected = [];
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
while (selected.length < maxRaw) {
|
|
52
|
+
// Once most slots are filled, inspect one liveness chunk beyond the
|
|
53
|
+
// already-selected rows so a single gone row does not force serial probes.
|
|
54
|
+
const scanLimit = Math.max(maxRaw, selected.length + PHASE2_LIVE_CHECK_CONCURRENCY);
|
|
55
|
+
const candidates = store
|
|
56
|
+
.getPhase2InputSelection(scanLimit, maxUnusedDays)
|
|
57
|
+
.filter((output) => !seen.has(output.session_id));
|
|
58
|
+
if (candidates.length === 0)
|
|
59
|
+
break;
|
|
60
|
+
for (const output of candidates)
|
|
61
|
+
seen.add(output.session_id);
|
|
62
|
+
const live = await dropGonePhase2Inputs(store, candidates);
|
|
63
|
+
for (const output of live) {
|
|
64
|
+
selected.push(output);
|
|
65
|
+
if (selected.length >= maxRaw)
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return selected;
|
|
70
|
+
}
|
|
16
71
|
function maybeExportToCodex(interop) {
|
|
17
72
|
if (!interop?.exportEnabled)
|
|
18
73
|
return;
|
|
@@ -42,9 +97,12 @@ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS) {
|
|
|
42
97
|
return { status: "already_running" };
|
|
43
98
|
phase2InFlight = true;
|
|
44
99
|
try {
|
|
45
|
-
// No process
|
|
46
|
-
//
|
|
47
|
-
|
|
100
|
+
// No 30s process gate: codex serializes phase 2 only via the DB claim.
|
|
101
|
+
// An observed quota stamp still skips both phases (Codex start.rs).
|
|
102
|
+
const consolidationModel = opts.consolidationModel;
|
|
103
|
+
const rl = await checkRateLimit("phase2", consolidationModel);
|
|
104
|
+
if (!rl.ok)
|
|
105
|
+
return { status: "skipped_rate_limit" };
|
|
48
106
|
const claim = store.claimGlobalPhase2Job();
|
|
49
107
|
if (claim.type !== "claimed")
|
|
50
108
|
return { status: claim.type };
|
|
@@ -73,7 +131,7 @@ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS) {
|
|
|
73
131
|
if (releaseIfShuttingDown(store, claim.ownershipToken)) {
|
|
74
132
|
return { status: "shutting_down" };
|
|
75
133
|
}
|
|
76
|
-
const outputs = store
|
|
134
|
+
const outputs = await selectLivePhase2Inputs(store, opts.maxRaw, opts.maxUnusedDays);
|
|
77
135
|
rebuildRawMemories(outputs);
|
|
78
136
|
writeRolloutSummaries(outputs);
|
|
79
137
|
pruneExtensionResources(opts.extensionRetentionDays);
|
|
@@ -158,7 +216,7 @@ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS) {
|
|
|
158
216
|
}
|
|
159
217
|
const heartbeat = setInterval(heartbeatOnce, opts.heartbeatIntervalMs ?? 90_000);
|
|
160
218
|
try {
|
|
161
|
-
await consolidateViaSubagent(memoryRoot(), DIFF_ARTIFACT,
|
|
219
|
+
await consolidateViaSubagent(memoryRoot(), DIFF_ARTIFACT, consolidationModel, consolidationSignal);
|
|
162
220
|
}
|
|
163
221
|
catch (err) {
|
|
164
222
|
// codex phase2.rs: when the consolidation agent's shutdown fails, keep
|
|
@@ -218,6 +276,8 @@ export async function runPhase2(store, opts = DEFAULT_PHASE2_OPTIONS) {
|
|
|
218
276
|
store.releasePhase2OnShutdown(claim.ownershipToken);
|
|
219
277
|
return { status: "shutting_down" };
|
|
220
278
|
}
|
|
279
|
+
if (isProviderCapacityError(err))
|
|
280
|
+
noteProviderCapacityExhausted("phase2", consolidationModel);
|
|
221
281
|
store.markPhase2Failed(claim.ownershipToken, err);
|
|
222
282
|
return { status: "failed" };
|
|
223
283
|
}
|
package/dist/src/ratelimit.d.ts
CHANGED
|
@@ -2,8 +2,24 @@ export interface RateLimitInfo {
|
|
|
2
2
|
ok: boolean;
|
|
3
3
|
reason?: string;
|
|
4
4
|
}
|
|
5
|
-
export
|
|
5
|
+
export type MemoryPhase = "phase1" | "phase2";
|
|
6
|
+
export interface ProviderCapacityBackoff {
|
|
7
|
+
scope: string;
|
|
8
|
+
retry_at: number;
|
|
9
|
+
}
|
|
10
|
+
export declare class ProviderCapacityError extends Error {
|
|
11
|
+
readonly statusCode?: number | undefined;
|
|
12
|
+
constructor(message: string, statusCode?: number | undefined);
|
|
13
|
+
}
|
|
14
|
+
export declare const PROVIDER_CAPACITY_BACKOFF_MS = 3600000;
|
|
15
|
+
export declare function providerCapacityMessage(error: unknown): string;
|
|
16
|
+
export declare function isProviderCapacityError(error: unknown): boolean;
|
|
17
|
+
export declare function activeProviderCapacityBackoffs(now?: number): ProviderCapacityBackoff[];
|
|
18
|
+
export declare function isProviderCapacityBlocked(phase: MemoryPhase, model?: string, now?: number): boolean;
|
|
19
|
+
/** Call after a quota/rate-limit failure so later passes skip claiming. */
|
|
20
|
+
export declare function noteProviderCapacityExhausted(phase: MemoryPhase, model?: string, now?: number): void;
|
|
21
|
+
export declare function checkRateLimit(kind?: MemoryPhase, model?: string): Promise<RateLimitInfo>;
|
|
6
22
|
/** Call after a phase-1 pass claimed at least one job (token-using work started). */
|
|
7
23
|
export declare function markRateLimitUsed(kind?: "phase1" | "phase2"): void;
|
|
8
|
-
/** Test seam: reset the process-local
|
|
24
|
+
/** Test seam: reset the process-local stamps. */
|
|
9
25
|
export declare function resetRateLimitForTest(): void;
|