@adhdev/daemon-core 0.9.82-rc.448 → 0.9.82-rc.449

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.
@@ -80,6 +80,13 @@ export declare class TerminalAdapter {
80
80
  col: number;
81
81
  };
82
82
  send_keys(text: string): void;
83
+ /** Forward runtime metadata (meshNodeId, workspaceLabel, lifecycle, …) to
84
+ * the underlying transport so it reaches the session registry. The spec
85
+ * path previously dropped everything but providerSessionId here, which
86
+ * left autoLaunch's meshNodeId stamp unbound on the record (see
87
+ * SESSION-ACCUMULATION-LEAK). No-op when the transport does not support
88
+ * metadata updates (e.g. plain node-pty). */
89
+ updateMeta(meta: Record<string, unknown>, replace?: boolean): void;
83
90
  /** Debug-only: most-recent PTY input/output/resize/cursor events, oldest
84
91
  * first. Pure observation — never consulted by the FSM. */
85
92
  getEventTimeline(limit?: number): SpecPtyEvent[];
@@ -119,6 +119,7 @@ export interface ISpecDriver {
119
119
  subscribe(listener: (ev: DashboardEvent) => void): () => void;
120
120
  start(): void;
121
121
  dispatch(cmd: DashboardCommand): void;
122
+ updateMeta(meta: Record<string, unknown>, replace?: boolean): void;
122
123
  snapshot(): string;
123
124
  getCursorPosition(): {
124
125
  row: number;
@@ -232,6 +233,11 @@ export declare class FsmDriver implements ISpecDriver {
232
233
  * declares no prime, so non-focus-gated CLIs are never poked. */
233
234
  private sendSpawnPrime;
234
235
  dispatch(cmd: DashboardCommand): void;
236
+ /** Forward runtime metadata to the terminal transport so mesh binding
237
+ * fields (meshNodeId / meshNodeFor / workspaceLabel / lifecycle) reach
238
+ * the session registry. Not a DashboardCommand — this is a control-plane
239
+ * update, not user input. */
240
+ updateMeta(meta: Record<string, unknown>, replace?: boolean): void;
235
241
  snapshot(): string;
236
242
  getCursorPosition(): {
237
243
  row: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.448",
3
+ "version": "0.9.82-rc.449",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -46,7 +46,7 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.448",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.449",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -209,6 +209,16 @@ export interface CliTransportFactoryParams {
209
209
  cliArgs?: string[];
210
210
  providerSessionId?: string;
211
211
  attachExisting?: boolean;
212
+ /**
213
+ * Launch-time record meta (meshNodeId / meshNodeFor / launchedByCoordinator /
214
+ * autoLaunchedForQueueTaskId). Defense-in-depth for SESSION-ACCUMULATION-LEAK:
215
+ * a session-host factory impl SHOULD seed the create_session record with this
216
+ * so the node binding is present the instant the record exists, rather than
217
+ * relying solely on the post-spawn updateRuntimeMeta round-trip. Optional and
218
+ * additive — factory impls that ignore it keep the prior behavior; the
219
+ * post-spawn WTDISPATCH stamp in register() still runs as the primary path.
220
+ */
221
+ initialMeta?: Record<string, unknown>;
212
222
  }
213
223
 
214
224
  export interface HostedCliRuntimeDescriptor {
@@ -578,6 +588,7 @@ export class DaemonCliManager {
578
588
  cliArgs?: string[],
579
589
  providerSessionId?: string,
580
590
  attachExisting = false,
591
+ initialMeta?: Record<string, unknown>,
581
592
  ): PtyTransportFactory | undefined {
582
593
  return this.deps.createPtyTransportFactory?.({
583
594
  runtimeId,
@@ -586,6 +597,7 @@ export class DaemonCliManager {
586
597
  cliArgs,
587
598
  providerSessionId,
588
599
  attachExisting,
600
+ ...(initialMeta && Object.keys(initialMeta).length ? { initialMeta } : {}),
589
601
  }) || undefined;
590
602
  }
591
603
 
@@ -674,6 +686,24 @@ export class DaemonCliManager {
674
686
  const instanceManager = this.deps.getInstanceManager();
675
687
  const sessionRegistry = this.deps.getSessionRegistry?.() || null;
676
688
  if (!instanceManager) throw new Error('InstanceManager not available');
689
+
690
+ // Launch-time record meta (mesh node binding) — computed BEFORE the
691
+ // transport factory so the session-host record can be seeded with the
692
+ // binding at create_session time (Fix ②, defense-in-depth for
693
+ // SESSION-ACCUMULATION-LEAK), not only via the post-spawn WTDISPATCH
694
+ // updateRuntimeMeta round-trip below. See CliTransportFactoryParams.initialMeta.
695
+ const launchMeshNodeId = typeof settings?.meshNodeId === 'string' ? settings.meshNodeId.trim() : '';
696
+ const launchMeshNodeFor = typeof settings?.meshNodeFor === 'string' ? settings.meshNodeFor.trim() : '';
697
+ const launchAutoLaunchedForQueueTaskId = typeof settings?.autoLaunchedForQueueTaskId === 'string'
698
+ ? settings.autoLaunchedForQueueTaskId.trim()
699
+ : '';
700
+ const launchRecordMeta: Record<string, unknown> = {
701
+ ...(launchMeshNodeId ? { meshNodeId: launchMeshNodeId } : {}),
702
+ ...(launchMeshNodeFor ? { meshNodeFor: launchMeshNodeFor } : {}),
703
+ ...(settings?.launchedByCoordinator === true ? { launchedByCoordinator: true } : {}),
704
+ ...(launchAutoLaunchedForQueueTaskId ? { autoLaunchedForQueueTaskId: launchAutoLaunchedForQueueTaskId } : {}),
705
+ };
706
+
677
707
  const transportFactory = this.getTransportFactory(
678
708
  key,
679
709
  normalizedType,
@@ -681,6 +711,9 @@ export class DaemonCliManager {
681
711
  cliArgs,
682
712
  options?.providerSessionId,
683
713
  attachExisting,
714
+ // Only seed at create time for fresh launches — an attach restores an
715
+ // existing record whose meta is already stamped; re-seeding could clobber.
716
+ attachExisting ? undefined : launchRecordMeta,
684
717
  );
685
718
  const cliInstance = new CliProviderInstance(provider, resolvedDir, cliArgs, key, transportFactory, options);
686
719
  try {
@@ -728,24 +761,15 @@ export class DaemonCliManager {
728
761
  // sibling worktree nodes cannot tell two co-located clones apart. Push the launch-time node
729
762
  // binding to the record meta so the record is 1:1 bound to its node (the spawned pty exists
730
763
  // by now, so updateMeta reaches the session-host store). Best-effort; guarded.
731
- const launchMeshNodeId = typeof settings?.meshNodeId === 'string' ? settings.meshNodeId.trim() : '';
732
- const launchMeshNodeFor = typeof settings?.meshNodeFor === 'string' ? settings.meshNodeFor.trim() : '';
733
- // Auto-launch attribution: the queue stamps the task id it auto-launched this
734
- // worker FOR (autoLaunchedForQueueTaskId). Mirror it onto the record meta so a
735
- // later cleanup (e.g. MAGI post-review auto-cleanup) can verify a session was
736
- // auto-launched for a SPECIFIC replica task before stopping/deleting it a reused
737
- // idle session never carries this marker, so it is preserved.
738
- const launchAutoLaunchedForQueueTaskId = typeof settings?.autoLaunchedForQueueTaskId === 'string'
739
- ? settings.autoLaunchedForQueueTaskId.trim()
740
- : '';
741
- if (launchMeshNodeId || launchMeshNodeFor || launchAutoLaunchedForQueueTaskId) {
764
+ //
765
+ // With the spec-CLI updateRuntimeMeta fix (SpecCliAdapter now forwards the FULL
766
+ // meta down to the transport, not just providerSessionId), this stamp finally
767
+ // reaches the session-host record for spec-backed providers too previously it
768
+ // was silently dropped, which is what let orphans accumulate. launchRecordMeta
769
+ // is hoisted above and also seeds the create_session record via initialMeta.
770
+ if (Object.keys(launchRecordMeta).length) {
742
771
  try {
743
- cliInstance.getAdapter().updateRuntimeMeta?.({
744
- ...(launchMeshNodeId ? { meshNodeId: launchMeshNodeId } : {}),
745
- ...(launchMeshNodeFor ? { meshNodeFor: launchMeshNodeFor } : {}),
746
- ...(settings?.launchedByCoordinator === true ? { launchedByCoordinator: true } : {}),
747
- ...(launchAutoLaunchedForQueueTaskId ? { autoLaunchedForQueueTaskId: launchAutoLaunchedForQueueTaskId } : {}),
748
- });
772
+ cliInstance.getAdapter().updateRuntimeMeta?.({ ...launchRecordMeta });
749
773
  } catch { /* best-effort — record-meta stamp is cleanup hygiene, not on the dispatch path */ }
750
774
  }
751
775
 
@@ -9,7 +9,7 @@
9
9
  * Extracted verbatim from executeDaemonCommand; the inline-cache, session/worktree
10
10
  * cleanup and aggregate-status collaborators come from ctx.
11
11
  */
12
- import { daemonIdsEquivalent, meshNodeIdMatches } from '@adhdev/mesh-shared';
12
+ import { daemonIdsEquivalent, meshNodeIdMatches, normalizeMeshNodeId } from '@adhdev/mesh-shared';
13
13
  import { resolveMeshHostStatus, normalizeMeshDaemonRole } from '../../mesh/mesh-host-ownership.js';
14
14
  import {
15
15
  loadMeshWorktreeBootstrapConfig,
@@ -558,6 +558,15 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
558
558
  && !Array.isArray(args.requireAutoLaunchedForTaskIds))
559
559
  ? args.requireAutoLaunchedForTaskIds as Record<string, string>
560
560
  : undefined;
561
+ // Opt-in orphan reclaim (SESSION-ACCUMULATION-LEAK). The live-node id set
562
+ // is the CURRENT mesh membership; a matched live session bound to a node
563
+ // still in this set is an active sibling and is never reclaimed. Only when
564
+ // the caller passes reclaimOrphans:true does the router loosen the
565
+ // shared-daemon guard for workspace-only / dead-node-bound live sessions.
566
+ const reclaimOrphans = args?.reclaimOrphans === true;
567
+ const liveMeshNodeIds = Array.isArray(mesh?.nodes)
568
+ ? mesh.nodes.map((n: any) => normalizeMeshNodeId(n)).filter(Boolean) as string[]
569
+ : [];
561
570
  const result = await ctx.cleanupMeshSessions({
562
571
  meshId,
563
572
  nodeId,
@@ -567,6 +576,8 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
567
576
  dryRun: args?.dryRun === true,
568
577
  source,
569
578
  requireAutoLaunchedForTaskIds,
579
+ reclaimOrphans,
580
+ liveMeshNodeIds,
570
581
  });
571
582
  return result;
572
583
  } catch (e: any) {
@@ -86,6 +86,16 @@ export interface MedFamilyContext {
86
86
  dryRun?: boolean;
87
87
  source?: 'mesh_cleanup_sessions' | 'mesh_remove_node' | 'magi_session_cleanup';
88
88
  requireAutoLaunchedForTaskIds?: Record<string, string>;
89
+ /**
90
+ * Opt-in orphan reclaim (default false). When true, a workspace-only live
91
+ * session (no node binding) OR a session bound to a node that no longer
92
+ * exists in `liveMeshNodeIds` is stopped instead of skipped — reclaims the
93
+ * SESSION-ACCUMULATION-LEAK orphans. `liveMeshNodeIds` is the current mesh's
94
+ * node id set; a session whose meshNodeId is still present is treated as an
95
+ * active sibling and left alone.
96
+ */
97
+ reclaimOrphans?: boolean;
98
+ liveMeshNodeIds?: string[];
89
99
  }) => Promise<{ success: boolean; [key: string]: unknown }>;
90
100
 
91
101
  /** Bound `DaemonCommandRouter.cleanupLocalWorktreeNode`. */
@@ -1468,6 +1468,16 @@ export class DaemonCommandRouter {
1468
1468
  * didn't itself spawn for this fan-out. Only consulted alongside explicit sessionIds.
1469
1469
  */
1470
1470
  requireAutoLaunchedForTaskIds?: Record<string, string>;
1471
+ /**
1472
+ * Opt-in orphan reclaim (default false). See SESSION-ACCUMULATION-LEAK.
1473
+ * When true, a workspace-only live_runtime session (no node binding) OR a
1474
+ * live_runtime session bound to a node absent from `liveMeshNodeIds` is
1475
+ * stopped instead of skipped by the conservative shared-daemon guard. A
1476
+ * session whose meshNodeId is STILL in `liveMeshNodeIds` (active sibling)
1477
+ * is never reclaimed — that is the shared-daemon safety this preserves.
1478
+ */
1479
+ reclaimOrphans?: boolean;
1480
+ liveMeshNodeIds?: string[];
1471
1481
  }): Promise<{ success: boolean; [key: string]: unknown }> {
1472
1482
  if (args.mode === 'preserve') {
1473
1483
  return { success: true, mode: 'preserve', matchedCount: 0, stoppedSessionIds: [], deletedSessionIds: [], skippedSessionIds: [] };
@@ -1477,6 +1487,23 @@ export class DaemonCommandRouter {
1477
1487
  const requestedSessionIds = Array.isArray(args.sessionIds)
1478
1488
  ? new Set(args.sessionIds.map(id => typeof id === 'string' ? id.trim() : '').filter(Boolean))
1479
1489
  : undefined;
1490
+ // Orphan-reclaim support (opt-in). The live-node id list lets us tell an
1491
+ // orphan (owning node gone from the mesh) apart from a still-active sibling
1492
+ // sharing this daemon. daemonIdsEquivalent (not raw ===) guards against the
1493
+ // canon-identity defect class: daemonId forms (daemon_mach_ vs mach_) and
1494
+ // normalized vs raw meshNodeId must compare equivalent, not literally equal.
1495
+ const reclaimOrphans = args.reclaimOrphans === true;
1496
+ const liveMeshNodeIds = Array.isArray(args.liveMeshNodeIds)
1497
+ ? args.liveMeshNodeIds.map(id => typeof id === 'string' ? id.trim() : '').filter(Boolean)
1498
+ : [];
1499
+ const isNodeStillLive = (candidateNodeId: string): boolean => {
1500
+ if (!candidateNodeId) return false;
1501
+ return liveMeshNodeIds.some(liveId =>
1502
+ liveId === candidateNodeId
1503
+ || meshNodeIdMatches({ id: liveId }, candidateNodeId)
1504
+ || daemonIdsEquivalent(liveId, candidateNodeId));
1505
+ };
1506
+ const reclaimedOrphanSessionIds: string[] = [];
1480
1507
  const sessions = await this.deps.sessionHostControl.listSessions();
1481
1508
  const matched = sessions.filter(record => this.sessionMatchesMeshNode(record, args.node, args.nodeId, requestedSessionIds));
1482
1509
  const hasExplicitSessionIds = !!requestedSessionIds?.size;
@@ -1579,7 +1606,18 @@ export class DaemonCommandRouter {
1579
1606
  const matchedByWorkspaceOnly = !recordNodeId;
1580
1607
  const isWorktreeNodeRemoval = cleanupSource === 'mesh_remove_node' && args.node?.isLocalWorktree === true;
1581
1608
  const cleanWorkspaceOnlyForWorktree = isWorktreeNodeRemoval && matchedByWorkspaceOnly;
1582
- if (!hasExplicitSessionIds && liveRuntime && !delegateBoundToThisNode && !cleanWorkspaceOnlyForWorktree) {
1609
+ // Opt-in orphan reclaim (SESSION-ACCUMULATION-LEAK): a live_runtime that
1610
+ // is either workspace-only (no node binding — the leaked spec-CLI case)
1611
+ // or bound to a node no longer present in the mesh is an orphan and safe
1612
+ // to stop. A session bound to a node STILL live in the mesh is an active
1613
+ // sibling on this shared daemon and is left to the normal guard. Node
1614
+ // equivalence uses isNodeStillLive (daemonIdsEquivalent under the hood),
1615
+ // never a raw === on the id form.
1616
+ const reclaimableOrphan = reclaimOrphans
1617
+ && liveRuntime
1618
+ && !delegateBoundToThisNode
1619
+ && (matchedByWorkspaceOnly || !isNodeStillLive(recordNodeId));
1620
+ if (!hasExplicitSessionIds && liveRuntime && !delegateBoundToThisNode && !cleanWorkspaceOnlyForWorktree && !reclaimableOrphan) {
1583
1621
  skippedSessionIds.push(sessionId);
1584
1622
  skippedLiveSessionIds.push(sessionId);
1585
1623
  const reason = recordNodeId && recordNodeId !== args.nodeId
@@ -1595,6 +1633,12 @@ export class DaemonCommandRouter {
1595
1633
  // like a bound delegate for accounting (so callers can see it was acted on).
1596
1634
  actedLiveDelegateSessionIds.push(sessionId);
1597
1635
  }
1636
+ if (reclaimableOrphan && !cleanWorkspaceOnlyForWorktree && !delegateBoundToThisNode) {
1637
+ // Reclaimed orphan — surface it distinctly so callers can audit what
1638
+ // the opt-in reclaim path stopped that the normal guard would have kept.
1639
+ reclaimedOrphanSessionIds.push(sessionId);
1640
+ actedLiveDelegateSessionIds.push(sessionId);
1641
+ }
1598
1642
  if (!hasExplicitSessionIds && liveRuntime && delegateBoundToThisNode && args.mode === 'delete_stopped') {
1599
1643
  // delete_stopped never stops live runtimes by contract — even bound delegates.
1600
1644
  // Surface a clear reason instead of an unexplained skip so callers know to use
@@ -1674,6 +1718,7 @@ export class DaemonCommandRouter {
1674
1718
  skippedCoordinatorSessionIds,
1675
1719
  ...(skippedMarkerMismatchSessionIds.length ? { skippedMarkerMismatchSessionIds } : {}),
1676
1720
  ...(actedLiveDelegateSessionIds.length ? { actedLiveDelegateSessionIds } : {}),
1721
+ ...(reclaimedOrphanSessionIds.length ? { reclaimedOrphanSessionIds } : {}),
1677
1722
  ...(skippedLiveSessionReasons.length ? { skippedLiveSessionReasons } : {}),
1678
1723
  ...(deleteUnsupported ? {
1679
1724
  deleteUnsupported: true,
@@ -180,6 +180,17 @@ export class TerminalAdapter {
180
180
  this.pty?.write(text);
181
181
  }
182
182
 
183
+ /** Forward runtime metadata (meshNodeId, workspaceLabel, lifecycle, …) to
184
+ * the underlying transport so it reaches the session registry. The spec
185
+ * path previously dropped everything but providerSessionId here, which
186
+ * left autoLaunch's meshNodeId stamp unbound on the record (see
187
+ * SESSION-ACCUMULATION-LEAK). No-op when the transport does not support
188
+ * metadata updates (e.g. plain node-pty). */
189
+ updateMeta(meta: Record<string, unknown>, replace = false): void {
190
+ if (!this.pty || typeof this.pty.updateMeta !== 'function') return;
191
+ this.pty.updateMeta(meta, replace);
192
+ }
193
+
183
194
  /** Debug-only: most-recent PTY input/output/resize/cursor events, oldest
184
195
  * first. Pure observation — never consulted by the FSM. */
185
196
  getEventTimeline(limit = MAX_PTY_EVENTS): SpecPtyEvent[] {
@@ -641,9 +641,17 @@ export class SpecCliAdapter implements CliAdapter {
641
641
  };
642
642
  }
643
643
  updateRuntimeMeta(meta?: Record<string, unknown>): void {
644
- if (meta && typeof meta.providerSessionId === 'string') {
644
+ if (!meta) return;
645
+ if (typeof meta.providerSessionId === 'string') {
645
646
  this.providerSessionId = meta.providerSessionId;
646
647
  }
648
+ // Forward the FULL meta (meshNodeId / meshNodeFor / workspaceLabel /
649
+ // lifecycle / …) down to the transport so it reaches the session
650
+ // registry. The legacy ProviderCliAdapter.updateRuntimeMeta does the
651
+ // same via ptyProcess.updateMeta; the spec path previously dropped
652
+ // everything but providerSessionId, leaving autoLaunch's meshNodeId
653
+ // stamp unbound on the record — the root of SESSION-ACCUMULATION-LEAK.
654
+ try { this.driver.updateMeta(meta); } catch { /* transport may not support meta */ }
647
655
  }
648
656
  refreshProviderDefinition(): void { /* hot reload handled by SpecDriver fs.watch */ }
649
657
 
@@ -116,6 +116,7 @@ export interface ISpecDriver {
116
116
  subscribe(listener: (ev: DashboardEvent) => void): () => void;
117
117
  start(): void;
118
118
  dispatch(cmd: DashboardCommand): void;
119
+ updateMeta(meta: Record<string, unknown>, replace?: boolean): void;
119
120
  snapshot(): string;
120
121
  getCursorPosition(): { row: number; col: number };
121
122
  getScreen(): string;
@@ -422,6 +423,14 @@ export class FsmDriver implements ISpecDriver {
422
423
  }
423
424
  }
424
425
 
426
+ /** Forward runtime metadata to the terminal transport so mesh binding
427
+ * fields (meshNodeId / meshNodeFor / workspaceLabel / lifecycle) reach
428
+ * the session registry. Not a DashboardCommand — this is a control-plane
429
+ * update, not user input. */
430
+ updateMeta(meta: Record<string, unknown>, replace = false): void {
431
+ this.adapter.updateMeta(meta, replace);
432
+ }
433
+
425
434
  snapshot(): string { return this.adapter.snapshot(); }
426
435
  getCursorPosition(): { row: number; col: number } { return this.adapter.getCursorPosition(); }
427
436
  getScreen(): string { return this.adapter.snapshot(); }