@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.
- package/dist/cli-adapter-types.d.ts +2 -1
- package/dist/cli-adapters/cli-state-engine.d.ts +1 -0
- package/dist/cli-adapters/provider-cli-adapter.d.ts +3 -1
- package/dist/cli-adapters/provider-cli-parse.d.ts +1 -0
- package/dist/git/git-worktree.d.ts +49 -1
- package/dist/git/index.d.ts +1 -1
- package/dist/index.js +382 -207
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +379 -204
- package/dist/index.mjs.map +1 -1
- package/dist/providers/cli-provider-instance.d.ts +10 -0
- package/package.json +2 -2
- package/src/cli-adapter-types.d.ts +2 -1
- package/src/cli-adapter-types.ts +2 -2
- package/src/cli-adapters/cli-state-engine.ts +15 -0
- package/src/cli-adapters/provider-cli-adapter.ts +32 -11
- package/src/cli-adapters/provider-cli-parse.d.ts +1 -0
- package/src/cli-adapters/provider-cli-parse.ts +6 -0
- package/src/commands/cli-manager.ts +16 -2
- package/src/commands/med-family/mesh-crud.ts +9 -0
- package/src/git/git-worktree.ts +185 -3
- package/src/git/index.ts +1 -0
- package/src/mesh/mesh-event-forwarding.ts +13 -2
- package/src/mesh/mesh-queue-assignment.ts +12 -0
- package/src/mesh/mesh-reconcile-loop.ts +4 -0
- package/src/mesh/mesh-runtime-store.ts +20 -2
- package/src/mesh/mesh-work-queue.ts +23 -0
- package/src/providers/cli-provider-instance.ts +52 -8
|
@@ -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.
|
|
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.
|
|
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>;
|
package/src/cli-adapter-types.ts
CHANGED
|
@@ -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; }
|
|
@@ -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
|
};
|
package/src/git/git-worktree.ts
CHANGED
|
@@ -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> [
|
|
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 (
|
|
106
|
-
args.push(
|
|
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
|
@@ -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
|
|
834
|
-
//
|
|
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
|