@adhdev/daemon-core 0.9.82-rc.262 → 0.9.82-rc.264

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.
@@ -68,10 +68,40 @@ export interface GitRepoStatus extends GitRepoIdentity {
68
68
  lastCheckedAt: number;
69
69
  /** Submodule statuses when auto-discover is enabled */
70
70
  submodules?: GitSubmoduleStatus[];
71
+ /**
72
+ * Set when the running daemon's build commit is a STRICT ancestor of this
73
+ * repo's HEAD (or a submodule HEAD) — i.e. the live daemon predates committed
74
+ * code in this workspace and is awaiting a deploy/restart to catch up.
75
+ * Omitted entirely when no staleness is provable (unknown build, commit not
76
+ * present in repo, or build commit == HEAD) to avoid over-warning.
77
+ */
78
+ daemonBuildBehind?: DaemonBuildBehind;
71
79
  error?: string;
72
80
  reason?: GitFailureReason;
73
81
  }
74
82
 
83
+ export interface DaemonBuildBehind {
84
+ /** Full build commit baked into the running daemon. */
85
+ buildCommit: string;
86
+ /** Short build commit. */
87
+ buildCommitShort: string;
88
+ /** HEAD commit the build commit is behind (repo or submodule). */
89
+ head: string;
90
+ /** Where the comparison matched: 'root' or the submodule path. */
91
+ scope: string;
92
+ /**
93
+ * Whether any package changed between buildCommit..HEAD affects the daemon
94
+ * runtime (daemon-core, standalone, session-host, terminal-mux, ghostty,
95
+ * mcp-server). When false, only web/render packages changed — the daemon does
96
+ * NOT need a rebuild/restart; only the web deploy is pending. Conservative:
97
+ * when the changed-package set can't be determined it defaults to true.
98
+ */
99
+ isDaemonAffecting: boolean;
100
+ /** Distinct package names changed between buildCommit..HEAD (best-effort). */
101
+ affectedPackages?: string[];
102
+ warning: string;
103
+ }
104
+
75
105
  export type GitFileChangeStatus =
76
106
  | 'added'
77
107
  | 'modified'
package/src/index.ts CHANGED
@@ -217,9 +217,10 @@ export { buildMeshLedgerReconciliationEvidence, buildMeshLedgerReplicaEvidence }
217
217
  export type { AnyLedgerSlice, MeshLedgerReconciliationEvidence, MeshLedgerReplicaEvidence, MeshLedgerReplicaStatus } from './mesh/mesh-ledger-reconciliation.js';
218
218
 
219
219
  // ── Mesh Work Queue (GUPP) ──
220
- export { enqueueTask, recordDirectDispatchTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest, buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, normalizeMeshCapabilityTags, insertDirectDispatch, getActiveDirectDispatches, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, markStaleDirectDispatches, recordMeshToolCall, assertNoDependencyCycle, hasPendingDependents, describeTaskDependencyState } from './mesh/mesh-work-queue.js';
220
+ export { enqueueTask, recordDirectDispatchTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest, buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, normalizeMeshCapabilityTags, insertDirectDispatch, getActiveDirectDispatches, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, markStaleDirectDispatches, deleteDirectDispatchesByTaskId, recordMeshToolCall, assertNoDependencyCycle, hasPendingDependents, describeTaskDependencyState } from './mesh/mesh-work-queue.js';
221
221
  export type { MeshWorkQueueEntry, MeshTaskStatus, MeshTaskMode, MeshWorkQueueStats, MeshQueueMutationOptions, MeshTaskModeValidationResult, DirectDispatchRecord, MeshToolCallRateResult } from './mesh/mesh-work-queue.js';
222
- export { buildCompactStaleDirectWorkSummary, buildMeshActiveWork, buildMeshActiveWorkSummary } from './mesh/mesh-active-work.js';
222
+ export { buildCompactStaleDirectWorkSummary, buildMeshActiveWork, buildMeshActiveWorkSummary, classifyStaleDirectForPrune, PRUNABLE_ORPHAN_STALE_REASONS } from './mesh/mesh-active-work.js';
223
+ export type { StaleDirectPruneClassification } from './mesh/mesh-active-work.js';
223
224
  export type { MeshActiveWorkRecord, MeshActiveWorkStatus, MeshActiveWorkSummary, MeshActiveWorkSource, MeshStaleDirectWorkSummary } from './mesh/mesh-active-work.js';
224
225
  export { buildMeshAsyncRefineJobs, summarizeMeshAsyncRefineJobs, STALE_TERMINAL_REFINE_WINDOW_MS, RECENT_TERMINAL_REFINE_CAP } from './mesh/mesh-refine-status.js';
