@adhdev/daemon-core 0.9.82-rc.521 → 0.9.82-rc.522

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.
@@ -1,5 +1,8 @@
1
1
  import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
2
2
  import type { RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
3
+ /** Whether the assignment path currently holds an ff lease on this node's workspace —
4
+ * used to skip a task claim while an auto ff is mutating the same workspace. */
5
+ export declare function isWorkspaceAutoFastForwardInFlight(workspace: string | undefined): boolean;
3
6
  export declare function __resetIdleAutoFastForwardForTests(): void;
4
7
  export declare function getMeshWithCache(components: DaemonComponents, meshId: string): any | undefined;
5
8
  export declare function tryAssignQueueTask(components: DaemonComponents, meshId: string, nodeId: string, sessionId: string, providerType: string): boolean;
@@ -154,6 +157,19 @@ export declare function maybeAutoFastForwardIdleNode(components: DaemonComponent
154
157
  sessionId?: string;
155
158
  providerType?: string;
156
159
  }): Promise<void>;
160
+ /**
161
+ * Continuous-mode remote auto fast-forward scan (mode:"continuous" only). Called by
162
+ * the reconcile tick BEFORE queue claim. Scans every connected, eligible, non-worktree
163
+ * remote node of every mesh this daemon hosts and delegates an ff to its owning daemon
164
+ * when it is online/clean/behind within policy. Per-node cooldown + the workspace lease
165
+ * keep the ~4s reconcile cadence from hammering peers or racing an assignment.
166
+ *
167
+ * Ephemeral worktree nodes are DELIBERATELY excluded from the continuous catch-up: a
168
+ * Refinery worktree branch must not be silently advanced by a background scan (only its
169
+ * own idle-edge ff, which the coordinator drives intentionally). Non-worktree base
170
+ * nodes are the sole continuous target.
171
+ */
172
+ export declare function runContinuousAutoFastForwardScan(components: DaemonComponents, mesh: any): Promise<void>;
157
173
  export declare function runIdleMaintenanceThenAssignQueue(components: DaemonComponents, args: {
158
174
  meshId: string;
159
175
  nodeId: string;
@@ -184,6 +184,25 @@ export interface RepoMeshAutoFastForwardPolicy {
184
184
  maxBehind?: number;
185
185
  /** Defaults to true. Require submodule status to be clean before automatic fast-forward. */
186
186
  requireCleanSubmodules?: boolean;
187
+ /**
188
+ * Opt-in: extend auto fast-forward to REMOTE owning-daemon nodes (not just the
189
+ * coordinator's local workspace). Defaults to **false** so the historical
190
+ * self-only behavior is preserved byte-for-byte. When true, the coordinator
191
+ * delegates the ff to the node's owning daemon via dispatchMeshCommand
192
+ * (fast_forward_mesh_node), which runs the same git safety gates on the machine
193
+ * that actually holds the workspace.
194
+ */
195
+ remoteNodes?: boolean;
196
+ /**
197
+ * When to run remote auto fast-forward detection. Defaults to **"idle"** — the
198
+ * historical behavior where the ff is only attempted on a node's idle edge
199
+ * (agent:ready / genuine generating_completed). "continuous" adds a periodic
200
+ * scan inside the reconcile tick so an online/clean/behind remote node is caught
201
+ * up even when it emits no fresh idle edge (e.g. a base node that has been idle
202
+ * for a while while upstream advanced). Only governs remote-node detection;
203
+ * local idle-edge ff is unchanged either way.
204
+ */
205
+ mode?: 'idle' | 'continuous';
187
206
  }
188
207
  export interface RepoMeshPolicy {
189
208
  requirePreTaskCheckpoint: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.521",
3
+ "version": "0.9.82-rc.522",
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",
@@ -47,8 +47,8 @@
47
47
  "author": "vilmire",
48
48
  "license": "AGPL-3.0-or-later",
49
49
  "dependencies": {
50
- "@adhdev/mesh-shared": "0.9.82-rc.521",
51
- "@adhdev/session-host-core": "0.9.82-rc.521",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.522",
51
+ "@adhdev/session-host-core": "0.9.82-rc.522",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -386,7 +386,10 @@ function shouldPreserveNativeIdentity(providerType: string, sessionId: string, m
386
386
  // (A2.3) v2 stamped identity is producer-owned and globally stable; trust it
387
387
  // unconditionally. Producers may omit _turnKey (the daemon recomputes it
388
388
  // from the current ordering), so do not require turnKey for v2 messages.
389
- if (providerUnitKey.startsWith('v2:') || providerUnitKey.startsWith('v2-pty:')) {
389
+ // (CHAT-FLAP-LONG-CONVO) v3 native identity is daemon-stamped and
390
+ // position-independent (see normalizeNativeHistoryMessages); trust it the
391
+ // same way so a re-read of an already-normalized message preserves its key.
392
+ if (providerUnitKey.startsWith('v2:') || providerUnitKey.startsWith('v2-pty:') || providerUnitKey.startsWith('v3:')) {
390
393
  return true;
391
394
  }
392
395
  // v1 identity always required both keys to be present.
@@ -474,8 +477,30 @@ function normalizeAndFilterNativeHistory(
474
477
  return maybeHideCoordinatorPromptMessage(h, providerType, sessionId, normalized);
475
478
  }
476
479
 
477
- function normalizeNativeHistoryMessages(providerType: string, messages: ChatMessage[], nativeSessionId?: string): ChatMessage[] {
480
+ export function normalizeNativeHistoryMessages(providerType: string, messages: ChatMessage[], nativeSessionId?: string): ChatMessage[] {
478
481
  let turnIndex = 0;
482
+ // (CHAT-FLAP-LONG-CONVO root fix) The providerUnitKey / bubbleId MUST be
483
+ // position-independent: native history is re-derived on every read_chat, so
484
+ // sending a user message grows the tail and shifts every array index by one.
485
+ // A key that embeds `index` therefore changes for every pre-existing bubble
486
+ // across a send → web-core getChatMessageStableKey (which correctly trusts
487
+ // bubbleId/providerUnitKey as identity) sees a new React key → unmount+remount
488
+ // flash. The invariant we enforce here: the same logical message keeps the
489
+ // same key as the tail grows; different messages get different keys.
490
+ //
491
+ // Position-independent identity = (role, kind, content-signature) plus, for
492
+ // messages whose (role, kind, content-signature) collides (e.g. an identical
493
+ // reply repeated in the transcript, or ts-less messages), a stable occurrence
494
+ // ordinal: the count of prior messages sharing the same signature. Appending
495
+ // to the tail never renumbers earlier occurrences, so the ordinal is stable.
496
+ // A provider-supplied native id (message.id) short-circuits the ordinal — it
497
+ // is already globally unique and position-independent.
498
+ const signatureOccurrences = new Map<string, number>();
499
+ // Anchor for the ts-less sequence fallback (see below): the last real
500
+ // timestamp seen, plus a running offset so consecutive ts-less messages stay
501
+ // strictly ordered after it.
502
+ let lastSequenceAnchor = 0;
503
+ let anchorOffset = 0;
479
504
  return normalizeChatMessages(messages).map((message, index) => {
480
505
  const role = typeof message.role === 'string' ? message.role.trim().toLowerCase() : '';
481
506
  const kind = typeof message.kind === 'string' && message.kind.trim() ? message.kind.trim() : (role === 'system' ? 'system' : 'standard');
@@ -483,21 +508,35 @@ function normalizeNativeHistoryMessages(providerType: string, messages: ChatMess
483
508
  const historySessionId = typeof (message as any).historySessionId === 'string'
484
509
  ? (message as any).historySessionId.trim()
485
510
  : '';
486
- const contentHash = hashSignatureParts([
511
+ // Content signature is intentionally position-independent: the ts fallback
512
+ // is '' (NOT `index`) so a message with no timestamp still hashes the same
513
+ // regardless of where it sits in the array. Hash the FULL flattened content
514
+ // (no slice) so distinct long messages that share a 12-char prefix do not
515
+ // collide.
516
+ const contentSignature = hashSignatureParts([
487
517
  providerType,
488
518
  historySessionId,
489
- String(message.receivedAt || message.timestamp || index),
519
+ String(message.receivedAt || message.timestamp || ''),
490
520
  role,
491
521
  kind,
492
522
  flattenContent(message.content),
493
- ]).slice(0, 12);
523
+ ]);
524
+ const contentHash = contentSignature.slice(0, 12);
494
525
  const nativeIdentitySessionId = historySessionId || (typeof nativeSessionId === 'string' ? nativeSessionId.trim() : '');
526
+ // Stable occurrence ordinal for signature collisions (0 for the first,
527
+ // 1 for the second identical-signature message, …). A provider-native id,
528
+ // when present, is preferred as the collision discriminator because it is
529
+ // globally unique and never renumbers.
530
+ const nativeMessageId = typeof message.id === 'string' && message.id.trim() ? message.id.trim() : '';
531
+ const occurrence = signatureOccurrences.get(contentSignature) ?? 0;
532
+ signatureOccurrences.set(contentSignature, occurrence + 1);
533
+ const collisionDiscriminator = nativeMessageId || `#${occurrence}`;
495
534
  const preserveNativeIdentity = shouldPreserveNativeIdentity(providerType, nativeIdentitySessionId, message);
496
535
  const existingProviderUnitKey = typeof message.providerUnitKey === 'string' ? message.providerUnitKey.trim() : '';
497
536
  const existingTurnKey = typeof message._turnKey === 'string' ? message._turnKey.trim() : '';
498
537
  const providerUnitKey = preserveNativeIdentity
499
538
  ? existingProviderUnitKey
500
- : `${providerType}:native:${nativeIdentitySessionId || 'workspace'}:${index}:${role || 'message'}:${kind}:${contentHash}`;
539
+ : `v3:${providerType}:native:${nativeIdentitySessionId || 'workspace'}:${role || 'message'}:${kind}:${contentHash}:${collisionDiscriminator}`;
501
540
  const meta = message.meta && typeof message.meta === 'object' ? message.meta as Record<string, unknown> : undefined;
502
541
  const isSystemSessionStart = role === 'system' || kind === 'system' || kind === 'session_start';
503
542
  const isActivity = role === 'assistant' && (kind === 'tool' || kind === 'terminal' || kind === 'thought');
@@ -510,9 +549,23 @@ function normalizeNativeHistoryMessages(providerType: string, messages: ChatMess
510
549
  ? (message as any).sequence
511
550
  : null;
512
551
  const tsCandidate = Number(message.receivedAt || message.timestamp || 0);
513
- const sequence = existingSequence !== null
514
- ? existingSequence
515
- : (tsCandidate > 0 ? tsCandidate : index);
552
+ // (CHAT-FLAP-LONG-CONVO) sequence is part of web-core's React-key
553
+ // composite, so a ts-less fallback of `index` would also shift the key
554
+ // across a send. Anchor a ts-less message to the last real timestamp
555
+ // seen (plus its occurrence offset within that anchor) so the value stays
556
+ // ordered AND stable under tail-append instead of tracking the raw
557
+ // array position.
558
+ let sequence: number;
559
+ if (existingSequence !== null) {
560
+ sequence = existingSequence;
561
+ } else if (tsCandidate > 0) {
562
+ sequence = tsCandidate;
563
+ lastSequenceAnchor = tsCandidate;
564
+ anchorOffset = 0;
565
+ } else {
566
+ anchorOffset += 1;
567
+ sequence = lastSequenceAnchor + anchorOffset;
568
+ }
516
569
  return {
517
570
  ...message,
518
571
  role: role === 'human' ? 'user' : (role || 'assistant'),
@@ -47,8 +47,47 @@ function localCoordinatorDaemonId(): string | undefined {
47
47
  const IDLE_AUTO_FAST_FORWARD_THROTTLE_MS = 30 * 60 * 1000;
48
48
  const idleAutoFastForwardLastAttempt = new Map<string, number>();
49
49
 
50
+ // Continuous-mode per-node scan cooldown (mode:"continuous" reconcile scan). The
51
+ // reconcile tick fires every ~4s; fetching every connected peer that often would
52
+ // hammer the network and each owning daemon's git. This cooldown throttles the
53
+ // per-node continuous scan to at most once per window (default 45s), independent of
54
+ // the 30-minute idle-edge throttle above (which stays the idle-edge cadence).
55
+ const CONTINUOUS_AUTO_FAST_FORWARD_SCAN_COOLDOWN_MS = 45 * 1000;
56
+ const continuousAutoFastForwardLastScan = new Map<string, number>();
57
+
58
+ // Workspace mutation lease. A git-mutating auto ff and the task-assignment path must
59
+ // not both touch the same workspace concurrently (an ff mid-checkout while a task is
60
+ // being dispatched can move HEAD out from under the worker). The lease is keyed by
61
+ // CANONICAL WORKSPACE — not nodeId — so two mesh nodes that reference the same
62
+ // on-disk workspace (e.g. a base node and a stale duplicate) cannot both ff it at
63
+ // once. Best-effort in-process advisory lock; a crash mid-ff simply leaves a stale
64
+ // entry that the finally-release clears on the same tick, so it never wedges.
65
+ const autoFastForwardWorkspaceLease = new Set<string>();
66
+
67
+ function acquireAutoFastForwardLease(workspace: string): boolean {
68
+ const key = normalizeMeshWorkspaceForCompare(workspace);
69
+ if (!key) return false;
70
+ if (autoFastForwardWorkspaceLease.has(key)) return false;
71
+ autoFastForwardWorkspaceLease.add(key);
72
+ return true;
73
+ }
74
+
75
+ function releaseAutoFastForwardLease(workspace: string): void {
76
+ const key = normalizeMeshWorkspaceForCompare(workspace);
77
+ if (key) autoFastForwardWorkspaceLease.delete(key);
78
+ }
79
+
80
+ /** Whether the assignment path currently holds an ff lease on this node's workspace —
81
+ * used to skip a task claim while an auto ff is mutating the same workspace. */
82
+ export function isWorkspaceAutoFastForwardInFlight(workspace: string | undefined): boolean {
83
+ const key = normalizeMeshWorkspaceForCompare(workspace || '');
84
+ return !!key && autoFastForwardWorkspaceLease.has(key);
85
+ }
86
+
50
87
  export function __resetIdleAutoFastForwardForTests(): void {
51
88
  idleAutoFastForwardLastAttempt.clear();
89
+ continuousAutoFastForwardLastScan.clear();
90
+ autoFastForwardWorkspaceLease.clear();
52
91
  }
53
92
 
54
93
  export function getMeshWithCache(components: DaemonComponents, meshId: string): any | undefined {
@@ -386,6 +425,18 @@ export function tryAssignQueueTask(
386
425
  // resolve, mirroring the remote idle-session path below (:1341).
387
426
  const node = mesh?.nodes.find((n: any) => meshNodeIdMatches(n, nodeId));
388
427
 
428
+ // AUTO-FF LEASE: an auto fast-forward may be mutating this node's workspace right
429
+ // now (git merge --ff-only can move HEAD). Dispatching a task into it mid-checkout
430
+ // would run the worker against an inconsistent tree. Skip the claim while the lease
431
+ // is held — the task stays pending (return false without touching its status) and
432
+ // re-fires on the next drain tick, by which time the short ff has released the lease.
433
+ // Keyed by canonical workspace so a node sharing a workspace with the one being ff'd
434
+ // is also gated. No-op unless an auto ff is actually in flight (default: never).
435
+ if (isWorkspaceAutoFastForwardInFlight(readNonEmptyString(node?.workspace))) {
436
+ LOG.info('MeshQueue', `Deferring queue claim for node ${nodeId} (${sessionId}): an auto fast-forward is mutating its workspace — task left pending, claim re-fires next tick`);
437
+ return false;
438
+ }
439
+
389
440
  // WORKTREE-CLAIM-GATE-BYPASS: the SINGLE claim-time gate for the worktree-bootstrap defer.
390
441
  // tryAssignQueueTask is the one funnel every claim path flows through — the event-driven
391
442
  // agent:ready drain, the triggerMeshQueue idle-session drain (local + remote), the
@@ -1006,7 +1057,7 @@ function isDirtyNode(node: any): boolean {
1006
1057
  return node?.health === 'dirty' || node?.git?.dirty === true;
1007
1058
  }
1008
1059
 
1009
- function resolveAutoFastForwardPolicy(mesh: any): { enabled: boolean; maxBehind?: number; requireCleanSubmodules: boolean } {
1060
+ function resolveAutoFastForwardPolicy(mesh: any): { enabled: boolean; maxBehind?: number; requireCleanSubmodules: boolean; remoteNodes: boolean; mode: 'idle' | 'continuous' } {
1010
1061
  const record = mesh?.policy?.autoFastForward && typeof mesh.policy.autoFastForward === 'object' && !Array.isArray(mesh.policy.autoFastForward)
1011
1062
  ? mesh.policy.autoFastForward as Record<string, unknown>
1012
1063
  : {};
@@ -1015,6 +1066,11 @@ function resolveAutoFastForwardPolicy(mesh: any): { enabled: boolean; maxBehind?
1015
1066
  enabled: record.enabled !== false,
1016
1067
  ...(Number.isFinite(maxBehind) && maxBehind >= 0 ? { maxBehind: Math.floor(maxBehind) } : {}),
1017
1068
  requireCleanSubmodules: record.requireCleanSubmodules !== false,
1069
+ // Strict opt-in: absent/false → self-only (historical behavior). Only an
1070
+ // explicit `true` extends auto ff to remote owning-daemon nodes.
1071
+ remoteNodes: record.remoteNodes === true,
1072
+ // Absent/anything-but-continuous → 'idle' (historical idle-edge-only detection).
1073
+ mode: record.mode === 'continuous' ? 'continuous' : 'idle',
1018
1074
  };
1019
1075
  }
1020
1076
 
@@ -2448,61 +2504,245 @@ export async function triggerMeshQueue(components: DaemonComponents, meshId: str
2448
2504
  };
2449
2505
  }
2450
2506
 
2451
- export async function maybeAutoFastForwardIdleNode(components: DaemonComponents, args: {
2507
+ /** Passed dry-run result satisfies the policy gates (maxBehind + clean submodules).
2508
+ * Shared by the local execute path and the remote preflight re-verification so both
2509
+ * apply exactly the same policy gate. */
2510
+ function dryRunSatisfiesAutoFastForwardPolicy(
2511
+ dryRun: { code?: string; allowed?: boolean; current?: any } | null | undefined,
2512
+ policy: { maxBehind?: number; requireCleanSubmodules: boolean },
2513
+ ): boolean {
2514
+ if (!dryRun || dryRun.code !== 'fast_forward_available' || dryRun.allowed !== true) return false;
2515
+ const behind = Number(dryRun.current?.behind);
2516
+ // Behind must be a real, positive count within the policy cap. ahead must be 0 for
2517
+ // an ff-only merge — fast_forward_available already encodes that (ahead=0,behind>0),
2518
+ // but re-assert behind>0 defensively for the continuous/remote path.
2519
+ if (!Number.isFinite(behind) || behind <= 0) return false;
2520
+ if (policy.maxBehind !== undefined && behind > policy.maxBehind) return false;
2521
+ if (policy.requireCleanSubmodules) {
2522
+ const submodules = Array.isArray(dryRun.current?.submodules) ? dryRun.current.submodules : [];
2523
+ if (submodules.some((submodule: any) => submodule?.dirty || submodule?.outOfSync || submodule?.error)) return false;
2524
+ }
2525
+ return true;
2526
+ }
2527
+
2528
+ function readNodeSubmoduleIgnorePaths(node: any): string[] | undefined {
2529
+ return Array.isArray(node?.policy?.submoduleIgnorePaths)
2530
+ ? node.policy.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string')
2531
+ : undefined;
2532
+ }
2533
+
2534
+ /** Whether a node is eligible to be an auto-ff target this instant: connected (for a
2535
+ * remote node), not disabled/removed/readOnly, not a worktree still bootstrapping,
2536
+ * and holding no active mesh work (assignment / direct dispatch / busy session). The
2537
+ * worktree exclusion for continuous mode is applied by the caller (idle-edge ff still
2538
+ * runs for worktree nodes on their own idle edge). */
2539
+ function nodeIsAutoFastForwardEligible(components: DaemonComponents, meshId: string, nodeId: string, node: any, currentSessionId?: string): boolean {
2540
+ if (!node) return false;
2541
+ if (node.status === 'disabled' || node.status === 'removed') return false;
2542
+ if (node.readOnly === true || node.policy?.readOnly === true) return false;
2543
+ if (isWorktreeBootstrapStaleRunning(node)) return false;
2544
+ if (node.worktreeBootstrap?.status === 'running') return false;
2545
+ if (nodeHasActiveMeshWork(components, meshId, nodeId, currentSessionId)) return false;
2546
+ return true;
2547
+ }
2548
+
2549
+ /** True when the node is connected (has an open peer/DataChannel) per the same
2550
+ * authoritative peer-status getter the remote-event pull uses. When the getter is
2551
+ * UNWIRED (standalone — no remote nodes at all) this returns false so the continuous
2552
+ * scan never claims to reach a remote node it cannot dispatch to. */
2553
+ function remoteNodeIsConnected(components: DaemonComponents, node: any): boolean {
2554
+ const daemonId = readMeshNodeDaemonId(node ?? {});
2555
+ if (!daemonId) return false;
2556
+ const getPeerStatus = components.getMeshPeerConnectionStatus;
2557
+ if (getPeerStatus) {
2558
+ const snapshot = getPeerStatus(daemonId);
2559
+ return !!snapshot && String(snapshot.state) === 'connected';
2560
+ }
2561
+ // No peer-status getter: fall back to the node's own reported connection state.
2562
+ return readNonEmptyString(node?.connection?.state).toLowerCase() === 'connected';
2563
+ }
2564
+
2565
+ /**
2566
+ * Execute an auto fast-forward for a REMOTE owning-daemon node by delegating to that
2567
+ * daemon via dispatchMeshCommand('fast_forward_mesh_node'). The owning daemon re-runs
2568
+ * the FULL git safety gate on the machine that actually holds the workspace, so the
2569
+ * coordinator's earlier dry-run is only an eligibility hint — the fresh preflight on
2570
+ * the owning daemon (STATUS_OPTIONS forceFresh) is the TOCTOU-safe decision point.
2571
+ *
2572
+ * We do NOT execute blindly: we first request a fresh remote dry-run, re-verify it
2573
+ * against the policy gate, and only then send execute:true — closing the window
2574
+ * between the coordinator's scan and the actual mutation. The lease is held across
2575
+ * both remote calls so the assignment path cannot dispatch a task onto this workspace
2576
+ * mid-ff.
2577
+ */
2578
+ async function delegateRemoteAutoFastForward(components: DaemonComponents, args: {
2452
2579
  meshId: string;
2453
2580
  nodeId: string;
2454
- sessionId?: string;
2455
- providerType?: string;
2581
+ node: any;
2582
+ daemonId: string;
2583
+ workspace: string;
2584
+ policy: { maxBehind?: number; requireCleanSubmodules: boolean };
2585
+ trigger: string;
2456
2586
  }): Promise<void> {
2587
+ const dispatchMeshCommand = components.dispatchMeshCommand;
2588
+ if (!dispatchMeshCommand) return;
2589
+ if (!acquireAutoFastForwardLease(args.workspace)) return; // another ff already mutating this workspace
2590
+ const submoduleIgnorePaths = readNodeSubmoduleIgnorePaths(args.node);
2457
2591
  const mesh = getMeshWithCache(components, args.meshId);
2458
- const node = mesh?.nodes?.find((candidate: any) => meshNodeIdMatches(candidate, args.nodeId));
2459
- const workspace = readNonEmptyString(node?.workspace);
2460
- if (!workspace) return;
2461
- if (!existsSync(workspace)) return;
2462
-
2463
- const policy = resolveAutoFastForwardPolicy(mesh);
2464
- if (!policy.enabled) return;
2465
- if (nodeHasActiveMeshWork(components, args.meshId, args.nodeId, args.sessionId)) return;
2466
-
2467
- const throttleKey = `${args.meshId}:${args.nodeId}`;
2468
- const now = Date.now();
2469
- const lastAttempt = idleAutoFastForwardLastAttempt.get(throttleKey) || 0;
2470
- if (now - lastAttempt < IDLE_AUTO_FAST_FORWARD_THROTTLE_MS) return;
2471
- idleAutoFastForwardLastAttempt.set(throttleKey, now);
2592
+ const baseArgs: Record<string, unknown> = {
2593
+ meshId: args.meshId,
2594
+ nodeId: args.nodeId,
2595
+ workspace: args.workspace,
2596
+ inlineMesh: mesh,
2597
+ ...(submoduleIgnorePaths ? { submoduleIgnorePaths } : {}),
2598
+ trigger: args.trigger,
2599
+ // Preserve the pre-fix self-only semantics for submodules: the local idle path
2600
+ // never updated submodules (updateSubmodules:false), so the remote path matches.
2601
+ updateSubmodules: false,
2602
+ };
2603
+ try {
2604
+ // Fresh remote dry-run (owning daemon re-reads live git state) — TOCTOU re-check.
2605
+ const remoteDry = await dispatchMeshCommand(args.daemonId, 'fast_forward_mesh_node', {
2606
+ ...baseArgs,
2607
+ execute: false,
2608
+ dryRun: true,
2609
+ }) as { code?: string; allowed?: boolean; current?: any } | null;
2610
+ if (!dryRunSatisfiesAutoFastForwardPolicy(remoteDry, args.policy)) return;
2611
+ // Re-check eligibility right before mutating: a task may have been dispatched to
2612
+ // this node between the scan and now (busy → skip, not an error).
2613
+ if (nodeHasActiveMeshWork(components, args.meshId, args.nodeId)) return;
2614
+ const executed = await dispatchMeshCommand(args.daemonId, 'fast_forward_mesh_node', {
2615
+ ...baseArgs,
2616
+ execute: true,
2617
+ dryRun: false,
2618
+ }) as { executed?: boolean; postStatus?: any; code?: string } | null;
2619
+ if (executed?.executed === true) {
2620
+ LOG.info('MeshFastForward', `Remote auto fast-forward executed for node ${args.nodeId} (daemon ${String(args.daemonId).slice(0, 12)}, trigger ${args.trigger})`);
2621
+ }
2622
+ } catch (e: any) {
2623
+ LOG.warn('MeshFastForward', `Remote auto fast-forward delegation failed for ${args.nodeId}: ${e?.message || e}`);
2624
+ } finally {
2625
+ releaseAutoFastForwardLease(args.workspace);
2626
+ }
2627
+ }
2472
2628
 
2473
- const submoduleIgnorePaths = Array.isArray(node?.policy?.submoduleIgnorePaths)
2474
- ? node.policy.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string')
2475
- : undefined;
2629
+ /** Execute an auto fast-forward for a LOCAL node (the coordinator's own workspace)
2630
+ * the historical self-only path, now lease-guarded so a continuous-mode local scan
2631
+ * and the assignment path cannot race the same workspace. */
2632
+ async function executeLocalAutoFastForward(args: {
2633
+ meshId: string;
2634
+ nodeId: string;
2635
+ node: any;
2636
+ workspace: string;
2637
+ policy: { maxBehind?: number; requireCleanSubmodules: boolean };
2638
+ trigger: string;
2639
+ }): Promise<void> {
2640
+ if (!acquireAutoFastForwardLease(args.workspace)) return;
2641
+ const submoduleIgnorePaths = readNodeSubmoduleIgnorePaths(args.node);
2476
2642
  try {
2477
2643
  const dryRun = await fastForwardMeshNode({
2478
2644
  meshId: args.meshId,
2479
2645
  nodeId: args.nodeId,
2480
- workspace,
2646
+ workspace: args.workspace,
2481
2647
  execute: false,
2482
2648
  dryRun: true,
2483
2649
  updateSubmodules: false,
2484
2650
  submoduleIgnorePaths,
2485
- trigger: 'idle_auto',
2651
+ trigger: args.trigger,
2486
2652
  });
2487
- if (!dryRun || dryRun.code !== 'fast_forward_available' || dryRun.allowed !== true) return;
2488
- const behind = Number(dryRun.current?.behind);
2489
- if (policy.maxBehind !== undefined && Number.isFinite(behind) && behind > policy.maxBehind) return;
2490
- if (policy.requireCleanSubmodules) {
2491
- const submodules = Array.isArray(dryRun.current?.submodules) ? dryRun.current.submodules : [];
2492
- if (submodules.some((submodule: any) => submodule?.dirty || submodule?.outOfSync || submodule?.error)) return;
2493
- }
2653
+ if (!dryRunSatisfiesAutoFastForwardPolicy(dryRun, args.policy)) return;
2494
2654
  await fastForwardMeshNode({
2495
2655
  meshId: args.meshId,
2496
2656
  nodeId: args.nodeId,
2497
- workspace,
2657
+ workspace: args.workspace,
2498
2658
  execute: true,
2499
2659
  dryRun: false,
2500
2660
  updateSubmodules: false,
2501
2661
  submoduleIgnorePaths,
2502
- trigger: 'idle_auto',
2662
+ trigger: args.trigger,
2503
2663
  });
2504
2664
  } catch (e: any) {
2505
2665
  LOG.warn('MeshFastForward', `Idle auto fast-forward check failed for ${args.nodeId}: ${e?.message || e}`);
2666
+ } finally {
2667
+ releaseAutoFastForwardLease(args.workspace);
2668
+ }
2669
+ }
2670
+
2671
+ export async function maybeAutoFastForwardIdleNode(components: DaemonComponents, args: {
2672
+ meshId: string;
2673
+ nodeId: string;
2674
+ sessionId?: string;
2675
+ providerType?: string;
2676
+ }): Promise<void> {
2677
+ const mesh = getMeshWithCache(components, args.meshId);
2678
+ const node = mesh?.nodes?.find((candidate: any) => meshNodeIdMatches(candidate, args.nodeId));
2679
+ const workspace = readNonEmptyString(node?.workspace);
2680
+ if (!workspace) return;
2681
+
2682
+ const policy = resolveAutoFastForwardPolicy(mesh);
2683
+ if (!policy.enabled) return;
2684
+ if (nodeHasActiveMeshWork(components, args.meshId, args.nodeId, args.sessionId)) return;
2685
+
2686
+ const throttleKey = `${args.meshId}:${args.nodeId}`;
2687
+ const now = Date.now();
2688
+ const lastAttempt = idleAutoFastForwardLastAttempt.get(throttleKey) || 0;
2689
+ if (now - lastAttempt < IDLE_AUTO_FAST_FORWARD_THROTTLE_MS) return;
2690
+ idleAutoFastForwardLastAttempt.set(throttleKey, now);
2691
+
2692
+ // Local node (coordinator's own workspace): the historical self-only path. Gate on
2693
+ // existsSync — a local workspace must be on THIS disk. Unchanged behavior.
2694
+ if (isLocalAutoLaunchNode(node)) {
2695
+ if (!existsSync(workspace)) return;
2696
+ await executeLocalAutoFastForward({ meshId: args.meshId, nodeId: args.nodeId, node, workspace, policy, trigger: 'idle_auto' });
2697
+ return;
2698
+ }
2699
+
2700
+ // Remote node: strictly opt-in. Without remoteNodes:true the historical behavior
2701
+ // (self-only) is preserved — a remote idle edge simply does nothing here.
2702
+ if (!policy.remoteNodes) return;
2703
+ const daemonId = readMeshNodeDaemonId(node ?? {});
2704
+ if (!daemonId || !components.dispatchMeshCommand) return;
2705
+ if (!remoteNodeIsConnected(components, node)) return;
2706
+ await delegateRemoteAutoFastForward(components, { meshId: args.meshId, nodeId: args.nodeId, node, daemonId, workspace, policy, trigger: 'idle_auto' });
2707
+ }
2708
+
2709
+ /**
2710
+ * Continuous-mode remote auto fast-forward scan (mode:"continuous" only). Called by
2711
+ * the reconcile tick BEFORE queue claim. Scans every connected, eligible, non-worktree
2712
+ * remote node of every mesh this daemon hosts and delegates an ff to its owning daemon
2713
+ * when it is online/clean/behind within policy. Per-node cooldown + the workspace lease
2714
+ * keep the ~4s reconcile cadence from hammering peers or racing an assignment.
2715
+ *
2716
+ * Ephemeral worktree nodes are DELIBERATELY excluded from the continuous catch-up: a
2717
+ * Refinery worktree branch must not be silently advanced by a background scan (only its
2718
+ * own idle-edge ff, which the coordinator drives intentionally). Non-worktree base
2719
+ * nodes are the sole continuous target.
2720
+ */
2721
+ export async function runContinuousAutoFastForwardScan(components: DaemonComponents, mesh: any): Promise<void> {
2722
+ if (!components.dispatchMeshCommand) return; // standalone has no remote nodes to scan
2723
+ const policy = resolveAutoFastForwardPolicy(mesh);
2724
+ if (!policy.enabled || !policy.remoteNodes || policy.mode !== 'continuous') return;
2725
+ const meshId = readNonEmptyString(mesh?.id);
2726
+ if (!meshId) return;
2727
+ const nodes = Array.isArray(mesh?.nodes) ? mesh.nodes : [];
2728
+ const now = Date.now();
2729
+ for (const node of nodes) {
2730
+ const nodeId = normalizeMeshNodeId(node);
2731
+ if (!nodeId) continue;
2732
+ // Continuous targets non-worktree remote base nodes only.
2733
+ if (node?.isLocalWorktree === true) continue;
2734
+ if (isLocalAutoLaunchNode(node)) continue; // local is covered by the idle-edge path
2735
+ const workspace = readNonEmptyString(node?.workspace);
2736
+ if (!workspace) continue;
2737
+ const daemonId = readMeshNodeDaemonId(node ?? {});
2738
+ if (!daemonId) continue;
2739
+ if (!nodeIsAutoFastForwardEligible(components, meshId, nodeId, node)) continue;
2740
+ if (!remoteNodeIsConnected(components, node)) continue;
2741
+ const cooldownKey = `${meshId}:${nodeId}`;
2742
+ const lastScan = continuousAutoFastForwardLastScan.get(cooldownKey) || 0;
2743
+ if (now - lastScan < CONTINUOUS_AUTO_FAST_FORWARD_SCAN_COOLDOWN_MS) continue;
2744
+ continuousAutoFastForwardLastScan.set(cooldownKey, now);
2745
+ await delegateRemoteAutoFastForward(components, { meshId, nodeId, node, daemonId, workspace, policy, trigger: 'reconcile_auto' });
2506
2746
  }
2507
2747
  }
2508
2748
 
@@ -61,7 +61,7 @@ import { readNonEmptyString, readMeshCompletionSummary, buildMeshSystemMessage }
61
61
  import { traceMeshEventStage, traceMeshEventDrop } from './mesh-event-trace.js';
62
62
  import { expandDaemonIdForms, daemonIdsEquivalent, sessionIdsEquivalent, meshNodeIdMatches, withStatusProbeMarker } from '@adhdev/mesh-shared';
63
63
  import { getQueue, reclaimStrandedAssignedTask, updateTaskStatus } from './mesh-work-queue.js';
64
- import { resolveSessionBusyVerdict } from './mesh-queue-assignment.js';
64
+ import { resolveSessionBusyVerdict, runContinuousAutoFastForwardScan } from './mesh-queue-assignment.js';
65
65
  import { readLedgerEntries } from './mesh-ledger.js';
66
66
  import type { MeshLedgerEntry } from './mesh-ledger.js';
67
67
  import { findTerminalLedgerEvidenceForTask } from './mesh-events-stale.js';
@@ -1082,6 +1082,26 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
1082
1082
  }
1083
1083
  }
1084
1084
 
1085
+ // ── PHASE 2.7: continuous remote auto fast-forward (opt-in, default OFF) ────
1086
+ // mode:"continuous" + remoteNodes:true only. Catch up an online/clean/behind
1087
+ // REMOTE base node that emits no fresh idle edge (e.g. a long-idle base node while
1088
+ // upstream advanced). Runs BEFORE PHASE 3's queue claim so a node it advances is
1089
+ // caught up before any new task is dispatched onto it. Cloud-only (dispatchMeshCommand);
1090
+ // a per-node cooldown + workspace lease inside the scan keep the 4s cadence from
1091
+ // hammering peers. No-op for every mesh that has not opted into continuous mode, so
1092
+ // the default (idle-edge only) path is byte-for-byte unchanged.
1093
+ if (dispatchMeshCommand) {
1094
+ for (const mesh of listMeshes()) {
1095
+ const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
1096
+ if (!daemonHostsMesh(mesh, selfIds)) continue;
1097
+ try {
1098
+ await runContinuousAutoFastForwardScan(components, mesh);
1099
+ } catch (e: any) {
1100
+ LOG.warn('MeshReconcile', `Continuous auto fast-forward scan failed for mesh ${mesh.id}: ${e?.message || e}`);
1101
+ }
1102
+ }
1103
+ }
1104
+
1085
1105
  // ── PHASE 3: recover pending queue claims for newly-idle sessions ──────────
1086
1106
  // The event-driven claim paths (agent:ready / agent:generating_completed in
1087
1107
  // mesh-events-coordinator) re-claim the queue the moment a session goes idle,
@@ -132,13 +132,26 @@ export function normalizeMessageIdentity<T extends { role?: string; kind?: strin
132
132
  }> {
133
133
  const list = Array.isArray(messages) ? messages : []
134
134
  let turnIndex = -1
135
+ // (CHAT-FLAP-LONG-CONVO root fix) The PTY-parsed providerUnitKey must be
136
+ // position-independent for the same reason as the native-history path: the
137
+ // transcript is re-parsed on every read, so a `index`-embedded key changes
138
+ // for every pre-existing bubble whenever the tail grows, causing web-core to
139
+ // remount the bubble (a visible flash). Identity = (role, kind, content-hash)
140
+ // plus a stable occurrence ordinal that disambiguates identical repeated
141
+ // lines without renumbering earlier occurrences when the tail grows.
142
+ const signatureOccurrences = new Map<string, number>()
135
143
  return list.map((message, index) => {
136
144
  const role = message?.role || 'assistant'
137
145
  const kind = message?.kind || 'standard'
138
146
  const content = typeof message?.content === 'string' ? message.content : ''
139
147
  if (role === 'user' || turnIndex < 0) turnIndex += 1
140
- const seed = [role, kind, '', index, content].join('\n')
141
- const providerUnitKey = `v2-pty:${role}:${kind}:${index}:${stableHash(seed)}`
148
+ // Seed is position-independent: no array index. The occurrence ordinal is
149
+ // appended separately so identical repeated lines still get distinct keys.
150
+ const seed = [role, kind, '', content].join('\n')
151
+ const contentHash = stableHash(seed)
152
+ const occurrence = signatureOccurrences.get(contentHash) ?? 0
153
+ signatureOccurrences.set(contentHash, occurrence + 1)
154
+ const providerUnitKey = `v2-pty:${role}:${kind}:${contentHash}:#${occurrence}`
142
155
  const bubbleId = `bubble:${providerUnitKey}`
143
156
  const turnKey = `turn:${turnIndex}`
144
157
  const isStreamingTail = status === 'generating' && role === 'assistant' && index === list.length - 1