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/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 res = await hostSessionCreate(input.client, {
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
- const res = await input.client.config.get();
77
- const cfg = res?.data;
78
- configModels = { model: cfg?.model, smallModel: cfg?.small_model };
125
+ return await request;
79
126
  }
80
- catch {
81
- // Config endpoint unavailable: leave models unset so the sub-agent runs
82
- // on the session default, the previous behavior.
83
- configModels = {};
127
+ finally {
128
+ if (configModelsInFlight === request)
129
+ configModelsInFlight = null;
84
130
  }
85
- return configModels;
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
- throw new Error(`prompt failed: ${JSON.stringify(res.error ?? {})}`);
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
- throw new Error(`sub-agent prompt failed${promptError.name ? ` (${promptError.name})` : ""}${detail ? `: ${detail}` : ""}`);
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 ?? (await getConfigModels()).smallModel;
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 = model ?? (await getConfigModels()).model;
310
- await promptSession(subId, prompt, agent, { model: resolved, timeoutMs: CONSOLIDATION_TIMEOUT_MS, signal });
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
- let timer;
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
- for (const s of list) {
349
- if (!s.id)
350
- continue;
351
- const pluginTitle = isPluginSubSessionTitle(s.title);
352
- const owned = s.metadata?.[SUBSESSION_METADATA_KEY] === true && pluginTitle;
353
- const legacy = s.metadata?.[SUBSESSION_METADATA_KEY] !== true && pluginTitle;
354
- if (!owned && !legacy)
355
- continue;
356
- // Durable ownership requires marker + generated title; a legacy title
357
- // alone can reseed the skip set but never authorizes deletion.
358
- activeSubSessions.add(s.id);
359
- if (!owned)
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
- clearTimeout(timer);
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
- input.client.session.delete({ path: { id }, signal: controller.signal }),
512
+ deletePromise,
395
513
  new Promise((_, reject) => {
396
514
  timer = setTimeout(() => {
397
515
  controller.abort();
398
- reject(new Error(`session.delete timed out after ${SUBSESSION_DELETE_TIMEOUT_MS}ms`));
399
- }, SUBSESSION_DELETE_TIMEOUT_MS);
516
+ reject(new Error(`session.delete timed out after ${timeoutMs}ms`));
517
+ }, timeoutMs);
400
518
  timer.unref?.();
401
519
  }),
402
520
  ]);
@@ -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
- const rl = await rateLimitCheck("phase1");
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: opts.extractModel,
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
  }
@@ -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<{
@@ -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-local rate gate: codex serializes phase 2 only via the DB
46
- // claim (cooldown / running / retry_at). Empty and cooldown skips must
47
- // not delay a later real claim.
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.getPhase2InputSelection(opts.maxRaw, opts.maxUnusedDays);
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, opts.consolidationModel, consolidationSignal);
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
  }
@@ -2,8 +2,24 @@ export interface RateLimitInfo {
2
2
  ok: boolean;
3
3
  reason?: string;
4
4
  }
5
- export declare function checkRateLimit(kind?: "phase1" | "phase2"): Promise<RateLimitInfo>;
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 stamp. */
24
+ /** Test seam: reset the process-local stamps. */
9
25
  export declare function resetRateLimitForTest(): void;