@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.
- package/dist/index.js +219 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +219 -5
- package/dist/index.mjs.map +1 -1
- package/dist/repo-mesh-types.d.ts +17 -0
- package/package.json +1 -1
- package/src/cli-adapters/cli-state-engine.ts +25 -1
- package/src/commands/router.ts +109 -0
- package/src/config/mesh-config.ts +23 -1
- package/src/mesh/mesh-events-coordinator.ts +43 -0
- package/src/providers/native-history/codex-cli-transcript.ts +62 -2
- 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
|
@@ -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
|
-
&& !
|
|
586
|
+
&& !hasFinalCurrentTurnAssistant;
|
|
563
587
|
|
|
564
588
|
if (shouldHoldGenerating) { this.applyHoldGenerating(ctx); return; }
|
|
565
589
|
if (status === 'error') {
|
package/src/commands/router.ts
CHANGED
|
@@ -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
|
|
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(),
|
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;
|