@adhdev/daemon-core 0.9.82-rc.521 → 0.9.82-rc.523
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/dist/commands/chat-commands-read.d.ts +2 -0
- package/dist/index.js +210 -40
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +210 -40
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-queue-assignment.d.ts +16 -0
- package/dist/repo-mesh-types.d.ts +19 -0
- package/package.json +3 -3
- package/src/commands/chat-commands-read.ts +62 -9
- package/src/mesh/mesh-queue-assignment.ts +272 -32
- package/src/mesh/mesh-reconcile-loop.ts +21 -1
- package/src/providers/sdk/v1/builders/cli/parse-session.ts +15 -2
- package/src/repo-mesh-types.ts +25 -0
|
@@ -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.
|
|
3
|
+
"version": "0.9.82-rc.523",
|
|
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.
|
|
51
|
-
"@adhdev/session-host-core": "0.9.82-rc.
|
|
50
|
+
"@adhdev/mesh-shared": "0.9.82-rc.523",
|
|
51
|
+
"@adhdev/session-host-core": "0.9.82-rc.523",
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
519
|
+
String(message.receivedAt || message.timestamp || ''),
|
|
490
520
|
role,
|
|
491
521
|
kind,
|
|
492
522
|
flattenContent(message.content),
|
|
493
|
-
])
|
|
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
|
-
:
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2455
|
-
|
|
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
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
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
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
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:
|
|
2651
|
+
trigger: args.trigger,
|
|
2486
2652
|
});
|
|
2487
|
-
if (!dryRun
|
|
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:
|
|
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
|
-
|
|
141
|
-
|
|
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
|