@felan-ai/felan 0.21.16 → 0.22.0

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.
Files changed (67) hide show
  1. package/README.md +4 -0
  2. package/dist/dependencies.js +1 -1
  3. package/dist/dependencies.js.map +1 -1
  4. package/dist/extensions.d.ts +1 -1
  5. package/dist/extensions.d.ts.map +1 -1
  6. package/dist/extensions.js +2 -2
  7. package/dist/extensions.js.map +1 -1
  8. package/dist/herdr.d.ts +22 -0
  9. package/dist/herdr.d.ts.map +1 -0
  10. package/dist/herdr.js +208 -0
  11. package/dist/herdr.js.map +1 -0
  12. package/dist/insights.d.ts +1 -1
  13. package/dist/insights.d.ts.map +1 -1
  14. package/dist/insights.js +11 -8
  15. package/dist/insights.js.map +1 -1
  16. package/dist/memory/control.d.ts +1 -1
  17. package/dist/memory/control.d.ts.map +1 -1
  18. package/dist/memory/control.js +90 -34
  19. package/dist/memory/control.js.map +1 -1
  20. package/dist/memory/coordinator.d.ts +13 -2
  21. package/dist/memory/coordinator.d.ts.map +1 -1
  22. package/dist/memory/coordinator.js +416 -67
  23. package/dist/memory/coordinator.js.map +1 -1
  24. package/dist/memory/dreamer.d.ts +7 -1
  25. package/dist/memory/dreamer.d.ts.map +1 -1
  26. package/dist/memory/dreamer.js +145 -59
  27. package/dist/memory/dreamer.js.map +1 -1
  28. package/dist/memory/history-view.d.ts +26 -0
  29. package/dist/memory/history-view.d.ts.map +1 -0
  30. package/dist/memory/history-view.js +192 -0
  31. package/dist/memory/history-view.js.map +1 -0
  32. package/dist/memory/history.d.ts +33 -0
  33. package/dist/memory/history.d.ts.map +1 -0
  34. package/dist/memory/history.js +314 -0
  35. package/dist/memory/history.js.map +1 -0
  36. package/dist/memory/run-retention.d.ts +12 -0
  37. package/dist/memory/run-retention.d.ts.map +1 -0
  38. package/dist/memory/run-retention.js +294 -0
  39. package/dist/memory/run-retention.js.map +1 -0
  40. package/dist/memory/run.d.ts +67 -0
  41. package/dist/memory/run.d.ts.map +1 -0
  42. package/dist/memory/run.js +417 -0
  43. package/dist/memory/run.js.map +1 -0
  44. package/dist/memory/store.d.ts +67 -3
  45. package/dist/memory/store.d.ts.map +1 -1
  46. package/dist/memory/store.js +517 -110
  47. package/dist/memory/store.js.map +1 -1
  48. package/dist/resume.d.ts.map +1 -1
  49. package/dist/resume.js +12 -7
  50. package/dist/resume.js.map +1 -1
  51. package/dist/runtime.d.ts +1 -0
  52. package/dist/runtime.d.ts.map +1 -1
  53. package/dist/runtime.js +27 -18
  54. package/dist/runtime.js.map +1 -1
  55. package/dist/session-picker.d.ts +8 -1
  56. package/dist/session-picker.d.ts.map +1 -1
  57. package/dist/session-picker.js +7 -4
  58. package/dist/session-picker.js.map +1 -1
  59. package/dist/settings.d.ts +0 -3
  60. package/dist/settings.d.ts.map +1 -1
  61. package/dist/settings.js +0 -22
  62. package/dist/settings.js.map +1 -1
  63. package/dist/subagents/agent-transcript.d.ts +4 -1
  64. package/dist/subagents/agent-transcript.d.ts.map +1 -1
  65. package/dist/subagents/agent-transcript.js +16 -4
  66. package/dist/subagents/agent-transcript.js.map +1 -1
  67. package/package.json +24 -23
@@ -1,12 +1,15 @@
1
1
  import { join } from 'node:path';
2
2
  import { mkdir, rm } from 'node:fs/promises';
3
- import { createMemorySnapshot, createMemoryProjectionSnapshot, createSessionCheckpoint, extractSourceIds, hydrateMemoryDirectory, readMemoryDirectory, validateMemoryArtifact, } from '@felan-ai/ext-memory';
3
+ import { createMemorySnapshot, createMemoryProjectionSnapshot, createSessionCheckpoint, extractSourceIds, hydrateMemoryDirectory, readMemoryDirectory, } from '@felan-ai/ext-memory';
4
4
  import { ModelRuntime, SessionManager, } from '@felan-ai/agent-core';
5
5
  import { acquireLocalMemoryLease, } from './lease.js';
6
6
  import { resolveLocalMemoryProject } from './project.js';
