@felan-ai/daemon 0.7.1 → 0.7.3

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.
@@ -16,6 +16,7 @@ import { sessionTip } from './session-cursor.js';
16
16
  import { shutdownStep } from './shutdown-error.js';
17
17
  import { HostWorkers } from './host-workers.js';
18
18
  import { assertStagedWorker } from './worker.js';
19
+ export const PROJECTION_CLEANUP_LIMIT_MS = 30_000;
19
20
  const nonRunMutations = new Set(['set_model', 'set_thinking', 'session.rename', 'session.entry.label', 'session.branch.select', 'session.export', 'command.invoke', 'shell.execute', 'processes.write', 'processes.stop', 'steer', 'follow_up', 'queue.clear', 'executions.steer', 'executions.cancel', 'ui.action']);
20
21
  class RegistryOpeningCancelled extends Error {
21
22
  }
@@ -33,6 +34,7 @@ export class SessionRegistry {
33
34
  copyClaims = new Set();
34
35
  reservations = new Map();
35
36
  lifecycleWriters = new Set();
37
+ lateWriters = new Map();
36
38
  reloadAttachments = new Map();
37
39
  invalidationListeners = new Set();
38
40
  observers = new Map();
@@ -108,7 +110,7 @@ export class SessionRegistry {
108
110
  if (session.deletedAt !== undefined)
109
111
  throw registryError('not_found', 'Session was deleted.');
110
112
  if (this.entries.get(id)?.projectionFailed)
111
- throw registryError('unavailable', 'Session projection failed; further work is disabled.');
113
+ throw registryError('unavailable', 'Session projection failed; it reopens once cleanup finishes, try again shortly.');
112
114
  if (this.reservations.has(session.piPath) && !activatingBinding)
113
115
  throw registryError('busy', 'Session has a lifecycle operation in progress.');
114
116
  if (session.state !== 'open' || this.entries.get(id)?.disposing || this.entries.get(id)?.cancelling || this.closing.has(id) || this.closing.has(session.root) || this.copyClaims.has(session.piPath))
@@ -175,9 +177,9 @@ export class SessionRegistry {
175
177
  async activeWork() {
176
178
  this.options.owner.assertCurrent();
177
179
  const interruptible = await this.processes.interruptibleCount();
178
- const busy = [...this.entries.values()].filter(entry => !entry.binding.runtime.session.isIdle || entry.dispatches.size || entry.writers.size
180
+ const busy = [...this.entries].filter(([id, entry]) => this.lateWriters.has(id) || !entry.binding.runtime.session.isIdle || entry.dispatches.size || entry.writers.size
179
181
  || entry.host?.hasPendingWork() || entry.host?.hasActiveJobs() || [...entry.services].some(service => service.busy())).length;
180
- return busy + interruptible;
182
+ return busy + [...this.lateWriters.keys()].filter(id => !this.entries.has(id)).length + interruptible;
181
183
  }
182
184
  create(request) {
183
185
  this.assertNewWork();
@@ -270,6 +272,8 @@ export class SessionRegistry {
270
272
  throw registryError('not_found', 'Session is not registered.');
271
273
  if (stored.deletedAt !== undefined)
272
274
  throw registryError('not_found', 'Session was deleted.');
275
+ if (this.lateWriters.has(id))
276
+ throw registryError('busy', 'Session transcript already has a writer.');
273
277
  if (this.reloadAttachments.has(id) && this.opening.has(id))
274
278
  return this.opening.get(id);
275
279
  if (this.closing.has(id))
@@ -282,6 +286,8 @@ export class SessionRegistry {
282
286
  const pending = this.opening.get(id);
283
287
  if (pending)
284
288
  return pending;
289
+ if (this.pathClaims.has(stored.piPath))
290
+ throw registryError('busy', 'Session transcript already has a writer.');
285
291
  const reopening = stored.state === 'closed';
286
292
  if (reopening)
287
293
  this.options.store.updateSession(id, { state: 'open' });
@@ -411,7 +417,7 @@ export class SessionRegistry {
411
417
  const entry = this.require(id);
412
418
  const attachment = randomUUID();
413
419
  if (entry.projectionFailed)
414
- throw registryError('unavailable', 'Session projection failed; close and reopen the session before attaching.');
420
+ throw registryError('unavailable', 'Session projection failed; cleanup is still in progress.');
415
421
  const snapshot = entry.projection.snapshot();
416
422
  entry.attachments.set(attachment, { ...metadata });
417
423
  return { attachment, snapshot };
@@ -761,6 +767,8 @@ export class SessionRegistry {
761
767
  throw registryError('busy', 'Wait for the current run to finish before reloading.');
762
768
  if (this.opening.has(id) || entry?.disposing || entry?.cancelling || entry?.writers.size)
763
769
  throw registryError('busy', 'Session has a lifecycle operation in progress.');
770
+ if (!entry && this.pathClaims.has(stored.piPath))
771
+ throw registryError('busy', 'Session transcript already has a writer.');
764
772
  const token = Symbol('reload');
765
773
  this.reservations.set(stored.piPath, { token, id });
766
774
  const attachments = entry?.attachments ?? new Map();
@@ -1057,23 +1065,27 @@ export class SessionRegistry {
1057
1065
  // Host shutdown cancels its full descendant tree and waits beyond terminal status records.
1058
1066
  const entry = this.entries.get(id);
1059
1067
  this.settleInteractions(id, 'close');
1060
- if (entry)
1068
+ if (entry && !entry.projectionFailed)
1061
1069
  await this.cancelViews(id);
1062
- await entry?.host?.shutdown();
1070
+ if (!entry?.projectionFailed)
1071
+ await entry?.host?.shutdown();
1063
1072
  for (const child of descendants)
1064
1073
  await this.close(child.id);
1065
1074
  if (entry) {
1066
- entry.runAbort?.abort();
1067
- await entry.cancelling;
1075
+ if (!entry.projectionFailed) {
1076
+ entry.runAbort?.abort();
1077
+ await entry.cancelling;
1078
+ }
1068
1079
  await entry.projectionCancellation;
1069
- await entry.binding.runtime.session.abort();
1070
- await this.processes.cancelForeground(id);
1071
- await Promise.all(entry.writers);
1072
- if (!entry.projectionFailed)
1080
+ if (!entry.projectionFailed) {
1081
+ await entry.binding.runtime.session.abort();
1082
+ await this.processes.cancelForeground(id);
1083
+ await Promise.all(entry.writers);
1073
1084
  entry.projection.settleRun(this.options.store.sessionMetadata(id).run.id);
1074
- await this.processes.settle(stored.root, false, id === stored.root ? undefined : new Set([id]));
1075
- for (const service of entry.services)
1076
- await this.processes.withCleanup(id, () => service.settle());
1085
+ await this.processes.settle(stored.root, false, id === stored.root ? undefined : new Set([id]));
1086
+ for (const service of entry.services)
1087
+ await this.processes.withCleanup(id, () => service.settle());
1088
+ }
1077
1089
  await this.disposeEntry(id, true);
1078
1090
  }
1079
1091
  else {
@@ -1109,7 +1121,7 @@ export class SessionRegistry {
1109
1121
  if (!options.interrupt || this.failure)
1110
1122
  throw error;
1111
1123
  });
1112
- await this.join([hostWorkers, ...cancellations, shutdownStep('registry-pending', () => this.join([...this.opening.values(), closing, ...this.lifecycleWriters], undefined, true))]);
1124
+ await this.join([hostWorkers, ...cancellations, shutdownStep('registry-pending', () => this.join([...this.opening.values(), closing, ...this.lifecycleWriters, ...this.lateWriters.values()], undefined, true))]);
1113
1125
  if (this.failure)
1114
1126
  await shutdownStep('registry-fenced', async () => { await this.failureSettlement; throw this.failure; });
1115
1127
  if (options.interrupt) {
@@ -1160,6 +1172,7 @@ export class SessionRegistry {
1160
1172
  this.options.store.interruptSessionRun(id, run); }, id);
1161
1173
  this.shutdownInterruptions.delete(id);
1162
1174
  }
1175
+ await shutdownStep('registry-pending', () => this.join([...this.lateWriters.values()]));
1163
1176
  this.retainedServices.clear();
1164
1177
  this.lossUnsubscribe();
1165
1178
  this.listeners.clear();
@@ -1607,15 +1620,21 @@ export class SessionRegistry {
1607
1620
  this.options.owner.assertCurrent();
1608
1621
  const stored = this.options.store.sessionMetadata(id);
1609
1622
  const closed = close || this.closes.has(id);
1610
- if (entry.projectionFailed)
1611
- this.options.store.transitionSessionForSafety(id, closed ? 'closed' : 'evicted');
1623
+ if (entry.projectionFailed) {
1624
+ this.interruptRun(id);
1625
+ const commit = this.options.store.transitionSessionForSafety(id, closed ? 'closed' : 'evicted');
1626
+ if (commit.revisions[id] !== undefined)
1627
+ this.publish({ v: 1, session: id, rev: commit.value.revision,
1628
+ change: { type: 'metadata.updated', title: commit.value.title, state: commit.value.state, residency: commit.value.residency } });
1629
+ }
1612
1630
  else
1613
1631
  entry.projection.transitionForSafety(closed ? 'closed' : 'evicted');
1614
1632
  entry.projection.dispose();
1615
1633
  this.entries.delete(id);
1616
1634
  if (!preserveView)
1617
1635
  this.listeners.delete(id);
1618
- this.pathClaims.delete(stored.piPath);
1636
+ if (!this.lateWriters.has(id))
1637
+ this.pathClaims.delete(stored.piPath);
1619
1638
  if (!close && entry.services.size)
1620
1639
  this.retainedServices.set(id, entry.services);
1621
1640
  })();
@@ -1628,6 +1647,14 @@ export class SessionRegistry {
1628
1647
  throw error;
1629
1648
  }
1630
1649
  }
1650
+ interruptRun(id) {
1651
+ const run = this.options.store.sessionMetadata(id).run;
1652
+ if (!run.id || !['running', 'waiting', 'cancelling'].includes(run.state))
1653
+ return;
1654
+ const commit = this.options.store.interruptSessionRun(id, run.id);
1655
+ if (commit.revisions[id] !== undefined)
1656
+ this.publish({ v: 1, session: id, rev: commit.value.revision, change: { type: 'run.updated', run: commit.value.run } });
1657
+ }
1631
1658
  projectionFailed(session, error) {
1632
1659
  this.diagnostic({ scope: 'projection', session, error });
1633
1660
  if (!this.projectionIntegrityIntact(error))
@@ -1635,19 +1662,29 @@ export class SessionRegistry {
1635
1662
  const entry = this.entries.get(session);
1636
1663
  if (!entry || entry.projectionFailed)
1637
1664
  return;
1665
+ const path = this.options.store.sessionMetadata(session).piPath;
1638
1666
  entry.projectionFailed = true;
1639
1667
  this.processes.seal(session);
1668
+ let expired = false;
1669
+ const failed = (cleanup) => {
1670
+ this.diagnostic({ scope: 'registry', session, error: cleanup });
1671
+ this.cancellationFailures.add(cleanup);
1672
+ this.projectionIntegrityIntact(cleanup);
1673
+ };
1640
1674
  const attempt = async (operation) => {
1675
+ if (expired || this.entries.get(session) !== entry)
1676
+ return;
1641
1677
  try {
1642
1678
  await operation();
1643
1679
  }
1644
1680
  catch (cleanup) {
1645
- this.diagnostic({ scope: 'registry', session, error: cleanup });
1646
- this.cancellationFailures.add(cleanup);
1647
- this.projectionIntegrityIntact(cleanup);
1681
+ try {
1682
+ failed(cleanup);
1683
+ }
1684
+ catch { }
1648
1685
  }
1649
1686
  };
1650
- entry.projectionCancellation = Promise.resolve().then(async () => {
1687
+ const cancellation = Promise.resolve().then(async () => {
1651
1688
  await attempt(() => entry.projection.dispose());
1652
1689
  await attempt(() => entry.runAbort?.abort());
1653
1690
  for (const controller of entry.viewActions ?? [])
@@ -1677,7 +1714,59 @@ export class SessionRegistry {
1677
1714
  throw new Error('Projection-failed session has not settled.');
1678
1715
  transitionRun('idle');
1679
1716
  });
1680
- }).finally(() => { delete entry.projectionCancellation; });
1717
+ }).catch(cleanup => { try {
1718
+ failed(cleanup);
1719
+ }
1720
+ catch { } });
1721
+ let timer;
1722
+ const timeout = new Error(`Projection-failure cleanup timed out after ${PROJECTION_CLEANUP_LIMIT_MS} milliseconds.`);
1723
+ const deadline = new Promise(resolve => {
1724
+ timer = setTimeout(() => {
1725
+ expired = true;
1726
+ const failures = [];
1727
+ const late = this.join([cancellation, ...(entry.cancelling ? [entry.cancelling] : [])], failures).then(async () => {
1728
+ do {
1729
+ await this.join([...entry.writers], failures);
1730
+ } while (entry.writers.size);
1731
+ await this.join([eviction, ...(this.closes.has(session) ? [this.closes.get(session)] : []), ...(entry.disposing ? [entry.disposing] : [])], failures);
1732
+ for (const failure of failures) {
1733
+ try {
1734
+ failed(failure);
1735
+ }
1736
+ catch { }
1737
+ }
1738
+ }).finally(() => {
1739
+ this.lateWriters.delete(session);
1740
+ this.cancellationFailures.delete(timeout);
1741
+ if (!this.entries.has(session))
1742
+ this.pathClaims.delete(path);
1743
+ });
1744
+ this.lateWriters.set(session, late);
1745
+ try {
1746
+ failed(timeout);
1747
+ }
1748
+ catch { }
1749
+ finally {
1750
+ resolve();
1751
+ }
1752
+ }, PROJECTION_CLEANUP_LIMIT_MS);
1753
+ });
1754
+ entry.projectionCancellation = Promise.race([cancellation, deadline]).then(() => {
1755
+ if (!this.failure)
1756
+ try {
1757
+ this.interruptRun(session);
1758
+ }
1759
+ catch (cleanup) {
1760
+ this.fence(cleanup);
1761
+ throw cleanup;
1762
+ }
1763
+ }).finally(() => { clearTimeout(timer); delete entry.projectionCancellation; });
1764
+ const eviction = entry.projectionCancellation.then(async () => {
1765
+ if (!this.failure && !this.stopping && !this.closing.has(session) && this.entries.get(session) === entry)
1766
+ await this.disposeEntry(session, false);
1767
+ });
1768
+ this.lifecycleWriters.add(eviction);
1769
+ void eviction.catch(cleanup => this.diagnostic({ scope: 'registry', session, error: cleanup })).finally(() => this.lifecycleWriters.delete(eviction));
1681
1770
  }
1682
1771
  projectionIntegrityIntact(error) {
1683
1772
  if (error instanceof AggregateError) {
@@ -1765,10 +1854,10 @@ export class SessionRegistry {
1765
1854
  throw new AggregateError(failures, 'Registry writers did not settle cleanly; ownership must remain held.');
1766
1855
  }
1767
1856
  pendingWriters() {
1768
- return [...this.opening.values(), ...this.lifecycleWriters, ...[...this.entries.values()].flatMap(entry => [...entry.writers, ...(entry.cancelling ? [entry.cancelling] : []), ...(entry.projectionCancellation ? [entry.projectionCancellation] : [])])];
1857
+ return [...this.opening.values(), ...this.lifecycleWriters, ...this.lateWriters.values(), ...[...this.entries.values()].flatMap(entry => [...entry.writers, ...(entry.cancelling ? [entry.cancelling] : []), ...(entry.projectionCancellation ? [entry.projectionCancellation] : [])])];
1769
1858
  }
1770
1859
  hasPendingWriters() {
1771
- return this.opening.size > 0 || this.lifecycleWriters.size > 0 || [...this.entries.values()].some(entry => entry.writers.size > 0 || entry.cancelling !== undefined || entry.projectionCancellation !== undefined);
1860
+ return this.opening.size > 0 || this.lifecycleWriters.size > 0 || this.lateWriters.size > 0 || [...this.entries.values()].some(entry => entry.writers.size > 0 || entry.cancelling !== undefined || entry.projectionCancellation !== undefined);
1772
1861
  }
1773
1862
  async join(operations, failures, allowCancelled = false) {
1774
1863
  const errors = failures ?? [];