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

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.214",
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",
@@ -555,11 +555,35 @@ export class CliStateEngine {
555
555
  // The real completion gate is applyIdle's idleFinishCandidate +
556
556
  // idleFinish timeout, which requires stable quiet AND a parsed
557
557
  // assistant message.
558
+ //
559
+ // Fast-path exception: only release the hold if the parser shows a
560
+ // *current-turn* final standard assistant after the last user message,
561
+ // and it is not still streaming. Using !!lastParsedAssistant was too
562
+ // broad — it matched previous-turn assistant messages and caused
563
+ // false-idle between the first assistant text chunk and the first
564
+ // tool call (the agent outputs text, then immediately begins tool use;
565
+ // the gap between them hit this fast path and committed idle).
566
+ const hasFinalCurrentTurnAssistant = (() => {
567
+ if (parsedStatus !== 'idle') return false;
568
+ const msgs: any[] = Array.isArray(parsedMessages) ? parsedMessages : [];
569
+ let lastUserIdx = -1;
570
+ for (let i = msgs.length - 1; i >= 0; i--) {
571
+ if (msgs[i]?.role === 'user') { lastUserIdx = i; break; }
572
+ }
573
+ // No user message visible: fall back to any non-streaming standard assistant.
574
+ const searchSlice = lastUserIdx >= 0 ? msgs.slice(lastUserIdx + 1) : msgs;
575
+ return searchSlice.some((m: any) => {
576
+ if (!m || m.role !== 'assistant') return false;
577
+ if (typeof m.content !== 'string' || !m.content.trim()) return false;
578
+ const kind = typeof m.kind === 'string' && m.kind.trim() ? m.kind.trim() : 'standard';
579
+ return kind === 'standard' && m.meta?.streaming !== true;
580
+ });
581
+ })();
558
582
  const shouldHoldGenerating = status === 'idle'
559
583
  && this.isWaitingForResponse
560
584
  && !!this.currentTurnScope
561
585
  && !modal
562
- && !(parsedStatus === 'idle' && !!lastParsedAssistant);
586
+ && !hasFinalCurrentTurnAssistant;
563
587
 
564
588
  if (shouldHoldGenerating) { this.applyHoldGenerating(ctx); return; }
565
589
  if (status === 'error') {
@@ -66,6 +66,7 @@ import { getSessionCompletionMarker } from '../status/snapshot.js';
66
66
  import { execNpmCommandSync, resolveCurrentGlobalInstallSurface, spawnDetachedDaemonUpgradeHelper } from './upgrade-helper.js';
67
67
  import { getMeshQueueRevision } from '../mesh/mesh-work-queue.js';
68
68
  import type { RepoMeshSessionCleanupMode } from '../repo-mesh-types.js';
69
+ import { DEFAULT_MESH_POLICY } from '../repo-mesh-types.js';
69
70
  import { homedir, hostname as osHostname } from 'os';
70
71
  import { basename as pathBasename, join as pathJoin, resolve as pathResolve } from 'path';
71
72
  import * as fs from 'fs';
@@ -669,6 +670,25 @@ function getGitSubmoduleDriftState(git: Record<string, unknown> | null | undefin
669
670
  return { dirty, outOfSync };
670
671
  }
671
672
 
673
+ function isInlineMeshAutoFastForwardEligible(git: Record<string, unknown> | null | undefined): boolean {
674
+ if (!git) return false;
675
+ if (readBooleanValue(git.isGitRepo) !== true) return false;
676
+ if (!readStringValue(git.branch)) return false;
677
+ if (!readStringValue(git.upstream)) return false;
678
+ const upstreamStatus = readStringValue(git.upstreamStatus, git.upstream_status);
679
+ if (upstreamStatus !== 'fresh') return false;
680
+ if ((readNumberValue(git.ahead) ?? 0) !== 0) return false;
681
+ if ((readNumberValue(git.behind) ?? 0) <= 0) return false;
682
+ const hasConflicts = readBooleanValue(git.hasConflicts)
683
+ ?? (Array.isArray(git.conflictFiles) && git.conflictFiles.length > 0);
684
+ if (hasConflicts) return false;
685
+ if ((readNumberValue(git.stashCount, git.stash_count) ?? 0) > 0) return false;
686
+ const submoduleDrift = getGitSubmoduleDriftState(git);
687
+ if (submoduleDrift.dirty || submoduleDrift.outOfSync) return false;
688
+ const dirty = readBooleanValue(git.dirty) ?? (countGitWorktreeChanges(git) > 0);
689
+ return dirty !== true && countGitWorktreeChanges(git) === 0;
690
+ }
691
+
672
692
  function deriveMeshNodeHealthFromGit(git: Record<string, unknown> | null | undefined): 'online' | 'dirty' | 'degraded' {
673
693
  if (!git || readBooleanValue(git.isGitRepo) === false) return 'degraded';
674
694
  const branch = readStringValue(git.branch);
@@ -815,6 +835,12 @@ function applyInlineMeshBranchConvergence(mesh: any, node: any, status: Record<s
815
835
  status.isDirty = uncommittedChanges > 0;
816
836
  status.uncommittedChanges = uncommittedChanges;
817
837
  status.branchConvergence = buildInlineMeshBranchConvergence({ mesh, node, status });
838
+ status.autoFastForwardEligible = isInlineMeshAutoFastForwardEligible(git);
839
+ if (status.autoFastForwardEligible) {
840
+ status.suggestedAction = 'auto_fast_forward';
841
+ } else {
842
+ delete status.suggestedAction;
843
+ }
818
844
  }
819
845
 
820
846
  function summarizeInlineMeshBranchConvergence(nodes: Array<Record<string, unknown>>): Record<string, unknown> {
@@ -3308,6 +3334,7 @@ export class DaemonCommandRouter {
3308
3334
  success: result.success === true,
3309
3335
  result,
3310
3336
  finalBranchConvergenceState: result.finalBranchConvergenceState,
3337
+ ...(result.blockerContext ? { blockerContext: result.blockerContext } : {}),
3311
3338
  } : {}),
3312
3339
  },
3313
3340
  });
