@adhdev/daemon-core 0.9.82-rc.212 → 0.9.82-rc.213

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.
@@ -70,6 +70,14 @@ export interface RepoMeshNode {
70
70
  export type RepoMeshNodeHealth = 'online' | 'offline' | 'degraded' | 'dirty' | 'wrong_branch' | 'unknown';
71
71
  export type RepoMeshSessionCleanupMode = 'preserve' | 'stop' | 'delete_stopped' | 'stop_and_delete';
72
72
  export type RepoMeshSpawnedSessionVisibility = 'visible' | 'hidden';
73
+ export interface RepoMeshAutoFastForwardPolicy {
74
+ /** Defaults to true. Set false to disable daemon-initiated idle fast-forwards. */
75
+ enabled: boolean;
76
+ /** Maximum behind count eligible for automatic fast-forward. Missing means no limit. */
77
+ maxBehind?: number;
78
+ /** Defaults to true. Require submodule status to be clean before automatic fast-forward. */
79
+ requireCleanSubmodules?: boolean;
80
+ }
73
81
  export interface RepoMeshPolicy {
74
82
  requirePreTaskCheckpoint: boolean;
75
83
  requirePostTaskCheckpoint: boolean;
@@ -97,6 +105,11 @@ export interface RepoMeshPolicy {
97
105
  * runtimes are never stopped/deleted unless the mesh owner opts in.
98
106
  */
99
107
  sessionCleanupOnNodeRemove?: RepoMeshSessionCleanupMode;
108
+ /**
109
+ * Daemon-initiated fast-forward for idle clean nodes that are only behind
110
+ * their tracked upstream. Defaults to enabled.
111
+ */
112
+ autoFastForward?: RepoMeshAutoFastForwardPolicy;
100
113
  /**
101
114
  * Maximum number of automatic retry recommendations for a failed task on the
102
115
  * same node before the daemon advises the coordinator to escalate or reassign.
@@ -358,6 +371,10 @@ export interface RepoMeshNodeStatus {
358
371
  activeSessionDetails?: RepoMeshSessionStatus[];
359
372
  providerPriority?: string[];
360
373
  launchReady?: boolean;
374
+ /** True when the node is clean, ahead=0, behind>0, and safe for fast-forward consideration. */
375
+ autoFastForwardEligible?: boolean;
376
+ /** Coordinator-facing suggestion for obvious clean catch-up work. */
377
+ suggestedAction?: 'auto_fast_forward';
361
378
  worktreeBootstrap?: LocalMeshNodeEntry['worktreeBootstrap'];
362
379
  launchBlockedReason?: string;
363
380
  launchBlockedMessage?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.212",
3
+ "version": "0.9.82-rc.213",
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",
@@ -669,6 +669,25 @@ function getGitSubmoduleDriftState(git: Record<string, unknown> | null | undefin
669
669
  return { dirty, outOfSync };
670
670
  }
671
671
 
672
+ function isInlineMeshAutoFastForwardEligible(git: Record<string, unknown> | null | undefined): boolean {
673
+ if (!git) return false;
674
+ if (readBooleanValue(git.isGitRepo) !== true) return false;
675
+ if (!readStringValue(git.branch)) return false;
676
+ if (!readStringValue(git.upstream)) return false;
677
+ const upstreamStatus = readStringValue(git.upstreamStatus, git.upstream_status);
678
+ if (upstreamStatus !== 'fresh') return false;
679
+ if ((readNumberValue(git.ahead) ?? 0) !== 0) return false;
680
+ if ((readNumberValue(git.behind) ?? 0) <= 0) return false;
681
+ const hasConflicts = readBooleanValue(git.hasConflicts)
682
+ ?? (Array.isArray(git.conflictFiles) && git.conflictFiles.length > 0);
683
+ if (hasConflicts) return false;
684
+ if ((readNumberValue(git.stashCount, git.stash_count) ?? 0) > 0) return false;
685
+ const submoduleDrift = getGitSubmoduleDriftState(git);
686
+ if (submoduleDrift.dirty || submoduleDrift.outOfSync) return false;
687
+ const dirty = readBooleanValue(git.dirty) ?? (countGitWorktreeChanges(git) > 0);
688
+ return dirty !== true && countGitWorktreeChanges(git) === 0;
689
+ }
690
+
672
691
  function deriveMeshNodeHealthFromGit(git: Record<string, unknown> | null | undefined): 'online' | 'dirty' | 'degraded' {
673
692
  if (!git || readBooleanValue(git.isGitRepo) === false) return 'degraded';
674
693
  const branch = readStringValue(git.branch);
@@ -815,6 +834,12 @@ function applyInlineMeshBranchConvergence(mesh: any, node: any, status: Record<s
815
834
  status.isDirty = uncommittedChanges > 0;
816
835
  status.uncommittedChanges = uncommittedChanges;
817
836
  status.branchConvergence = buildInlineMeshBranchConvergence({ mesh, node, status });
837
+ status.autoFastForwardEligible = isInlineMeshAutoFastForwardEligible(git);
838
+ if (status.autoFastForwardEligible) {
839
+ status.suggestedAction = 'auto_fast_forward';
840
+ } else {
841
+ delete status.suggestedAction;
842
+ }
818
843
  }
819
844
 
820
845
  function summarizeInlineMeshBranchConvergence(nodes: Array<Record<string, unknown>>): Record<string, unknown> {
@@ -95,7 +95,17 @@ const SESSION_CLEANUP_MODES = new Set(['preserve', 'stop', 'delete_stopped', 'st
95
95
  const SPAWNED_SESSION_VISIBILITY_MODES = new Set(['visible', 'hidden']);
96
96
 
97
97
  function mergeMeshPolicy(base: RepoMeshPolicy | undefined, patch: Partial<RepoMeshPolicy> | undefined): RepoMeshPolicy {
98
- const policy: RepoMeshPolicy = { ...DEFAULT_MESH_POLICY, ...(base || {}), ...(patch || {}) };
98
+ const autoFastForward = normalizeAutoFastForwardPolicy({
99
+ ...DEFAULT_MESH_POLICY.autoFastForward,
100
+ ...((base?.autoFastForward && typeof base.autoFastForward === 'object') ? base.autoFastForward : {}),
101
+ ...((patch?.autoFastForward && typeof patch.autoFastForward === 'object') ? patch.autoFastForward : {}),
102
+ });
103
+ const policy: RepoMeshPolicy = {
104
+ ...DEFAULT_MESH_POLICY,
105
+ ...(base || {}),
106
+ ...(patch || {}),
107
+ autoFastForward,
108
+ };
99
109
  if (!['block', 'warn', 'checkpoint_then_continue'].includes(policy.dirtyWorkspaceBehavior)) {
100
110
  policy.dirtyWorkspaceBehavior = 'warn';
101
111
  }
@@ -111,6 +121,18 @@ function mergeMeshPolicy(base: RepoMeshPolicy | undefined, patch: Partial<RepoMe
111
121
  return policy;
112
122
  }
113
123
 
124
+ function normalizeAutoFastForwardPolicy(value: unknown): NonNullable<RepoMeshPolicy['autoFastForward']> {
125
+ const record = value && typeof value === 'object' && !Array.isArray(value)
126
+ ? value as Record<string, unknown>
127
+ : {};
128
+ const maxBehind = Number(record.maxBehind);
129
+ return {
130
+ enabled: record.enabled !== false,
131
+ ...(Number.isFinite(maxBehind) && maxBehind >= 0 ? { maxBehind: Math.floor(maxBehind) } : {}),
132
+ requireCleanSubmodules: record.requireCleanSubmodules !== false,
133
+ };
134
+ }
135
+
114
136
  export function listMeshes(): LocalMeshEntry[] {
115
137
  return loadMeshConfig().meshes;
116
138
  }
@@ -349,6 +349,39 @@ function isDirtyNode(node: any): boolean {
349
349
  return node?.health === 'dirty' || node?.git?.dirty === true;
350
350
  }
351
351
 
352
+ function resolveAutoFastForwardPolicy(mesh: any): { enabled: boolean; maxBehind?: number; requireCleanSubmodules: boolean } {
353
+ const record = mesh?.policy?.autoFastForward && typeof mesh.policy.autoFastForward === 'object' && !Array.isArray(mesh.policy.autoFastForward)
354
+ ? mesh.policy.autoFastForward as Record<string, unknown>
355
+ : {};
356
+ const maxBehind = Number(record.maxBehind);
357
+ return {
358
+ enabled: record.enabled !== false,
359
+ ...(Number.isFinite(maxBehind) && maxBehind >= 0 ? { maxBehind: Math.floor(maxBehind) } : {}),
360
+ requireCleanSubmodules: record.requireCleanSubmodules !== false,
361
+ };
362
+ }
363
+
364
+ function sessionStateLooksActive(state: any): boolean {
365
+ const status = readNonEmptyString(state?.status).toLowerCase();
366
+ const chatStatus = readNonEmptyString(state?.activeChat?.status).toLowerCase();
367
+ const active = new Set(['generating', 'streaming', 'long_generating', 'working', 'starting', 'waiting_approval']);
368
+ return active.has(status) || active.has(chatStatus);
369
+ }
370
+
371
+ function nodeHasActiveMeshWork(components: DaemonComponents, meshId: string, nodeId: string, currentSessionId?: string): boolean {
372
+ if (nodeHasActiveAssignment(meshId, nodeId)) return true;
373
+ return components.instanceManager.getByCategory('cli').some((inst: any) => {
374
+ const state = inst.getState();
375
+ const settings = state.settings as Record<string, unknown> || {};
376
+ if (readNonEmptyString(settings.meshNodeFor) !== meshId) return false;
377
+ const instNodeId = readNonEmptyString(settings.meshNodeId) || readNonEmptyString(settings.nodeId);
378
+ if (instNodeId !== nodeId) return false;
379
+ const sessionId = readNonEmptyString(state.instanceId);
380
+ if (currentSessionId && sessionId === currentSessionId && isIdleSessionState(state)) return false;
381
+ return sessionStateLooksActive(state);
382
+ });
383
+ }
384
+
352
385
  function isLaunchableNode(node: any): boolean {
353
386
  if (!node || node.status === 'disabled' || node.status === 'removed') return false;
354
387
  const health = readNonEmptyString(node.health).toLowerCase();
@@ -759,6 +792,10 @@ async function maybeAutoFastForwardIdleNode(components: DaemonComponents, args:
759
792
  if (!workspace) return;
760
793
  if (!existsSync(workspace)) return;
761
794
 
795
+ const policy = resolveAutoFastForwardPolicy(mesh);
796
+ if (!policy.enabled) return;
797
+ if (nodeHasActiveMeshWork(components, args.meshId, args.nodeId, args.sessionId)) return;
798
+
762
799
  const throttleKey = `${args.meshId}:${args.nodeId}`;
763
800
  const now = Date.now();
764
801
  const lastAttempt = idleAutoFastForwardLastAttempt.get(throttleKey) || 0;
@@ -780,6 +817,12 @@ async function maybeAutoFastForwardIdleNode(components: DaemonComponents, args:
780
817
  trigger: 'idle_auto',
781
818
  });
782
819
  if (!dryRun || dryRun.code !== 'fast_forward_available' || dryRun.allowed !== true) return;
820
+ const behind = Number(dryRun.current?.behind);
821
+ if (policy.maxBehind !== undefined && Number.isFinite(behind) && behind > policy.maxBehind) return;
822
+ if (policy.requireCleanSubmodules) {
823
+ const submodules = Array.isArray(dryRun.current?.submodules) ? dryRun.current.submodules : [];
824
+ if (submodules.some((submodule: any) => submodule?.dirty || submodule?.outOfSync || submodule?.error)) return;
825
+ }
783
826
  await fastForwardMeshNode({
784
827
  meshId: args.meshId,
785
828
  nodeId: args.nodeId,
@@ -90,6 +90,15 @@ export type RepoMeshNodeHealth =
90
90
  export type RepoMeshSessionCleanupMode = 'preserve' | 'stop' | 'delete_stopped' | 'stop_and_delete';
91
91
  export type RepoMeshSpawnedSessionVisibility = 'visible' | 'hidden';
92
92
 
93
+ export interface RepoMeshAutoFastForwardPolicy {
94
+ /** Defaults to true. Set false to disable daemon-initiated idle fast-forwards. */
95
+ enabled: boolean;
96
+ /** Maximum behind count eligible for automatic fast-forward. Missing means no limit. */
97
+ maxBehind?: number;
98
+ /** Defaults to true. Require submodule status to be clean before automatic fast-forward. */
99
+ requireCleanSubmodules?: boolean;
100
+ }
101
+
93
102
  export interface RepoMeshPolicy {
94
103
  requirePreTaskCheckpoint: boolean;
95
104
  requirePostTaskCheckpoint: boolean;
@@ -117,6 +126,11 @@ export interface RepoMeshPolicy {
117
126
  * runtimes are never stopped/deleted unless the mesh owner opts in.
118
127
  */
119
128
  sessionCleanupOnNodeRemove?: RepoMeshSessionCleanupMode;
129
+ /**
130
+ * Daemon-initiated fast-forward for idle clean nodes that are only behind
131
+ * their tracked upstream. Defaults to enabled.
132
+ */
133
+ autoFastForward?: RepoMeshAutoFastForwardPolicy;
120
134
  /**
121
135
  * Maximum number of automatic retry recommendations for a failed task on the
122
136
  * same node before the daemon advises the coordinator to escalate or reassign.
@@ -171,6 +185,7 @@ export const DEFAULT_MESH_POLICY: RepoMeshPolicy = {
171
185
  maxParallelTasks: 2,
172
186
  spawnedSessionVisibility: 'visible',
173
187
  sessionCleanupOnNodeRemove: 'preserve',
188
+ autoFastForward: { enabled: true },
174
189
  maxTaskRetries: 1,
175
190
  };
176
191
 
@@ -415,6 +430,10 @@ export interface RepoMeshNodeStatus {
415
430
  activeSessionDetails?: RepoMeshSessionStatus[];
416
431
  providerPriority?: string[];
417
432
  launchReady?: boolean;
433
+ /** True when the node is clean, ahead=0, behind>0, and safe for fast-forward consideration. */
434
+ autoFastForwardEligible?: boolean;
435
+ /** Coordinator-facing suggestion for obvious clean catch-up work. */
436
+ suggestedAction?: 'auto_fast_forward';
418
437
  worktreeBootstrap?: LocalMeshNodeEntry['worktreeBootstrap'];
419
438
  launchBlockedReason?: string;
420
439
  launchBlockedMessage?: string;