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

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.
@@ -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 {
@@ -676,6 +676,25 @@ export class MeshRuntimeStore {
676
676
  this.db.prepare(`DELETE FROM mesh_direct_dispatches WHERE mesh_id = ?`).run(meshId);
677
677
  }
678
678
 
679
+ /**
680
+ * Delete specific direct dispatch rows by taskId for a mesh. Used by the staleDirect prune
681
+ * path to remove orphaned/terminal dispatch records whose node/session is no longer in the
682
+ * live mesh. Returns the number of rows actually deleted. No-op for an empty taskId list.
683
+ */
684
+ deleteDirectDispatchesByTaskId(meshId: string, taskIds: string[]): number {
685
+ const ids = (taskIds || []).map(id => typeof id === 'string' ? id.trim() : '').filter(Boolean);
686
+ if (!ids.length) return 0;
687
+ const stmt = this.db.prepare(`DELETE FROM mesh_direct_dispatches WHERE mesh_id = ? AND task_id = ?`);
688
+ let deleted = 0;
689
+ const run = this.db.transaction((rows: string[]) => {
690
+ for (const taskId of rows) {
691
+ deleted += stmt.run(meshId, taskId).changes;
692
+ }
693
+ });
694
+ run(ids);
695
+ return deleted;
696
+ }
697
+
679
698
  markStaleDirectDispatches(meshId: string, olderThanMs: number): void {
680
699
  const cutoff = new Date(Date.now() - olderThanMs).toISOString();
681
700
  const now = new Date().toISOString();
@@ -750,6 +750,19 @@ export function markStaleDirectDispatches(meshId: string, olderThanMs = 60 * 60_
750
750
  } catch { /* best-effort */ }
751
751
  }
752
752
 
753
+ /**
754
+ * Delete specific direct dispatch rows by taskId. Returns the number of rows deleted.
755
+ * Used by the staleDirect prune path to evict orphaned/terminal dispatch records from the
756
+ * active staleDirect surface while leaving the append-only mesh ledger (audit history) intact.
757
+ */
758
+ export function deleteDirectDispatchesByTaskId(meshId: string, taskIds: string[]): number {
759
+ try {
760
+ return MeshRuntimeStore.getInstance().deleteDirectDispatchesByTaskId(meshId, taskIds);
761
+ } catch {
762
+ return 0;
763
+ }
764
+ }
765
+
753
766
  export type MeshToolCallRateResult = { rateLimitExceeded: boolean; callsInWindow: number; advisory: string | null };
754
767
 
755
768
  /**