225
226
  export type { MeshAsyncRefineJobStatus, MeshAsyncRefineJobSummary, MeshAsyncRefineJobsSummary } from './mesh/mesh-refine-status.js';
@@ -306,6 +307,8 @@ export type {
306
307
  export { DaemonStatusReporter } from './status/reporter.js';
307
308
  export { buildSessionEntries, findCdpManager, hasCdpManager, isCdpConnected } from './status/builders.js';
308
309
  export { buildStatusSnapshot, buildMachineInfo } from './status/snapshot.js';
310
+ export { getDaemonBuildInfo } from './build-info.js';
311
+ export type { DaemonBuildInfo } from './build-info.js';
309
312
  export { normalizeManagedStatus, isManagedStatusWorking, isManagedStatusWaiting, normalizeActiveChatData } from './status/normalize.js';
310
313
  export type { ManagedStatus } from './status/normalize.js';
311
314
  export type { StatusSnapshotOptions, StatusSnapshot } from './status/snapshot.js';
@@ -408,6 +408,37 @@ export function buildMeshActiveWork(opts: BuildMeshActiveWorkOptions): { activeW
408
408
  return { activeWork: records, staleDirectWork, staleDirectWorkNote, terminalDirectWork, summary };
409
409
  }
410
410
 
411
+ /**
412
+ * staleReason strings (produced by sessionStatusFromNodes above) that indicate the original
413
+ * node/session is GONE from the live mesh — i.e. the staleDirect record is an orphaned ledger
414
+ * artifact, not active or recoverable work. These are the only reasons safe to prune from the
415
+ * active staleDirect surface. The "no provider acknowledgement" reason is deliberately excluded:
416
+ * those entries have a still-live node/session (staleDispatchUnacknowledged) and represent
417
+ * recoverable dispatch failures, never orphans.
418
+ */
419
+ export const PRUNABLE_ORPHAN_STALE_REASONS: ReadonlySet<string> = new Set([
420
+ 'direct task node is no longer in the live mesh',
421
+ 'direct task session is not present in live session records',
422
+ 'direct task has no node id',
423
+ ]);
424
+
425
+ export type StaleDirectPruneClassification = 'prunable_orphan' | 'prunable_terminal' | 'preserve_unacknowledged' | 'preserve_active';
426
+
427
+ /**
428
+ * Classify a direct-work record for the staleDirect prune path. Pure function — the prune tool
429
+ * uses this so the safety rules (never touch active work or recoverable unacknowledged dispatches)
430
+ * live next to the staleReason producers and are independently testable.
431
+ */
432
+ export function classifyStaleDirectForPrune(
433
+ record: Pick<MeshActiveWorkRecord, 'staleReason' | 'staleDispatchUnacknowledged' | 'terminal'>,
434
+ opts: { includeTerminal?: boolean } = {},
435
+ ): StaleDirectPruneClassification {
436
+ if (record.staleDispatchUnacknowledged === true) return 'preserve_unacknowledged';
437
+ if (record.terminal === true) return opts.includeTerminal ? 'prunable_terminal' : 'preserve_active';
438
+ if (record.staleReason && PRUNABLE_ORPHAN_STALE_REASONS.has(record.staleReason)) return 'prunable_orphan';
439
+ return 'preserve_active';
440
+ }
441
+
411
442
  export function buildCompactStaleDirectWorkSummary(
412
443
  staleDirectWork: MeshActiveWorkRecord[],
413
444
  opts: { sampleLimit?: number; detailHint?: string; note?: string } = {},
@@ -13,21 +13,54 @@ export interface MeshFastForwardNodeArgs {
13
13
  submoduleIgnorePaths?: string[];
14
14
  timeoutMs?: number;
15
15
  trigger?: 'manual' | 'idle_auto' | string;
16
+ /**
17
+ * Operation mode. 'merge' (default) absorbs upstream commits into the local
18
+ * branch via git merge --ff-only (requires ahead=0, behind>0). 'push' publishes
19
+ * local commits to origin via a strict ff-only push (requires HEAD to be a
20
+ * descendant of origin/<branch>); it never force-pushes, resets, or rebases.
21
+ */
22
+ mode?: 'merge' | 'push';
23
+ /**
24
+ * When mode='push', also fast-forward push submodule (e.g. oss) HEADs to their
25
+ * origin main branch. Gated by allowAutoPublishSubmoduleMainCommits — skipped
26
+ * unless that policy is true. Each submodule must still pass the descendant gate.
27
+ * Defaults false (root push only).
28
+ */
29
+ pushSubmodules?: boolean;
30
+ /**
31
+ * Mesh policy flag mirrored from RepoMeshPolicy.allowAutoPublishSubmoduleMainCommits.
32
+ * Submodule pushes are refused unless this is true.
33
+ */
34
+ allowAutoPublishSubmoduleMainCommits?: boolean;
16
35
  }
17
36
 
18
37
  export interface MeshFastForwardPlannedStep {
19
- operation: 'refresh_upstream' | 'verify_clean_worktree' | 'verify_fast_forward' | 'merge_ff_only' | 'submodule_update' | 'verify_post_status';
38
+ operation: 'refresh_upstream' | 'verify_clean_worktree' | 'verify_fast_forward' | 'merge_ff_only'
39
+ | 'submodule_update' | 'verify_post_status' | 'verify_push_descendant' | 'push_ff_only' | 'push_submodules_ff_only';
20
40
  description: string;
21
41
  safe: true;
22
42
  willMutateWorktree: boolean;
23
43
  }
24
44
 
45
+ export interface MeshFastForwardSubmodulePushResult {
46
+ path: string;
47
+ commit?: string;
48
+ remote: string;
49
+ remoteBranch: string;
50
+ pushed: boolean;
51
+ skipped: boolean;
52
+ code: string;
53
+ refspec?: string;
54
+ error?: string;
55
+ }
56
+
25
57
  export interface MeshFastForwardResult {
26
58
  success: boolean;
27
59
  code: string;
28
60
  nodeId?: string;
29
61
  meshId?: string;
30
62
  workspace: string;
63
+ mode: 'merge' | 'push';
31
64
  allowed: boolean;
32
65
  dryRun: boolean;
33
66
  willRun: boolean;
@@ -38,15 +71,20 @@ export interface MeshFastForwardResult {
38
71
  current?: GitRepoStatus;
39
72
  preStatus?: GitRepoStatus;
40
73
  postStatus?: GitRepoStatus;
74
+ /** Push target derived from the tracked upstream (mode='push'). */
75
+ pushTarget?: { remote: string; remoteBranch: string; refspec: string };
76
+ /** Submodule ff-only push outcomes (mode='push' with pushSubmodules). */
77
+ submodulePushes?: MeshFastForwardSubmodulePushResult[];
41
78
  finalBranchConvergenceState?: Record<string, unknown>;
42
79
  operationError?: string;
43
80
  ledgerError?: string;
81
+ nextStep?: string;
44
82
  trigger?: string;
45
83
  }
46
84
 
47
85
  type MeshFastForwardBase = Pick<
48
86
  MeshFastForwardResult,
49
- 'workspace' | 'dryRun' | 'updateSubmodules' | 'plannedSteps' | 'trigger'
87
+ 'workspace' | 'mode' | 'dryRun' | 'updateSubmodules' | 'plannedSteps' | 'trigger'
50
88
  > & Pick<Partial<MeshFastForwardResult>, 'nodeId' | 'meshId'>;
51
89
 
52
90
  const STATUS_OPTIONS = { refreshUpstream: true, includeSubmodules: true, timeoutMs: 15_000 } as const;
@@ -59,11 +97,14 @@ export async function fastForwardMeshNode(args: MeshFastForwardNodeArgs): Promis
59
97
  const trigger = normalizeOptionalString(args.trigger) || 'manual';
60
98
  const updateSubmodules = args.updateSubmodules === true;
61
99
  const dryRun = args.dryRun === true || args.execute !== true;
62
- const plannedSteps = buildPlannedSteps(updateSubmodules);
100
+ const mode: 'merge' | 'push' = args.mode === 'push' ? 'push' : 'merge';
101
+ const pushSubmodules = mode === 'push' && args.pushSubmodules === true;
102
+ const plannedSteps = buildPlannedSteps(mode, updateSubmodules, pushSubmodules);
63
103
  const base: MeshFastForwardBase = {
64
104
  ...(nodeId ? { nodeId } : {}),
65
105
  ...(meshId ? { meshId } : {}),
66
106
  workspace,
107
+ mode,
67
108
  dryRun,
68
109
  updateSubmodules,
69
110
  plannedSteps,
@@ -80,13 +121,28 @@ export async function fastForwardMeshNode(args: MeshFastForwardNodeArgs): Promis
80
121
  timeoutMs: args.timeoutMs ?? STATUS_OPTIONS.timeoutMs,
81
122
  });
82
123
 
124
+ if (mode === 'push') {
125
+ return pushMeshNode(base, args, current, {
126
+ pushSubmodules,
127
+ allowAutoPublishSubmoduleMainCommits: args.allowAutoPublishSubmoduleMainCommits === true,
128
+ });
129
+ }
130
+
83
131
  const earlyBlockers = collectPreflightBlockers(current, requestedBranch);
84
132
  if (earlyBlockers.length > 0) {
133
+ const blockCode = chooseBlockCode(current, earlyBlockers);
85
134
  const result: MeshFastForwardResult = {
86
- ...block(base, chooseBlockCode(current, earlyBlockers), earlyBlockers),
135
+ ...block(base, blockCode, earlyBlockers),
87
136
  current,
88
- finalBranchConvergenceState: buildConvergenceState(current, codeToConvergenceStatus(chooseBlockCode(current, earlyBlockers))),
137
+ finalBranchConvergenceState: buildConvergenceState(current, codeToConvergenceStatus(blockCode)),
89
138
  };
139
+ // Pure ahead (local commits not yet on origin, nothing to merge in) is not a
140
+ // hard failure — it's a push-needed case. Reclassify so the coordinator can
141
+ // route to push mode without launching an agent session.
142
+ if (blockCode === 'branch_ahead' && current.ahead > 0 && current.behind === 0 && otherBlockersAreOnlyAhead(earlyBlockers)) {
143
+ result.code = 'ahead_needs_push';
144
+ result.nextStep = 'Local branch is ahead of origin with nothing to merge. Re-run mesh_fast_forward_node with mode="push" (execute=true) to ff-only push the local commits to origin.';
145
+ }
90
146
  await appendFastForwardLedger(result, 'blocked');
91
147
  return result;
92
148
  }
@@ -210,7 +266,300 @@ export async function fastForwardMeshNode(args: MeshFastForwardNodeArgs): Promis
210
266
  return result;
211
267
  }
212
268
 
213
- function buildPlannedSteps(updateSubmodules: boolean): MeshFastForwardPlannedStep[] {
269
+ interface PushOptions {
270
+ pushSubmodules: boolean;
271
+ allowAutoPublishSubmoduleMainCommits: boolean;
272
+ }
273
+
274
+ /**
275
+ * Strict ff-only push of a node's local commits to origin/<branch>. Only proceeds
276
+ * when HEAD is a descendant of origin/<branch> (origin/<branch> is an ancestor of
277
+ * HEAD). Never force-pushes, resets, rebases, cleans, or checks out. Optionally
278
+ * ff-only pushes submodule HEADs to their origin main when policy allows.
279
+ */
280
+ async function pushMeshNode(
281
+ base: MeshFastForwardBase,
282
+ args: MeshFastForwardNodeArgs,
283
+ current: GitRepoStatus,
284
+ options: PushOptions,
285
+ ): Promise<MeshFastForwardResult> {
286
+ const workspace = base.workspace;
287
+ const requestedBranch = normalizeOptionalString(args.branch);
288
+ const dryRun = base.dryRun;
289
+
290
+ const blockers = collectPushPreflightBlockers(current, requestedBranch);
291
+ if (blockers.length > 0) {
292
+ const code = choosePushBlockCode(current, blockers);
293
+ const result: MeshFastForwardResult = {
294
+ ...block(base, code, blockers),
295
+ current,
296
+ preStatus: current,
297
+ finalBranchConvergenceState: buildConvergenceState(current, codeToConvergenceStatus(code)),
298
+ };
299
+ await appendFastForwardLedger(result, 'blocked');
300
+ return result;
301
+ }
302
+
303
+ const target = parseUpstreamTarget(current.upstream || '');
304
+ if (!target) {
305
+ const result: MeshFastForwardResult = {
306
+ ...block(base, 'upstream_unparseable', ['upstream_unparseable']),
307
+ current,
308
+ preStatus: current,
309
+ finalBranchConvergenceState: buildConvergenceState(current, 'blocked'),
310
+ };
311
+ await appendFastForwardLedger(result, 'blocked');
312
+ return result;
313
+ }
314
+ const refspec = `HEAD:refs/heads/${target.remoteBranch}`;
315
+ const pushTarget = { remote: target.remote, remoteBranch: target.remoteBranch, refspec };
316
+
317
+ if (current.ahead <= 0) {
318
+ // Nothing local to publish.
319
+ const result: MeshFastForwardResult = {
320
+ ...base,
321
+ success: true,
322
+ code: 'nothing_to_push',
323
+ allowed: true,
324
+ willRun: false,
325
+ executed: false,
326
+ blockingReasons: [],
327
+ current,
328
+ preStatus: current,
329
+ postStatus: current,
330
+ pushTarget,
331
+ finalBranchConvergenceState: buildConvergenceState(current, 'up_to_date'),
332
+ };
333
+ await appendFastForwardLedger(result, 'noop');
334
+ return result;
335
+ }
336
+
337
+ // Strict ff-only gate: origin/<branch> must be an ancestor of HEAD.
338
+ const descendant = await verifyUpstreamIsAncestorOfHead(workspace, current.upstream || '', args.timeoutMs);
339
+ if (!descendant.ok) {
340
+ const result: MeshFastForwardResult = {
341
+ ...block(base, 'non_fast_forward_push', ['head_is_not_descendant_of_upstream']),
342
+ current,
343
+ preStatus: current,
344
+ pushTarget,
345
+ operationError: descendant.error,
346
+ nextStep: 'origin/<branch> has commits not in local HEAD; a ff-only push would lose them. Converge by rebasing onto origin first, then re-run. This operation never force-pushes.',
347
+ finalBranchConvergenceState: buildConvergenceState(current, 'not_mergeable'),
348
+ };
349
+ await appendFastForwardLedger(result, 'blocked');
350
+ return result;
351
+ }
352
+
353
+ if (dryRun) {
354
+ const result: MeshFastForwardResult = {
355
+ ...base,
356
+ success: true,
357
+ code: 'push_available',
358
+ allowed: true,
359
+ willRun: false,
360
+ executed: false,
361
+ blockingReasons: [],
362
+ current,
363
+ preStatus: current,
364
+ pushTarget,
365
+ ...(options.pushSubmodules ? { submodulePushes: await planSubmodulePushes(current, options, args.timeoutMs) } : {}),
366
+ finalBranchConvergenceState: buildConvergenceState(current, 'push_available'),
367
+ };
368
+ await appendFastForwardLedger(result, 'dry_run');
369
+ return result;
370
+ }
371
+
372
+ // Execute the root ff-only push.
373
+ try {
374
+ await runGit(workspace, ['push', target.remote, refspec], { timeoutMs: args.timeoutMs ?? 30_000 });
375
+ } catch (error) {
376
+ const result: MeshFastForwardResult = {
377
+ ...block(base, 'push_ff_only_failed', ['push_ff_only_failed']),
378
+ current,
379
+ preStatus: current,
380
+ pushTarget,
381
+ operationError: formatGitError(error),
382
+ finalBranchConvergenceState: buildConvergenceState(current, 'not_mergeable'),
383
+ };
384
+ await appendFastForwardLedger(result, 'failed');
385
+ return result;
386
+ }
387
+
388
+ let submodulePushes: MeshFastForwardSubmodulePushResult[] | undefined;
389
+ if (options.pushSubmodules) {
390
+ submodulePushes = await executeSubmodulePushes(current, options, args.timeoutMs);
391
+ }
392
+
393
+ const postStatus = await getGitRepoStatus(workspace, {
394
+ ...STATUS_OPTIONS,
395
+ submoduleIgnorePaths: args.submoduleIgnorePaths,
396
+ timeoutMs: args.timeoutMs ?? STATUS_OPTIONS.timeoutMs,
397
+ });
398
+
399
+ const submodulePushFailed = (submodulePushes || []).some((entry) => !entry.pushed && !entry.skipped);
400
+ const blockingReasons: string[] = [];
401
+ if (postStatus.ahead !== 0) blockingReasons.push('post_branch_ahead');
402
+ if (submodulePushFailed) blockingReasons.push('submodule_push_failed');
403
+
404
+ const success = blockingReasons.length === 0;
405
+ const code = success
406
+ ? 'push_applied'
407
+ : submodulePushFailed && postStatus.ahead === 0
408
+ ? 'push_applied_submodule_push_failed'
409
+ : 'post_push_verify_failed';
410
+ const result: MeshFastForwardResult = {
411
+ ...base,
412
+ success,
413
+ code,
414
+ allowed: true,
415
+ willRun: true,
416
+ executed: true,
417
+ blockingReasons,
418
+ current,
419
+ preStatus: current,
420
+ postStatus,
421
+ pushTarget,
422
+ ...(submodulePushes ? { submodulePushes } : {}),
423
+ finalBranchConvergenceState: buildConvergenceState(postStatus, success ? 'pushed' : 'post_verify_failed'),
424
+ };
425
+ await appendFastForwardLedger(result, success ? 'executed' : 'failed');
426
+ return result;
427
+ }
428
+
429
+ function collectPushPreflightBlockers(status: GitRepoStatus, requestedBranch?: string): string[] {
430
+ const blockers: string[] = [];
431
+ if (!status.isGitRepo) blockers.push('not_git_repo');
432
+ if (!status.branch) blockers.push('detached_head_or_unknown_branch');
433
+ if (requestedBranch && status.branch !== requestedBranch) blockers.push('branch_mismatch');
434
+ if (!status.upstream) blockers.push('upstream_missing');
435
+ if (status.upstreamStatus !== 'fresh') blockers.push('upstream_not_fresh');
436
+ if (status.hasConflicts) blockers.push('conflicts_present');
437
+ if (status.staged > 0) blockers.push('staged_changes_present');
438
+ if (status.modified > 0) blockers.push('modified_changes_present');
439
+ if (status.untracked > 0) blockers.push('untracked_changes_present');
440
+ if (status.deleted > 0) blockers.push('deleted_changes_present');
441
+ if (status.renamed > 0) blockers.push('renamed_changes_present');
442
+ if (status.stashCount > 0) blockers.push('stash_entries_present');
443
+ // A diverged branch (ahead>0 AND behind>0) cannot ff-only push: origin has
444
+ // commits not in HEAD. The descendant gate also catches this, but flagging it
445
+ // in preflight gives a clearer code.
446
+ if (status.ahead > 0 && status.behind > 0) blockers.push('branch_diverged_from_upstream');
447
+ else if (status.behind > 0) blockers.push('branch_behind_upstream');
448
+ return blockers;
449
+ }
450
+
451
+ function choosePushBlockCode(status: GitRepoStatus, blockers: string[]): string {
452
+ if (blockers.includes('not_git_repo')) return 'not_git_repo';
453
+ if (blockers.includes('branch_mismatch')) return 'branch_mismatch';
454
+ if (blockers.includes('upstream_missing')) return 'upstream_missing';
455
+ if (blockers.includes('upstream_not_fresh')) return 'upstream_not_fresh';
456
+ if (blockers.includes('branch_diverged_from_upstream')) return 'branch_diverged';
457
+ if (blockers.includes('branch_behind_upstream')) return 'non_fast_forward_push';
458
+ if (blockers.some((reason) => reason.includes('changes') || reason.includes('conflicts') || reason.includes('stash'))) return 'dirty_worktree';
459
+ return 'preflight_blocked';
460
+ }
461
+
462
+ /** Parse an upstream ref like "origin/main" into { remote, remoteBranch }. */
463
+ function parseUpstreamTarget(upstream: string): { remote: string; remoteBranch: string } | null {
464
+ const trimmed = upstream.trim();
465
+ const slash = trimmed.indexOf('/');
466
+ if (slash <= 0 || slash >= trimmed.length - 1) return null;
467
+ return { remote: trimmed.slice(0, slash), remoteBranch: trimmed.slice(slash + 1) };
468
+ }
469
+
470
+ async function verifyUpstreamIsAncestorOfHead(workspace: string, upstream: string, timeoutMs?: number): Promise<{ ok: boolean; error?: string }> {
471
+ if (!upstream) return { ok: false, error: 'missing upstream' };
472
+ try {
473
+ await runGit(workspace, ['merge-base', '--is-ancestor', upstream, 'HEAD'], { timeoutMs: timeoutMs ?? 15_000 });
474
+ return { ok: true };
475
+ } catch (error) {
476
+ return { ok: false, error: formatGitError(error) };
477
+ }
478
+ }
479
+
480
+ /** Dry-run plan for submodule pushes: classify each as would-push / skipped / blocked. */
481
+ async function planSubmodulePushes(status: GitRepoStatus, options: PushOptions, timeoutMs?: number): Promise<MeshFastForwardSubmodulePushResult[]> {
482
+ return resolveSubmodulePushes(status, options, false, timeoutMs);
483
+ }
484
+
485
+ async function executeSubmodulePushes(status: GitRepoStatus, options: PushOptions, timeoutMs?: number): Promise<MeshFastForwardSubmodulePushResult[]> {
486
+ return resolveSubmodulePushes(status, options, true, timeoutMs);
487
+ }
488
+
489
+ async function resolveSubmodulePushes(
490
+ status: GitRepoStatus,
491
+ options: PushOptions,
492
+ execute: boolean,
493
+ timeoutMs?: number,
494
+ ): Promise<MeshFastForwardSubmodulePushResult[]> {
495
+ const submodules = Array.isArray(status.submodules) ? status.submodules : [];
496
+ const results: MeshFastForwardSubmodulePushResult[] = [];
497
+ for (const submodule of submodules) {
498
+ const base: MeshFastForwardSubmodulePushResult = {
499
+ path: submodule.path,
500
+ commit: submodule.commit,
501
+ remote: 'origin',
502
+ remoteBranch: 'main',
503
+ pushed: false,
504
+ skipped: true,
505
+ code: 'submodule_push_skipped',
506
+ };
507
+ if (!options.allowAutoPublishSubmoduleMainCommits) {
508
+ results.push({ ...base, code: 'submodule_push_policy_disabled', error: 'allowAutoPublishSubmoduleMainCommits is not enabled' });
509
+ continue;
510
+ }
511
+ if (submodule.error || submodule.dirty) {
512
+ results.push({ ...base, code: 'submodule_not_clean', error: submodule.error || 'submodule worktree is dirty' });
513
+ continue;
514
+ }
515
+ const repoPath = submodule.repoPath;
516
+ if (!repoPath || !submodule.commit) {
517
+ results.push({ ...base, code: 'submodule_status_incomplete' });
518
+ continue;
519
+ }
520
+ // Refresh the submodule's origin/main, then require it to be an ancestor of
521
+ // the gitlink commit (strict ff-only).
522
+ try {
523
+ await runGit(repoPath, ['-c', 'protocol.file.allow=always', 'fetch', 'origin', 'refs/heads/main:refs/remotes/origin/main'], { timeoutMs: timeoutMs ?? 30_000 });
524
+ } catch (error) {
525
+ results.push({ ...base, code: 'submodule_fetch_failed', error: formatGitError(error) });
526
+ continue;
527
+ }
528
+ let alreadyReachable = false;
529
+ try {
530
+ await runGit(repoPath, ['merge-base', '--is-ancestor', submodule.commit, 'refs/remotes/origin/main'], { timeoutMs: timeoutMs ?? 15_000 });
531
+ alreadyReachable = true;
532
+ } catch { /* not yet on origin/main — candidate for push */ }
533
+ if (alreadyReachable) {
534
+ results.push({ ...base, pushed: false, skipped: true, code: 'submodule_already_reachable' });
535
+ continue;
536
+ }
537
+ // Strict ff-only: origin/main must be an ancestor of the commit we publish.
538
+ try {
539
+ await runGit(repoPath, ['merge-base', '--is-ancestor', 'refs/remotes/origin/main', submodule.commit], { timeoutMs: timeoutMs ?? 15_000 });
540
+ } catch (error) {
541
+ results.push({ ...base, pushed: false, skipped: false, code: 'submodule_non_fast_forward', error: formatGitError(error) });
542
+ continue;
543
+ }
544
+ const refspec = `${submodule.commit}:refs/heads/main`;
545
+ if (!execute) {
546
+ results.push({ ...base, pushed: false, skipped: false, code: 'submodule_push_available', refspec });
547
+ continue;
548
+ }
549
+ try {
550
+ await runGit(repoPath, ['push', 'origin', refspec], { timeoutMs: timeoutMs ?? 30_000 });
551
+ // Verify reachability after the push.
552
+ await runGit(repoPath, ['-c', 'protocol.file.allow=always', 'fetch', 'origin', 'refs/heads/main:refs/remotes/origin/main'], { timeoutMs: timeoutMs ?? 30_000 });
553
+ await runGit(repoPath, ['merge-base', '--is-ancestor', submodule.commit, 'refs/remotes/origin/main'], { timeoutMs: timeoutMs ?? 15_000 });
554
+ results.push({ ...base, pushed: true, skipped: false, code: 'submodule_pushed', refspec });
555
+ } catch (error) {
556
+ results.push({ ...base, pushed: false, skipped: false, code: 'submodule_push_failed', refspec, error: formatGitError(error) });
557
+ }
558
+ }
559
+ return results;
560
+ }
561
+
562
+ function buildPlannedSteps(mode: 'merge' | 'push', updateSubmodules: boolean, pushSubmodules: boolean): MeshFastForwardPlannedStep[] {
214
563
  const steps: MeshFastForwardPlannedStep[] = [
215
564
  {
216
565
  operation: 'refresh_upstream',
@@ -224,19 +573,48 @@ function buildPlannedSteps(updateSubmodules: boolean): MeshFastForwardPlannedSte
224
573
  safe: true,
225
574
  willMutateWorktree: false,
226
575
  },
227
- {
228
- operation: 'verify_fast_forward',
229
- description: 'Require ahead=0, behind>0, and HEAD to be an ancestor of the upstream ref.',
576
+ ];
577
+ if (mode === 'push') {
578
+ steps.push({
579
+ operation: 'verify_push_descendant',
580
+ description: 'Require HEAD to be a descendant of origin/<branch> (origin/<branch> is an ancestor of HEAD); refuse any non-fast-forward push.',
230
581
  safe: true,
231
582
  willMutateWorktree: false,
232
- },
233
- {
234
- operation: 'merge_ff_only',
235
- description: 'Apply git merge --ff-only against the tracked upstream; no force, reset, rebase, push, or deploy.',
583
+ });
584
+ steps.push({
585
+ operation: 'push_ff_only',
586
+ description: 'Run git push origin HEAD:<branch> as a strict ff-only push; never --force, --force-with-lease, reset, or rebase. Does not mutate the worktree.',
236
587
  safe: true,
237
- willMutateWorktree: true,
238
- },
239
- ];
588
+ willMutateWorktree: false,
589
+ });
590
+ if (pushSubmodules) {
591
+ steps.push({
592
+ operation: 'push_submodules_ff_only',
593
+ description: 'For each submodule, if allowAutoPublishSubmoduleMainCommits is enabled and the submodule HEAD is a descendant of its origin main, ff-only push it to submodule origin main; otherwise skip.',
594
+ safe: true,
595
+ willMutateWorktree: false,
596
+ });
597
+ }
598
+ steps.push({
599
+ operation: 'verify_post_status',
600
+ description: 'Re-read daemon-owned git status and report final branch convergence state.',
601
+ safe: true,
602
+ willMutateWorktree: false,
603
+ });
604
+ return steps;
605
+ }
606
+ steps.push({
607
+ operation: 'verify_fast_forward',
608
+ description: 'Require ahead=0, behind>0, and HEAD to be an ancestor of the upstream ref.',
609
+ safe: true,
610
+ willMutateWorktree: false,
611
+ });
612
+ steps.push({
613
+ operation: 'merge_ff_only',
614
+ description: 'Apply git merge --ff-only against the tracked upstream; no force, reset, rebase, push, or deploy.',
615
+ safe: true,
616
+ willMutateWorktree: true,
617
+ });
240
618
  if (updateSubmodules) {
241
619
  steps.push({
242
620
  operation: 'submodule_update',
@@ -254,6 +632,16 @@ function buildPlannedSteps(updateSubmodules: boolean): MeshFastForwardPlannedSte
254
632
  return steps;
255
633
  }
256
634
 
635
+ /**
636
+ * True when every preflight blocker is attributable purely to the branch being
637
+ * ahead of its upstream (local commits not yet pushed) — i.e. nothing dirty,
638
+ * diverged, or submodule-broken. Used to reclassify branch_ahead → ahead_needs_push.
639
+ */
640
+ function otherBlockersAreOnlyAhead(blockers: string[]): boolean {
641
+ const aheadOnly = new Set(['branch_has_local_commits']);
642
+ return blockers.every((reason) => aheadOnly.has(reason));
643
+ }
644
+
257
645
  function collectPreflightBlockers(status: GitRepoStatus, requestedBranch?: string): string[] {
258
646
  const blockers: string[] = [];
259
647
  if (!status.isGitRepo) blockers.push('not_git_repo');
@@ -314,7 +702,8 @@ function chooseBlockCode(status: GitRepoStatus, blockers: string[]): string {
314
702
  }
315
703
 
316
704
  function codeToConvergenceStatus(code: string): string {
317
- if (code === 'branch_diverged' || code === 'branch_ahead' || code === 'non_fast_forward') return 'not_mergeable';
705
+ if (code === 'branch_diverged' || code === 'branch_ahead' || code === 'non_fast_forward'
706
+ || code === 'non_fast_forward_push' || code === 'upstream_unparseable') return 'not_mergeable';
318
707
  if (code === 'dirty_worktree' || code === 'submodule_not_clean') return 'blocked_review';
319
708
  return 'blocked';
320
709
  }
@@ -409,6 +798,7 @@ async function appendFastForwardLedger(result: MeshFastForwardResult, outcome: '
409
798
  ...(result.nodeId ? { nodeId: result.nodeId } : {}),
410
799
  payload: {
411
800
  operation: 'mesh_fast_forward_node',
801
+ mode: result.mode,
412
802
  trigger: result.trigger || 'manual',
413
803
  outcome,
414
804
  code: result.code,
@@ -419,6 +809,7 @@ async function appendFastForwardLedger(result: MeshFastForwardResult, outcome: '
419
809
  executed: result.executed,
420
810
  branch: result.postStatus?.branch ?? result.current?.branch,
421
811
  upstream: result.postStatus?.upstream ?? result.current?.upstream,
812
+ ...(result.pushTarget ? { pushTarget: result.pushTarget } : {}),
422
813
  before: result.current ? {
423
814
  headCommit: result.current.headCommit,
424
815
  ahead: result.current.ahead,
@@ -429,6 +820,16 @@ async function appendFastForwardLedger(result: MeshFastForwardResult, outcome: '
429
820
  ahead: result.postStatus.ahead,
430
821
  behind: result.postStatus.behind,
431
822
  } : undefined,
823
+ ...(result.submodulePushes ? {
824
+ submodulePushes: result.submodulePushes.map((entry) => ({
825
+ path: entry.path,
826
+ commit: entry.commit,
827
+ pushed: entry.pushed,
828
+ skipped: entry.skipped,
829
+ code: entry.code,
830
+ ...(entry.refspec ? { refspec: entry.refspec } : {}),
831
+ })),
832
+ } : {}),
432
833
  blockingReasons: result.blockingReasons,
433
834
  },
434
835
  });
@@ -41,6 +41,7 @@ export type MeshLedgerKind =
41
41
  | 'ledger_reconciled'
42
42
  | 'direct_fast_forward'
43
43
  | 'delivery_unroutable'
44
+ | 'direct_dispatch_pruned'
44
45
  ;
45
46
 
46
47
  export interface MeshLedgerEntry {