@adhdev/daemon-core 0.9.82-rc.319 → 0.9.82-rc.320

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.319",
3
+ "version": "0.9.82-rc.320",
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.319",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.320",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -379,6 +379,17 @@ export function tryAssignQueueTask(
379
379
  const autoLaunchInProgress = new Set<string>();
380
380
  const autoLaunchCooldownUntil = new Map<string, number>();
381
381
  const AUTO_LAUNCH_COOLDOWN_MS = 5_000;
382
+ // A remote auto-launch (launch_cli forward) is fire-and-async: the worker session
383
+ // spawns, reaches idle, emits agent:ready, that ready is queued on the worker, pulled
384
+ // by this coordinator (reconcile PHASE 1), and only THEN claims the task. That round
385
+ // trip routinely exceeds the 5s per-(mesh,node) cooldown, so cooldown alone lets the
386
+ // reconcile loop fire a SECOND launch for the same still-pending task before the first
387
+ // session's claim lands — every tick spawns yet another orphan session (observed live:
388
+ // 26 sessions for one task). This is a per-TASK await-claim window: once a task has a
389
+ // successfully-launched session whose claim we are still waiting on, do not launch it
390
+ // again until the window lapses. It is generous (a slow remote spawn can take tens of
391
+ // seconds) but bounded so a launch that silently never reaches idle is eventually retried.
392
+ const AUTO_LAUNCH_AWAIT_CLAIM_MS = 90_000;
382
393
 
383
394
  // De-dup for repeated `skipped` ledger noise: the reconcile loop re-runs the queue
384
395
  // trigger every 4s, so a task that can't be claimed (e.g. a remote node with no
@@ -818,6 +829,25 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
818
829
  continue;
819
830
  }
820
831
 
832
+ // Per-task await-claim guard. A prior auto-launch already spawned a session for
833
+ // this task and we are waiting for that session's idle→claim to land (remote
834
+ // claims arrive via the worker→coordinator agent:ready pull, which can lag well
835
+ // past the per-node cooldown). Re-launching now would spawn a duplicate orphan
836
+ // session that never gets work. The task leaves `pending` the instant the claim
837
+ // succeeds, so this guard only suppresses the in-flight window; if the launched
838
+ // session never reaches idle within the window, a later tick retries.
839
+ if (task.autoLaunch?.status === 'completed' && task.autoLaunch.sessionId) {
840
+ const launchedAtMs = Date.parse(task.autoLaunch.updatedAt);
841
+ if (Number.isFinite(launchedAtMs) && Date.now() - launchedAtMs < AUTO_LAUNCH_AWAIT_CLAIM_MS) {
842
+ // Record the skip in the ledger ONLY (dedup'd). Do NOT call markAutoLaunch
843
+ // here: recordTaskAutoLaunch overwrites task.autoLaunch wholesale, which would
844
+ // erase the very `completed` record (status + sessionId + updatedAt) this guard
845
+ // reads on the next tick, reopening the duplicate-launch hole it closes.
846
+ recordAutoLaunchEvent(meshId, { phase: 'skipped', taskId: task.id, reason: 'awaiting_launched_session_claim', nodeId: task.autoLaunch.nodeId, sessionId: task.autoLaunch.sessionId });
847
+ continue;
848
+ }
849
+ }
850
+
821
851
  const candidateNodes = Array.isArray(mesh?.nodes)
822
852
  ? mesh.nodes.filter((node: any) => {
823
853
  if (task.targetNodeId && readMeshNodeId(node) !== task.targetNodeId) return false;
@@ -1738,7 +1768,27 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1738
1768
  metadataEvent: args.metadataEvent,
1739
1769
  recoveryContext,
1740
1770
  });
1741
- if (!messageText) return { success: false, error: 'unsupported mesh event' };
1771
+ if (!messageText) {
1772
+ // Lifecycle events that carry no coordinator-facing message (agent:ready /
1773
+ // agent:generating_started) still drive the remote-claim state machine: the
1774
+ // coordinator's agent:ready branch above runs setRemoteIdleSession +
1775
+ // tryAssignQueueTask, and agent:generating_started clears the remote-idle entry.
1776
+ // For a LOCAL worker whose coordinator is a REMOTE daemon those side effects ran
1777
+ // on the wrong daemon (this worker's empty queue / store), so the coordinator never
1778
+ // learns the auto-launched session went idle and re-auto-launches it forever
1779
+ // (queue task stuck pending). Queue the silent event so the coordinator pulls it
1780
+ // (PHASE 1 pullRemoteNodeQueues → handleMeshForwardEvent) and re-runs the claim on
1781
+ // the daemon that actually owns the queue. Gate strictly on a present, REMOTE
1782
+ // coordinator daemon id: a co-located worker already ran the claim on the right
1783
+ // daemon, and a coordinator processing a *pulled* event has no sourceSession so
1784
+ // workerCoordinatorDaemonId is empty — neither re-queues, so there is no loop.
1785
+ const isSilentClaimRelevantEvent = args.event === 'agent:ready' || args.event === 'agent:generating_started';
1786
+ const coordinatorIsRemote = !!workerCoordinatorDaemonId
1787
+ && !resolveCoordinatorDrainDaemonIds(components).includes(workerCoordinatorDaemonId);
1788
+ if (!(isSilentClaimRelevantEvent && coordinatorIsRemote)) {
1789
+ return { success: false, error: 'unsupported mesh event' };
1790
+ }
1791
+ }
1742
1792
 
1743
1793
  // ── Queue-only delivery (single-model: queue + periodic poll) ──────────────
1744
1794
  // Every mesh coordinator event — terminal or not, local-coordinator or
@@ -1768,7 +1818,11 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1768
1818
  ...args.metadataEvent,
1769
1819
  ...(recoveryContext ? { recoveryContext } : {}),
1770
1820
  },
1771
- coordinatorMessage: messageText,
1821
+ // Silent lifecycle events (agent:ready / agent:generating_started) carry no
1822
+ // coordinator message; they are queued only so the coordinator re-runs the
1823
+ // remote-claim state machine on pull. injectPendingIntoCoordinator skips
1824
+ // entries without a coordinatorMessage, so a live CLI coordinator is not spammed.
1825
+ ...(messageText ? { coordinatorMessage: messageText } : {}),
1772
1826
  queuedAt: Date.now(),
1773
1827
  ...(workerCoordinatorDaemonId ? { targetCoordinatorDaemonId: workerCoordinatorDaemonId } : {}),
1774
1828
  };