@adhdev/daemon-core 0.9.82-rc.396 → 0.9.82-rc.398

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.396",
3
+ "version": "0.9.82-rc.398",
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.396",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.398",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -100,6 +100,10 @@ interface SendMessageState {
100
100
  maxEchoWaitMs: number;
101
101
  retryDelayMs: number;
102
102
  didCommitUserTurn: boolean;
103
+ // Whether this was the session's first turn at the moment of dispatch — captured
104
+ // before commitSendUserTurn flips this.firstTurnSent, so a later stuck-retry still
105
+ // knows it is recovering the win32 premature-ready first-turn swallow.
106
+ isFirstTurn: boolean;
103
107
  }
104
108
 
105
109
  interface SendMessageCompletion {
@@ -171,6 +175,16 @@ export class ProviderCliAdapter implements CliAdapter {
171
175
  private providerSessionId: string | null = null;
172
176
  private responseTimeout: NodeJS.Timeout | null = null;
173
177
  private ready = false;
178
+ // WIN32-READY-HOLD: the ready barrier can release on screen/spec-FSM grace
179
+ // before win32 ConPTY's input layer is live. The first split write (text, then a
180
+ // separate trailing CR via waitForEchoAndSubmit) then has its submit CR swallowed,
181
+ // and every CR-only retry re-sends a bare CR the input layer keeps dropping — the
182
+ // first message is typed-but-never-submitted and lost. Routing only the FIRST turn
183
+ // through the atomic content+sendKey single write (submitImmediatePrompt) keeps the
184
+ // Enter in the same PTY write unit as the text, the invariant win32 ConPTY needs to
185
+ // recognize a submit, so the swallow is bypassed. Subsequent turns (input layer now
186
+ // proven live) keep the normal echo-gated path. Flips true on first committed turn.
187
+ private firstTurnSent = false;
174
188
  private startupBuffer = '';
175
189
  private startupParseGate = false;
176
190
  private startupSettleTimer: NodeJS.Timeout | null = null;
@@ -599,6 +613,9 @@ export class ProviderCliAdapter implements CliAdapter {
599
613
  this.resetTerminalScreen(DEFAULT_SESSION_HOST_ROWS, DEFAULT_SESSION_HOST_COLS);
600
614
  this.pendingTerminalQueryTail = '';
601
615
  this.ready = false;
616
+ // Each fresh spawn re-enters the premature-ready swallow window — the next
617
+ // turn is again a "first turn" and must use the win32-safe atomic send path.
618
+ this.firstTurnSent = false;
602
619
  await this.ptyProcess.ready;
603
620
  this.engine.onSpawnReady();
604
621
  this.scheduleStartupSettleCheck();
@@ -1210,6 +1227,9 @@ export class ProviderCliAdapter implements CliAdapter {
1210
1227
  private commitSendUserTurn(state: SendMessageState): void {
1211
1228
  if (state.didCommitUserTurn) return;
1212
1229
  state.didCommitUserTurn = true;
1230
+ // The first turn has now been written atomically (win32-safe); later turns
1231
+ // can use the normal echo-gated path now that the input layer is proven live.
1232
+ this.firstTurnSent = true;
1213
1233
  }
1214
1234
 
1215
1235
  private armResponseTimeout(): void {
@@ -1240,12 +1260,31 @@ export class ProviderCliAdapter implements CliAdapter {
1240
1260
  });
1241
1261
  }
1242
1262
 
1263
+ // WIN32-READY-HOLD: choose the retry write for a stuck prompt. When the FIRST turn
1264
+ // is stuck on win32 — the premature-ready swallow window — the prompt text itself
1265
+ // may have been partially eaten by a not-yet-live ConPTY input layer, so re-sending
1266
+ // a bare CR keeps hitting nothing. Re-type the whole `text + sendKey` atomically
1267
+ // once so the input layer (now live) receives a self-contained, submit-coupled
1268
+ // write. All other cases keep the cheap bare-CR retry (the prompt is fully echoed
1269
+ // and only the Enter is missing).
1270
+ private writeStuckRetry(state: SendMessageState, mode: string): void {
1271
+ const retypeFirstTurn = process.platform === 'win32' && state.isFirstTurn;
1272
+ if (retypeFirstTurn) {
1273
+ LOG.info('CLI', `[${this.cliType}] ${mode}: re-typing full prompt atomically (win32 first-turn swallow recovery)`);
1274
+ void this.writeToPty(state.text + this.sendKey).catch((error) => {
1275
+ LOG.warn('CLI', `[${this.cliType}] ${mode} re-type write failed: ${error?.message || error}`);
1276
+ });
1277
+ return;
1278
+ }
1279
+ this.writeSubmitKeyForRetry(mode);
1280
+ }
1281
+
1243
1282
  private retrySubmitIfStuck(state: SendMessageState, attempt: number): void {
1244
1283
  this.submitRetryTimer = null;
1245
1284
  if (!this.isSubmitStuck(state.normalizedPromptSnippet)) return;
1246
1285
  this.engine.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1247
1286
  LOG.info('CLI', `[${this.cliType}] Retrying submit key for stuck prompt (attempt ${attempt})`);
1248
- this.writeSubmitKeyForRetry('submit_retry');
1287
+ this.writeStuckRetry(state, 'submit_retry');
1249
1288
  if (attempt >= 3) { this.engine.submitRetryUsed = true; return; }
1250
1289
  this.submitRetryTimer = setTimeout(() => this.retrySubmitIfStuck(state, attempt + 1), state.retryDelayMs);
1251
1290
  }
@@ -1255,7 +1294,7 @@ export class ProviderCliAdapter implements CliAdapter {
1255
1294
  if (!this.isSubmitStuck(state.normalizedPromptSnippet)) return;
1256
1295
  this.engine.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1257
1296
  LOG.info('CLI', `[${this.cliType}] Retrying submit key for stuck prompt (attempt 1)`);
1258
- this.writeSubmitKeyForRetry('immediate_retry');
1297
+ this.writeStuckRetry(state, 'immediate_retry');
1259
1298
  this.engine.submitRetryUsed = true;
1260
1299
  }
1261
1300
 
@@ -1597,6 +1636,8 @@ export class ProviderCliAdapter implements CliAdapter {
1597
1636
  maxEchoWaitMs,
1598
1637
  retryDelayMs,
1599
1638
  didCommitUserTurn: false,
1639
+ // Capture BEFORE the send commits — commitSendUserTurn flips firstTurnSent.
1640
+ isFirstTurn: !this.firstTurnSent,
1600
1641
  };
1601
1642
  this.engine.responseSettleIgnoreUntil = Date.now() + submitDelayMs + this.timeouts.outputSettle + 250;
1602
1643
  await new Promise<void>((resolve, reject) => {
@@ -1615,7 +1656,15 @@ export class ProviderCliAdapter implements CliAdapter {
1615
1656
  },
1616
1657
  };
1617
1658
 
1618
- if (this.submitStrategy === 'immediate') {
1659
+ // WIN32-READY-HOLD: the very first turn after startup is the one exposed to
1660
+ // the premature-ready swallow (ready released before win32 ConPTY input is
1661
+ // live). Force it through the atomic content+sendKey single write so the
1662
+ // submit CR can never be separated from the text it submits — the same path
1663
+ // the `immediate` strategy already uses. Restricted to win32 + the first
1664
+ // turn so Mac/linux echo-gated behavior and all later turns are unchanged.
1665
+ const useAtomicFirstTurn = this.submitStrategy === 'immediate'
1666
+ || (process.platform === 'win32' && sendState.isFirstTurn);
1667
+ if (useAtomicFirstTurn) {
1619
1668
  this.submitImmediatePrompt(sendState, completion);
1620
1669
  return;
1621
1670
  }
@@ -727,6 +727,72 @@ export class DaemonCommandRouter {
727
727
  return true;
728
728
  }
729
729
 
730
+ /**
731
+ * WORKTREE-BOOTSTRAP-COORD-STATE: mark a worktree node's bootstrap as reaching a
732
+ * terminal state (complete / failed) in THIS daemon's mesh view.
733
+ *
734
+ * Root cause this fixes: clone_mesh_node forwards the clone+bootstrap to the
735
+ * source node's daemon (the worktree's machine). persistWorktreeSetupState
736
+ * therefore flips worktreeBootstrap.status to 'complete' on the WORKER daemon's
737
+ * mesh object — never on the coordinator's. The coordinator only ever holds the
738
+ * 'running' state it stamped from the forwarded clone reply. The claim path's
739
+ * bootstrap gate (mesh-event-forwarding agent:ready / mesh-queue-assignment)
740
+ * reads the coordinator's mesh via getMeshWithCache, sees status==='running'
741
+ * forever, and DEFERS every claim — so the worktree_bootstrap_complete re-fire
742
+ * (triggerMeshQueue) loops against a gate that never opens: claim never lands,
743
+ * the idle session is re-registered each tick, and auto-launch keeps spawning
744
+ * fresh sessions (runaway worktree-session multiplication).
745
+ *
746
+ * Called from the worktree_bootstrap_complete/_failed event handler BEFORE the
747
+ * queue re-fire so the gate sees the terminal state and the deferred claim can
748
+ * finally land. Updates the inline cache (clone worktree nodes are inline-only)
749
+ * and, when the node also exists in local config, persists there too; both paths
750
+ * invalidate the aggregate status cache. Best-effort and idempotent.
751
+ */
752
+ public markWorktreeBootstrapTerminalState(meshId: string, nodeId: string, status: 'complete' | 'failed'): void {
753
+ if (!meshId || !nodeId) return;
754
+ const stamp = (mesh: any): boolean => {
755
+ if (!mesh || !Array.isArray(mesh.nodes)) return false;
756
+ const node = mesh.nodes.find((entry: any) => meshNodeIdMatches(entry, nodeId));
757
+ if (!node) return false;
758
+ const prev = (node.worktreeBootstrap && typeof node.worktreeBootstrap === 'object')
759
+ ? node.worktreeBootstrap as Record<string, unknown>
760
+ : {};
761
+ if (prev.status === status) return false;
762
+ node.worktreeBootstrap = {
763
+ ...prev,
764
+ status,
765
+ completedAt: prev.completedAt ?? new Date().toISOString(),
766
+ };
767
+ return true;
768
+ };
769
+ let changed = false;
770
+ // Inline cache (the authoritative view for inline-only clone worktree nodes).
771
+ try {
772
+ const cached = this.getCachedInlineMesh(meshId);
773
+ if (cached && stamp(cached)) {
774
+ cached.updatedAt = new Date().toISOString();
775
+ this.inlineMeshCache.set(meshId, cached);
776
+ changed = true;
777
+ }
778
+ } catch { /* best-effort */ }
779
+ if (changed) this.invalidateAggregateMeshStatus(meshId);
780
+ // Local config (a worktree node registered via addNode also lives here).
781
+ // Done in a detached dynamic-import chain so the method stays sync; both the
782
+ // stamp and the persist are best-effort, and the inline-cache stamp above is
783
+ // what the coordinator's claim gate reads.
784
+ void import('../config/mesh-config.js')
785
+ .then(({ getMesh, updateNode }) => {
786
+ const local = getMesh(meshId);
787
+ if (local && stamp(local)) {
788
+ const node = local.nodes.find((entry: any) => meshNodeIdMatches(entry, nodeId));
789
+ if (node) updateNode(meshId, node.id, { worktreeBootstrap: node.worktreeBootstrap } as any);
790
+ this.invalidateAggregateMeshStatus(meshId);
791
+ }
792
+ })
793
+ .catch(() => { /* persistence is best-effort */ });
794
+ }
795
+
730
796
  private tombstoneRemovedInlineMeshNode(meshId: string, nodeId: string): void {
731
797
  if (!nodeId) return;
732
798
  let set = this.removedInlineMeshNodeIds.get(meshId);
@@ -2371,6 +2437,13 @@ export class DaemonCommandRouter {
2371
2437
  sessionCleanupMode: refineSessionCleanupMode,
2372
2438
  ...(refineSessionIds && refineSessionIds.length > 0 ? { sessionIds: refineSessionIds } : {}),
2373
2439
  inlineMesh: args?.inlineMesh,
2440
+ // REFINE-CLEANUP: refine reaches cleanup only AFTER a verified merge
2441
+ // convergence, so any residual worktree dirtiness here is incidental
2442
+ // (e.g. a bootstrap lockfile rewrite) — never unmerged work. `force`
2443
+ // sets requireClean=false so a plain-dirty worktree no longer aborts
2444
+ // removal with merged_cleanup_failed. Branch-ref deletion still keys off
2445
+ // mergeConvergence (NOT the force flag), so no merged work can be lost.
2446
+ force: true,
2374
2447
  });
2375
2448
  recordMeshRefineStage(refineStages, 'cleanup', removeResult?.success === false ? 'failed' : 'passed', cleanupStarted, {
2376
2449
  removed: removeResult?.removed,
@@ -1053,6 +1053,29 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1053
1053
  // is drained in every topology. 'failed' is re-fired too so a deferred-then-failed bootstrap
1054
1054
  // dispatches and fails loudly/visibly rather than stranding silently. Falls through to the
1055
1055
  // coordinator broadcast below — the bootstrap event is still delivered to the coordinator.
1056
+ // WORKTREE-BOOTSTRAP-COORD-STATE: stamp the terminal bootstrap status onto the
1057
+ // COORDINATOR's mesh view BEFORE re-firing the queue. The clone+bootstrap ran on
1058
+ // the worker daemon (clone_mesh_node forwards to the source node's machine), so
1059
+ // persistWorktreeSetupState only flipped status→'complete' on the worker's mesh
1060
+ // object — the coordinator still holds the 'running' state it stamped from the
1061
+ // forwarded clone reply. Without this, the claim gate (agent:ready defer above +
1062
+ // mesh-queue-assignment) reads getMeshWithCache, sees 'running' forever, and
1063
+ // defers every claim — so this very re-fire loops against a gate that never opens
1064
+ // (claim never lands; idle session re-registered each tick; auto-launch spawns a
1065
+ // fresh session every cycle → runaway worktree-session multiplication). Stamping
1066
+ // the terminal state opens the gate so the deferred claim lands on this re-fire.
1067
+ const bootstrapNodeId = readNonEmptyString(args.nodeId) || readNonEmptyString(args.metadataEvent.meshNodeId);
1068
+ if (bootstrapNodeId) {
1069
+ try {
1070
+ (components.router as any)?.markWorktreeBootstrapTerminalState?.(
1071
+ args.meshId,
1072
+ bootstrapNodeId,
1073
+ args.event === 'worktree_bootstrap_failed' ? 'failed' : 'complete',
1074
+ );
1075
+ } catch (e: any) {
1076
+ LOG.warn('MeshQueue', `Failed to stamp terminal bootstrap state for ${bootstrapNodeId} (mesh ${args.meshId}): ${e?.message || e}`);
1077
+ }
1078
+ }
1056
1079
  setImmediate(() => {
1057
1080
  triggerMeshQueue(components, args.meshId).catch((e: any) => {
1058
1081
  LOG.warn('MeshQueue', `Queue re-fire after ${args.event} failed (mesh ${args.meshId}): ${e?.message || e}`);
@@ -267,7 +267,10 @@ export function tryAssignQueueTask(
267
267
  providerType: string
268
268
  ): boolean {
269
269
  const mesh = getMeshWithCache(components, meshId);
270
- const node = mesh?.nodes.find((n: any) => readMeshNodeId(n) === nodeId);
270
+ // Match with the shared 3-form normalizer (id / nodeId / node_id), not raw
271
+ // `n.id` — a stamp-form nodeId vs the mesh node's config-form id must still
272
+ // resolve, mirroring the remote idle-session path below (:1341).
273
+ const node = mesh?.nodes.find((n: any) => meshNodeIdMatches(n, nodeId));
271
274
 
272
275
  // WORKTREE-CLAIM-GATE-BYPASS: the SINGLE claim-time gate for the worktree-bootstrap defer.
273
276
  // tryAssignQueueTask is the one funnel every claim path flows through — the event-driven
@@ -1317,7 +1320,7 @@ export async function triggerMeshQueue(components: DaemonComponents, meshId: str
1317
1320
 
1318
1321
  if (providerType) {
1319
1322
  localIdleSessionsChecked += 1;
1320
- localCandidates.push({ nodeId, sessionId, providerType, origin: 'local', node: mesh.nodes.find((n: any) => readMeshNodeId(n) === nodeId) });
1323
+ localCandidates.push({ nodeId, sessionId, providerType, origin: 'local', node: mesh.nodes.find((n: any) => meshNodeIdMatches(n, nodeId)) });
1321
1324
  } else {
1322
1325
  skippedSessions.push({
1323
1326
  nodeId,
@@ -4,6 +4,7 @@ import { LOG } from '../logging/logger.js';
4
4
  import { loadBetterSqlite3 } from '../system/load-better-sqlite3.js';
5
5
  import { getLedgerDir } from './mesh-ledger.js';
6
6
  import { nodeSatisfiesRequiredTags } from './mesh-work-queue.js';
7
+ import { meshNodeIdMatches, daemonIdsEquivalent, expandDaemonIdForms } from '@adhdev/mesh-shared';
7
8
  import type { MeshTaskStatus, MeshWorkQueueEntry } from './mesh-work-queue.js';
8
9
  import type BetterSqlite3 from 'better-sqlite3';
9
10
  import type { Database as DatabaseHandle } from 'better-sqlite3';
@@ -687,6 +688,15 @@ export class MeshRuntimeStore {
687
688
  return null;
688
689
  }
689
690
 
691
+ // The node-pinned SELECT must match a row whose target_node_id was stamped
692
+ // in ANY equivalent daemon-id form (config-form `daemon_mach_X` vs the
693
+ // claiming session's stamp-form `mach_X`). A single `= ?` bind on the
694
+ // stamp-form silently fails to fetch a config-form row, leaving the task
695
+ // pending forever (the empty-session WORKTREE-CLAIM-GATE repro). Expand to
696
+ // every equivalent form and bind an IN (...) set; the per-candidate
697
+ // targetMatches() JS gate above re-validates each fetched row.
698
+ const nodeIdForms = expandDaemonIdForms(nodeId);
699
+ const nodePinnedPlaceholders = nodeIdForms.map(() => '?').join(', ');
690
700
  // Priority: session-targeted > node-targeted (no session) > unconstrained
691
701
  const rows = [
692
702
  ...(
@@ -699,9 +709,9 @@ export class MeshRuntimeStore {
699
709
  ...(
700
710
  this.db.prepare(`
701
711
  SELECT payload FROM mesh_queue
702
- WHERE mesh_id = ? AND status = 'pending' AND target_node_id = ? AND target_session_id IS NULL
712
+ WHERE mesh_id = ? AND status = 'pending' AND target_node_id IN (${nodePinnedPlaceholders}) AND target_session_id IS NULL
703
713
  ORDER BY created_at ASC
704
- `).all(meshId, nodeId) as Array<{ payload: string }>
714
+ `).all(meshId, ...nodeIdForms) as Array<{ payload: string }>
705
715
  ),
706
716
  ...(
707
717
  this.db.prepare(`
@@ -755,9 +765,21 @@ export class MeshRuntimeStore {
755
765
  // sibling worktree session on the same daemon absorb another node's/session's
756
766
  // pinned task. When a task carries an explicit target, require an exact match
757
767
  // here too — fail-closed.
768
+ // The target id may have been stamped in a different serialization /
769
+ // daemon-id form than the claiming session's nodeId (config-form
770
+ // `daemon_mach_X` vs stamp-form `mach_X`, or the 3-way id/nodeId/node_id
771
+ // node forms). A raw `!==` here permanently strands a node-pinned task as
772
+ // an empty session. Accept the candidate when the target resolves to the
773
+ // same node under ANY equivalent form; keep targetSessionId an exact match.
758
774
  const targetMatches = (candidate: MeshWorkQueueEntry): boolean => {
759
775
  if (candidate.targetSessionId && candidate.targetSessionId !== sessionId) return false;
760
- if (candidate.targetNodeId && candidate.targetNodeId !== nodeId) return false;
776
+ if (
777
+ candidate.targetNodeId
778
+ && !daemonIdsEquivalent(candidate.targetNodeId, nodeId)
779
+ && !meshNodeIdMatches({ id: candidate.targetNodeId }, nodeId)
780
+ ) {
781
+ return false;
782
+ }
761
783
  return true;
762
784
  };
763
785