@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.
- package/dist/index.js +81 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +81 -1
- package/dist/index.mjs.map +1 -1
- package/dist/repo-mesh-types.d.ts +17 -0
- package/package.json +1 -1
- package/src/commands/router.ts +25 -0
- package/src/config/mesh-config.ts +23 -1
- package/src/mesh/mesh-events-coordinator.ts +43 -0
- package/src/repo-mesh-types.ts +19 -0
|
@@ -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
package/src/commands/router.ts
CHANGED
|
@@ -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
|
|
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,
|
package/src/repo-mesh-types.ts
CHANGED
|
@@ -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;
|