@adhdev/daemon-core 0.9.82-rc.172 → 0.9.82-rc.173

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.
@@ -27,7 +27,7 @@ export declare class BeadsDB {
27
27
  updateQueueEntry(entry: MeshWorkQueueEntry): void;
28
28
  findQueueEntryById(meshId: string, id: string): MeshWorkQueueEntry | null;
29
29
  hasActiveAssignment(meshId: string, sessionId: string, nodeId: string): boolean;
30
- claimNextQueueTask(meshId: string, nodeId: string, sessionId: string): MeshWorkQueueEntry | null;
30
+ claimNextQueueTask(meshId: string, nodeId: string, sessionId: string, capabilityTags?: string[]): MeshWorkQueueEntry | null;
31
31
  getQueueStatsByStatus(meshId: string): {
32
32
  status: string;
33
33
  count: number;
@@ -1,4 +1,5 @@
1
1
  import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
2
+ export declare function __resetIdleAutoFastForwardForTests(): void;
2
3
  export interface PendingMeshCoordinatorEvent {
3
4
  event: string;
4
5
  meshId: string;
@@ -9,6 +9,7 @@ export interface MeshFastForwardNodeArgs {
9
9
  updateSubmodules?: boolean;
10
10
  submoduleIgnorePaths?: string[];
11
11
  timeoutMs?: number;
12
+ trigger?: 'manual' | 'idle_auto' | string;
12
13
  }
13
14
  export interface MeshFastForwardPlannedStep {
14
15
  operation: 'refresh_upstream' | 'verify_clean_worktree' | 'verify_fast_forward' | 'merge_ff_only' | 'submodule_update' | 'verify_post_status';
@@ -35,5 +36,6 @@ export interface MeshFastForwardResult {
35
36
  finalBranchConvergenceState?: Record<string, unknown>;
36
37
  operationError?: string;
37
38
  ledgerError?: string;
39
+ trigger?: string;
38
40
  }
39
41
  export declare function fastForwardMeshNode(args: MeshFastForwardNodeArgs): Promise<MeshFastForwardResult>;
@@ -25,6 +25,8 @@ export interface MeshWorkQueueEntry {
25
25
  targetNodeId?: string;
26
26
  /** If specified, only this runtime session can claim the task */
27
27
  targetSessionId?: string;
28
+ /** If specified, a node must expose all tags before it can claim the task. */
29
+ requiredTags?: string[];
28
30
  /** The node that actually claimed and is executing the task */
29
31
  assignedNodeId?: string;
30
32
  /** The session currently executing the task */
@@ -53,6 +55,12 @@ export interface MeshWorkQueueEntry {
53
55
  export interface MeshQueueMutationOptions {
54
56
  ownerRole?: RepoMeshDaemonRole;
55
57
  }
58
+ export declare function normalizeMeshCapabilityTags(value: unknown): string[];
59
+ export declare function buildMeshNodeCapabilityTags(node: {
60
+ capabilities?: unknown;
61
+ policy?: unknown;
62
+ } | undefined, providerType?: string): string[];
63
+ export declare function nodeSatisfiesRequiredTags(requiredTags: unknown, capabilityTags: unknown): boolean;
56
64
  /**
57
65
  * Add a new task to the mesh queue.
58
66
  */
@@ -60,6 +68,7 @@ export declare function enqueueTask(meshId: string, message: string, opts?: {
60
68
  targetNodeId?: string;
61
69
  targetSessionId?: string;
62
70
  taskMode?: MeshTaskMode | string;
71
+ requiredTags?: string[];
63
72
  } & MeshQueueMutationOptions): MeshWorkQueueEntry;
64
73
  /**
65
74
  * Get all tasks in the queue, optionally filtered by status.
@@ -71,7 +80,7 @@ export declare function getMeshQueueRevision(meshId: string): string;
71
80
  /**
72
81
  * Find the next pending task that this node is allowed to claim, and mark it as assigned.
73
82
  */
74
- export declare function claimNextTask(meshId: string, nodeId: string, sessionId: string): MeshWorkQueueEntry | null;
83
+ export declare function claimNextTask(meshId: string, nodeId: string, sessionId: string, capabilityTags?: string[]): MeshWorkQueueEntry | null;
75
84
  /**
76
85
  * Update the status of a specific task.
77
86
  * Used when a session completes, fails, or stalls.
@@ -253,6 +253,8 @@ export interface LocalMeshNodeEntry {
253
253
  daemonId?: string;
254
254
  /** Machine registry ID that owns this workspace, when known. */
255
255
  machineId?: string;
256
+ /** Operator-defined capability tags used by mesh queue matching. */
257
+ capabilities?: string[];
256
258
  userOverrides: Partial<RepoMeshNodeCapabilities>;
257
259
  policy: RepoMeshNodePolicy;
258
260
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.172",
3
+ "version": "0.9.82-rc.173",
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",
@@ -42,6 +42,20 @@ function loadMeshConfig(): LocalMeshConfig {
42
42
  }
43
43
  }
44
44
 
45
+ function normalizeCapabilityTags(value: unknown): string[] | undefined {
46
+ if (!Array.isArray(value)) return undefined;
47
+ const seen = new Set<string>();
48
+ const tags = value
49
+ .map(tag => typeof tag === 'string' ? tag.trim() : '')
50
+ .filter(Boolean)
51
+ .filter(tag => {
52
+ if (seen.has(tag)) return false;
53
+ seen.add(tag);
54
+ return true;
55
+ });
56
+ return tags.length ? tags : undefined;
57
+ }
58
+
45
59
  function saveMeshConfig(config: LocalMeshConfig): void {
46
60
  const path = getMeshConfigPath();
47
61
  writeFileSync(path, JSON.stringify(config, null, 2), { encoding: 'utf-8', mode: 0o600 });
@@ -423,6 +437,7 @@ export interface AddNodeOptions {
423
437
  repoRoot?: string;
424
438
  daemonId?: string;
425
439
  machineId?: string;
440
+ capabilities?: string[];
426
441
  userOverrides?: Partial<RepoMeshNodeCapabilities>;
427
442
  policy?: RepoMeshNodePolicy;
428
443
  isLocalWorktree?: boolean;
@@ -452,6 +467,7 @@ export function addNode(meshId: string, opts: AddNodeOptions): LocalMeshNodeEntr
452
467
  repoRoot: opts.repoRoot,
453
468
  daemonId: opts.daemonId,
454
469
  machineId: opts.machineId,
470
+ capabilities: normalizeCapabilityTags(opts.capabilities),
455
471
  userOverrides: opts.userOverrides || {},
456
472
  policy: opts.policy || {},
457
473
  isLocalWorktree: opts.isLocalWorktree,
package/src/index.ts CHANGED
@@ -201,8 +201,6 @@ export type {
201
201
  RepoMeshRefineConfig,
202
202
  RepoMeshRefineValidationCommandConfig,
203
203
  } from './mesh/refine-config.js';
204
- export { syncMeshes } from './mesh/mesh-sync.js';
205
- export type { MeshSyncTransport, MeshSyncResult, RemoteMeshRecord } from './mesh/mesh-sync.js';
206
204
 
207
205
  // ── Mesh Task Ledger ──
208
206
  export { appendLedgerEntry, appendRemoteLedgerEntries, buildTaskCompletionEvidence, normalizeMeshWorkerResult, readLedgerEntries, readLedgerSlice, getLedgerSummary, getLedgerDir, getSessionRecoveryContext, MAX_LEDGER_SLICE_LIMIT } from './mesh/mesh-ledger.js';
@@ -213,7 +211,7 @@ export { buildMeshLedgerReconciliationEvidence, buildMeshLedgerReplicaEvidence }
213
211
  export type { MeshLedgerReconciliationEvidence, MeshLedgerReplicaEvidence, MeshLedgerReplicaStatus } from './mesh/mesh-ledger-reconciliation.js';
214
212
 
215
213
  // ── Mesh Work Queue (GUPP) ──
216
- export { enqueueTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest, insertDirectDispatch, getActiveDirectDispatches, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, markStaleDirectDispatches } from './mesh/mesh-work-queue.js';
214
+ export { enqueueTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest, buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, normalizeMeshCapabilityTags, insertDirectDispatch, getActiveDirectDispatches, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, markStaleDirectDispatches } from './mesh/mesh-work-queue.js';
217
215
  export type { MeshWorkQueueEntry, MeshTaskStatus, MeshTaskMode, MeshWorkQueueStats, MeshQueueMutationOptions, MeshTaskModeValidationResult, DirectDispatchRecord } from './mesh/mesh-work-queue.js';
218
216
  export { buildCompactStaleDirectWorkSummary, buildMeshActiveWork, buildMeshActiveWorkSummary } from './mesh/mesh-active-work.js';
219
217
  export type { MeshActiveWorkRecord, MeshActiveWorkStatus, MeshActiveWorkSummary, MeshActiveWorkSource, MeshStaleDirectWorkSummary } from './mesh/mesh-active-work.js';
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, statSync } from 'fs';
2
2
  import { dirname, join } from 'path';
3
3
  import { createRequire } from 'module';
4
4
  import { getLedgerDir } from './mesh-ledger.js';
5
+ import { nodeSatisfiesRequiredTags } from './mesh-work-queue.js';
5
6
  import type { MeshTaskStatus, MeshWorkQueueEntry } from './mesh-work-queue.js';
6
7
  import type BetterSqlite3 from 'better-sqlite3';
7
8
  import type { Database as DatabaseHandle } from 'better-sqlite3';
@@ -280,34 +281,40 @@ export class BeadsDB {
280
281
  }
281
282
 
282
283
  // O(1) claim: transaction ensures only one session claims a pending task
283
- claimNextQueueTask(meshId: string, nodeId: string, sessionId: string): MeshWorkQueueEntry | null {
284
+ claimNextQueueTask(meshId: string, nodeId: string, sessionId: string, capabilityTags: string[] = []): MeshWorkQueueEntry | null {
284
285
  return this.transaction(() => {
285
286
  this.ensureLegacyQueueMigrated(meshId);
286
287
  if (this.hasActiveAssignment(meshId, sessionId, nodeId)) return null;
287
288
 
288
289
  // Priority: session-targeted > node-targeted (no session) > unconstrained
289
- const row = (
290
+ const rows = [
291
+ ...(
290
292
  this.db.prepare(`
291
293
  SELECT payload FROM mesh_queue
292
294
  WHERE mesh_id = ? AND status = 'pending' AND target_session_id = ?
293
- ORDER BY created_at ASC LIMIT 1
294
- `).get(meshId, sessionId) as { payload: string } | undefined
295
- ) || (
295
+ ORDER BY created_at ASC
296
+ `).all(meshId, sessionId) as Array<{ payload: string }>
297
+ ),
298
+ ...(
296
299
  this.db.prepare(`
297
300
  SELECT payload FROM mesh_queue
298
301
  WHERE mesh_id = ? AND status = 'pending' AND target_node_id = ? AND target_session_id IS NULL
299
- ORDER BY created_at ASC LIMIT 1
300
- `).get(meshId, nodeId) as { payload: string } | undefined
301
- ) || (
302
+ ORDER BY created_at ASC
303
+ `).all(meshId, nodeId) as Array<{ payload: string }>
304
+ ),
305
+ ...(
302
306
  this.db.prepare(`
303
307
  SELECT payload FROM mesh_queue
304
308
  WHERE mesh_id = ? AND status = 'pending' AND target_node_id IS NULL AND target_session_id IS NULL
305
- ORDER BY created_at ASC LIMIT 1
306
- `).get(meshId) as { payload: string } | undefined
307
- );
308
- if (!row) return null;
309
+ ORDER BY created_at ASC
310
+ `).all(meshId) as Array<{ payload: string }>
311
+ ),
312
+ ];
313
+ const entry = rows
314
+ .map(row => JSON.parse(row.payload) as MeshWorkQueueEntry)
315
+ .find(candidate => nodeSatisfiesRequiredTags(candidate.requiredTags, capabilityTags));
316
+ if (!entry) return null;
309
317
 
310
- const entry = JSON.parse(row.payload) as MeshWorkQueueEntry;
311
318
  const now = new Date().toISOString();
312
319
  entry.status = 'assigned';
313
320
  entry.assignedNodeId = nodeId;
@@ -7,8 +7,9 @@ import { detectCLI } from '../detection/cli-detector.js';
7
7
  import { LOG } from '../logging/logger.js';
8
8
  import { appendLedgerEntry, buildTaskCompletionEvidence, getLedgerDir, getSessionRecoveryContext, isIntentionalCleanupStopEntry, readLedgerEntries } from './mesh-ledger.js';
9
9
  import type { MeshLedgerKind, SessionRecoveryContext } from './mesh-ledger.js';
10
- import { claimNextTask, updateSessionTaskStatus, enqueueTask, updateTaskStatus, getQueue, recordTaskAutoLaunch, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, getActiveDirectDispatches } from './mesh-work-queue.js';
10
+ import { buildMeshNodeCapabilityTags, claimNextTask, updateSessionTaskStatus, enqueueTask, updateTaskStatus, getQueue, recordTaskAutoLaunch, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, getActiveDirectDispatches } from './mesh-work-queue.js';
11
11
  import { BeadsDB } from './beads-db.js';
12
+ import { fastForwardMeshNode } from './mesh-fast-forward.js';
12
13
 
13
14
  // ---------------------------------------------------------------------------
14
15
  // Remote Node Idle Session Tracking
@@ -33,6 +34,8 @@ const remoteIdleSessions = new Map<string, RemoteIdleSession>(); // key: `${node
33
34
  // meshNodeFor. Cache results for 5 seconds to avoid repeated config reads.
34
35
  const meshByWorkspaceCache = new Map<string, { mesh: any; cachedAt: number }>();
35
36
  const MESH_WORKSPACE_CACHE_TTL_MS = 5_000;
37
+ const IDLE_AUTO_FAST_FORWARD_THROTTLE_MS = 30 * 60 * 1000;
38
+ const idleAutoFastForwardLastAttempt = new Map<string, number>();
36
39
 
37
40
  function getCachedMeshByWorkspace(workspace: string): any {
38
41
  const now = Date.now();
@@ -47,6 +50,10 @@ function readWorkerResultMetadata(event: Record<string, unknown>): Record<string
47
50
  return readRecord(event.workerResult) || readRecord(event.meshWorkerResult) || readRecord(event.structuredResult);
48
51
  }
49
52
 
53
+ export function __resetIdleAutoFastForwardForTests(): void {
54
+ idleAutoFastForwardLastAttempt.clear();
55
+ }
56
+
50
57
  function sweepExpiredRemoteIdleSessions(): void {
51
58
  const now = Date.now();
52
59
  for (const [key, session] of remoteIdleSessions) {
@@ -663,7 +670,10 @@ export function tryAssignQueueTask(
663
670
  sessionId: string,
664
671
  providerType: string
665
672
  ): boolean {
666
- const task = claimNextTask(meshId, nodeId, sessionId);
673
+ const mesh = getMeshWithCache(components, meshId);
674
+ const node = mesh?.nodes.find((n: any) => n.id === nodeId);
675
+ const capabilityTags = buildMeshNodeCapabilityTags(node, providerType);
676
+ const task = claimNextTask(meshId, nodeId, sessionId, capabilityTags);
667
677
  if (!task) {
668
678
  return false;
669
679
  }
@@ -671,9 +681,6 @@ export function tryAssignQueueTask(
671
681
  LOG.info('MeshQueue', `Node ${nodeId} (${sessionId}) pulled task ${task.id}`);
672
682
 
673
683
  // Check if the node is remote
674
- const mesh = getMeshWithCache(components, meshId);
675
- const node = mesh?.nodes.find((n: any) => n.id === nodeId);
676
-
677
684
  // If the node is explicitly remote and we have a dispatch mechanism, route via P2P
678
685
  if (node?.daemonId && components.dispatchMeshCommand) {
679
686
  const isLocalNode = components.cliManager.adapters.has(sessionId);
@@ -1061,6 +1068,72 @@ export async function triggerMeshQueue(components: DaemonComponents, meshId: str
1061
1068
  await maybeAutoLaunchOneQueueSession(components, meshId, mesh);
1062
1069
  }
1063
1070
 
1071
+ async function maybeAutoFastForwardIdleNode(components: DaemonComponents, args: {
1072
+ meshId: string;
1073
+ nodeId: string;
1074
+ sessionId?: string;
1075
+ providerType?: string;
1076
+ }): Promise<void> {
1077
+ const mesh = getMeshWithCache(components, args.meshId);
1078
+ const node = mesh?.nodes?.find((candidate: any) => candidate?.id === args.nodeId || candidate?.nodeId === args.nodeId);
1079
+ const workspace = readNonEmptyString(node?.workspace);
1080
+ if (!workspace) return;
1081
+ if (!existsSync(workspace)) return;
1082
+
1083
+ const throttleKey = `${args.meshId}:${args.nodeId}`;
1084
+ const now = Date.now();
1085
+ const lastAttempt = idleAutoFastForwardLastAttempt.get(throttleKey) || 0;
1086
+ if (now - lastAttempt < IDLE_AUTO_FAST_FORWARD_THROTTLE_MS) return;
1087
+ idleAutoFastForwardLastAttempt.set(throttleKey, now);
1088
+
1089
+ const submoduleIgnorePaths = Array.isArray(node?.policy?.submoduleIgnorePaths)
1090
+ ? node.policy.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string')
1091
+ : undefined;
1092
+ try {
1093
+ const dryRun = await fastForwardMeshNode({
1094
+ meshId: args.meshId,
1095
+ nodeId: args.nodeId,
1096
+ workspace,
1097
+ execute: false,
1098
+ dryRun: true,
1099
+ updateSubmodules: false,
1100
+ submoduleIgnorePaths,
1101
+ trigger: 'idle_auto',
1102
+ });
1103
+ if (!dryRun || dryRun.code !== 'fast_forward_available' || dryRun.allowed !== true) return;
1104
+ await fastForwardMeshNode({
1105
+ meshId: args.meshId,
1106
+ nodeId: args.nodeId,
1107
+ workspace,
1108
+ execute: true,
1109
+ dryRun: false,
1110
+ updateSubmodules: false,
1111
+ submoduleIgnorePaths,
1112
+ trigger: 'idle_auto',
1113
+ });
1114
+ } catch (e: any) {
1115
+ LOG.warn('MeshFastForward', `Idle auto fast-forward check failed for ${args.nodeId}: ${e?.message || e}`);
1116
+ }
1117
+ }
1118
+
1119
+ function runIdleMaintenanceThenAssignQueue(components: DaemonComponents, args: {
1120
+ meshId: string;
1121
+ nodeId: string;
1122
+ sessionId: string;
1123
+ providerType: string;
1124
+ }): void {
1125
+ setImmediate(() => {
1126
+ maybeAutoFastForwardIdleNode(components, args)
1127
+ .finally(() => {
1128
+ try {
1129
+ tryAssignQueueTask(components, args.meshId, args.nodeId, args.sessionId, args.providerType);
1130
+ } catch (e: any) {
1131
+ LOG.warn('MeshQueue', `Failed to assign idle queue task after maintenance for ${args.nodeId}: ${e?.message || e}`);
1132
+ }
1133
+ });
1134
+ });
1135
+ }
1136
+
1064
1137
  function buildMeshSystemMessage(args: {
1065
1138
  event: string;
1066
1139
  nodeLabel: string;
@@ -1313,9 +1386,7 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1313
1386
  updateDirectDispatchStatus(args.meshId, sessionId, 'completed');
1314
1387
  setImmediate(() => cleanupTerminalDirectDispatches());
1315
1388
  if (nodeId && providerType) {
1316
- setImmediate(() => {
1317
- tryAssignQueueTask(components, args.meshId, nodeId, sessionId, providerType);
1318
- });
1389
+ runIdleMaintenanceThenAssignQueue(components, { meshId: args.meshId, nodeId, sessionId, providerType });
1319
1390
  }
1320
1391
  }
1321
1392
  } else if (args.event === 'agent:ready') {
@@ -1370,8 +1441,15 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1370
1441
  expiresAt: Date.now() + REMOTE_IDLE_SESSION_TTL_MS,
1371
1442
  });
1372
1443
  setImmediate(() => {
1373
- const assigned = tryAssignQueueTask(components, args.meshId, nodeId, sessionId, providerType);
1374
- if (assigned) remoteIdleSessions.delete(`${nodeId}:${sessionId}`);
1444
+ maybeAutoFastForwardIdleNode(components, { meshId: args.meshId, nodeId, sessionId, providerType })
1445
+ .finally(() => {
1446
+ try {
1447
+ const assigned = tryAssignQueueTask(components, args.meshId, nodeId, sessionId, providerType);
1448
+ if (assigned) remoteIdleSessions.delete(`${nodeId}:${sessionId}`);
1449
+ } catch (e: any) {
1450
+ LOG.warn('MeshQueue', `Failed to assign idle queue task after maintenance for ${nodeId}: ${e?.message || e}`);
1451
+ }
1452
+ });
1375
1453
  });
1376
1454
  }
1377
1455
  } else if (args.event === 'agent:generating_started') {
@@ -12,6 +12,7 @@ export interface MeshFastForwardNodeArgs {
12
12
  updateSubmodules?: boolean;
13
13
  submoduleIgnorePaths?: string[];
14
14
  timeoutMs?: number;
15
+ trigger?: 'manual' | 'idle_auto' | string;
15
16
  }
16
17
 
17
18
  export interface MeshFastForwardPlannedStep {
@@ -40,11 +41,12 @@ export interface MeshFastForwardResult {
40
41
  finalBranchConvergenceState?: Record<string, unknown>;
41
42
  operationError?: string;
42
43
  ledgerError?: string;
44
+ trigger?: string;
43
45
  }
44
46
 
45
47
  type MeshFastForwardBase = Pick<
46
48
  MeshFastForwardResult,
47
- 'workspace' | 'dryRun' | 'updateSubmodules' | 'plannedSteps'
49
+ 'workspace' | 'dryRun' | 'updateSubmodules' | 'plannedSteps' | 'trigger'
48
50
  > & Pick<Partial<MeshFastForwardResult>, 'nodeId' | 'meshId'>;
49
51
 
50
52
  const STATUS_OPTIONS = { refreshUpstream: true, includeSubmodules: true, timeoutMs: 15_000 } as const;
@@ -54,6 +56,7 @@ export async function fastForwardMeshNode(args: MeshFastForwardNodeArgs): Promis
54
56
  const nodeId = normalizeOptionalString(args.nodeId);
55
57
  const meshId = normalizeOptionalString(args.meshId);
56
58
  const requestedBranch = normalizeOptionalString(args.branch);
59
+ const trigger = normalizeOptionalString(args.trigger) || 'manual';
57
60
  const updateSubmodules = args.updateSubmodules === true;
58
61
  const dryRun = args.dryRun === true || args.execute !== true;
59
62
  const plannedSteps = buildPlannedSteps(updateSubmodules);
@@ -64,6 +67,7 @@ export async function fastForwardMeshNode(args: MeshFastForwardNodeArgs): Promis
64
67
  dryRun,
65
68
  updateSubmodules,
66
69
  plannedSteps,
70
+ trigger,
67
71
  };
68
72
 
69
73
  if (!workspace) {
@@ -78,11 +82,13 @@ export async function fastForwardMeshNode(args: MeshFastForwardNodeArgs): Promis
78
82
 
79
83
  const earlyBlockers = collectPreflightBlockers(current, requestedBranch);
80
84
  if (earlyBlockers.length > 0) {
81
- return {
85
+ const result: MeshFastForwardResult = {
82
86
  ...block(base, chooseBlockCode(current, earlyBlockers), earlyBlockers),
83
87
  current,
84
88
  finalBranchConvergenceState: buildConvergenceState(current, codeToConvergenceStatus(chooseBlockCode(current, earlyBlockers))),
85
89
  };
90
+ await appendFastForwardLedger(result, 'blocked');
91
+ return result;
86
92
  }
87
93
 
88
94
  if (current.behind === 0) {
@@ -129,6 +135,7 @@ export async function fastForwardMeshNode(args: MeshFastForwardNodeArgs): Promis
129
135
  preStatus: current,
130
136
  finalBranchConvergenceState: buildConvergenceState(current, 'fast_forward_available'),
131
137
  };
138
+ await appendFastForwardLedger(result, 'dry_run');
132
139
  return result;
133
140
  }
134
141
 
@@ -393,7 +400,7 @@ function formatGitError(error: unknown): string {
393
400
  return String(error);
394
401
  }
395
402
 
396
- async function appendFastForwardLedger(result: MeshFastForwardResult, outcome: 'noop' | 'blocked' | 'executed' | 'failed'): Promise<void> {
403
+ async function appendFastForwardLedger(result: MeshFastForwardResult, outcome: 'noop' | 'blocked' | 'dry_run' | 'executed' | 'failed'): Promise<void> {
397
404
  if (!result.meshId) return;
398
405
  try {
399
406
  const { appendLedgerEntry } = await import('./mesh-ledger.js');
@@ -402,6 +409,7 @@ async function appendFastForwardLedger(result: MeshFastForwardResult, outcome: '
402
409
  ...(result.nodeId ? { nodeId: result.nodeId } : {}),
403
410
  payload: {
404
411
  operation: 'mesh_fast_forward_node',
412
+ trigger: result.trigger || 'manual',
405
413
  outcome,
406
414
  code: result.code,
407
415
  workspace: result.workspace,
@@ -69,6 +69,8 @@ export interface MeshWorkQueueEntry {
69
69
  targetNodeId?: string;
70
70
  /** If specified, only this runtime session can claim the task */
71
71
  targetSessionId?: string;
72
+ /** If specified, a node must expose all tags before it can claim the task. */
73
+ requiredTags?: string[];
72
74
  /** The node that actually claimed and is executing the task */
73
75
  assignedNodeId?: string;
74
76
  /** The session currently executing the task */
@@ -99,6 +101,49 @@ export interface MeshQueueMutationOptions {
99
101
  ownerRole?: RepoMeshDaemonRole;
100
102
  }
101
103
 
104
+ export function normalizeMeshCapabilityTags(value: unknown): string[] {
105
+ if (!Array.isArray(value)) return [];
106
+ const seen = new Set<string>();
107
+ return value
108
+ .map(tag => typeof tag === 'string' ? tag.trim() : '')
109
+ .filter(Boolean)
110
+ .filter(tag => {
111
+ if (seen.has(tag)) return false;
112
+ seen.add(tag);
113
+ return true;
114
+ });
115
+ }
116
+
117
+ function firstProviderPriority(policy: unknown): string | undefined {
118
+ const raw = policy && typeof policy === 'object' && !Array.isArray(policy)
119
+ ? (policy as Record<string, unknown>).providerPriority
120
+ : undefined;
121
+ if (!Array.isArray(raw)) return undefined;
122
+ return raw.find(type => typeof type === 'string' && type.trim())?.trim();
123
+ }
124
+
125
+ export function buildMeshNodeCapabilityTags(
126
+ node: { capabilities?: unknown; policy?: unknown } | undefined,
127
+ providerType?: string,
128
+ ): string[] {
129
+ const provider = typeof providerType === 'string' && providerType.trim()
130
+ ? providerType.trim()
131
+ : firstProviderPriority(node?.policy);
132
+ return normalizeMeshCapabilityTags([
133
+ ...(Array.isArray(node?.capabilities) ? node.capabilities : []),
134
+ `os=${process.platform}`,
135
+ `arch=${process.arch}`,
136
+ ...(provider ? [`provider=${provider}`] : []),
137
+ ]);
138
+ }
139
+
140
+ export function nodeSatisfiesRequiredTags(requiredTags: unknown, capabilityTags: unknown): boolean {
141
+ const required = normalizeMeshCapabilityTags(requiredTags);
142
+ if (required.length === 0) return true;
143
+ const available = new Set(normalizeMeshCapabilityTags(capabilityTags));
144
+ return required.every(tag => available.has(tag));
145
+ }
146
+
102
147
  function withQueueLock<T>(_meshId: string, fn: () => T): T {
103
148
  return BeadsDB.getInstance().transaction(fn);
104
149
  }
@@ -117,7 +162,7 @@ function writeQueue(meshId: string, queue: MeshWorkQueueEntry[]): void {
117
162
  export function enqueueTask(
118
163
  meshId: string,
119
164
  message: string,
120
- opts?: { targetNodeId?: string; targetSessionId?: string; taskMode?: MeshTaskMode | string } & MeshQueueMutationOptions,
165
+ opts?: { targetNodeId?: string; targetSessionId?: string; taskMode?: MeshTaskMode | string; requiredTags?: string[] } & MeshQueueMutationOptions,
121
166
  ): MeshWorkQueueEntry {
122
167
  requireMeshHostQueueOwner(opts);
123
168
  const modeValidation = validateMeshTaskModeRequest(opts?.taskMode, message);
@@ -132,6 +177,7 @@ export function enqueueTask(
132
177
  taskMode: modeValidation.taskMode,
133
178
  targetNodeId: opts?.targetNodeId,
134
179
  targetSessionId: opts?.targetSessionId,
180
+ requiredTags: normalizeMeshCapabilityTags(opts?.requiredTags),
135
181
  createdAt: new Date().toISOString(),
136
182
  updatedAt: new Date().toISOString(),
137
183
  };
@@ -153,8 +199,8 @@ export function getMeshQueueRevision(meshId: string): string {
153
199
  /**
154
200
  * Find the next pending task that this node is allowed to claim, and mark it as assigned.
155
201
  */
156
- export function claimNextTask(meshId: string, nodeId: string, sessionId: string): MeshWorkQueueEntry | null {
157
- return BeadsDB.getInstance().claimNextQueueTask(meshId, nodeId, sessionId);
202
+ export function claimNextTask(meshId: string, nodeId: string, sessionId: string, capabilityTags?: string[]): MeshWorkQueueEntry | null {
203
+ return BeadsDB.getInstance().claimNextQueueTask(meshId, nodeId, sessionId, capabilityTags);
158
204
  }
159
205
 
160
206
  /**
@@ -303,6 +303,8 @@ export interface LocalMeshNodeEntry {
303
303
  daemonId?: string;
304
304
  /** Machine registry ID that owns this workspace, when known. */
305
305
  machineId?: string;
306
+ /** Operator-defined capability tags used by mesh queue matching. */
307
+ capabilities?: string[];
306
308
  userOverrides: Partial<RepoMeshNodeCapabilities>;
307
309
  policy: RepoMeshNodePolicy;
308
310
  /**
@@ -1,53 +0,0 @@
1
- /**
2
- * Mesh Sync — Sync local mesh metadata to/from cloud D1
3
- *
4
- * When cloud is available, this module pushes local mesh config
5
- * to the server and pulls remote meshes that were created from
6
- * other machines. The local ~/.adhdev/meshes.json remains the
7
- * canonical source; cloud is a membership/metadata layer only.
8
- * Task/chat/ledger evidence remains local-first and must not be
9
- * synchronized through Cloud/D1.
10
- *
11
- * This is called lazily (not on daemon startup) — only when the
12
- * user explicitly opens the mesh page or runs `adhdev mesh sync`.
13
- */
14
- export interface MeshSyncTransport {
15
- /** GET /api/v1/repo-meshes */
16
- listRemoteMeshes(): Promise<{
17
- meshes: RemoteMeshRecord[];
18
- }>;
19
- /** POST /api/v1/repo-meshes */
20
- createRemoteMesh(data: {
21
- name: string;
22
- repo_identity: string;
23
- repo_remote_url?: string;
24
- default_branch?: string;
25
- policy?: string;
26
- }): Promise<{
27
- mesh: RemoteMeshRecord;
28
- }>;
29
- /** DELETE /api/v1/repo-meshes/:id */
30
- deleteRemoteMesh(meshId: string): Promise<void>;
31
- }
32
- export interface RemoteMeshRecord {
33
- id: string;
34
- name: string;
35
- repo_identity: string;
36
- repo_remote_url: string | null;
37
- default_branch: string | null;
38
- policy: string;
39
- status: string;
40
- created_at: string;
41
- updated_at: string;
42
- }
43
- export interface MeshSyncResult {
44
- pushed: number;
45
- pulled: number;
46
- deleted: number;
47
- errors: string[];
48
- }
49
- /**
50
- * Push local meshes to cloud (upsert by repo_identity).
51
- * Pull remote meshes that don't exist locally.
52
- */
53
- export declare function syncMeshes(transport: MeshSyncTransport): Promise<MeshSyncResult>;