@@ -3879,6 +3906,30 @@ export class DaemonCommandRouter {
3879
3906
  };
3880
3907
  }
3881
3908
 
3909
+ // Push logic: after a successful merge, either auto-push or surface push info
3910
+ // so coordinators don't need manual discovery after each refine.
3911
+ const requireApprovalForPush: boolean = (mesh as any)?.policy?.requireApprovalForPush ?? DEFAULT_MESH_POLICY.requireApprovalForPush;
3912
+ let pushResult: Record<string, unknown> | undefined;
3913
+ if (!requireApprovalForPush) {
3914
+ const pushStarted = Date.now();
3915
+ try {
3916
+ await execFileAsync('git', ['push', 'origin', baseBranch], { cwd: repoRoot, encoding: 'utf8' });
3917
+ pushResult = { pushed: true, remote: 'origin', branch: baseBranch, durationMs: Date.now() - pushStarted };
3918
+ recordMeshRefineStage(refineStages, 'push', 'passed', pushStarted, pushResult);
3919
+ finalBranchConvergenceState.status = 'merged_pushed';
3920
+ } catch (e: any) {
3921
+ pushResult = {
3922
+ pushed: false,
3923
+ remote: 'origin',
3924
+ branch: baseBranch,
3925
+ error: e?.message || String(e),
3926
+ stderr: e?.stderr,
3927
+ durationMs: Date.now() - pushStarted,
3928
+ };
3929
+ recordMeshRefineStage(refineStages, 'push', 'failed', pushStarted, pushResult);
3930
+ }
3931
+ }
3932
+
3882
3933
  return {
3883
3934
  success: true,
3884
3935
  merged: true,
@@ -3893,6 +3944,14 @@ export class DaemonCommandRouter {
3893
3944
  refineStages,
3894
3945
  ...(ledgerError ? { ledgerError } : {}),
3895
3946
  finalBranchConvergenceState,
3947
+ // Push outcome or readiness info for coordinator.
3948
+ ...(pushResult
3949
+ ? { pushResult }
3950
+ : {
3951
+ pushReady: true,
3952
+ pushCommand: `git push origin ${baseBranch}`,
3953
+ pushNote: 'requireApprovalForPush is enabled — run the push command or obtain user approval before pushing.',
3954
+ }),
3896
3955
  };
3897
3956
  } catch (e: any) {
3898
3957
  return { success: false, error: e.message, refineStages };
@@ -3927,9 +3986,59 @@ export class DaemonCommandRouter {
3927
3986
  ? 'cleanup_failed'
3928
3987
  : 'merge_failed'; // fallback for unclassified failures
3929
3988
  const isTerminalSuccess = refineTerminalKind === 'completed';
3989
+
3990
+ // Build structured blocker context for task_failed ledger entries so coordinators
3991
+ // can inspect the failure cause without parsing free-form error strings.
3992
+ const blockerContext: Record<string, unknown> | undefined = isTerminalSuccess ? undefined : (() => {
3993
+ const code = typeof result.code === 'string' ? result.code : refineTerminalKind;
3994
+ const stage = refineTerminalKind === 'validation_failed' ? 'validation'
3995
+ : refineTerminalKind === 'submodule_reachability_failed' ? 'submodule_reachability'
3996
+ : refineCode === 'patch_equivalence_failed' ? 'patch_equivalence'
3997
+ : refineCode === 'needs_rebase' || refineCode === 'needs_rebase_with_conflicts' ? 'patch_equivalence'
3998
+ : refineTerminalKind === 'merge_failed' ? 'merge'
3999
+ : refineTerminalKind === 'cleanup_failed' ? 'cleanup'
4000
+ : 'unknown';
4001
+ const ctx: Record<string, unknown> = {
4002
+ stage,
4003
+ reason: code,
4004
+ terminalKind: refineTerminalKind,
4005
+ };
4006
+ if (typeof result.error === 'string') ctx.error = result.error;
4007
+ if (typeof result.blockedReason === 'string') ctx.blockedReason = result.blockedReason;
4008
+ // Patch equivalence details
4009
+ if (stage === 'patch_equivalence' && result.patchEquivalence) {
4010
+ const pe = result.patchEquivalence as Record<string, unknown>;
4011
+ ctx.details = {
4012
+ expectedPatchId: pe.expectedPatchId,
4013
+ actualPatchId: pe.actualPatchId,
4014
+ status: pe.status,
4015
+ actionableHint: pe.actionableHint,
4016
+ error: pe.error,
4017
+ };
4018
+ }
4019
+ // Submodule reachability details
4020
+ if (stage === 'submodule_reachability' && Array.isArray(result.unreachableSubmoduleCommits)) {
4021
+ ctx.details = {
4022
+ unreachableCount: (result.unreachableSubmoduleCommits as unknown[]).length,
4023
+ paths: (result.unreachableSubmoduleCommits as Array<Record<string, unknown>>).map(e => e.path),
4024
+ autoPublishAllowed: (result.unreachableSubmoduleCommits as Array<Record<string, unknown>>)[0]?.autoPublishAllowed,
4025
+ };
4026
+ }
4027
+ // Validation details
4028
+ if (stage === 'validation' && result.validationSummary) {
4029
+ const vs = result.validationSummary as Record<string, unknown>;
4030
+ ctx.details = {
4031
+ failureCode: vs.failureCode,
4032
+ commandsRun: Array.isArray(vs.commandsRun) ? vs.commandsRun.length : undefined,
4033
+ };
4034
+ }
4035
+ return ctx;
4036
+ })();
4037
+
3930
4038
  const normalizedResult = {
3931
4039
  ...result,
3932
4040
  terminalKind: refineTerminalKind,
4041
+ ...(blockerContext ? { blockerContext } : {}),
3933
4042
  ...(result.nextStep === undefined && !isTerminalSuccess ? {
3934
4043
  nextStep: refineTerminalKind === 'blocked_review'
3935
4044
  ? 'Request user review/approval before attempting to merge again.'
@@ -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,
@@ -159,6 +159,44 @@ function extractToolOutputContent(payload: Record<string, unknown>): string {
159
159
  return '';
160
160
  }
161
161
 
162
+ function hasAssistantStandardMessageSinceLastUser(records: NativeHistoryMessage[], content: string): boolean {
163
+ const normalized = content.trim();
164
+ if (!normalized) return false;
165
+ for (let i = records.length - 1; i >= 0; i--) {
166
+ const record = records[i];
167
+ if (record.kind === 'session_start') continue;
168
+ if (record.role === 'user') return false;
169
+ if (record.role === 'assistant' && record.kind === 'standard' && record.content.trim() === normalized) {
170
+ return true;
171
+ }
172
+ }
173
+ return false;
174
+ }
175
+
176
+ function pushAssistantStandardMessage(
177
+ records: NativeHistoryMessage[],
178
+ sessionId: string,
179
+ receivedAt: number,
180
+ content: string,
181
+ workspace?: string,
182
+ ): void {
183
+ const text = content.trim();
184
+ if (!text) return;
185
+ if (hasAssistantStandardMessageSinceLastUser(records, text)) return;
186
+
187
+ const msg: NativeHistoryMessage = {
188
+ ts: new Date(receivedAt).toISOString(),
189
+ receivedAt,
190
+ role: 'assistant',
191
+ content: text,
192
+ kind: 'standard',
193
+ agent: 'codex-cli',
194
+ historySessionId: sessionId,
195
+ };
196
+ if (workspace) msg.workspace = workspace;
197
+ records.push(msg);
198
+ }
199
+
162
200
  /**
163
201
  * Read the first line of a Codex JSONL session file and parse the session_meta record.
164
202
  * Returns the payload object (containing id, cwd, etc.) or null.
@@ -233,16 +271,38 @@ function parseSessionFile(
233
271
  continue;
234
272
  }
235
273
 
236
- if (type !== 'response_item') continue;
237
-
238
274
  const payloadType = String(payload.type ?? '').trim();
239
275
 
276
+ if (type === 'event_msg') {
277
+ if (payloadType === 'task_complete') {
278
+ pushAssistantStandardMessage(
279
+ records,
280
+ sessionId,
281
+ receivedAt,
282
+ flattenCodexContent(payload.last_agent_message),
283
+ detectedWorkspace,
284
+ );
285
+ } else if (payloadType === 'agent_message' && String(payload.phase ?? '').trim() === 'final_answer') {
286
+ pushAssistantStandardMessage(
287
+ records,
288
+ sessionId,
289
+ receivedAt,
290
+ flattenCodexContent(payload.message),
291
+ detectedWorkspace,
292
+ );
293
+ }
294
+ continue;
295
+ }
296
+
297
+ if (type !== 'response_item') continue;
298
+
240
299
  if (payloadType === 'message') {
241
300
  const role = String(payload.role ?? '').trim();
242
301
  if (role !== 'user' && role !== 'assistant') continue;
243
302
 
244
303
  const content = flattenCodexContent(payload.content);
245
304
  if (!content) continue;
305
+ if (role === 'assistant' && hasAssistantStandardMessageSinceLastUser(records, content)) continue;
246
306
 
247
307
  const msg: NativeHistoryMessage = {
248
308
  ts: new Date(receivedAt).toISOString(),
@@ -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;