@adhdev/daemon-core 0.9.82-rc.292 → 0.9.82-rc.293

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.
@@ -0,0 +1,185 @@
1
+ // ---------------------------------------------------------------------------
2
+ // mesh-unresolved-forward-outbox — durable retry for unresolved-delegate forwards
3
+ // ---------------------------------------------------------------------------
4
+ // A REMOTE worker daemon that is P2P-remote-controlled by a coordinator is NOT a
5
+ // member of the coordinator's mesh — it has no local mesh record. So when its
6
+ // completion event reaches setupMeshEventForwarding, resolveWorkerDelegateRouting
7
+ // returns mesh_unresolved: the worker carries the coordinator daemon anchor but no
8
+ // resolvable mesh id, so the normal queue-then-coordinator-pull path can't be used
9
+ // (the coordinator's reconcile PHASE 1 only pulls mesh.nodes, and this worker is in
10
+ // none of them — see docs/refactoring/2026-06-16-mesh-completion-polling-single-model.md).
11
+ //
12
+ // The ONLY delivery route for this case is a directed push to the coordinator
13
+ // daemon (mesh_forward_event over P2P). Previously that push was fire-and-forget:
14
+ // a single dispatchMeshCommand whose rejection was only logged, so one transient
15
+ // P2P failure dropped the completion forever (delivery_unroutable).
16
+ //
17
+ // This outbox makes that push DURABLE without inventing a new persistence layer:
18
+ // it reuses the existing mesh_pending_events SQLite table (the same store every
19
+ // other mesh coordinator event is persisted to), under a synthetic, reserved
20
+ // "mesh id" namespace so it never collides with a real mesh's queue. The worker's
21
+ // reconcile tick (PHASE 0) peeks the outbox, pushes each entry to its coordinator,
22
+ // and marks it drained (acked) ONLY on a successful push — a failed push leaves the
23
+ // row undrained for the next tick. Entries that exceed a max age are expired so the
24
+ // outbox can't grow unbounded when a coordinator stays permanently unreachable.
25
+ //
26
+ // Idempotency:
27
+ // - Enqueue is idempotent on the event fingerprint (mesh_pending_events has a
28
+ // UNIQUE (mesh_id, fingerprint) index + INSERT OR IGNORE), so the same
29
+ // completion fired twice on the worker queues once.
30
+ // - The receiver dedups independently: handleMeshForwardEvent → injectMeshSystemMessage
31
+ // → queuePendingMeshCoordinatorEvent recomputes the fingerprint and suppresses a
32
+ // duplicate, so an at-least-once retry that double-delivers is harmless.
33
+ // ---------------------------------------------------------------------------
34
+
35
+ import { randomUUID } from 'crypto';
36
+ import { LOG } from '../logging/logger.js';
37
+ import { MeshRuntimeStore } from './mesh-runtime-store.js';
38
+ import { buildPendingEventFingerprint, type PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
39
+ import { readNonEmptyString } from './mesh-events-utils.js';
40
+
41
+ // Reserved synthetic mesh id for the worker-side unresolved-forward outbox. The `::`
42
+ // and leading `__` make it impossible to collide with a real mesh id (which are
43
+ // `mesh_*` / `mreg_*` slugs). Scoping rows by coordinator_daemon_id inside this one
44
+ // namespace lets a single worker forward to multiple coordinators.
45
+ export const UNRESOLVED_FORWARD_OUTBOX_MESH_ID = '__unresolved_forward_outbox__';
46
+
47
+ // Entries older than this are expired (dropped) rather than retried forever — a
48
+ // coordinator that has been unreachable this long is treated as gone. Generous
49
+ // enough to ride out a long coordinator outage / restart, bounded enough that a
50
+ // permanently-dead coordinator can't accumulate outbox rows indefinitely.
51
+ const UNRESOLVED_FORWARD_MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes
52
+
53
+ export interface UnresolvedForwardEntry {
54
+ /** Row id in mesh_pending_events; pass back to ack/expire after a push attempt. */
55
+ id: string;
56
+ /** The coordinator daemon to push this event to (mesh_forward_event target). */
57
+ coordinatorDaemonId: string;
58
+ /** The flat payload to forward (already shaped for handleMeshForwardEvent). */
59
+ payload: Record<string, unknown>;
60
+ /** When the entry was first enqueued (epoch ms) — used for age-based expiry. */
61
+ queuedAt: number;
62
+ }
63
+
64
+ function getStore(): MeshRuntimeStore | undefined {
65
+ try { return MeshRuntimeStore.getInstance(); } catch { return undefined; }
66
+ }
67
+
68
+ /**
69
+ * Durably enqueue an unresolved-delegate forward for a coordinator daemon. The
70
+ * `forwardPayload` is the flat shape handleMeshForwardEvent reads on the coordinator.
71
+ * Idempotent on the event fingerprint. Returns true when a new row was written (or a
72
+ * duplicate was harmlessly ignored), false on a hard persistence failure.
73
+ */
74
+ export function enqueueUnresolvedDelegateForward(
75
+ coordinatorDaemonId: string,
76
+ eventName: string,
77
+ forwardPayload: Record<string, unknown>,
78
+ ): boolean {
79
+ const target = readNonEmptyString(coordinatorDaemonId);
80
+ const event = readNonEmptyString(eventName);
81
+ if (!target || !event) return false;
82
+ const store = getStore();
83
+ if (!store) return false;
84
+
85
+ const queuedAt = Date.now();
86
+ // Reuse the standard pending-event fingerprint so enqueue is idempotent AND the
87
+ // receiver's own dedup keys on a comparable identity. We synthesise the minimal
88
+ // PendingMeshCoordinatorEvent shape buildPendingEventFingerprint needs.
89
+ const fingerprintSource: PendingMeshCoordinatorEvent = {
90
+ event,
91
+ meshId: UNRESOLVED_FORWARD_OUTBOX_MESH_ID,
92
+ nodeLabel: readNonEmptyString(forwardPayload.nodeId) || readNonEmptyString(forwardPayload.workspace) || 'unresolved-delegate',
93
+ nodeId: readNonEmptyString(forwardPayload.nodeId) || undefined,
94
+ workspace: readNonEmptyString(forwardPayload.workspace) || undefined,
95
+ metadataEvent: forwardPayload,
96
+ queuedAt,
97
+ targetCoordinatorDaemonId: target,
98
+ };
99
+ // Scope the fingerprint to the coordinator so two coordinators awaiting the same
100
+ // worker session don't collapse into one outbox row.
101
+ const fingerprint = `${target}::${buildPendingEventFingerprint(fingerprintSource)}`;
102
+
103
+ try {
104
+ const inserted = store.insertPendingEvent({
105
+ id: randomUUID(),
106
+ meshId: UNRESOLVED_FORWARD_OUTBOX_MESH_ID,
107
+ coordinatorDaemonId: target,
108
+ event,
109
+ // Store the flat forward payload + the queue timestamp so the retry tick can
110
+ // rebuild the push args and apply age-based expiry without a schema change.
111
+ payload: { forwardPayload, coordinatorDaemonId: target, queuedAt },
112
+ fingerprint,
113
+ queuedAt,
114
+ });
115
+ if (inserted) {
116
+ LOG.info('MeshEvents', `Durably queued unresolved-delegate ${event} for coordinator ${target} (outbox)`);
117
+ }
118
+ return true;
119
+ } catch (e: any) {
120
+ LOG.warn('MeshEvents', `Failed to persist unresolved-delegate forward to outbox: ${e?.message || e}`);
121
+ return false;
122
+ }
123
+ }
124
+
125
+ /** Peek (non-destructive) every undrained outbox entry across all coordinators. */
126
+ export function peekUnresolvedDelegateForwards(): UnresolvedForwardEntry[] {
127
+ const store = getStore();
128
+ if (!store) return [];
129
+ let rows: Array<{ id: string; event: string; payload: unknown }>;
130
+ try {
131
+ rows = store.peekPendingEvents(UNRESOLVED_FORWARD_OUTBOX_MESH_ID);
132
+ } catch {
133
+ return [];
134
+ }
135
+ const out: UnresolvedForwardEntry[] = [];
136
+ for (const row of rows) {
137
+ const stored = row.payload && typeof row.payload === 'object' ? row.payload as Record<string, unknown> : {};
138
+ const coordinatorDaemonId = readNonEmptyString(stored.coordinatorDaemonId);
139
+ const forwardPayload = stored.forwardPayload && typeof stored.forwardPayload === 'object'
140
+ ? stored.forwardPayload as Record<string, unknown>
141
+ : undefined;
142
+ if (!coordinatorDaemonId || !forwardPayload) continue;
143
+ const queuedAt = typeof stored.queuedAt === 'number' ? stored.queuedAt : 0;
144
+ out.push({ id: row.id, coordinatorDaemonId, payload: forwardPayload, queuedAt });
145
+ }
146
+ return out;
147
+ }
148
+
149
+ /** Mark an outbox entry delivered (acked) after a successful push. */
150
+ export function ackUnresolvedDelegateForward(id: string): void {
151
+ const store = getStore();
152
+ if (!store) return;
153
+ try { store.markPendingEventsDrainedById([id]); } catch { /* best-effort */ }
154
+ }
155
+
156
+ /**
157
+ * Drop outbox entries that have exceeded the max retry age. Returns the count
158
+ * expired so the caller can log a fail-loud trace (a dropped completion is a real
159
+ * loss; it must be visible, not silent).
160
+ */
161
+ export function expireStaleUnresolvedDelegateForwards(nowMs: number = Date.now()): number {
162
+ const entries = peekUnresolvedDelegateForwards();
163
+ const staleIds = entries
164
+ .filter(e => e.queuedAt > 0 && nowMs - e.queuedAt >= UNRESOLVED_FORWARD_MAX_AGE_MS)
165
+ .map(e => e.id);
166
+ if (staleIds.length === 0) return 0;
167
+ const store = getStore();
168
+ if (!store) return 0;
169
+ try {
170
+ const removed = store.deletePendingEventsById(staleIds);
171
+ if (removed > 0) {
172
+ LOG.warn('MeshEvents', `Expired ${removed} unresolved-delegate forward(s) after ${Math.round(UNRESOLVED_FORWARD_MAX_AGE_MS / 60000)}m of failed retries — coordinator unreachable, completion dropped`);
173
+ }
174
+ return removed;
175
+ } catch {
176
+ return 0;
177
+ }
178
+ }
179
+
180
+ /** Test helper: purge the entire outbox. */
181
+ export function __clearUnresolvedDelegateForwardOutboxForTests(): void {
182
+ const store = getStore();
183
+ if (!store) return;
184
+ try { store.clearPendingEventsForMesh(UNRESOLVED_FORWARD_OUTBOX_MESH_ID); } catch { /* nothing to clear */ }
185
+ }
@@ -829,22 +829,27 @@ export class CliProviderInstance implements ProviderInstance {
829
829
  }
830
830
 
831
831
  updateSettings(newSettings: Record<string, any>): void {
832
- const runtimeMeshSettings: Record<string, any> = {};
833
- for (const key of [
834
- 'meshNodeFor',
835
- 'meshNodeId',
836
- 'meshActiveTaskId',
837
- 'meshCoordinatorFor',
838
- 'meshCoordinatorDaemonId',
839
- 'meshCoordinatorNodeId',
840
- 'spawnedSessionVisibility',
841
- 'launchedByCoordinator',
842
- ]) {
843
- if (this.settings[key] !== undefined && newSettings[key] === undefined) {
844
- runtimeMeshSettings[key] = this.settings[key];
845
- }
846
- }
847
- this.settings = { ...newSettings, ...runtimeMeshSettings };
832
+ // Merge semantics: a key omitted from newSettings preserves its existing
833
+ // value, a key present in newSettings (even as false) overrides it.
834
+ //
835
+ // This is required because updateSettings has two callers with opposite
836
+ // intent:
837
+ // 1. Full re-injection — the dashboard toggle path (handleSetProviderSetting
838
+ // → getSettings → updateInstanceSettings) sends the COMPLETE settings
839
+ // object, so an explicit autoApprove:false must win.
840
+ // 2. Partial stamp — the mesh relay-safety stamp (router.ts agent_command,
841
+ // buildMeshWorkerRelayStamp) sends ONLY {meshNodeFor, meshNodeId,
842
+ // meshCoordinatorDaemonId, launchedByCoordinator} on every coordinator
843
+ // re-dispatch. It carries no autoApprove, so a full replacement would
844
+ // wipe the launch-time autoApprove:true the worker was started with,
845
+ // silently dropping every later approval to a manual gate until the
846
+ // machine-page toggle re-injected the full settings.
847
+ //
848
+ // A plain merge satisfies both: undefined keys fall through to the existing
849
+ // value (preserving launch-stamp settings like autoApprove + the mesh routing
850
+ // keys), explicit keys override. This subsumes the previous mesh-key preserve
851
+ // list, which only protected the routing keys and not autoApprove.
852
+ this.settings = { ...this.settings, ...newSettings };
848
853
  this.adapter.updateRuntimeSettings?.(this.settings);
849
854
  this.monitor.updateConfig({
850
855
  approvalAlert: this.settings.approvalAlert !== false,
@@ -171,7 +171,11 @@ export class ExtensionProviderInstance implements ProviderInstance {
171
171
  }
172
172
 
173
173
  updateSettings(newSettings: Record<string, any>): void {
174
- this.settings = { ...newSettings };
174
+ // Merge (not replace): a key omitted from newSettings preserves its existing
175
+ // value, a key present (even false) overrides. Mirrors cli/ide-provider-instance
176
+ // so partial updates do not wipe launch-time settings (e.g. autoApprove) while a
177
+ // full settings object still applies explicit values.
178
+ this.settings = { ...this.settings, ...newSettings };
175
179
  this.monitor.updateConfig({
176
180
  approvalAlert: this.settings.approvalAlert !== false,
177
181
  longGeneratingAlert: this.settings.longGeneratingAlert !== false,
@@ -259,7 +259,12 @@ export class IdeProviderInstance implements ProviderInstance {
259
259
  }
260
260
 
261
261
  updateSettings(newSettings: Record<string, any>): void {
262
- this.settings = { ...newSettings };
262
+ // Merge (not replace): a key omitted from newSettings preserves its existing
263
+ // value, a key present (even false) overrides. Mirrors cli-provider-instance —
264
+ // a partial mesh relay-stamp must not wipe launch-time settings such as
265
+ // autoApprove, while the dashboard toggle's full settings object still applies
266
+ // an explicit autoApprove:false. See cli-provider-instance.updateSettings.
267
+ this.settings = { ...this.settings, ...newSettings };
263
268
  this.monitor.updateConfig({
264
269
  approvalAlert: this.settings.approvalAlert !== false,
265
270
  longGeneratingAlert: this.settings.longGeneratingAlert !== false,