@adhdev/daemon-core 0.9.82-rc.400 → 0.9.82-rc.402

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.
@@ -228,6 +228,16 @@ export declare class CliProviderInstance implements ProviderInstance {
228
228
  private approvalResolutionFinalizationBlock;
229
229
  private scheduleCompletedDebounceFlush;
230
230
  private isMeshWorkerSession;
231
+ /**
232
+ * ARCH-REFACTOR R1: the taskId to attribute the CURRENTLY-completing turn to.
233
+ * Prefers the per-turn binding (engine.currentTurnTaskId, set when the turn was
234
+ * submitted and surviving until the next turn starts) over the last-write-wins
235
+ * session scalar (settings.meshActiveTaskId). The scalar is retained only as a
236
+ * backward-compat alias for the "current/last assignment" and is the source of the
237
+ * NOTIF-MISDELIVER / TASK-MSG-MISROUTE race: a second task attaching before this
238
+ * turn completes overwrites it. Returns undefined for a non-task ad-hoc turn.
239
+ */
240
+ private completingTurnTaskId;
231
241
  private meshTraceCtx;
232
242
  private flushCompletedDebounceIfFinalized;
233
243
  private maybeAutoApproveStatus;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.400",
3
+ "version": "0.9.82-rc.402",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -46,7 +46,7 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.400",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.402",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -36,7 +36,8 @@ export interface CliAdapter {
36
36
  workingDir: string;
37
37
  _acpInstance?: AcpAdapterHandle;
38
38
  spawn(): Promise<void>;
39
- sendMessage(text: string): Promise<void>;
39
+ sendMessage(text: string, options?: { force?: boolean; meshTaskId?: string }): Promise<void>;
40
+ forceSendMessage?(text: string, meshTaskId?: string): Promise<void>;
40
41
  getStatus(): CliAdapterStatus;
41
42
  getScriptParsedStatus?(): unknown;
42
43
  invokeScript?(scriptName: string, args?: Record<string, unknown>): Promise<unknown>;
@@ -88,8 +88,8 @@ export interface CliAdapter {
88
88
  workingDir: string;
89
89
  _acpInstance?: AcpAdapterHandle;
90
90
  spawn(): Promise<void>;
91
- sendMessage(text: string, options?: { force?: boolean }): Promise<void>;
92
- forceSendMessage?(text: string): Promise<void>;
91
+ sendMessage(text: string, options?: { force?: boolean; meshTaskId?: string }): Promise<void>;
92
+ forceSendMessage?(text: string, meshTaskId?: string): Promise<void>;
93
93
  getStatus(options?: { allowParse?: boolean }): CliAdapterStatus;
94
94
  getScriptParsedStatus?(): unknown;
95
95
  getDebugSnapshot?(): unknown;
@@ -102,6 +102,15 @@ export class CliStateEngine {
102
102
  currentStatus: CliSessionStatus['status'] = 'starting';
103
103
  isWaitingForResponse = false;
104
104
  currentTurnScope: TurnParseScope | null = null;
105
+ // ARCH-REFACTOR R1 (per-turn task identity): the mesh taskId bound to the most
106
+ // recently STARTED turn. Unlike currentTurnScope (nulled the moment the turn
107
+ // settles, before the completion event is even built), this persists past
108
+ // completion and is only overwritten when the NEXT turn starts. That window is
109
+ // exactly what the completion path needs: when a turn settles to idle, this still
110
+ // holds THAT turn's taskId (the next task's turn cannot have started yet — it is
111
+ // queued in pendingOutbound and only flushed asynchronously after idle), so the
112
+ // completion event carries the correct id instead of the racy session scalar.
113
+ currentTurnTaskId: string | null = null;
105
114
  activeModal: { message: string; buttons: string[] } | null = null;
106
115
 
107
116
  // ── Approval ─────────────────────────────────────
@@ -232,6 +241,12 @@ export class CliStateEngine {
232
241
  this.finishRetryCount = 0;
233
242
  this.clearIdleFinishCandidate('send_message');
234
243
  this.currentTurnScope = turnScope;
244
+ // ARCH-REFACTOR R1: bind this turn's mesh taskId. A task-less (ad-hoc dashboard)
245
+ // turn carries no taskId → null here, which correctly clears any prior task's id
246
+ // so an ad-hoc turn's completion is never stamped with a stale taskId.
247
+ this.currentTurnTaskId = typeof turnScope.taskId === 'string' && turnScope.taskId.trim()
248
+ ? turnScope.taskId
249
+ : null;
235
250
  this.responseEpoch += 1;
236
251
  }
237
252
 
@@ -117,6 +117,10 @@ interface PendingOutboundMessage {
117
117
  content: string;
118
118
  queuedAt: number;
119
119
  source: 'sendMessage';
120
+ // ARCH-REFACTOR R1: the mesh taskId this queued message belongs to. Carried so that
121
+ // when the queue is flushed (once the prior turn settles) the new turn is bound to
122
+ // its OWN task, not whatever scalar the session happens to hold at flush time.
123
+ meshTaskId?: string;
120
124
  }
121
125
 
122
126
  export function appendBoundedText(current: string, chunk: string, maxChars: number): string {
@@ -1383,18 +1387,25 @@ export class ProviderCliAdapter implements CliAdapter {
1383
1387
  ), 50);
1384
1388
  }
1385
1389
 
1386
- async sendMessage(text: string, options: { force?: boolean } = {}): Promise<void> {
1390
+ async sendMessage(text: string, options: { force?: boolean; meshTaskId?: string } = {}): Promise<void> {
1387
1391
  if (options.force === true) {
1388
- await this.forceSendMessage(text);
1392
+ await this.forceSendMessage(text, options.meshTaskId);
1389
1393
  return;
1390
1394
  }
1391
- await this.sendMessageNow(text, true);
1395
+ await this.sendMessageNow(text, true, options.meshTaskId);
1392
1396
  }
1393
1397
 
1394
- async forceSendMessage(text: string): Promise<void> {
1398
+ async forceSendMessage(text: string, meshTaskId?: string): Promise<void> {
1395
1399
  if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
1396
1400
  const content = String(text || '');
1397
1401
  if (!content.trim()) return;
1402
+ // ARCH-REFACTOR R1: a force-send (mesh coordinator dispatch / reconcile
1403
+ // redelivery) bypasses the normal turnScope pipeline (raw PTY write), so there is
1404
+ // no turnScope to carry the taskId. Bind it directly on the engine so the
1405
+ // resulting turn's completion is still attributed to the right task.
1406
+ if (typeof meshTaskId === 'string' && meshTaskId.trim()) {
1407
+ this.engine.currentTurnTaskId = meshTaskId;
1408
+ }
1398
1409
  // Modal-park guard (defense-in-depth — the primary guard is at the
1399
1410
  // cli-provider-instance force-forward chokepoint). A force-write writes raw
1400
1411
  // keystrokes into the PTY, bypassing the busy send-guard. If the session is
@@ -1435,7 +1446,7 @@ export class ProviderCliAdapter implements CliAdapter {
1435
1446
  await new Promise<void>(resolve => setTimeout(resolve, FORCE_SUBMIT_SETTLE_MS));
1436
1447
  }
1437
1448
 
1438
- private enqueuePendingOutboundMessage(text: string, reason: string): void {
1449
+ private enqueuePendingOutboundMessage(text: string, reason: string, meshTaskId?: string): void {
1439
1450
  const content = String(text || '');
1440
1451
  const duplicate = this.pendingOutboundQueue.some((message) => message.content === content);
1441
1452
  if (duplicate) {
@@ -1448,6 +1459,7 @@ export class ProviderCliAdapter implements CliAdapter {
1448
1459
  content,
1449
1460
  queuedAt,
1450
1461
  source: 'sendMessage',
1462
+ ...(typeof meshTaskId === 'string' && meshTaskId.trim() ? { meshTaskId } : {}),
1451
1463
  };
1452
1464
  this.pendingOutboundQueue.push(message);
1453
1465
  LOG.info('CLI', `[${this.cliType}] queued outbound message while busy (${reason}); queue=${this.pendingOutboundQueue.length}`);
@@ -1502,7 +1514,7 @@ export class ProviderCliAdapter implements CliAdapter {
1502
1514
  if (this.engine.currentStatus !== 'idle' || this.engine.isWaitingForResponse || this.engine.hasActionableApproval()) break;
1503
1515
  const next = this.pendingOutboundQueue[0];
1504
1516
  try {
1505
- await this.sendMessageNow(next.content, false);
1517
+ await this.sendMessageNow(next.content, false, next.meshTaskId);
1506
1518
  this.pendingOutboundQueue.shift();
1507
1519
  this.onStatusChange?.();
1508
1520
  } catch (error: any) {
@@ -1516,7 +1528,7 @@ export class ProviderCliAdapter implements CliAdapter {
1516
1528
  }
1517
1529
  }
1518
1530
 
1519
- private async sendMessageNow(text: string, allowQueue: boolean): Promise<void> {
1531
+ private async sendMessageNow(text: string, allowQueue: boolean, meshTaskId?: string): Promise<void> {
1520
1532
  if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
1521
1533
  const allowInputDuringGeneration = this.provider.allowInputDuringGeneration === true;
1522
1534
  const allowInterventionPrompt = allowInputDuringGeneration
@@ -1540,7 +1552,7 @@ export class ProviderCliAdapter implements CliAdapter {
1540
1552
  : null;
1541
1553
  const queueReason = this.shouldQueuePendingOutboundMessage(parsedStatusBeforeSend);
1542
1554
  if (allowQueue && queueReason) {
1543
- this.enqueuePendingOutboundMessage(text, queueReason);
1555
+ this.enqueuePendingOutboundMessage(text, queueReason, meshTaskId);
1544
1556
  return;
1545
1557
  }
1546
1558
  if (!allowInterventionPrompt) {
@@ -1568,7 +1580,7 @@ export class ProviderCliAdapter implements CliAdapter {
1568
1580
  // so the message is delivered late rather than dropped. A non-queueable caller
1569
1581
  // (e.g. an internal flush) still throws so it isn't silently swallowed.
1570
1582
  if (allowQueue) {
1571
- this.enqueuePendingOutboundMessage(text, 'not_ready_pending_prompt');
1583
+ this.enqueuePendingOutboundMessage(text, 'not_ready_pending_prompt', meshTaskId);
1572
1584
  return;
1573
1585
  }
1574
1586
  throw new Error(`${this.cliName} not ready (status: ${this.engine.currentStatus})`);
@@ -1591,7 +1603,7 @@ export class ProviderCliAdapter implements CliAdapter {
1591
1603
  && !parsedHasActionableModal;
1592
1604
  if (!terminalLooksIdle) {
1593
1605
  if (allowQueue) {
1594
- this.enqueuePendingOutboundMessage(text, `parsed_status_${parsedSessionStatus}`);
1606
+ this.enqueuePendingOutboundMessage(text, `parsed_status_${parsedSessionStatus}`, meshTaskId);
1595
1607
  return;
1596
1608
  }
1597
1609
  throw new Error(`${this.cliName} is still processing the previous prompt`);
@@ -1604,7 +1616,7 @@ export class ProviderCliAdapter implements CliAdapter {
1604
1616
  && !this.engine.clearParsedIdleResponseGuard('send_message_parsed_idle_guard', parsedStatusBeforeSend, snap)
1605
1617
  ) {
1606
1618
  if (allowQueue) {
1607
- this.enqueuePendingOutboundMessage(text, 'waiting_for_response');
1619
+ this.enqueuePendingOutboundMessage(text, 'waiting_for_response', meshTaskId);
1608
1620
  return;
1609
1621
  }
1610
1622
  throw new Error(`${this.cliName} is still processing the previous prompt`);
@@ -1616,6 +1628,10 @@ export class ProviderCliAdapter implements CliAdapter {
1616
1628
  startedAt: Date.now(),
1617
1629
  bufferStart: this.accumulatedBuffer.length,
1618
1630
  rawBufferStart: this.accumulatedRawBuffer.length,
1631
+ // ARCH-REFACTOR R1: bind this turn to its mesh task. engine.onTurnStarted
1632
+ // copies this into currentTurnTaskId so the turn's completion event carries
1633
+ // the right id even if a later task overwrites the session scalar meanwhile.
1634
+ ...(typeof meshTaskId === 'string' && meshTaskId.trim() ? { taskId: meshTaskId } : {}),
1619
1635
  };
1620
1636
  LOG.info('CLI', `[${this.cliType}] sendMessage turn scope buffer=${turnScope.bufferStart} raw=${turnScope.rawBufferStart} prompt=${JSON.stringify(text).slice(0, 120)}`);
1621
1637
  if (this.submitRetryTimer) {
@@ -1967,6 +1983,11 @@ export class ProviderCliAdapter implements CliAdapter {
1967
1983
 
1968
1984
  get currentTurnScope(): TurnParseScope | null { return this.engine.currentTurnScope; }
1969
1985
  set currentTurnScope(v: TurnParseScope | null) { this.engine.currentTurnScope = v; }
1986
+ // ARCH-REFACTOR R1: the mesh taskId bound to the most recently started turn,
1987
+ // surviving past turn settle until the next turn starts. The provider instance
1988
+ // reads this when stamping completion events so they carry the completing turn's
1989
+ // task rather than the racy last-write-wins session scalar.
1990
+ get currentTurnTaskId(): string | null { return this.engine.currentTurnTaskId; }
1970
1991
 
1971
1992
  get responseEpoch(): number { return this.engine.responseEpoch; }
1972
1993
  set responseEpoch(v: number) { this.engine.responseEpoch = v; }
@@ -4,6 +4,7 @@ export interface TurnParseScope {
4
4
  startedAt: number;
5
5
  bufferStart: number;
6
6
  rawBufferStart: number;
7
+ taskId?: string;
7
8
  }
8
9
  export declare function normalizeCliParsedMessages(parsedMessages: any[], options: {
9
10
  scope?: TurnParseScope | null;
@@ -10,6 +10,12 @@ export interface TurnParseScope {
10
10
  startedAt: number;
11
11
  bufferStart: number;
12
12
  rawBufferStart: number;
13
+ // ARCH-REFACTOR R1 (per-turn task identity): the mesh task this turn was started
14
+ // for, bound to the turn at submit time. The completion event for this turn reads
15
+ // its taskId from here (via engine.currentTurnTaskId) instead of the last-write-wins
16
+ // session scalar (settings.meshActiveTaskId), so a second task that attaches before
17
+ // this turn completes can no longer make this turn's completion carry the wrong id.
18
+ taskId?: string;
13
19
  }
14
20
 
15
21
  function sliceFromOffset(text: string, start: number): string {
@@ -1553,11 +1553,25 @@ export class DaemonCliManager {
1553
1553
  }
1554
1554
  const message = input.textFallback;
1555
1555
  if (!message) throw new Error('message required for send_chat');
1556
+ // ARCH-REFACTOR R1: thread the dispatched task's id into the turn so the
1557
+ // worker's completion event is bound to THIS task (per-turn identity),
1558
+ // not the last-write-wins session scalar. Carried for both local and
1559
+ // remote (P2P-echoed meshContext) dispatch; absent for plain ad-hoc chat.
1560
+ const meshTaskId = (meshContext && typeof meshContext === 'object'
1561
+ && typeof (meshContext as any).taskId === 'string' && (meshContext as any).taskId.trim())
1562
+ ? (meshContext as any).taskId as string
1563
+ : undefined;
1556
1564
  const forceSend = args?.force === true || args?.forceSend === true;
1565
+ // Preserve the exact prior call shape when there is no taskId (plain
1566
+ // ad-hoc chat / non-mesh dispatch); only thread the per-turn taskId when
1567
+ // present, so existing non-mesh callers and their contracts are unchanged.
1557
1568
  if (forceSend && typeof (adapter as any).forceSendMessage === 'function') {
1558
- await (adapter as any).forceSendMessage(message);
1569
+ if (meshTaskId) await (adapter as any).forceSendMessage(message, meshTaskId);
1570
+ else await (adapter as any).forceSendMessage(message);
1559
1571
  } else if (forceSend) {
1560
- await adapter.sendMessage(message, { force: true });
1572
+ await adapter.sendMessage(message, meshTaskId ? { force: true, meshTaskId } : { force: true });
1573
+ } else if (meshTaskId) {
1574
+ await adapter.sendMessage(message, { meshTaskId });
1561
1575
  } else {
1562
1576
  await adapter.sendMessage(message);
1563
1577
  }
@@ -553,6 +553,11 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
553
553
  baseBranch,
554
554
  meshName: mesh.name,
555
555
  });
556
+ if (result.baseSync?.warning) {
557
+ console.warn(`[mesh] clone_mesh_node base sync (${result.baseSync.action}): ${result.baseSync.warning}`);
558
+ } else if (result.baseSync && result.baseSync.action !== 'up_to_date') {
559
+ console.log(`[mesh] clone_mesh_node base sync: ${result.baseSync.action} (startRef=${result.baseSync.startRef})`);
560
+ }
556
561
 
557
562
  let node: any;
558
563
  if (meshRecord.inline) {
@@ -767,6 +772,8 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
767
772
  node,
768
773
  worktreePath: result.worktreePath,
769
774
  branch: result.branch,
775
+ ...(result.baseSync ? { baseSync: result.baseSync } : {}),
776
+ ...(result.baseSync?.warning ? { baseStaleWarning: result.baseSync.warning } : {}),
770
777
  worktreeBootstrap: runningBootstrapState,
771
778
  worktreeSetup: {
772
779
  status: 'running',
@@ -783,6 +790,8 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
783
790
  node,
784
791
  worktreePath: result.worktreePath,
785
792
  branch: result.branch,
793
+ ...(result.baseSync ? { baseSync: result.baseSync } : {}),
794
+ ...(result.baseSync?.warning ? { baseStaleWarning: result.baseSync.warning } : {}),
786
795
  submodulesInitialized,
787
796
  worktreeBootstrap: bootstrapState,
788
797
  };
@@ -35,12 +35,62 @@ export interface WorktreeCreateOptions {
35
35
  meshName: string;
36
36
  /** Override the auto-resolved target directory */
37
37
  targetDir?: string;
38
+ /**
39
+ * Remote to fetch+compare the base branch against before branching.
40
+ * Default: 'origin'.
41
+ */
42
+ remote?: string;
43
+ /**
44
+ * When true (default) and `baseBranch` is given, fetch the base branch from
45
+ * `remote` and, if the local base branch is strictly behind the remote
46
+ * (no divergence), branch the worktree from the remote-tracking ref instead
47
+ * of the stale local ref. The decision is always surfaced via `baseSync`.
48
+ * Set false to preserve the legacy "branch from local ref, never fetch"
49
+ * behavior.
50
+ */
51
+ syncBaseFromRemote?: boolean;
52
+ }
53
+
54
+ /**
55
+ * How the worktree's start point was resolved relative to the remote base.
56
+ * Surfaced so the coordinator can detect a stale base node before dispatching
57
+ * work onto a worktree built on a behind/diverged base.
58
+ */
59
+ export interface WorktreeBaseSync {
60
+ /** The base branch name requested (e.g. 'main'). */
61
+ branch: string;
62
+ /** The remote compared against (e.g. 'origin'). */
63
+ remote: string;
64
+ /** The actual ref/commit-ish used as the worktree branch start point. */
65
+ startRef: string;
66
+ /** Whether `git fetch <remote> <branch>` succeeded. */
67
+ fetched: boolean;
68
+ /** Local base-branch SHA before clone, if the local ref exists. */
69
+ localSha?: string;
70
+ /** Remote-tracking base-branch SHA after fetch, if it exists. */
71
+ remoteSha?: string;
72
+ /** Commits the local base ref is behind the remote (0 when up-to-date/ahead). */
73
+ behindBy: number;
74
+ /** Commits the local base ref is ahead of the remote. */
75
+ aheadBy: number;
76
+ /** What was done with the base ref. */
77
+ action:
78
+ | 'up_to_date'
79
+ | 'local_behind_used_remote'
80
+ | 'local_ahead_used_local'
81
+ | 'diverged_used_local'
82
+ | 'no_remote_ref_used_local'
83
+ | 'no_local_ref_used_remote';
84
+ /** Human-readable warning when the base was stale/diverged. */
85
+ warning?: string;
38
86
  }
39
87
 
40
88
  export interface WorktreeCreateResult {
41
89
  success: true;
42
90
  worktreePath: string;
43
91
  branch: string;
92
+ /** Present when `baseBranch` was given and base sync resolution ran. */
93
+ baseSync?: WorktreeBaseSync;
44
94
  }
45
95
 
46
96
  export interface WorktreeEntry {
@@ -83,15 +133,139 @@ export function resolveWorktreePath(repoRoot: string, meshName: string, branch:
83
133
  return path.join(parentDir, WORKTREE_DIR_NAME, safeMeshName, safeBranch);
84
134
  }
85
135
 
136
+ // ─── Base sync (anti-stale-base) ─────────────────
137
+
138
+ /** Run a git command, returning ok/stdout/stderr instead of throwing. */
139
+ async function tryGit(cwd: string, args: string[]): Promise<{ ok: boolean; stdout: string; stderr: string }> {
140
+ try {
141
+ const { stdout, stderr } = await execFileAsync('git', args, {
142
+ cwd,
143
+ encoding: 'utf8',
144
+ timeout: GIT_TIMEOUT_MS,
145
+ maxBuffer: GIT_MAX_BUFFER,
146
+ windowsHide: true,
147
+ });
148
+ return { ok: true, stdout: (stdout || '').trim(), stderr: (stderr || '').trim() };
149
+ } catch (error: any) {
150
+ return {
151
+ ok: false,
152
+ stdout: typeof error?.stdout === 'string' ? error.stdout.trim() : '',
153
+ stderr: typeof error?.stderr === 'string' ? error.stderr.trim() : (error?.message || ''),
154
+ };
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Fetch the base branch from the remote and decide what to branch the new
160
+ * worktree from. Without this, `git worktree add -b <branch> main` always uses
161
+ * the local `refs/heads/main`, so a base node whose local main is behind a
162
+ * cross-machine-pushed origin/main produces a worktree on a STALE base —
163
+ * unaware of already-converged work, forcing a rebase before its push can
164
+ * fast-forward.
165
+ *
166
+ * Resolution (no history rewrite, never mutates the checked-out local branch):
167
+ * - local strictly behind remote → branch from the remote-tracking ref.
168
+ * - local ahead / up-to-date / no remote ref → branch from the local ref.
169
+ * - diverged → branch from local + emit a warning for the coordinator.
170
+ */
171
+ async function resolveWorktreeBaseStartPoint(
172
+ repoRoot: string,
173
+ baseBranch: string,
174
+ remote: string,
175
+ ): Promise<WorktreeBaseSync> {
176
+ const fetchResult = await tryGit(repoRoot, ['fetch', remote, baseBranch]);
177
+ const fetched = fetchResult.ok;
178
+
179
+ const localRev = await tryGit(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/heads/${baseBranch}`]);
180
+ const remoteRev = await tryGit(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${baseBranch}`]);
181
+ const localSha = localRev.ok && localRev.stdout ? localRev.stdout : undefined;
182
+ const remoteSha = remoteRev.ok && remoteRev.stdout ? remoteRev.stdout : undefined;
183
+ const remoteRef = `${remote}/${baseBranch}`;
184
+
185
+ const base: WorktreeBaseSync = {
186
+ branch: baseBranch,
187
+ remote,
188
+ startRef: baseBranch,
189
+ fetched,
190
+ localSha,
191
+ remoteSha,
192
+ behindBy: 0,
193
+ aheadBy: 0,
194
+ action: 'up_to_date',
195
+ };
196
+
197
+ const fetchWarn = fetched ? '' : ` (warning: git fetch ${remote} ${baseBranch} failed: ${fetchResult.stderr || 'unknown error'})`;
198
+
199
+ // No remote-tracking ref → nothing to compare; keep legacy local behavior.
200
+ if (!remoteSha) {
201
+ return {
202
+ ...base,
203
+ action: 'no_remote_ref_used_local',
204
+ ...(fetched ? {} : { warning: `Could not fetch ${remoteRef}${fetchWarn}; worktree branched from local ${baseBranch}.` }),
205
+ };
206
+ }
207
+
208
+ // Base branch only exists on the remote → branch from the remote ref.
209
+ if (!localSha) {
210
+ return {
211
+ ...base,
212
+ startRef: remoteRef,
213
+ action: 'no_local_ref_used_remote',
214
+ };
215
+ }
216
+
217
+ if (localSha === remoteSha) {
218
+ return base; // up_to_date
219
+ }
220
+
221
+ const localIsAncestor = (await tryGit(repoRoot, ['merge-base', '--is-ancestor', localSha, remoteSha])).ok;
222
+ const remoteIsAncestor = (await tryGit(repoRoot, ['merge-base', '--is-ancestor', remoteSha, localSha])).ok;
223
+ const behindBy = Number((await tryGit(repoRoot, ['rev-list', '--count', `${localSha}..${remoteSha}`])).stdout) || 0;
224
+ const aheadBy = Number((await tryGit(repoRoot, ['rev-list', '--count', `${remoteSha}..${localSha}`])).stdout) || 0;
225
+
226
+ if (localIsAncestor && !remoteIsAncestor) {
227
+ // Local strictly behind — THE stale-base fix: branch from the remote tip.
228
+ return {
229
+ ...base,
230
+ startRef: remoteRef,
231
+ behindBy,
232
+ aheadBy,
233
+ action: 'local_behind_used_remote',
234
+ warning: `Base node local ${baseBranch} was behind ${remoteRef} by ${behindBy} commit(s); worktree branched from ${remoteRef} (${remoteSha.slice(0, 8)}) instead of stale local ${localSha.slice(0, 8)}.${fetchWarn}`,
235
+ };
236
+ }
237
+
238
+ if (remoteIsAncestor) {
239
+ // Local ahead of remote (or remote is an ancestor) — local has the newer work.
240
+ return { ...base, behindBy, aheadBy, action: 'local_ahead_used_local' };
241
+ }
242
+
243
+ // Diverged: neither is an ancestor of the other. Don't silently pick a side —
244
+ // keep local (preserve local-only commits) and warn so the coordinator rebases.
245
+ return {
246
+ ...base,
247
+ behindBy,
248
+ aheadBy,
249
+ action: 'diverged_used_local',
250
+ warning: `Base node local ${baseBranch} (${localSha.slice(0, 8)}) has DIVERGED from ${remoteRef} (${remoteSha.slice(0, 8)}): behind ${behindBy}, ahead ${aheadBy}. Worktree branched from local; a rebase onto ${remoteRef} will be required before its push can fast-forward.${fetchWarn}`,
251
+ };
252
+ }
253
+
86
254
  // ─── Create ─────────────────────────────────────
87
255
 
88
256
  /**
89
257
  * Create a new git worktree with a fresh branch.
90
258
  *
91
- * Runs: git worktree add <targetDir> -b <branch> [baseBranch]
259
+ * Runs: git worktree add <targetDir> -b <branch> [startRef]
260
+ *
261
+ * When `baseBranch` is given and `syncBaseFromRemote` is not disabled, the
262
+ * start point is resolved against the remote first (see
263
+ * resolveWorktreeBaseStartPoint) so a stale local base does not produce a stale
264
+ * worktree. The resolution is returned as `baseSync`.
92
265
  */
93
266
  export async function createWorktree(opts: WorktreeCreateOptions): Promise<WorktreeCreateResult> {
94
267
  const { repoRoot, branch, baseBranch, meshName } = opts;
268
+ const remote = (opts.remote || 'origin').trim() || 'origin';
95
269
  const targetDir = opts.targetDir || resolveWorktreePath(repoRoot, meshName, branch);
96
270
 
97
271
  if (existsSync(targetDir)) {
@@ -101,9 +275,16 @@ export async function createWorktree(opts: WorktreeCreateOptions): Promise<Workt
101
275
  // Ensure parent directory exists
102
276
  await mkdir(path.dirname(targetDir), { recursive: true });
103
277
 
278
+ let baseSync: WorktreeBaseSync | undefined;
279
+ let startRef = baseBranch;
280
+ if (baseBranch && opts.syncBaseFromRemote !== false) {
281
+ baseSync = await resolveWorktreeBaseStartPoint(repoRoot, baseBranch, remote);
282
+ startRef = baseSync.startRef;
283
+ }
284
+
104
285
  const args = ['worktree', 'add', targetDir, '-b', branch];
105
- if (baseBranch) {
106
- args.push(baseBranch);
286
+ if (startRef) {
287
+ args.push(startRef);
107
288
  }
108
289
 
109
290
  try {
@@ -130,6 +311,7 @@ export async function createWorktree(opts: WorktreeCreateOptions): Promise<Workt
130
311
  success: true,
131
312
  worktreePath: targetDir,
132
313
  branch,
314
+ ...(baseSync ? { baseSync } : {}),
133
315
  };
134
316
  }
135
317
 
package/src/git/index.ts CHANGED
@@ -98,6 +98,7 @@ export {
98
98
  resolveWorktreePath,
99
99
  } from './git-worktree.js';
100
100
  export type {
101
+ WorktreeBaseSync,
101
102
  WorktreeCreateOptions,
102
103
  WorktreeCreateResult,
103
104
  WorktreeEntry,
@@ -203,6 +203,9 @@ function hasRecentIntentionalCleanupStop(meshId: string, sessionId?: string, nod
203
203
  const timestamp = new Date(entry.timestamp).getTime();
204
204
  if (!Number.isNaN(timestamp) && timestamp < cutoff) break;
205
205
  if (!isIntentionalCleanupStopEntry(entry)) continue;
206
+ // SESSION-ID IS SINGLE-FORM: a session id is one canonical UUID (crypto.randomUUID
207
+ // in the provider instance), carried verbatim across daemons — no serialization
208
+ // variants like node/daemon ids. Exact `===` is correct; no equivalence helper.
206
209
  if (sessionId && entry.sessionId === sessionId) return true;
207
210
  // Normalized node-id match (P4): the cleanup-stop entry's node id may be stored as
208
211
  // `nodeId` or `node_id` and the `nodeId` arg can be in either form — a raw `===`
@@ -435,6 +438,8 @@ function supersedesTruncatedTerminalSummary(args: {
435
438
  // task surfaces as status='unknown' / terminalKind=null in computeMeshTaskStats.
436
439
  function resolveActiveDirectDispatchTaskId(meshId: string, sessionId: string): string | undefined {
437
440
  try {
441
+ // SESSION-ID IS SINGLE-FORM (canonical crypto.randomUUID, carried verbatim across
442
+ // daemons) — exact `===` filter is correct; no node-id-style form normalization.
438
443
  const matches = getActiveDirectDispatches(meshId).filter(d => d.sessionId === sessionId);
439
444
  if (!matches.length) return undefined;
440
445
  // getActiveDirectDispatches returns rows ordered by dispatched_at ASC; the last is
@@ -830,8 +835,14 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
830
835
  const providerType = readNonEmptyString(args.metadataEvent.providerType);
831
836
 
832
837
  if (sessionId) {
833
- // CANON-B: trust the taskId the completion echoed; only fall back to the
834
- // most-recent-by-session heuristic when the worker carried none.
838
+ // CANON-B / ARCH-REFACTOR R1: trust the taskId the completion echoed. With R1's
839
+ // per-turn identity binding the worker stamps the COMPLETING turn's own taskId
840
+ // (not the racy session scalar), so the echoed id is authoritative and this is
841
+ // the path that should always be taken for an R1+ worker. The most-recent-by-
842
+ // session heuristic (resolveActiveDirectDispatchTaskId) is retained ONLY as a
843
+ // backward-compat fallback for legacy / version-skewed workers that carry no
844
+ // taskId — it is the very re-derive R1 exists to make unnecessary, and must not
845
+ // override a present echoed id, hence the `||` short-circuit order.
835
846
  directDispatchTaskIdForLedger = readNonEmptyString(args.metadataEvent.taskId)
836
847
  || resolveActiveDirectDispatchTaskId(args.meshId, sessionId);
837
848
  // A false-idle completion of a direct dispatch is recorded but kept tentative (the
@@ -495,12 +495,24 @@ export function tryAssignQueueTask(
495
495
 
496
496
  // CONS3: same shared dispatch lifecycle as the remote branch — only the transport
497
497
  // (cliManager.handleCliCommand) differs.
498
+ // ARCH-REFACTOR R1: carry meshContext (incl. taskId) on the LOCAL dispatch too, so
499
+ // handleCliCommand's send_chat path binds this task to its turn (per-turn identity).
500
+ // Previously only the remote branch shipped meshContext.taskId; the local path relied
501
+ // on the last-write-wins session scalar, which races a follow-up task and made the
502
+ // completion echo the wrong taskId (the standalone NOTIF-MISDELIVER repro).
498
503
  deliverTaskToSession(
499
504
  () => components.cliManager.handleCliCommand('agent_command', {
500
505
  targetSessionId: sessionId,
501
506
  cliType: providerType,
502
507
  action: 'send_chat',
503
508
  message: task.message,
509
+ meshContext: {
510
+ meshId,
511
+ nodeId,
512
+ taskId: task.id,
513
+ ...(readNonEmptyString(loadConfig().machineId) ? { coordinatorDaemonId: readNonEmptyString(loadConfig().machineId) } : {}),
514
+ ...(readNonEmptyString(task.sourceCoordinatorSessionId) ? { coordinatorSessionId: readNonEmptyString(task.sourceCoordinatorSessionId) } : {}),
515
+ },
504
516
  }),
505
517
  {
506
518
  meshId,
@@ -920,6 +920,10 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
920
920
  // (unchanged behaviour — regression-0 for the common case).
921
921
  const wantSession = readNonEmptyString(pending.targetCoordinatorSessionId);
922
922
  if (wantSession) {
923
+ // SESSION-ID IS SINGLE-FORM: a coordinator session id is one canonical
924
+ // UUID (crypto.randomUUID), carried verbatim end-to-end — no node/daemon-id
925
+ // style serialization variants. Exact `===` is the correct match; unlike
926
+ // the daemon-level set below it needs no equivalence helper.
923
927
  const matched = targetCoordinators.filter(c => c.sessionId === wantSession);
924
928
  if (matched.length === 0) {
925
929
  // The originating coordinator session is not deliverable on this daemon