memorix 1.2.7 → 1.2.9
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/CHANGELOG.md +15 -0
- package/dist/cli/index.js +19531 -19017
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/static/app.js +7 -2
- package/dist/index.js +7870 -7497
- package/dist/index.js.map +1 -1
- package/dist/maintenance-jobs-o1rYSFcM.d.ts +36 -0
- package/dist/maintenance-runner.d.ts +1 -28
- package/dist/maintenance-runner.js +5937 -5596
- package/dist/maintenance-runner.js.map +1 -1
- package/dist/memcode-runtime/CHANGELOG.md +15 -0
- package/dist/sdk.js +7866 -7493
- package/dist/sdk.js.map +1 -1
- package/dist/vector-backfill-runner.d.ts +31 -0
- package/dist/vector-backfill-runner.js +12670 -0
- package/dist/vector-backfill-runner.js.map +1 -0
- package/docs/dev-log/progress.txt +25 -0
- package/package.json +3 -3
- package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
- package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/copilot/memorix/plugin.json +1 -1
- package/plugins/gemini/memorix/gemini-extension.json +1 -1
- package/plugins/omp/memorix/package.json +1 -1
- package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/pi/memorix/package.json +1 -1
- package/src/cli/commands/agent-integrations.ts +102 -5
- package/src/cli/commands/operator-shared.ts +4 -1
- package/src/cli/commands/serve-http.ts +58 -50
- package/src/cli/commands/setup.ts +44 -0
- package/src/dashboard/server.ts +54 -63
- package/src/memory/observations.ts +175 -99
- package/src/memory/retention.ts +106 -28
- package/src/memory/session.ts +71 -12
- package/src/runtime/maintenance-jobs.ts +84 -4
- package/src/runtime/project-maintenance.ts +63 -6
- package/src/runtime/vector-backfill-runner.ts +144 -0
- package/src/search/intent-detector.ts +39 -1
- package/src/server.ts +5 -5
package/src/memory/retention.ts
CHANGED
|
@@ -319,6 +319,40 @@ export interface RetentionExplanation {
|
|
|
319
319
|
summary: string;
|
|
320
320
|
}
|
|
321
321
|
|
|
322
|
+
/** Access metadata maintained outside the persisted observation record. */
|
|
323
|
+
export type ObservationAccessMap = ReadonlyMap<number, {
|
|
324
|
+
accessCount: number;
|
|
325
|
+
lastAccessedAt: string;
|
|
326
|
+
}>;
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* One canonical retention result for a persisted observation.
|
|
330
|
+
*
|
|
331
|
+
* Consumers should use this projection instead of recreating a local decay
|
|
332
|
+
* formula. That keeps the UI, HTTP control plane, and archive worker aligned
|
|
333
|
+
* on the same retention state.
|
|
334
|
+
*/
|
|
335
|
+
export interface RetentionProjection extends RetentionExplanation {
|
|
336
|
+
relevance: RelevanceScore;
|
|
337
|
+
/** A backwards-compatible 0-10 display value derived from relevance. */
|
|
338
|
+
displayScore: number;
|
|
339
|
+
ageHours: number;
|
|
340
|
+
accessCount: number;
|
|
341
|
+
lastAccessedAt: string;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export interface ObservationRetentionProjectionOptions {
|
|
345
|
+
referenceTime?: Date;
|
|
346
|
+
accessMap?: ObservationAccessMap;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export interface RetentionProjectionSummary {
|
|
350
|
+
active: number;
|
|
351
|
+
stale: number;
|
|
352
|
+
archiveCandidates: number;
|
|
353
|
+
immune: number;
|
|
354
|
+
}
|
|
355
|
+
|
|
322
356
|
/**
|
|
323
357
|
* Produce a structured explanation of why an observation has its current
|
|
324
358
|
* retention posture. Designed for operator-facing reporting.
|
|
@@ -371,27 +405,16 @@ export function explainRetention(
|
|
|
371
405
|
};
|
|
372
406
|
}
|
|
373
407
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
/** Omit only for trusted background maintenance. */
|
|
383
|
-
reader?: ObservationReader;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
export interface ArchiveExpiredBatchResult {
|
|
387
|
-
archived: number;
|
|
388
|
-
scanned: number;
|
|
389
|
-
nextCursor?: number;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
function toRetentionDocument(
|
|
408
|
+
/**
|
|
409
|
+
* Convert a stored observation into the search/retention document shape.
|
|
410
|
+
*
|
|
411
|
+
* Observation fields such as concepts are arrays, while the retrieval schema
|
|
412
|
+
* intentionally stores them as strings. Centralising that conversion prevents
|
|
413
|
+
* callers from accidentally feeding raw observations into retention helpers.
|
|
414
|
+
*/
|
|
415
|
+
export function toRetentionDocument(
|
|
393
416
|
obs: Observation,
|
|
394
|
-
accessMap?:
|
|
417
|
+
accessMap?: ObservationAccessMap,
|
|
395
418
|
): MemorixDocument {
|
|
396
419
|
const access = accessMap?.get(obs.id);
|
|
397
420
|
return {
|
|
@@ -418,6 +441,62 @@ function toRetentionDocument(
|
|
|
418
441
|
};
|
|
419
442
|
}
|
|
420
443
|
|
|
444
|
+
/** Build the canonical retention result used by every Observation consumer. */
|
|
445
|
+
export function projectObservationRetention(
|
|
446
|
+
observation: Observation,
|
|
447
|
+
options: ObservationRetentionProjectionOptions = {},
|
|
448
|
+
): RetentionProjection {
|
|
449
|
+
const document = toRetentionDocument(observation, options.accessMap);
|
|
450
|
+
const relevance = calculateRelevance(document, options.referenceTime);
|
|
451
|
+
const explanation = explainRetention(document, options.referenceTime);
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
...explanation,
|
|
455
|
+
relevance,
|
|
456
|
+
displayScore: Math.round(Math.min(10, relevance.totalScore * 10) * 100) / 100,
|
|
457
|
+
ageHours: Math.round(relevance.ageDays * 24 * 10) / 10,
|
|
458
|
+
accessCount: document.accessCount,
|
|
459
|
+
lastAccessedAt: document.lastAccessedAt,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Summarise canonical projections without reimplementing retention thresholds. */
|
|
464
|
+
export function summarizeRetentionProjections(
|
|
465
|
+
projections: readonly Pick<RetentionProjection, 'zone' | 'immune'>[],
|
|
466
|
+
): RetentionProjectionSummary {
|
|
467
|
+
let active = 0;
|
|
468
|
+
let stale = 0;
|
|
469
|
+
let archiveCandidates = 0;
|
|
470
|
+
let immune = 0;
|
|
471
|
+
|
|
472
|
+
for (const projection of projections) {
|
|
473
|
+
if (projection.zone === 'active') active++;
|
|
474
|
+
else if (projection.zone === 'stale') stale++;
|
|
475
|
+
else archiveCandidates++;
|
|
476
|
+
if (projection.immune) immune++;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return { active, stale, archiveCandidates, immune };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ── Auto-Archive ────────────────────────────────────────────────────
|
|
483
|
+
|
|
484
|
+
export interface ArchiveExpiredBatchOptions {
|
|
485
|
+
projectId: string;
|
|
486
|
+
afterId?: number;
|
|
487
|
+
limit?: number;
|
|
488
|
+
referenceTime?: Date;
|
|
489
|
+
accessMap?: ObservationAccessMap;
|
|
490
|
+
/** Omit only for trusted background maintenance. */
|
|
491
|
+
reader?: ObservationReader;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export interface ArchiveExpiredBatchResult {
|
|
495
|
+
archived: number;
|
|
496
|
+
scanned: number;
|
|
497
|
+
nextCursor?: number;
|
|
498
|
+
}
|
|
499
|
+
|
|
421
500
|
/**
|
|
422
501
|
* Archive one bounded page of a project's active memories. The caller owns the
|
|
423
502
|
* cursor, which makes the operation safe to run from a durable maintenance job.
|
|
@@ -438,10 +517,10 @@ export async function archiveExpiredBatch(
|
|
|
438
517
|
const scanned = hasMore ? page.slice(0, limit) : page;
|
|
439
518
|
const candidateIds = scanned
|
|
440
519
|
.filter((observation) => !options.reader || canManageObservation(observation, options.reader))
|
|
441
|
-
.filter((observation) =>
|
|
442
|
-
|
|
443
|
-
options.referenceTime,
|
|
444
|
-
) === 'archive-candidate')
|
|
520
|
+
.filter((observation) => projectObservationRetention(observation, {
|
|
521
|
+
accessMap: options.accessMap,
|
|
522
|
+
referenceTime: options.referenceTime,
|
|
523
|
+
}).zone === 'archive-candidate')
|
|
445
524
|
.map((observation) => observation.id);
|
|
446
525
|
|
|
447
526
|
const archived = candidateIds.length === 0
|
|
@@ -474,7 +553,7 @@ export async function archiveExpiredBatch(
|
|
|
474
553
|
export async function archiveExpired(
|
|
475
554
|
projectDir: string,
|
|
476
555
|
referenceTime?: Date,
|
|
477
|
-
accessMap?:
|
|
556
|
+
accessMap?: ObservationAccessMap,
|
|
478
557
|
projectId?: string,
|
|
479
558
|
reader?: ObservationReader,
|
|
480
559
|
): Promise<{ archived: number; remaining: number }> {
|
|
@@ -512,9 +591,8 @@ export async function archiveExpired(
|
|
|
512
591
|
const archivedIds: number[] = [];
|
|
513
592
|
|
|
514
593
|
for (const obs of activeObs) {
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
if (zone === 'archive-candidate') {
|
|
594
|
+
const retention = projectObservationRetention(obs, { accessMap, referenceTime });
|
|
595
|
+
if (retention.zone === 'archive-candidate') {
|
|
518
596
|
archivedIds.push(obs.id);
|
|
519
597
|
}
|
|
520
598
|
}
|
package/src/memory/session.ts
CHANGED
|
@@ -21,7 +21,18 @@ import { KnowledgeGraphManager } from './graph.js';
|
|
|
21
21
|
import { redactCredentials, sanitizeCredentials } from './secret-filter.js';
|
|
22
22
|
import { canReadObservation } from './visibility.js';
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
// Types eligible for L2 "Key Project Memories" injection. Session requests carry
|
|
25
|
+
// the original goal and change records carry the latest outcome, so both matter
|
|
26
|
+
// when an agent resumes work after a handoff.
|
|
27
|
+
const PRIORITY_TYPES = new Set([
|
|
28
|
+
'gotcha',
|
|
29
|
+
'decision',
|
|
30
|
+
'problem-solution',
|
|
31
|
+
'trade-off',
|
|
32
|
+
'discovery',
|
|
33
|
+
'session-request',
|
|
34
|
+
'what-changed',
|
|
35
|
+
]);
|
|
25
36
|
const RESUME_TYPES = new Set([
|
|
26
37
|
'gotcha',
|
|
27
38
|
'decision',
|
|
@@ -31,7 +42,6 @@ const RESUME_TYPES = new Set([
|
|
|
31
42
|
'how-it-works',
|
|
32
43
|
'what-changed',
|
|
33
44
|
'reasoning',
|
|
34
|
-
'session-request',
|
|
35
45
|
]);
|
|
36
46
|
const TYPE_EMOJI: Record<string, string> = {
|
|
37
47
|
'gotcha': '[DISCOVERY]',
|
|
@@ -50,19 +60,24 @@ const TYPE_WEIGHTS: Record<string, number> = {
|
|
|
50
60
|
'decision': 5.5,
|
|
51
61
|
'problem-solution': 5.25,
|
|
52
62
|
'trade-off': 4.75,
|
|
63
|
+
'session-request': 4.5,
|
|
53
64
|
'discovery': 4.25,
|
|
65
|
+
'what-changed': 4,
|
|
54
66
|
};
|
|
67
|
+
// Markers that identify a memory as a demo/scratch artifact rather than real
|
|
68
|
+
// working context. These are TITLE conventions — see isNoiseObservation.
|
|
69
|
+
//
|
|
70
|
+
// Deliberately excluded: 验证 / 兼容 / compat / 交接 / handoff. Those are ordinary
|
|
71
|
+
// engineering vocabulary, not noise signals. Matching them silently dropped real
|
|
72
|
+
// memories: any Chinese note about verification or compatibility, and any handoff
|
|
73
|
+
// note that merely named a handoff tool, disappeared from session context with no
|
|
74
|
+
// diagnostic. A noise filter must not veto the words its users normally write.
|
|
55
75
|
const NOISE_PATTERNS = [
|
|
56
76
|
/\[测试\]/i,
|
|
57
77
|
/\[test\]/i,
|
|
58
|
-
/验证/i,
|
|
59
|
-
/兼容/i,
|
|
60
|
-
/\bcompat(?:ibility)?\b/i,
|
|
61
78
|
/\bdemo\b/i,
|
|
62
79
|
/展示/i,
|
|
63
80
|
/全能力/i,
|
|
64
|
-
/handoff/i,
|
|
65
|
-
/交接/i,
|
|
66
81
|
/for_memmcp_test/i,
|
|
67
82
|
/\bbenchmark\b/i,
|
|
68
83
|
/\bsandbox\b/i,
|
|
@@ -180,8 +195,13 @@ function isCommandTrace(obs: Observation): boolean {
|
|
|
180
195
|
}
|
|
181
196
|
|
|
182
197
|
function isNoiseObservation(obs: Observation): boolean {
|
|
183
|
-
|
|
184
|
-
|
|
198
|
+
// Match the title only. Demo/scratch markers are a titling convention, so scanning
|
|
199
|
+
// the narrative/facts/concepts produced false positives on real memories that merely
|
|
200
|
+
// *mentioned* one of these words in prose — and because this filter runs before the
|
|
201
|
+
// PRIORITY_TYPES check and drops the observation outright (not a score penalty), a
|
|
202
|
+
// single unlucky word in a long narrative made the whole memory unreachable.
|
|
203
|
+
const title = obs.title ?? '';
|
|
204
|
+
return NOISE_PATTERNS.some((pattern) => pattern.test(title)) || isCommandTrace(obs);
|
|
185
205
|
}
|
|
186
206
|
|
|
187
207
|
function isSystemSelfObservation(obs: Observation): boolean {
|
|
@@ -321,6 +341,42 @@ export async function getSessionResumeBrief(
|
|
|
321
341
|
};
|
|
322
342
|
}
|
|
323
343
|
|
|
344
|
+
const AUTO_RESUME_SUMMARY_LIMIT = 480;
|
|
345
|
+
const AUTO_RESUME_TITLE_LIMIT = 180;
|
|
346
|
+
|
|
347
|
+
function compactResumeText(text: string, limit: number): string {
|
|
348
|
+
const normalized = text.replace(/\s+/g, ' ').trim();
|
|
349
|
+
return normalized.length <= limit ? normalized : `${normalized.slice(0, Math.max(0, limit - 3)).trimEnd()}...`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Render the bounded automatic delivery form of a resume brief.
|
|
354
|
+
*
|
|
355
|
+
* Full session context remains available through getSessionContext() and
|
|
356
|
+
* individual memory details remain on demand. Automatic session start gets an
|
|
357
|
+
* index, not a transcript, so it cannot crowd out the new task's context.
|
|
358
|
+
*/
|
|
359
|
+
export function renderSessionResumeCard(brief: SessionResumeBrief): string {
|
|
360
|
+
if (!brief.previousSession && brief.memories.length === 0) return '';
|
|
361
|
+
|
|
362
|
+
const lines = ['## Memorix Resume'];
|
|
363
|
+
if (brief.previousSession) {
|
|
364
|
+
const agent = brief.previousSession.agent ? ` (${brief.previousSession.agent})` : '';
|
|
365
|
+
lines.push(`Previous session: ${brief.previousSession.id}${agent}`);
|
|
366
|
+
lines.push(compactResumeText(brief.previousSession.summary, AUTO_RESUME_SUMMARY_LIMIT));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (brief.memories.length > 0) {
|
|
370
|
+
lines.push('', 'Relevant memory references:');
|
|
371
|
+
for (const memory of brief.memories) {
|
|
372
|
+
lines.push(`- #${memory.id} [${memory.type}] ${compactResumeText(memory.title, AUTO_RESUME_TITLE_LIMIT)}`);
|
|
373
|
+
}
|
|
374
|
+
lines.push('Read a listed memory only when its title is relevant to the current task.');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return lines.join('\n');
|
|
378
|
+
}
|
|
379
|
+
|
|
324
380
|
export function scoreObservationForSessionContext(obs: Observation, projectTokens: string[], now = Date.now()): number {
|
|
325
381
|
let score = TYPE_WEIGHTS[obs.type] ?? 1;
|
|
326
382
|
const text = stringifyObservation(obs);
|
|
@@ -384,7 +440,7 @@ export function scoreObservationForSessionContext(obs: Observation, projectToken
|
|
|
384
440
|
* so the agent can resume work without re-explaining everything.
|
|
385
441
|
*/
|
|
386
442
|
export async function startSession(
|
|
387
|
-
|
|
443
|
+
_projectDir: string,
|
|
388
444
|
projectId: string,
|
|
389
445
|
opts?: { sessionId?: string; agent?: string; reader?: ObservationReader },
|
|
390
446
|
): Promise<{ session: Session; previousContext: string }> {
|
|
@@ -399,8 +455,11 @@ export async function startSession(
|
|
|
399
455
|
agent: opts?.agent,
|
|
400
456
|
};
|
|
401
457
|
|
|
402
|
-
//
|
|
403
|
-
|
|
458
|
+
// Automatic delivery stays intentionally small. The explicit context tool can
|
|
459
|
+
// still provide the expanded packet when an agent chooses to inspect it.
|
|
460
|
+
const previousContext = renderSessionResumeCard(
|
|
461
|
+
await getSessionResumeBrief(projectId, undefined, opts?.reader),
|
|
462
|
+
);
|
|
404
463
|
|
|
405
464
|
// Atomic rollover: complete all active sessions for this project's aliases
|
|
406
465
|
// and insert the new session in a single SQLite transaction.
|
|
@@ -67,7 +67,18 @@ export interface MaintenanceJobSummary {
|
|
|
67
67
|
|
|
68
68
|
export type MaintenanceJobRunResult =
|
|
69
69
|
| { action: 'complete' }
|
|
70
|
-
| {
|
|
70
|
+
| {
|
|
71
|
+
action: 'reschedule';
|
|
72
|
+
delayMs: number;
|
|
73
|
+
resetAttempts?: boolean;
|
|
74
|
+
payload?: Record<string, unknown>;
|
|
75
|
+
/** Keep recoverable work out of the immediate pending queue during a cooldown. */
|
|
76
|
+
status?: 'pending' | 'retry';
|
|
77
|
+
/** Persist a sanitized diagnostic without consuming the job's retry budget. */
|
|
78
|
+
lastError?: string;
|
|
79
|
+
/** Clear a stale transient diagnostic after the job makes progress. */
|
|
80
|
+
clearLastError?: boolean;
|
|
81
|
+
};
|
|
71
82
|
|
|
72
83
|
export type MaintenanceJobHandler = (
|
|
73
84
|
job: MaintenanceJob,
|
|
@@ -160,6 +171,12 @@ export class MaintenanceJobStore {
|
|
|
160
171
|
`).get(input.projectId, input.kind, dedupeKey);
|
|
161
172
|
|
|
162
173
|
if (existing) {
|
|
174
|
+
// A vector backfill in cooldown is a circuit-breaker state. A new MCP
|
|
175
|
+
// process must not pull it forward and recreate the original retry storm.
|
|
176
|
+
if (input.kind === 'vector-backfill' && existing.status === 'retry') {
|
|
177
|
+
this.commit();
|
|
178
|
+
return rowToJob(existing);
|
|
179
|
+
}
|
|
163
180
|
const nextRunAfter = Math.min(Number(existing.run_after), runAfter);
|
|
164
181
|
this.db.prepare(`
|
|
165
182
|
UPDATE maintenance_jobs
|
|
@@ -171,6 +188,30 @@ export class MaintenanceJobStore {
|
|
|
171
188
|
return rowToJob(updated);
|
|
172
189
|
}
|
|
173
190
|
|
|
191
|
+
// Older releases exhausted vector jobs on transient provider/index errors.
|
|
192
|
+
// Reuse one failed record so the next healthy session recovers work instead
|
|
193
|
+
// of adding another permanent failure row for the same project.
|
|
194
|
+
if (input.kind === 'vector-backfill') {
|
|
195
|
+
const failed = this.db.prepare(`
|
|
196
|
+
SELECT * FROM maintenance_jobs
|
|
197
|
+
WHERE project_id = ? AND kind = ? AND dedupe_key = ? AND status = 'failed'
|
|
198
|
+
ORDER BY updated_at DESC
|
|
199
|
+
LIMIT 1
|
|
200
|
+
`).get(input.projectId, input.kind, dedupeKey);
|
|
201
|
+
if (failed) {
|
|
202
|
+
this.db.prepare(`
|
|
203
|
+
UPDATE maintenance_jobs
|
|
204
|
+
SET status = 'pending', attempts = 0, max_attempts = ?, run_after = ?,
|
|
205
|
+
payload_json = ?, lease_owner = NULL, lease_expires_at = NULL,
|
|
206
|
+
last_error = NULL, completed_at = NULL, updated_at = ?
|
|
207
|
+
WHERE id = ?
|
|
208
|
+
`).run(maxAttempts, runAfter, payloadJson, now, failed.id);
|
|
209
|
+
const revived = this.getRow(failed.id)!;
|
|
210
|
+
this.commit();
|
|
211
|
+
return rowToJob(revived);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
174
215
|
const id = randomUUID();
|
|
175
216
|
this.db.prepare(`
|
|
176
217
|
INSERT INTO maintenance_jobs (
|
|
@@ -361,21 +402,57 @@ export class MaintenanceJobStore {
|
|
|
361
402
|
reschedule(
|
|
362
403
|
id: string,
|
|
363
404
|
workerId: string,
|
|
364
|
-
options: {
|
|
405
|
+
options: {
|
|
406
|
+
delayMs: number;
|
|
407
|
+
resetAttempts?: boolean;
|
|
408
|
+
payload?: Record<string, unknown>;
|
|
409
|
+
status?: 'pending' | 'retry';
|
|
410
|
+
lastError?: string;
|
|
411
|
+
clearLastError?: boolean;
|
|
412
|
+
now?: number;
|
|
413
|
+
},
|
|
365
414
|
): MaintenanceJob | undefined {
|
|
366
415
|
const now = options.now ?? Date.now();
|
|
367
416
|
const delayMs = Number.isFinite(options.delayMs) ? Math.max(0, Math.floor(options.delayMs)) : 0;
|
|
368
417
|
const payloadJson = options.payload === undefined ? null : JSON.stringify(options.payload);
|
|
418
|
+
const status = options.status === 'retry' ? 'retry' : 'pending';
|
|
419
|
+
const hasLastError = options.lastError !== undefined;
|
|
420
|
+
const lastError = hasLastError ? errorText(options.lastError) : null;
|
|
369
421
|
this.db.prepare(`
|
|
370
422
|
UPDATE maintenance_jobs
|
|
371
|
-
SET status =
|
|
423
|
+
SET status = ?, run_after = ?, attempts = CASE WHEN ? THEN 0 ELSE attempts END,
|
|
372
424
|
payload_json = COALESCE(?, payload_json),
|
|
425
|
+
last_error = CASE
|
|
426
|
+
WHEN ? THEN NULL
|
|
427
|
+
WHEN ? THEN ?
|
|
428
|
+
ELSE last_error
|
|
429
|
+
END,
|
|
373
430
|
lease_owner = NULL, lease_expires_at = NULL, updated_at = ?
|
|
374
431
|
WHERE id = ? AND status = 'running' AND lease_owner = ?
|
|
375
|
-
`).run(
|
|
432
|
+
`).run(
|
|
433
|
+
status,
|
|
434
|
+
now + delayMs,
|
|
435
|
+
options.resetAttempts ? 1 : 0,
|
|
436
|
+
payloadJson,
|
|
437
|
+
options.clearLastError ? 1 : 0,
|
|
438
|
+
hasLastError ? 1 : 0,
|
|
439
|
+
lastError,
|
|
440
|
+
now,
|
|
441
|
+
id,
|
|
442
|
+
workerId,
|
|
443
|
+
);
|
|
376
444
|
return this.get(id);
|
|
377
445
|
}
|
|
378
446
|
|
|
447
|
+
resolveFailedVectorBackfills(projectId: string, dedupeKey = 'vector-backfill', now = Date.now()): number {
|
|
448
|
+
const result = this.db.prepare(`
|
|
449
|
+
UPDATE maintenance_jobs
|
|
450
|
+
SET status = 'completed', completed_at = ?, updated_at = ?
|
|
451
|
+
WHERE project_id = ? AND kind = 'vector-backfill' AND dedupe_key = ? AND status = 'failed'
|
|
452
|
+
`).run(now, now, projectId, dedupeKey);
|
|
453
|
+
return Number(result.changes ?? 0);
|
|
454
|
+
}
|
|
455
|
+
|
|
379
456
|
fail(id: string, workerId: string, error: unknown, now = Date.now()): MaintenanceJob | undefined {
|
|
380
457
|
this.begin();
|
|
381
458
|
try {
|
|
@@ -486,6 +563,9 @@ export class MaintenanceJobWorker {
|
|
|
486
563
|
delayMs: result.delayMs,
|
|
487
564
|
resetAttempts: result.resetAttempts,
|
|
488
565
|
payload: result.payload,
|
|
566
|
+
status: result.status,
|
|
567
|
+
lastError: result.lastError,
|
|
568
|
+
clearLastError: result.clearLastError,
|
|
489
569
|
now,
|
|
490
570
|
});
|
|
491
571
|
return { state: 'rescheduled', job: updated };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import {
|
|
2
|
+
MaintenanceJobStore,
|
|
3
|
+
type MaintenanceJobHandler,
|
|
4
|
+
type MaintenanceJobRunResult,
|
|
4
5
|
} from './maintenance-jobs.js';
|
|
5
6
|
import { runMaintenanceInChildProcess } from './isolated-maintenance.js';
|
|
6
7
|
import {
|
|
@@ -18,6 +19,8 @@ const DEFAULT_CODEGRAPH_MAX_FILES = 5_000;
|
|
|
18
19
|
const DEFAULT_CLAIM_DERIVATION_BATCH_SIZE = 100;
|
|
19
20
|
const DEFAULT_OBSERVATION_QUALIFICATION_BATCH_SIZE = 100;
|
|
20
21
|
const VECTOR_RETRY_DELAY_MS = 5_000;
|
|
22
|
+
const VECTOR_FAILURE_BASE_RETRY_DELAY_MS = 60_000;
|
|
23
|
+
const VECTOR_FAILURE_MAX_RETRY_DELAY_MS = 15 * 60_000;
|
|
21
24
|
const DEDUP_PER_PAIR_TIMEOUT_MS = 5_000;
|
|
22
25
|
|
|
23
26
|
function vectorBatchSize(payload: Record<string, unknown>): number {
|
|
@@ -26,6 +29,33 @@ function vectorBatchSize(payload: Record<string, unknown>): number {
|
|
|
26
29
|
return Math.min(100, Math.max(1, Math.floor(value)));
|
|
27
30
|
}
|
|
28
31
|
|
|
32
|
+
function vectorBackfillFailureStreak(payload: Record<string, unknown>): number {
|
|
33
|
+
const value = payload.vectorBackfillFailureStreak;
|
|
34
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
35
|
+
? Math.min(16, Math.max(0, Math.floor(value)))
|
|
36
|
+
: 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function vectorBackfillRetryDelayMs(streak: number): number {
|
|
40
|
+
return Math.min(
|
|
41
|
+
VECTOR_FAILURE_MAX_RETRY_DELAY_MS,
|
|
42
|
+
VECTOR_FAILURE_BASE_RETRY_DELAY_MS * (2 ** Math.max(0, streak - 1)),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function withoutVectorBackfillFailureStreak(payload: Record<string, unknown>): Record<string, unknown> {
|
|
47
|
+
const { vectorBackfillFailureStreak: _streak, ...rest } = payload;
|
|
48
|
+
return rest;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resolveSupersededVectorFailures(projectDir: string, projectId: string): void {
|
|
52
|
+
try {
|
|
53
|
+
new MaintenanceJobStore(projectDir).resolveFailedVectorBackfills(projectId);
|
|
54
|
+
} catch {
|
|
55
|
+
// Health cleanup must never block the live memory path.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
29
59
|
function retentionBatchSize(payload: Record<string, unknown>): number {
|
|
30
60
|
const value = payload.limit;
|
|
31
61
|
if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_RETENTION_BATCH_SIZE;
|
|
@@ -498,23 +528,50 @@ export function createProjectMaintenanceHandler(
|
|
|
498
528
|
|
|
499
529
|
const { backfillVectorEmbeddings, getVectorStatus } = await import('../memory/observations.js');
|
|
500
530
|
const before = getVectorStatus(projectId);
|
|
501
|
-
if (before.missing === 0)
|
|
531
|
+
if (before.missing === 0) {
|
|
532
|
+
resolveSupersededVectorFailures(projectDir, projectId);
|
|
533
|
+
return { action: 'complete' };
|
|
534
|
+
}
|
|
502
535
|
|
|
503
536
|
const result = await backfillVectorEmbeddings({
|
|
504
537
|
projectId,
|
|
505
538
|
limit: vectorBatchSize(job.payload),
|
|
506
539
|
});
|
|
507
540
|
const after = getVectorStatus(projectId);
|
|
508
|
-
if (after.missing === 0)
|
|
541
|
+
if (after.missing === 0) {
|
|
542
|
+
resolveSupersededVectorFailures(projectDir, projectId);
|
|
543
|
+
return { action: 'complete' };
|
|
544
|
+
}
|
|
509
545
|
|
|
510
546
|
if (result.failed > 0 && result.succeeded === 0) {
|
|
511
|
-
|
|
547
|
+
const streak = vectorBackfillFailureStreak(job.payload) + 1;
|
|
548
|
+
return {
|
|
549
|
+
action: 'reschedule',
|
|
550
|
+
status: 'retry',
|
|
551
|
+
delayMs: vectorBackfillRetryDelayMs(streak),
|
|
552
|
+
// This is a recoverable provider/index state, not a terminal job error.
|
|
553
|
+
// Keeping one retry row prevents new MCP processes from creating a storm.
|
|
554
|
+
resetAttempts: true,
|
|
555
|
+
payload: {
|
|
556
|
+
...job.payload,
|
|
557
|
+
vectorBackfillFailureStreak: streak,
|
|
558
|
+
},
|
|
559
|
+
lastError: result.lastError ?? `vector backfill made no progress (${result.failed}/${result.attempted} failed)`,
|
|
560
|
+
};
|
|
512
561
|
}
|
|
513
562
|
|
|
563
|
+
const priorFailureStreak = vectorBackfillFailureStreak(job.payload);
|
|
564
|
+
const payload = withoutVectorBackfillFailureStreak(job.payload);
|
|
514
565
|
return {
|
|
515
566
|
action: 'reschedule',
|
|
516
567
|
delayMs: result.attempted === 0 ? VECTOR_RETRY_DELAY_MS : 0,
|
|
517
568
|
resetAttempts: result.succeeded > 0,
|
|
569
|
+
...(result.succeeded > 0 ? {
|
|
570
|
+
...(priorFailureStreak > 0 ? { payload } : {}),
|
|
571
|
+
...(result.failed > 0 && result.lastError
|
|
572
|
+
? { lastError: result.lastError }
|
|
573
|
+
: priorFailureStreak > 0 ? { clearLastError: true } : {}),
|
|
574
|
+
} : {}),
|
|
518
575
|
};
|
|
519
576
|
};
|
|
520
577
|
}
|