@adhdev/daemon-core 0.9.82-rc.395 → 0.9.82-rc.397
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-adapters/provider-cli-adapter.d.ts +2 -0
- package/dist/index.js +95 -33
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +95 -33
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-events-stale.d.ts +9 -0
- package/package.json +2 -2
- package/src/cli-adapters/provider-cli-adapter.ts +52 -3
- package/src/commands/router.ts +7 -0
- package/src/mesh/mesh-events-stale.ts +43 -17
- package/src/mesh/mesh-queue-assignment.ts +5 -2
- package/src/mesh/mesh-reconcile-loop.ts +26 -3
- package/src/mesh/mesh-runtime-store.ts +25 -3
|
@@ -34,6 +34,15 @@ export declare function reconcileDirectDispatchCompletionFromTranscript(args: {
|
|
|
34
34
|
transcriptMessageAt?: string;
|
|
35
35
|
completedAt?: string;
|
|
36
36
|
targetCoordinatorDaemonId?: string;
|
|
37
|
+
/**
|
|
38
|
+
* NOTIF-DROP-SYNTH-NO-MESSAGE: the originating coordinator SESSION that dispatched this
|
|
39
|
+
* task (the `coordinatorSessionId` stamped onto the task_dispatched ledger payload at
|
|
40
|
+
* dispatch). Stamped onto the synthesized completion's targetCoordinatorSessionId so PHASE 2
|
|
41
|
+
* STRICT routing matches the exact coordinator session — instead of relying on the
|
|
42
|
+
* daemon-keyed fallback. When the caller does not pass it, it is recovered from the dispatch
|
|
43
|
+
* ledger entry below; absent on legacy rows → daemon-level routing (unchanged).
|
|
44
|
+
*/
|
|
45
|
+
targetCoordinatorSessionId?: string;
|
|
37
46
|
source?: string;
|
|
38
47
|
}): {
|
|
39
48
|
reconciled: boolean;
|
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.397",
|
|
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.397",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
|
@@ -100,6 +100,10 @@ interface SendMessageState {
|
|
|
100
100
|
maxEchoWaitMs: number;
|
|
101
101
|
retryDelayMs: number;
|
|
102
102
|
didCommitUserTurn: boolean;
|
|
103
|
+
// Whether this was the session's first turn at the moment of dispatch — captured
|
|
104
|
+
// before commitSendUserTurn flips this.firstTurnSent, so a later stuck-retry still
|
|
105
|
+
// knows it is recovering the win32 premature-ready first-turn swallow.
|
|
106
|
+
isFirstTurn: boolean;
|
|
103
107
|
}
|
|
104
108
|
|
|
105
109
|
interface SendMessageCompletion {
|
|
@@ -171,6 +175,16 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
171
175
|
private providerSessionId: string | null = null;
|
|
172
176
|
private responseTimeout: NodeJS.Timeout | null = null;
|
|
173
177
|
private ready = false;
|
|
178
|
+
// WIN32-READY-HOLD: the ready barrier can release on screen/spec-FSM grace
|
|
179
|
+
// before win32 ConPTY's input layer is live. The first split write (text, then a
|
|
180
|
+
// separate trailing CR via waitForEchoAndSubmit) then has its submit CR swallowed,
|
|
181
|
+
// and every CR-only retry re-sends a bare CR the input layer keeps dropping — the
|
|
182
|
+
// first message is typed-but-never-submitted and lost. Routing only the FIRST turn
|
|
183
|
+
// through the atomic content+sendKey single write (submitImmediatePrompt) keeps the
|
|
184
|
+
// Enter in the same PTY write unit as the text, the invariant win32 ConPTY needs to
|
|
185
|
+
// recognize a submit, so the swallow is bypassed. Subsequent turns (input layer now
|
|
186
|
+
// proven live) keep the normal echo-gated path. Flips true on first committed turn.
|
|
187
|
+
private firstTurnSent = false;
|
|
174
188
|
private startupBuffer = '';
|
|
175
189
|
private startupParseGate = false;
|
|
176
190
|
private startupSettleTimer: NodeJS.Timeout | null = null;
|
|
@@ -599,6 +613,9 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
599
613
|
this.resetTerminalScreen(DEFAULT_SESSION_HOST_ROWS, DEFAULT_SESSION_HOST_COLS);
|
|
600
614
|
this.pendingTerminalQueryTail = '';
|
|
601
615
|
this.ready = false;
|
|
616
|
+
// Each fresh spawn re-enters the premature-ready swallow window — the next
|
|
617
|
+
// turn is again a "first turn" and must use the win32-safe atomic send path.
|
|
618
|
+
this.firstTurnSent = false;
|
|
602
619
|
await this.ptyProcess.ready;
|
|
603
620
|
this.engine.onSpawnReady();
|
|
604
621
|
this.scheduleStartupSettleCheck();
|
|
@@ -1210,6 +1227,9 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
1210
1227
|
private commitSendUserTurn(state: SendMessageState): void {
|
|
1211
1228
|
if (state.didCommitUserTurn) return;
|
|
1212
1229
|
state.didCommitUserTurn = true;
|
|
1230
|
+
// The first turn has now been written atomically (win32-safe); later turns
|
|
1231
|
+
// can use the normal echo-gated path now that the input layer is proven live.
|
|
1232
|
+
this.firstTurnSent = true;
|
|
1213
1233
|
}
|
|
1214
1234
|
|
|
1215
1235
|
private armResponseTimeout(): void {
|
|
@@ -1240,12 +1260,31 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
1240
1260
|
});
|
|
1241
1261
|
}
|
|
1242
1262
|
|
|
1263
|
+
// WIN32-READY-HOLD: choose the retry write for a stuck prompt. When the FIRST turn
|
|
1264
|
+
// is stuck on win32 — the premature-ready swallow window — the prompt text itself
|
|
1265
|
+
// may have been partially eaten by a not-yet-live ConPTY input layer, so re-sending
|
|
1266
|
+
// a bare CR keeps hitting nothing. Re-type the whole `text + sendKey` atomically
|
|
1267
|
+
// once so the input layer (now live) receives a self-contained, submit-coupled
|
|
1268
|
+
// write. All other cases keep the cheap bare-CR retry (the prompt is fully echoed
|
|
1269
|
+
// and only the Enter is missing).
|
|
1270
|
+
private writeStuckRetry(state: SendMessageState, mode: string): void {
|
|
1271
|
+
const retypeFirstTurn = process.platform === 'win32' && state.isFirstTurn;
|
|
1272
|
+
if (retypeFirstTurn) {
|
|
1273
|
+
LOG.info('CLI', `[${this.cliType}] ${mode}: re-typing full prompt atomically (win32 first-turn swallow recovery)`);
|
|
1274
|
+
void this.writeToPty(state.text + this.sendKey).catch((error) => {
|
|
1275
|
+
LOG.warn('CLI', `[${this.cliType}] ${mode} re-type write failed: ${error?.message || error}`);
|
|
1276
|
+
});
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
this.writeSubmitKeyForRetry(mode);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1243
1282
|
private retrySubmitIfStuck(state: SendMessageState, attempt: number): void {
|
|
1244
1283
|
this.submitRetryTimer = null;
|
|
1245
1284
|
if (!this.isSubmitStuck(state.normalizedPromptSnippet)) return;
|
|
1246
1285
|
this.engine.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
|
|
1247
1286
|
LOG.info('CLI', `[${this.cliType}] Retrying submit key for stuck prompt (attempt ${attempt})`);
|
|
1248
|
-
this.
|
|
1287
|
+
this.writeStuckRetry(state, 'submit_retry');
|
|
1249
1288
|
if (attempt >= 3) { this.engine.submitRetryUsed = true; return; }
|
|
1250
1289
|
this.submitRetryTimer = setTimeout(() => this.retrySubmitIfStuck(state, attempt + 1), state.retryDelayMs);
|
|
1251
1290
|
}
|
|
@@ -1255,7 +1294,7 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
1255
1294
|
if (!this.isSubmitStuck(state.normalizedPromptSnippet)) return;
|
|
1256
1295
|
this.engine.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
|
|
1257
1296
|
LOG.info('CLI', `[${this.cliType}] Retrying submit key for stuck prompt (attempt 1)`);
|
|
1258
|
-
this.
|
|
1297
|
+
this.writeStuckRetry(state, 'immediate_retry');
|
|
1259
1298
|
this.engine.submitRetryUsed = true;
|
|
1260
1299
|
}
|
|
1261
1300
|
|
|
@@ -1597,6 +1636,8 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
1597
1636
|
maxEchoWaitMs,
|
|
1598
1637
|
retryDelayMs,
|
|
1599
1638
|
didCommitUserTurn: false,
|
|
1639
|
+
// Capture BEFORE the send commits — commitSendUserTurn flips firstTurnSent.
|
|
1640
|
+
isFirstTurn: !this.firstTurnSent,
|
|
1600
1641
|
};
|
|
1601
1642
|
this.engine.responseSettleIgnoreUntil = Date.now() + submitDelayMs + this.timeouts.outputSettle + 250;
|
|
1602
1643
|
await new Promise<void>((resolve, reject) => {
|
|
@@ -1615,7 +1656,15 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
1615
1656
|
},
|
|
1616
1657
|
};
|
|
1617
1658
|
|
|
1618
|
-
|
|
1659
|
+
// WIN32-READY-HOLD: the very first turn after startup is the one exposed to
|
|
1660
|
+
// the premature-ready swallow (ready released before win32 ConPTY input is
|
|
1661
|
+
// live). Force it through the atomic content+sendKey single write so the
|
|
1662
|
+
// submit CR can never be separated from the text it submits — the same path
|
|
1663
|
+
// the `immediate` strategy already uses. Restricted to win32 + the first
|
|
1664
|
+
// turn so Mac/linux echo-gated behavior and all later turns are unchanged.
|
|
1665
|
+
const useAtomicFirstTurn = this.submitStrategy === 'immediate'
|
|
1666
|
+
|| (process.platform === 'win32' && sendState.isFirstTurn);
|
|
1667
|
+
if (useAtomicFirstTurn) {
|
|
1619
1668
|
this.submitImmediatePrompt(sendState, completion);
|
|
1620
1669
|
return;
|
|
1621
1670
|
}
|
package/src/commands/router.ts
CHANGED
|
@@ -2371,6 +2371,13 @@ export class DaemonCommandRouter {
|
|
|
2371
2371
|
sessionCleanupMode: refineSessionCleanupMode,
|
|
2372
2372
|
...(refineSessionIds && refineSessionIds.length > 0 ? { sessionIds: refineSessionIds } : {}),
|
|
2373
2373
|
inlineMesh: args?.inlineMesh,
|
|
2374
|
+
// REFINE-CLEANUP: refine reaches cleanup only AFTER a verified merge
|
|
2375
|
+
// convergence, so any residual worktree dirtiness here is incidental
|
|
2376
|
+
// (e.g. a bootstrap lockfile rewrite) — never unmerged work. `force`
|
|
2377
|
+
// sets requireClean=false so a plain-dirty worktree no longer aborts
|
|
2378
|
+
// removal with merged_cleanup_failed. Branch-ref deletion still keys off
|
|
2379
|
+
// mergeConvergence (NOT the force flag), so no merged work can be lost.
|
|
2380
|
+
force: true,
|
|
2374
2381
|
});
|
|
2375
2382
|
recordMeshRefineStage(refineStages, 'cleanup', removeResult?.success === false ? 'failed' : 'passed', cleanupStarted, {
|
|
2376
2383
|
removed: removeResult?.removed,
|
|
@@ -3,7 +3,7 @@ import type { MeshLedgerKind } from './mesh-ledger.js';
|
|
|
3
3
|
import { updateDirectDispatchStatus, cleanupTerminalDirectDispatches } from './mesh-work-queue.js';
|
|
4
4
|
import { markSessionDeliveriesTerminal } from './mesh-delivery-policy.js';
|
|
5
5
|
import { queuePendingMeshCoordinatorEvent } from './mesh-events-pending.js';
|
|
6
|
-
import { readNonEmptyString, readRecord, resolveEventSessionId, readWorkerResultMetadata, isWeakCompletionEvidence } from './mesh-events-utils.js';
|
|
6
|
+
import { readNonEmptyString, readRecord, resolveEventSessionId, readWorkerResultMetadata, isWeakCompletionEvidence, buildMeshSystemMessage } from './mesh-events-utils.js';
|
|
7
7
|
import { meshNodeIdMatches, type MeshNodeIdentified } from '@adhdev/mesh-shared';
|
|
8
8
|
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
@@ -188,6 +188,15 @@ export function reconcileDirectDispatchCompletionFromTranscript(args: {
|
|
|
188
188
|
transcriptMessageAt?: string;
|
|
189
189
|
completedAt?: string;
|
|
190
190
|
targetCoordinatorDaemonId?: string;
|
|
191
|
+
/**
|
|
192
|
+
* NOTIF-DROP-SYNTH-NO-MESSAGE: the originating coordinator SESSION that dispatched this
|
|
193
|
+
* task (the `coordinatorSessionId` stamped onto the task_dispatched ledger payload at
|
|
194
|
+
* dispatch). Stamped onto the synthesized completion's targetCoordinatorSessionId so PHASE 2
|
|
195
|
+
* STRICT routing matches the exact coordinator session — instead of relying on the
|
|
196
|
+
* daemon-keyed fallback. When the caller does not pass it, it is recovered from the dispatch
|
|
197
|
+
* ledger entry below; absent on legacy rows → daemon-level routing (unchanged).
|
|
198
|
+
*/
|
|
199
|
+
targetCoordinatorSessionId?: string;
|
|
191
200
|
source?: string;
|
|
192
201
|
}): { reconciled: boolean; kind?: MeshLedgerKind; alreadyTerminal?: boolean; workerResult?: unknown; ledgerEntryId?: string; reason?: string } {
|
|
193
202
|
const finalSummary = readNonEmptyString(args.finalSummary);
|
|
@@ -282,27 +291,44 @@ export function reconcileDirectDispatchCompletionFromTranscript(args: {
|
|
|
282
291
|
updateDirectDispatchStatus(args.meshId, args.sessionId, kind === 'task_completed' ? 'completed' : 'failed', args.taskId);
|
|
283
292
|
markSessionDeliveriesTerminal(args.meshId, args.sessionId, kind === 'task_completed' ? 'completed' : 'failed');
|
|
284
293
|
setImmediate(() => cleanupTerminalDirectDispatches());
|
|
294
|
+
// NOTIF-DROP-SYNTH-NO-MESSAGE: the queued synth completion MUST carry a coordinatorMessage,
|
|
295
|
+
// or injectPendingIntoCoordinator early-returns (`!pending.coordinatorMessage`) and the row is
|
|
296
|
+
// drained-without-inject → no [System] surface. The ~8s-later native completion then collides
|
|
297
|
+
// on the taskId-anchored fingerprint and is blocked at INSERT, so the notification is lost
|
|
298
|
+
// forever. Build the SAME [System] message the native path builds (buildMeshSystemMessage) so
|
|
299
|
+
// the synth is itself a complete, deliverable completion. Routing is made STRICT too: stamp the
|
|
300
|
+
// originating coordinator session so PHASE 2 strict-match delivers to the exact coordinator
|
|
301
|
+
// session that dispatched the task instead of falling back to a daemon-level broadcast.
|
|
302
|
+
const eventName = kind === 'task_completed' ? 'agent:generating_completed' : 'agent:stopped';
|
|
303
|
+
const nodeLabel = nodeId ? `Node '${nodeId}'` : 'Remote agent';
|
|
304
|
+
const metadataEvent = {
|
|
305
|
+
targetSessionId: args.sessionId,
|
|
306
|
+
providerType: providerType || undefined,
|
|
307
|
+
providerSessionId: readNonEmptyString(args.providerSessionId),
|
|
308
|
+
finalSummary,
|
|
309
|
+
taskId: args.taskId,
|
|
310
|
+
workerResult,
|
|
311
|
+
completionDiagnostic: {
|
|
312
|
+
reason: 'direct_task_transcript_reconciliation',
|
|
313
|
+
terminalLedgerKind: kind,
|
|
314
|
+
terminalLedgerId: entry.id,
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
// Originating coordinator session: prefer the explicit arg; otherwise recover it from the
|
|
318
|
+
// task_dispatched ledger payload (the MCP dispatch path stamps `coordinatorSessionId` there).
|
|
319
|
+
// Absent on legacy rows → undefined → daemon-level routing (unchanged, no regression).
|
|
320
|
+
const targetCoordinatorSessionId = readNonEmptyString(args.targetCoordinatorSessionId)
|
|
321
|
+
|| readNonEmptyString(dispatch?.payload?.coordinatorSessionId);
|
|
285
322
|
queuePendingMeshCoordinatorEvent({
|
|
286
|
-
event:
|
|
323
|
+
event: eventName,
|
|
287
324
|
meshId: args.meshId,
|
|
288
|
-
nodeLabel
|
|
325
|
+
nodeLabel,
|
|
289
326
|
nodeId: nodeId || undefined,
|
|
290
|
-
metadataEvent
|
|
291
|
-
|
|
292
|
-
providerType: providerType || undefined,
|
|
293
|
-
providerSessionId: readNonEmptyString(args.providerSessionId),
|
|
294
|
-
finalSummary,
|
|
295
|
-
taskId: args.taskId,
|
|
296
|
-
workerResult,
|
|
297
|
-
completionDiagnostic: {
|
|
298
|
-
reason: 'direct_task_transcript_reconciliation',
|
|
299
|
-
terminalLedgerKind: kind,
|
|
300
|
-
terminalLedgerId: entry.id,
|
|
301
|
-
},
|
|
302
|
-
},
|
|
303
|
-
coordinatorMessage: undefined,
|
|
327
|
+
metadataEvent,
|
|
328
|
+
coordinatorMessage: buildMeshSystemMessage({ event: eventName, nodeLabel, metadataEvent }),
|
|
304
329
|
queuedAt: Date.now(),
|
|
305
330
|
...(readNonEmptyString(args.targetCoordinatorDaemonId) ? { targetCoordinatorDaemonId: readNonEmptyString(args.targetCoordinatorDaemonId) } : {}),
|
|
331
|
+
...(targetCoordinatorSessionId ? { targetCoordinatorSessionId } : {}),
|
|
306
332
|
});
|
|
307
333
|
|
|
308
334
|
return { reconciled: true, kind, workerResult, ledgerEntryId: entry.id };
|
|
@@ -267,7 +267,10 @@ export function tryAssignQueueTask(
|
|
|
267
267
|
providerType: string
|
|
268
268
|
): boolean {
|
|
269
269
|
const mesh = getMeshWithCache(components, meshId);
|
|
270
|
-
|
|
270
|
+
// Match with the shared 3-form normalizer (id / nodeId / node_id), not raw
|
|
271
|
+
// `n.id` — a stamp-form nodeId vs the mesh node's config-form id must still
|
|
272
|
+
// resolve, mirroring the remote idle-session path below (:1341).
|
|
273
|
+
const node = mesh?.nodes.find((n: any) => meshNodeIdMatches(n, nodeId));
|
|
271
274
|
|
|
272
275
|
// WORKTREE-CLAIM-GATE-BYPASS: the SINGLE claim-time gate for the worktree-bootstrap defer.
|
|
273
276
|
// tryAssignQueueTask is the one funnel every claim path flows through — the event-driven
|
|
@@ -1317,7 +1320,7 @@ export async function triggerMeshQueue(components: DaemonComponents, meshId: str
|
|
|
1317
1320
|
|
|
1318
1321
|
if (providerType) {
|
|
1319
1322
|
localIdleSessionsChecked += 1;
|
|
1320
|
-
localCandidates.push({ nodeId, sessionId, providerType, origin: 'local', node: mesh.nodes.find((n: any) =>
|
|
1323
|
+
localCandidates.push({ nodeId, sessionId, providerType, origin: 'local', node: mesh.nodes.find((n: any) => meshNodeIdMatches(n, nodeId)) });
|
|
1321
1324
|
} else {
|
|
1322
1325
|
skippedSessions.push({
|
|
1323
1326
|
nodeId,
|
|
@@ -55,7 +55,7 @@ import {
|
|
|
55
55
|
ackUnresolvedDelegateForward,
|
|
56
56
|
expireStaleUnresolvedDelegateForwards,
|
|
57
57
|
} from './mesh-unresolved-forward-outbox.js';
|
|
58
|
-
import { readNonEmptyString, readMeshCompletionSummary } from './mesh-events-utils.js';
|
|
58
|
+
import { readNonEmptyString, readMeshCompletionSummary, buildMeshSystemMessage } from './mesh-events-utils.js';
|
|
59
59
|
import { traceMeshEventStage, traceMeshEventDrop } from './mesh-event-trace.js';
|
|
60
60
|
import { expandDaemonIdForms, daemonIdsEquivalent } from '@adhdev/mesh-shared';
|
|
61
61
|
import { getActiveDirectDispatches, getQueue, reclaimStrandedAssignedTask, updateTaskStatus } from './mesh-work-queue.js';
|
|
@@ -368,7 +368,30 @@ function injectPendingIntoCoordinator(
|
|
|
368
368
|
coordinator: LiveCoordinator['instance'],
|
|
369
369
|
pending: PendingMeshCoordinatorEvent,
|
|
370
370
|
): void {
|
|
371
|
-
if (!coordinator
|
|
371
|
+
if (!coordinator) return;
|
|
372
|
+
// NOTIF-DROP-SYNTH-NO-MESSAGE (defence-in-depth): a queued event with no coordinatorMessage
|
|
373
|
+
// used to be dropped here (drain-without-inject) — the row had already been consumed
|
|
374
|
+
// (drained=1) by the caller's drain, so silently returning lost it forever. The primary fix
|
|
375
|
+
// makes the transcript-reconcile synth always carry a coordinatorMessage, but as a backstop,
|
|
376
|
+
// lazily synthesize the [System] text for any force-inject (terminal: completion / approval /
|
|
377
|
+
// stop / refine·bootstrap) event that still arrives message-less, so it surfaces instead of
|
|
378
|
+
// vanishing. A NON-force lifecycle event (agent:ready / generating_started) legitimately
|
|
379
|
+
// carries no message and must NOT be injected (it is queued only to re-drive the claim state
|
|
380
|
+
// machine on pull) — for it we still return without injecting.
|
|
381
|
+
let coordinatorMessage = pending.coordinatorMessage;
|
|
382
|
+
if (!coordinatorMessage) {
|
|
383
|
+
if (!shouldForceInjectMeshEvent(pending.event)) return;
|
|
384
|
+
const metadataEvent = pending.metadataEvent && typeof pending.metadataEvent === 'object'
|
|
385
|
+
? pending.metadataEvent
|
|
386
|
+
: {};
|
|
387
|
+
coordinatorMessage = buildMeshSystemMessage({
|
|
388
|
+
event: pending.event,
|
|
389
|
+
nodeLabel: pending.nodeLabel,
|
|
390
|
+
metadataEvent,
|
|
391
|
+
});
|
|
392
|
+
if (!coordinatorMessage) return; // builder produced nothing — nothing to surface
|
|
393
|
+
LOG.warn('MeshReconcile', `Lazily synthesized missing coordinatorMessage for ${pending.event} (mesh ${pending.meshId}) at inject time — a queued terminal event arrived message-less`);
|
|
394
|
+
}
|
|
372
395
|
const force = shouldForceInjectMeshEvent(pending.event);
|
|
373
396
|
// EVTTRACE: event surfaced to the coordinator (injected into its live CLI session).
|
|
374
397
|
// This is the terminal happy-path stage. Observation only.
|
|
@@ -380,7 +403,7 @@ function injectPendingIntoCoordinator(
|
|
|
380
403
|
event: pending.event,
|
|
381
404
|
}, force ? 'force-inject' : 'inject');
|
|
382
405
|
coordinator.onEvent('send_message', {
|
|
383
|
-
input: { text:
|
|
406
|
+
input: { text: coordinatorMessage, textFallback: coordinatorMessage },
|
|
384
407
|
...(force ? { force: true } : {}),
|
|
385
408
|
});
|
|
386
409
|
}
|
|
@@ -4,6 +4,7 @@ import { LOG } from '../logging/logger.js';
|
|
|
4
4
|
import { loadBetterSqlite3 } from '../system/load-better-sqlite3.js';
|
|
5
5
|
import { getLedgerDir } from './mesh-ledger.js';
|
|
6
6
|
import { nodeSatisfiesRequiredTags } from './mesh-work-queue.js';
|
|
7
|
+
import { meshNodeIdMatches, daemonIdsEquivalent, expandDaemonIdForms } from '@adhdev/mesh-shared';
|
|
7
8
|
import type { MeshTaskStatus, MeshWorkQueueEntry } from './mesh-work-queue.js';
|
|
8
9
|
import type BetterSqlite3 from 'better-sqlite3';
|
|
9
10
|
import type { Database as DatabaseHandle } from 'better-sqlite3';
|
|
@@ -687,6 +688,15 @@ export class MeshRuntimeStore {
|
|
|
687
688
|
return null;
|
|
688
689
|
}
|
|
689
690
|
|
|
691
|
+
// The node-pinned SELECT must match a row whose target_node_id was stamped
|
|
692
|
+
// in ANY equivalent daemon-id form (config-form `daemon_mach_X` vs the
|
|
693
|
+
// claiming session's stamp-form `mach_X`). A single `= ?` bind on the
|
|
694
|
+
// stamp-form silently fails to fetch a config-form row, leaving the task
|
|
695
|
+
// pending forever (the empty-session WORKTREE-CLAIM-GATE repro). Expand to
|
|
696
|
+
// every equivalent form and bind an IN (...) set; the per-candidate
|
|
697
|
+
// targetMatches() JS gate above re-validates each fetched row.
|
|
698
|
+
const nodeIdForms = expandDaemonIdForms(nodeId);
|
|
699
|
+
const nodePinnedPlaceholders = nodeIdForms.map(() => '?').join(', ');
|
|
690
700
|
// Priority: session-targeted > node-targeted (no session) > unconstrained
|
|
691
701
|
const rows = [
|
|
692
702
|
...(
|
|
@@ -699,9 +709,9 @@ export class MeshRuntimeStore {
|
|
|
699
709
|
...(
|
|
700
710
|
this.db.prepare(`
|
|
701
711
|
SELECT payload FROM mesh_queue
|
|
702
|
-
WHERE mesh_id = ? AND status = 'pending' AND target_node_id
|
|
712
|
+
WHERE mesh_id = ? AND status = 'pending' AND target_node_id IN (${nodePinnedPlaceholders}) AND target_session_id IS NULL
|
|
703
713
|
ORDER BY created_at ASC
|
|
704
|
-
`).all(meshId,
|
|
714
|
+
`).all(meshId, ...nodeIdForms) as Array<{ payload: string }>
|
|
705
715
|
),
|
|
706
716
|
...(
|
|
707
717
|
this.db.prepare(`
|
|
@@ -755,9 +765,21 @@ export class MeshRuntimeStore {
|
|
|
755
765
|
// sibling worktree session on the same daemon absorb another node's/session's
|
|
756
766
|
// pinned task. When a task carries an explicit target, require an exact match
|
|
757
767
|
// here too — fail-closed.
|
|
768
|
+
// The target id may have been stamped in a different serialization /
|
|
769
|
+
// daemon-id form than the claiming session's nodeId (config-form
|
|
770
|
+
// `daemon_mach_X` vs stamp-form `mach_X`, or the 3-way id/nodeId/node_id
|
|
771
|
+
// node forms). A raw `!==` here permanently strands a node-pinned task as
|
|
772
|
+
// an empty session. Accept the candidate when the target resolves to the
|
|
773
|
+
// same node under ANY equivalent form; keep targetSessionId an exact match.
|
|
758
774
|
const targetMatches = (candidate: MeshWorkQueueEntry): boolean => {
|
|
759
775
|
if (candidate.targetSessionId && candidate.targetSessionId !== sessionId) return false;
|
|
760
|
-
if (
|
|
776
|
+
if (
|
|
777
|
+
candidate.targetNodeId
|
|
778
|
+
&& !daemonIdsEquivalent(candidate.targetNodeId, nodeId)
|
|
779
|
+
&& !meshNodeIdMatches({ id: candidate.targetNodeId }, nodeId)
|
|
780
|
+
) {
|
|
781
|
+
return false;
|
|
782
|
+
}
|
|
761
783
|
return true;
|
|
762
784
|
};
|
|
763
785
|
|