7
- import { createDefaultLocalMemoryDreamRunner, materializeMemoryInput, } from './dreamer.js';
8
- import { LocalMemoryStore, } from './store.js';
7
+ import { createDefaultLocalMemoryDreamRunner, MemoryModelUnavailableError, materializeMemoryInput, } from './dreamer.js';
8
+ import { LocalMemoryStore, LocalMemoryControlError, } from './store.js';
9
+ import { LocalMemoryRun, sanitizeMemoryDiagnostic } from './run.js';
9
10
  const MEMORY_INPUT_BLOCKED_MESSAGE = 'Some memory checkpoints could not be materialized; evidence remains pending';
11
+ const MEMORY_AUTOMATIC_UPDATE_THRESHOLD = 5;
12
+ const MEMORY_AUTOMATIC_INTERVAL_MS = 60 * 60 * 1_000;
10
13
  export class LocalMemoryCoordinator {
11
14
  #options;
12
15
  #contexts = new Map();
@@ -16,11 +19,16 @@ export class LocalMemoryCoordinator {
16
19
  #enabled;
17
20
  #selectedModel;
18
21
  #disposed = false;
22
+ #monitor;
23
+ #monitoring;
24
+ #enableVersion = 0;
19
25
  constructor(options) {
20
26
  this.#options = options;
21
27
  this.#enabled = options.enabled !== false;
22
28
  this.#selectedModel = options.selectedModel;
23
29
  this.#dreamRunner = options.dreamRunner ?? createDefaultLocalMemoryDreamRunner();
30
+ if (this.#enabled)
31
+ this.#startMonitor();
24
32
  }
25
33
  createSessionHost(options) {
26
34
  return new LocalMemorySessionHost(this, options);
@@ -36,19 +44,39 @@ export class LocalMemoryCoordinator {
36
44
  }
37
45
  setSelectedModel(model) {
38
46
  this.#selectedModel = model;
47
+ for (const context of this.#contexts.values()) {
48
+ context.blocked.clear();
49
+ if (context.initialized)
50
+ void this.#trackScheduleIfPending(context);
51
+ }
39
52
  }
40
53
  setEnabled(enabled) {
54
+ this.#enableVersion += 1;
41
55
  this.#enabled = enabled;
56
+ if (enabled)
57
+ this.#startMonitor();
58
+ else if (this.#monitor) {
59
+ clearInterval(this.#monitor);
60
+ this.#monitor = undefined;
61
+ }
42
62
  for (const context of this.#contexts.values()) {
43
- if (!enabled && context.timer) {
44
- clearTimeout(context.timer);
45
- context.timer = undefined;
63
+ if (!enabled)
64
+ this.#cancelScheduledRun(context);
65
+ if (!enabled)
66
+ this.#cancelContext(context, 'Memory processing was disabled', true);
67
+ if (enabled) {
68
+ context.storageBlocked = false;
69
+ context.writerBlocked = false;
70
+ context.blocked.clear();
71
+ if (context.initialized)
72
+ this.#startRecovery(context);
46
73
  }
47
74
  context.state = enabled ? 'idle' : 'disabled';
48
75
  }
49
76
  if (enabled) {
50
77
  for (const context of this.#contexts.values()) {
51
- void this.#trackScheduleIfPending(context, 0);
78
+ if (context.initialized)
79
+ void this.#trackScheduleIfPending(context);
52
80
  }
53
81
  }
54
82
  this.#emitStatusChange();
@@ -57,26 +85,36 @@ export class LocalMemoryCoordinator {
57
85
  try {
58
86
  const context = await this.#context(cwd);
59
87
  const state = await context.store.status();
88
+ const control = await context.store.readControl();
60
89
  const processed = Object.values(state.processed);
61
90
  const lastProcessedAt = processed
62
91
  .map((entry) => entry.processedAt)
63
92
  .sort()
64
93
  .at(-1);
65
94
  return {
66
- enabled: this.#enabled,
67
- state: this.#enabled ? context.state : 'disabled',
95
+ enabled: this.#enabled && !control.autoDisabled,
96
+ state: !this.#enabled || control.autoDisabled ? 'disabled'
97
+ : control.nextRetryAt && Date.parse(control.nextRetryAt) > this.#now() ? 'scheduled'
98
+ : context.state === 'error' ? 'error'
99
+ : control.activeAttempt ? 'processing' : context.state,
68
100
  pendingCheckpoints: Object.keys(state.pending).length,
69
101
  memoryFingerprint: state.memoryFingerprint,
70
102
  ...(lastProcessedAt === undefined ? {} : { lastProcessedAt }),
71
103
  ...(context.message === undefined ? {} : { message: context.message }),
104
+ consecutiveFailures: control.consecutiveFailures,
105
+ ...(control.nextRetryAt ? { nextRetryAt: control.nextRetryAt } : {}),
106
+ ...(control.autoDisabled ? { autoDisabled: control.autoDisabled } : {}),
107
+ ...(control.activeAttempt || control.lastOutcome ? {
108
+ lastRunId: control.activeAttempt?.runId ?? control.lastOutcome.runId,
109
+ } : {}),
72
110
  };
73
111
  }
74
- catch {
112
+ catch (error) {
75
113
  return {
76
114
  enabled: this.#enabled,
77
115
  state: this.#enabled ? 'error' : 'disabled',
78
116
  pendingCheckpoints: 0,
79
- message: 'Local memory storage is unavailable',
117
+ message: error instanceof LocalMemoryControlError ? error.message : 'Local memory storage is unavailable',
80
118
  };
81
119
  }
82
120
  }
@@ -95,9 +133,8 @@ export class LocalMemoryCoordinator {
95
133
  async recordCheckpoint(cwd, checkpoint) {
96
134
  const context = await this.#context(cwd);
97
135
  if (await context.store.recordCheckpoint(checkpoint)) {
98
- context.checkpointVersion += 1;
99
136
  context.blocked.delete(checkpoint.sessionId);
100
- await this.#schedule(context);
137
+ await this.#trackScheduleIfPending(context);
101
138
  this.#emitStatusChange();
102
139
  }
103
140
  }
@@ -114,8 +151,7 @@ export class LocalMemoryCoordinator {
114
151
  this.#cancelScheduledRun(context);
115
152
  }
116
153
  context.blocked.clear();
117
- const checkpointVersion = context.checkpointVersion;
118
- const run = this.#run(context);
154
+ const run = this.#run(context, true);
119
155
  context.run = run;
120
156
  try {
121
157
  await run;
@@ -123,10 +159,31 @@ export class LocalMemoryCoordinator {
123
159
  finally {
124
160
  if (context.run === run)
125
161
  context.run = undefined;
126
- if (context.checkpointVersion !== checkpointVersion) {
127
- await this.#trackScheduleIfPending(context);
128
- }
162
+ await this.#trackScheduleIfPending(context);
163
+ }
164
+ return this.status(cwd);
165
+ }
166
+ async retryProject(cwd) {
167
+ if (!this.#enabled)
168
+ return this.status(cwd);
169
+ const context = await this.#context(cwd);
170
+ if (!this.#enabled || this.#disposed)
171
+ return this.status(cwd);
172
+ const enableVersion = this.#enableVersion;
173
+ this.#cancelScheduledRun(context);
174
+ try {
175
+ await context.store.resetProjectControl(this.#now(), () => this.#enabled && !this.#disposed && this.#enableVersion === enableVersion);
129
176
  }
177
+ catch (error) {
178
+ throw error;
179
+ }
180
+ context.abort?.abort(new Error('Memory processing was explicitly retried'));
181
+ if (context.run)
182
+ await context.run;
183
+ context.blocked.clear();
184
+ context.storageBlocked = false;
185
+ context.message = undefined;
186
+ await this.runNow(cwd);
130
187
  return this.status(cwd);
131
188
  }
132
189
  async canonicalDirectory(cwd) {
@@ -137,17 +194,22 @@ export class LocalMemoryCoordinator {
137
194
  if (this.#disposed)
138
195
  return;
139
196
  this.#disposed = true;
197
+ if (this.#monitor)
198
+ clearInterval(this.#monitor);
199
+ this.#monitor = undefined;
140
200
  for (const context of this.#contexts.values()) {
141
- if (context.timer)
142
- clearTimeout(context.timer);
143
- context.timer = undefined;
144
- context.abort?.abort();
201
+ this.#cancelScheduledRun(context);
202
+ this.#cancelContext(context, 'Memory processing was cancelled during shutdown', false);
145
203
  }
146
204
  while (true) {
147
205
  const work = [...this.#contexts.values()].flatMap((context) => [
148
206
  ...(context.run ? [context.run] : []),
207
+ ...(context.ready ? [context.ready] : []),
208
+ ...(context.recovery ? [context.recovery] : []),
149
209
  ...context.followUps,
150
210
  ]);
211
+ if (this.#monitoring)
212
+ work.push(this.#monitoring);
151
213
  if (work.length === 0)
152
214
  break;
153
215
  await Promise.allSettled(work);
@@ -158,67 +220,250 @@ export class LocalMemoryCoordinator {
158
220
  if (this.#disposed)
159
221
  throw new Error('Local memory coordinator is closed');
160
222
  const project = await resolveLocalMemoryProject(cwd);
223
+ if (this.#disposed)
224
+ throw new Error('Local memory coordinator is closed');
161
225
  const existing = this.#contexts.get(project.key);
162
- if (existing)
226
+ if (existing) {
227
+ await this.#initialize(existing);
163
228
  return existing;
164
- const store = new LocalMemoryStore(this.#options.agentDir, project);
229
+ }
230
+ const store = new LocalMemoryStore(this.#options.agentDir, project, {
231
+ ...(this.#options.retryDelaysMs ? { retryDelaysMs: this.#options.retryDelaysMs } : {}),
232
+ });
165
233
  const context = {
166
234
  project,
167
235
  cwd,
168
236
  ...(this.#options.sessionDir === undefined ? {} : { sessionDir: this.#options.sessionDir }),
169
237
  store,
170
- ready: store.initialize(),
171
- recoveryStarted: false,
238
+ ready: undefined,
239
+ initialized: false,
240
+ recovery: undefined,
241
+ recoveryCompleted: false,
242
+ diagnosticsReconciled: false,
172
243
  timer: undefined,
244
+ timerDueAt: undefined,
173
245
  run: undefined,
174
246
  followUps: new Set(),
175
247
  abort: undefined,
248
+ attempt: undefined,
249
+ storageBlocked: false,
250
+ writerBlocked: false,
251
+ statusSignature: undefined,
176
252
  blocked: new Map(),
177
- checkpointVersion: 0,
178
253
  state: this.#enabled ? 'idle' : 'disabled',
179
254
  message: undefined,
180
255
  };
181
256
  this.#contexts.set(project.key, context);
182
- await context.ready;
257
+ await this.#initialize(context);
258
+ return context;
259
+ }
260
+ async #initialize(context) {
261
+ if (context.ready) {
262
+ await context.ready;
263
+ return;
264
+ }
265
+ if (context.initialized && context.diagnosticsReconciled) {
266
+ this.#startRecovery(context);
267
+ return;
268
+ }
269
+ const ready = this.#initializeContext(context).finally(() => {
270
+ if (context.ready === ready)
271
+ context.ready = undefined;
272
+ });
273
+ context.ready = ready;
274
+ await ready;
275
+ }
276
+ async #initializeContext(context) {
277
+ if (!context.initialized) {
278
+ await context.store.initialize(this.#now()).then(() => {
279
+ context.initialized = true;
280
+ context.state = this.#enabled ? 'idle' : 'disabled';
281
+ context.message = undefined;
282
+ }).catch(async (error) => {
283
+ if (!(error instanceof LocalMemoryControlError))
284
+ throw error;
285
+ const current = await context.store.readCurrent();
286
+ if (current.fingerprint !== (await context.store.status()).memoryFingerprint)
287
+ throw error;
288
+ context.message = error.message;
289
+ });
290
+ }
291
+ if (!context.initialized)
292
+ return;
293
+ if (!context.diagnosticsReconciled) {
294
+ try {
295
+ const lease = await acquireLocalMemoryLease(context.store.projectDirectory, this.#options.leaseOptions);
296
+ if (lease) {
297
+ try {
298
+ await this.#reconcileRunDiagnostics(context, lease);
299
+ context.diagnosticsReconciled = true;
300
+ }
301
+ finally {
302
+ await lease.release();
303
+ }
304
+ }
305
+ }
306
+ catch (error) {
307
+ context.diagnosticsReconciled = true;
308
+ context.storageBlocked = true;
309
+ context.message = `Memory diagnostics recovery is blocked: ${sanitizeMemoryDiagnostic(error)}`;
310
+ }
311
+ }
312
+ if (this.#disposed)
313
+ return;
183
314
  if (this.#enabled)
184
315
  void this.#trackScheduleIfPending(context);
185
- if (this.#options.recover !== false && !context.recoveryStarted) {
186
- context.recoveryStarted = true;
187
- void this.#recover(context).catch(() => {
188
- context.message = 'Memory startup recovery could not complete';
189
- });
316
+ this.#startRecovery(context);
317
+ }
318
+ #startRecovery(context) {
319
+ if (this.#disposed || !this.#enabled || this.#options.recover === false
320
+ || context.recoveryCompleted || context.recovery)
321
+ return;
322
+ const recovery = this.#recover(context).then((completed) => {
323
+ if (completed)
324
+ context.recoveryCompleted = true;
325
+ }).catch(() => {
326
+ context.message = 'Memory startup recovery could not complete';
327
+ }).finally(() => {
328
+ if (context.recovery === recovery)
329
+ context.recovery = undefined;
330
+ });
331
+ context.recovery = recovery;
332
+ }
333
+ #now() { return this.#options.now?.() ?? Date.now(); }
334
+ #startMonitor() {
335
+ if (this.#disposed || this.#monitor)
336
+ return;
337
+ const interval = this.#options.monitorIntervalMs ?? 2_000;
338
+ if (!Number.isFinite(interval) || interval < 1)
339
+ throw new Error('Memory monitor interval must be positive');
340
+ this.#monitor = setInterval(() => {
341
+ if (this.#monitoring)
342
+ return;
343
+ this.#monitoring = this.#refresh().catch(() => { }).finally(() => { this.#monitoring = undefined; });
344
+ }, interval);
345
+ this.#monitor.unref?.();
346
+ }
347
+ async #refresh() {
348
+ if (this.#disposed)
349
+ return;
350
+ for (const context of this.#contexts.values()) {
351
+ try {
352
+ await this.#initialize(context);
353
+ const observedAttempt = context.attempt;
354
+ const control = await context.store.readControl();
355
+ if (observedAttempt && context.attempt === observedAttempt && (!this.#enabled || control.autoDisabled
356
+ || control.activeAttempt?.runId !== observedAttempt.runId
357
+ || control.activeAttempt?.generation !== observedAttempt.generation)) {
358
+ this.#cancelContext(context, 'Memory processing was cancelled by another session', true);
359
+ }
360
+ context.writerBlocked = false;
361
+ if (context.initialized)
362
+ await this.#trackScheduleIfPending(context);
363
+ const signature = JSON.stringify([this.#enabled, context.state, context.message, control.updatedAt]);
364
+ if (signature !== context.statusSignature) {
365
+ context.statusSignature = signature;
366
+ this.#emitStatusChange();
367
+ }
368
+ }
369
+ catch (error) {
370
+ context.state = 'error';
371
+ context.message = sanitizeMemoryDiagnostic(error);
372
+ context.abort?.abort(error);
373
+ this.#emitStatusChange();
374
+ }
190
375
  }
191
- return context;
192
376
  }
193
- async #schedule(context, delay = this.#options.debounceMs ?? 2_000) {
194
- if (this.#disposed || !this.#enabled || context.run || context.timer)
377
+ #cancelContext(context, reason, cancelPersisted) {
378
+ const alreadyAborted = context.abort?.signal.aborted ?? false;
379
+ context.abort?.abort(new Error(reason));
380
+ if (alreadyAborted || !context.initialized || (!cancelPersisted && !context.attempt))
381
+ return;
382
+ const cancellation = cancelPersisted
383
+ ? context.store.cancelActiveAttempt(this.#now())
384
+ : context.store.finishAttempt(undefined, context.attempt, { status: 'cancelled', reason }, this.#now());
385
+ const followUp = cancellation
386
+ .then(() => { })
387
+ .catch(() => { })
388
+ .finally(() => { context.followUps.delete(followUp); });
389
+ context.followUps.add(followUp);
390
+ }
391
+ async #schedule(context, dueAt) {
392
+ if (this.#disposed || !this.#enabled || context.run || context.storageBlocked)
393
+ return;
394
+ if (context.timer && context.timerDueAt === dueAt)
195
395
  return;
396
+ this.#cancelScheduledRun(context);
196
397
  context.state = 'scheduled';
398
+ const delay = Math.max(0, dueAt - this.#now());
197
399
  const timer = setTimeout(() => {
198
400
  if (context.timer !== timer)
199
401
  return;
200
402
  context.timer = undefined;
201
- const run = this.#run(context).finally(async () => {
403
+ context.timerDueAt = undefined;
404
+ const run = this.#run(context).catch((error) => {
405
+ context.state = 'error';
406
+ context.storageBlocked = true;
407
+ context.message = sanitizeMemoryDiagnostic(error);
408
+ this.#emitStatusChange();
409
+ }).finally(async () => {
202
410
  context.run = undefined;
203
411
  await this.#trackScheduleIfPending(context);
204
412
  });
205
413
  context.run = run;
206
414
  }, delay);
207
415
  context.timer = timer;
416
+ context.timerDueAt = dueAt;
208
417
  timer.unref?.();
209
418
  }
210
- async #scheduleIfPending(context, delay = this.#options.debounceMs ?? 2_000) {
419
+ async #scheduleIfPending(context) {
211
420
  if (!this.#enabled || this.#disposed)
212
421
  return;
213
422
  try {
423
+ const control = await context.store.readControl();
424
+ if (control.autoDisabled) {
425
+ this.#cancelScheduledRun(context);
426
+ context.state = 'disabled';
427
+ return;
428
+ }
429
+ if (context.storageBlocked)
430
+ return;
431
+ if (context.writerBlocked)
432
+ return;
214
433
  const state = await context.store.status();
215
434
  const pending = Object.values(state.pending);
216
- if (pending.some(({ checkpoint }) => !isBlockedCheckpoint(context, checkpoint))) {
217
- await this.#schedule(context, delay);
435
+ if (control.activeAttempt) {
436
+ this.#cancelScheduledRun(context);
437
+ }
438
+ else if (pending.some(({ checkpoint }) => !isBlockedCheckpoint(context, checkpoint))) {
439
+ const acceptedUpdates = await context.store.pendingAcceptedUpdates((checkpoint) => !isBlockedCheckpoint(context, checkpoint));
440
+ if (acceptedUpdates < MEMORY_AUTOMATIC_UPDATE_THRESHOLD) {
441
+ this.#cancelScheduledRun(context);
442
+ if (context.state === 'scheduled')
443
+ context.state = 'idle';
444
+ return;
445
+ }
446
+ const processedAt = Object.values(state.processed)
447
+ .map(({ processedAt }) => Date.parse(processedAt))
448
+ .filter(Number.isFinite)
449
+ .reduce((latest, at) => Math.max(latest, at), 0);
450
+ const dueAt = control.nextRetryAt
451
+ ? Date.parse(control.nextRetryAt)
452
+ : processedAt === 0
453
+ ? this.#now()
454
+ : processedAt + (this.#options.debounceMs ?? MEMORY_AUTOMATIC_INTERVAL_MS);
455
+ await this.#schedule(context, dueAt);
218
456
  }
219
457
  else if (pending.length > 0) {
458
+ this.#cancelScheduledRun(context);
459
+ const message = context.state === 'blocked' ? context.message : undefined;
220
460
  context.state = 'blocked';
221
- context.message = MEMORY_INPUT_BLOCKED_MESSAGE;
461
+ context.message = message ?? MEMORY_INPUT_BLOCKED_MESSAGE;
462
+ }
463
+ else {
464
+ this.#cancelScheduledRun(context);
465
+ if (context.state === 'scheduled')
466
+ context.state = 'idle';
222
467
  }
223
468
  }
224
469
  catch {
@@ -226,15 +471,15 @@ export class LocalMemoryCoordinator {
226
471
  context.message = 'Local memory storage is unavailable';
227
472
  }
228
473
  }
229
- #trackScheduleIfPending(context, delay = this.#options.debounceMs ?? 2_000) {
230
- const followUp = this.#scheduleIfPending(context, delay).finally(() => {
474
+ #trackScheduleIfPending(context) {
475
+ const followUp = this.#scheduleIfPending(context).finally(() => {
231
476
  context.followUps.delete(followUp);
232
477
  });
233
478
  context.followUps.add(followUp);
234
479
  return followUp;
235
480
  }
236
- async #run(context) {
237
- if (this.#disposed || !this.#enabled)
481
+ async #run(context, bypassRetryAt = false) {
482
+ if (this.#disposed || !this.#enabled || context.storageBlocked)
238
483
  return;
239
484
  context.state = 'processing';
240
485
  context.message = undefined;
@@ -243,15 +488,18 @@ export class LocalMemoryCoordinator {
243
488
  lease = await acquireLocalMemoryLease(context.store.projectDirectory, this.#options.leaseOptions);
244
489
  }
245
490
  catch (error) {
491
+ context.writerBlocked = true;
246
492
  context.state = 'error';
247
493
  context.message = safeProcessingMessage(error);
248
494
  return;
249
495
  }
250
496
  if (!lease) {
497
+ context.writerBlocked = true;
251
498
  context.state = 'blocked';
252
499
  context.message = 'Another Felan process owns the memory writer';
253
500
  return;
254
501
  }
502
+ context.writerBlocked = false;
255
503
  const abort = new AbortController();
256
504
  context.abort = abort;
257
505
  const abortOnLeaseCompromise = () => {
@@ -259,13 +507,34 @@ export class LocalMemoryCoordinator {
259
507
  };
260
508
  lease.compromised.addEventListener('abort', abortOnLeaseCompromise, { once: true });
261
509
  let staging;
510
+ let retainedRun;
511
+ let published = false;
512
+ let attempt;
262
513
  try {
263
- const snapshot = await context.store.processingSnapshot(this.#options.batchSize ?? 8);
514
+ const control = await this.#reconcileRunDiagnostics(context, lease);
515
+ if (control.autoDisabled) {
516
+ context.state = 'disabled';
517
+ return;
518
+ }
519
+ if (!bypassRetryAt && control.nextRetryAt && Date.parse(control.nextRetryAt) > this.#now()) {
520
+ context.state = 'scheduled';
521
+ return;
522
+ }
523
+ const snapshot = await context.store.processingSnapshot(this.#options.batchSize ?? 8, (checkpoint) => !isBlockedCheckpoint(context, checkpoint));
264
524
  if (snapshot.checkpoints.length === 0) {
265
525
  context.state = 'idle';
266
526
  return;
267
527
  }
268
- staging = await context.store.createStagingDirectory();
528
+ retainedRun = await LocalMemoryRun.create({
529
+ projectDirectory: context.store.projectDirectory,
530
+ sessionDirectory: join(this.#options.sessionDir ?? join(this.#options.agentDir, 'sessions'), 'memory'),
531
+ projectKey: context.project.key,
532
+ projectRoot: context.project.canonicalRoot,
533
+ checkpoints: snapshot.checkpoints.map(({ checkpoint }) => checkpoint),
534
+ baseFingerprint: snapshot.fingerprint,
535
+ ...(control.lastDisabledRunId ? { protectedRunIds: [control.lastDisabledRunId] } : {}),
536
+ });
537
+ staging = retainedRun.workspace;
269
538
  const memoryDirectory = join(staging, '.memory');
270
539
  await hydrateMemoryDirectory(snapshot.artifact, memoryDirectory, { memoryPath: '.memory', mode: 'read' });
271
540
  const manifest = await materializeMemoryInput({
@@ -287,8 +556,24 @@ export class LocalMemoryCoordinator {
287
556
  if (processedCheckpoints.length === 0) {
288
557
  context.state = 'blocked';
289
558
  context.message = MEMORY_INPUT_BLOCKED_MESSAGE;
559
+ await retainedRun.finish('blocked', MEMORY_INPUT_BLOCKED_MESSAGE);
290
560
  return;
291
561
  }
562
+ await retainedRun.record({ phase: 'model', checkpoints: processedCheckpoints.map(({ checkpoint }) => checkpoint) });
563
+ if (abort.signal.aborted || !this.#enabled || this.#disposed)
564
+ throw new Error('Memory processing was cancelled');
565
+ attempt = await context.store.beginAttempt(lease, {
566
+ runId: retainedRun.metadata.sessionId,
567
+ baseFingerprint: snapshot.fingerprint,
568
+ checkpoints: processedCheckpoints,
569
+ ...(bypassRetryAt ? { bypassRetryAt: true } : {}),
570
+ }, this.#now());
571
+ if (!attempt) {
572
+ await retainedRun.finish('cancelled', 'Memory inputs or processing policy changed before the attempt started');
573
+ return;
574
+ }
575
+ context.attempt = attempt;
576
+ this.#emitStatusChange();
292
577
  const result = await this.#dreamRunner({
293
578
  stagingDirectory: staging,
294
579
  memoryDirectory,
@@ -297,25 +582,28 @@ export class LocalMemoryCoordinator {
297
582
  manifest,
298
583
  modelRuntime: this.#options.modelRuntime,
299
584
  ...(this.#selectedModel === undefined ? {} : { selectedModel: this.#selectedModel }),
585
+ sessionDirectory: join(this.#options.sessionDir ?? join(this.#options.agentDir, 'sessions'), 'memory'),
586
+ run: retainedRun,
300
587
  signal: abort.signal,
301
588
  });
589
+ if (abort.signal.aborted || !this.#enabled || this.#disposed)
590
+ throw new Error('Memory processing was cancelled');
302
591
  if (result)
303
592
  await hydrateMemoryDirectory(result, memoryDirectory, { replace: true, memoryPath: '.memory' });
593
+ await retainedRun.record({ phase: 'validate' });
304
594
  const artifact = await readMemoryDirectory(memoryDirectory, {
305
595
  memoryPath: '.memory',
306
596
  sourceSessionIds: allowedSourceIds(snapshot, manifest),
307
597
  requireSources: true,
308
- validateNavigation: true,
309
598
  });
310
- const validation = validateMemoryArtifact(artifact, {
311
- memoryPath: '.memory',
312
- sourceSessionIds: allowedSourceIds(snapshot, manifest),
313
- requireSources: true,
314
- validateNavigation: true,
599
+ await retainedRun.record({
600
+ phase: 'publish',
601
+ outputFingerprint: createMemorySnapshot(artifact, '.memory').fingerprint,
315
602
  });
316
- if (!validation.ok || !validation.artifact)
317
- throw new Error('Memory output validation failed');
318
- await context.store.commit(lease, snapshot.fingerprint, validation.artifact, processedCheckpoints);
603
+ await context.store.commit(lease, snapshot.fingerprint, artifact, processedCheckpoints, attempt);
604
+ published = true;
605
+ await context.store.finishAttempt(lease, attempt, { status: 'success' }, this.#now());
606
+ await retainedRun.finish('completed');
319
607
  if (manifest.failures.length > 0) {
320
608
  context.state = 'blocked';
321
609
  context.message = MEMORY_INPUT_BLOCKED_MESSAGE;
@@ -326,22 +614,76 @@ export class LocalMemoryCoordinator {
326
614
  this.#emitStatusChange();
327
615
  }
328
616
  catch (error) {
617
+ const leaseValid = await lease.verify();
618
+ const explicitlyCancelled = !this.#enabled || this.#disposed
619
+ || (abort.signal.aborted && !lease.compromised.aborted);
620
+ const unavailableModel = error instanceof MemoryModelUnavailableError;
621
+ const blockedBeforeInference = retainedRun !== undefined && attempt === undefined && !explicitlyCancelled;
622
+ if (attempt && (leaseValid || explicitlyCancelled)) {
623
+ try {
624
+ const control = await context.store.finishAttempt(explicitlyCancelled || unavailableModel ? undefined : lease, attempt, explicitlyCancelled || unavailableModel ? { status: 'cancelled' }
625
+ : { status: 'failure', reason: sanitizeMemoryDiagnostic(error) }, this.#now());
626
+ published ||= control.lastOutcome?.runId === attempt.runId && control.lastOutcome.status === 'success';
627
+ }
628
+ catch {
629
+ context.storageBlocked = true;
630
+ }
631
+ }
632
+ if (published) {
633
+ context.state = 'idle';
634
+ context.message = 'Memory was published, but its diagnostics could not be fully saved';
635
+ this.#emitStatusChange();
636
+ return;
637
+ }
329
638
  context.state = 'error';
330
- context.message = !await lease.verify()
639
+ context.message = !leaseValid
331
640
  ? safeProcessingMessage(new Error('Memory writer lease was lost'))
332
641
  : safeProcessingMessage(error);
642
+ if (unavailableModel || blockedBeforeInference) {
643
+ context.state = 'blocked';
644
+ for (const checkpoint of retainedRun?.metadata.checkpoints ?? []) {
645
+ context.blocked.set(checkpoint.sessionId, checkpoint);
646
+ }
647
+ }
648
+ if (!retainedRun) {
649
+ context.storageBlocked = true;
650
+ context.message = `Memory processing is paused: ${sanitizeMemoryDiagnostic(error)}`;
651
+ }
652
+ await retainedRun?.finish(explicitlyCancelled ? 'cancelled'
653
+ : unavailableModel || blockedBeforeInference ? 'blocked'
654
+ : !leaseValid ? 'interrupted' : 'failed', error).catch(() => {
655
+ context.storageBlocked = true;
656
+ context.message = 'Memory diagnostics could not be saved; evidence remains pending';
657
+ });
658
+ this.#emitStatusChange();
333
659
  }
334
660
  finally {
335
661
  lease.compromised.removeEventListener('abort', abortOnLeaseCompromise);
336
662
  context.abort = undefined;
663
+ context.attempt = undefined;
337
664
  if (staging)
338
- await rm(staging, { recursive: true, force: true }).catch(() => { });
665
+ await rm(join(staging, '.pi-memory-runtime'), { recursive: true, force: true }).catch(() => { });
339
666
  await lease.release();
340
667
  }
341
668
  }
669
+ async #reconcileRunDiagnostics(context, lease) {
670
+ let control = await context.store.readControl();
671
+ control = await context.store.reconcileAbandonedAttempt(lease, this.#now());
672
+ const options = {
673
+ projectDirectory: context.store.projectDirectory,
674
+ projectKey: context.project.key,
675
+ sessionDirectory: join(this.#options.sessionDir ?? join(this.#options.agentDir, 'sessions'), 'memory'),
676
+ ...(control.lastDisabledRunId ? { protectedRunIds: [control.lastDisabledRunId] } : {}),
677
+ };
678
+ if (control.lastOutcome)
679
+ await LocalMemoryRun.reconcile(options, control.lastOutcome);
680
+ await LocalMemoryRun.reconcileAbandoned(options, [
681
+ ...(control.activeAttempt ? [control.activeAttempt.runId] : []),
682
+ ...(control.lastOutcome ? [control.lastOutcome.runId] : []),
683
+ ], this.#now());
684
+ return control;
685
+ }
342
686
  async #recover(context) {
343
- if (!this.#enabled)
344
- return;
345
687
  const sessionDir = context.sessionDir ?? join(this.#options.agentDir, 'sessions');
346
688
  let sessions;
347
689
  try {
@@ -349,26 +691,33 @@ export class LocalMemoryCoordinator {
349
691
  }
350
692
  catch {
351
693
  context.message = 'Memory startup recovery could not inspect sessions';
352
- return;
694
+ return true;
353
695
  }
696
+ if (this.#disposed || !this.#enabled)
697
+ return false;
354
698
  let recovered = false;
355
699
  const stableAfter = this.#options.recoveryStableMs ?? 30_000;
356
700
  for (const info of sessions) {
357
- if (info.parentSessionPath || Date.now() - info.modified.getTime() < stableAfter)
701
+ if (this.#disposed || !this.#enabled)
702
+ return false;
703
+ if (info.parentSessionPath || this.#now() - info.modified.getTime() < stableAfter)
358
704
  continue;
359
705
  try {
360
706
  const manager = SessionManager.open(info.path, sessionDir, context.cwd);
361
707
  const checkpoint = createSessionCheckpoint(manager);
362
708
  if (!checkpoint)
363
709
  continue;
710
+ if (this.#disposed || !this.#enabled)
711
+ return false;
364
712
  recovered = (await context.store.recordCheckpoint(checkpoint)) || recovered;
365
713
  }
366
714
  catch { }
367
715
  }
368
- if (recovered) {
369
- await this.#schedule(context);
716
+ if (recovered && !this.#disposed && this.#enabled) {
717
+ await this.#trackScheduleIfPending(context);
370
718
  this.#emitStatusChange();
371
719
  }
720
+ return true;
372
721
  }
373
722
  #emitStatusChange() {
374
723
  for (const listener of [...this.#statusListeners]) {
@@ -379,10 +728,10 @@ export class LocalMemoryCoordinator {
379
728
  }
380
729
  }
381
730
  #cancelScheduledRun(context) {
382
- if (!context.timer)
383
- return;
384
- clearTimeout(context.timer);
731
+ if (context.timer)
732
+ clearTimeout(context.timer);
385
733
  context.timer = undefined;
734
+ context.timerDueAt = undefined;
386
735
  }
387
736
  }
388
737
  class LocalMemorySessionHost {