@adhdev/daemon-core 0.9.82-rc.396 → 0.9.82-rc.398
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/commands/router.d.ts +23 -0
- package/dist/index.js +129 -14
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +129 -14
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/cli-adapters/provider-cli-adapter.ts +52 -3
- package/src/commands/router.ts +73 -0
- package/src/mesh/mesh-event-forwarding.ts +23 -0
- package/src/mesh/mesh-queue-assignment.ts +5 -2
- package/src/mesh/mesh-runtime-store.ts +25 -3
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.398",
|
|
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.398",
|
|
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
|
@@ -727,6 +727,72 @@ export class DaemonCommandRouter {
|
|
|
727
727
|
return true;
|
|
728
728
|
}
|
|
729
729
|
|
|
730
|
+
/**
|
|
731
|
+
* WORKTREE-BOOTSTRAP-COORD-STATE: mark a worktree node's bootstrap as reaching a
|
|
732
|
+
* terminal state (complete / failed) in THIS daemon's mesh view.
|
|
733
|
+
*
|
|
734
|
+
* Root cause this fixes: clone_mesh_node forwards the clone+bootstrap to the
|
|
735
|
+
* source node's daemon (the worktree's machine). persistWorktreeSetupState
|
|
736
|
+
* therefore flips worktreeBootstrap.status to 'complete' on the WORKER daemon's
|
|
737
|
+
* mesh object — never on the coordinator's. The coordinator only ever holds the
|
|
738
|
+
* 'running' state it stamped from the forwarded clone reply. The claim path's
|
|
739
|
+
* bootstrap gate (mesh-event-forwarding agent:ready / mesh-queue-assignment)
|
|
740
|
+
* reads the coordinator's mesh via getMeshWithCache, sees status==='running'
|
|
741
|
+
* forever, and DEFERS every claim — so the worktree_bootstrap_complete re-fire
|
|
742
|
+
* (triggerMeshQueue) loops against a gate that never opens: claim never lands,
|
|
743
|
+
* the idle session is re-registered each tick, and auto-launch keeps spawning
|
|
744
|
+
* fresh sessions (runaway worktree-session multiplication).
|
|
745
|
+
*
|
|
746
|
+
* Called from the worktree_bootstrap_complete/_failed event handler BEFORE the
|
|
747
|
+
* queue re-fire so the gate sees the terminal state and the deferred claim can
|
|
748
|
+
* finally land. Updates the inline cache (clone worktree nodes are inline-only)
|
|
749
|
+
* and, when the node also exists in local config, persists there too; both paths
|
|
750
|
+
* invalidate the aggregate status cache. Best-effort and idempotent.
|
|
751
|
+
*/
|
|
752
|
+
public markWorktreeBootstrapTerminalState(meshId: string, nodeId: string, status: 'complete' | 'failed'): void {
|
|
753
|
+
if (!meshId || !nodeId) return;
|
|
754
|
+
const stamp = (mesh: any): boolean => {
|
|
755
|
+
if (!mesh || !Array.isArray(mesh.nodes)) return false;
|
|
756
|
+
const node = mesh.nodes.find((entry: any) => meshNodeIdMatches(entry, nodeId));
|
|
757
|
+
if (!node) return false;
|
|
758
|
+
const prev = (node.worktreeBootstrap && typeof node.worktreeBootstrap === 'object')
|
|
759
|
+
? node.worktreeBootstrap as Record<string, unknown>
|
|
760
|
+
: {};
|
|
761
|
+
if (prev.status === status) return false;
|
|
762
|
+
node.worktreeBootstrap = {
|
|
763
|
+
...prev,
|
|
764
|
+
status,
|
|
765
|
+
completedAt: prev.completedAt ?? new Date().toISOString(),
|
|
766
|
+
};
|
|
767
|
+
return true;
|
|
768
|
+
};
|
|
769
|
+
let changed = false;
|
|
770
|
+
// Inline cache (the authoritative view for inline-only clone worktree nodes).
|
|
771
|
+
try {
|
|
772
|
+
const cached = this.getCachedInlineMesh(meshId);
|
|
773
|
+
if (cached && stamp(cached)) {
|
|
774
|
+
cached.updatedAt = new Date().toISOString();
|
|
775
|
+
this.inlineMeshCache.set(meshId, cached);
|
|
776
|
+
changed = true;
|
|
777
|
+
}
|
|
778
|
+
} catch { /* best-effort */ }
|
|
779
|
+
if (changed) this.invalidateAggregateMeshStatus(meshId);
|
|
780
|
+
// Local config (a worktree node registered via addNode also lives here).
|
|
781
|
+
// Done in a detached dynamic-import chain so the method stays sync; both the
|
|
782
|
+
// stamp and the persist are best-effort, and the inline-cache stamp above is
|
|
783
|
+
// what the coordinator's claim gate reads.
|
|
784
|
+
void import('../config/mesh-config.js')
|
|
785
|
+
.then(({ getMesh, updateNode }) => {
|
|
786
|
+
const local = getMesh(meshId);
|
|
787
|
+
if (local && stamp(local)) {
|
|
788
|
+
const node = local.nodes.find((entry: any) => meshNodeIdMatches(entry, nodeId));
|
|
789
|
+
if (node) updateNode(meshId, node.id, { worktreeBootstrap: node.worktreeBootstrap } as any);
|
|
790
|
+
this.invalidateAggregateMeshStatus(meshId);
|
|
791
|
+
}
|
|
792
|
+
})
|
|
793
|
+
.catch(() => { /* persistence is best-effort */ });
|
|
794
|
+
}
|
|
795
|
+
|
|
730
796
|
private tombstoneRemovedInlineMeshNode(meshId: string, nodeId: string): void {
|
|
731
797
|
if (!nodeId) return;
|
|
732
798
|
let set = this.removedInlineMeshNodeIds.get(meshId);
|
|
@@ -2371,6 +2437,13 @@ export class DaemonCommandRouter {
|
|
|
2371
2437
|
sessionCleanupMode: refineSessionCleanupMode,
|
|
2372
2438
|
...(refineSessionIds && refineSessionIds.length > 0 ? { sessionIds: refineSessionIds } : {}),
|
|
2373
2439
|
inlineMesh: args?.inlineMesh,
|
|
2440
|
+
// REFINE-CLEANUP: refine reaches cleanup only AFTER a verified merge
|
|
2441
|
+
// convergence, so any residual worktree dirtiness here is incidental
|
|
2442
|
+
// (e.g. a bootstrap lockfile rewrite) — never unmerged work. `force`
|
|
2443
|
+
// sets requireClean=false so a plain-dirty worktree no longer aborts
|
|
2444
|
+
// removal with merged_cleanup_failed. Branch-ref deletion still keys off
|
|
2445
|
+
// mergeConvergence (NOT the force flag), so no merged work can be lost.
|
|
2446
|
+
force: true,
|
|
2374
2447
|
});
|
|
2375
2448
|
recordMeshRefineStage(refineStages, 'cleanup', removeResult?.success === false ? 'failed' : 'passed', cleanupStarted, {
|
|
2376
2449
|
removed: removeResult?.removed,
|
|
@@ -1053,6 +1053,29 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
1053
1053
|
// is drained in every topology. 'failed' is re-fired too so a deferred-then-failed bootstrap
|
|
1054
1054
|
// dispatches and fails loudly/visibly rather than stranding silently. Falls through to the
|
|
1055
1055
|
// coordinator broadcast below — the bootstrap event is still delivered to the coordinator.
|
|
1056
|
+
// WORKTREE-BOOTSTRAP-COORD-STATE: stamp the terminal bootstrap status onto the
|
|
1057
|
+
// COORDINATOR's mesh view BEFORE re-firing the queue. The clone+bootstrap ran on
|
|
1058
|
+
// the worker daemon (clone_mesh_node forwards to the source node's machine), so
|
|
1059
|
+
// persistWorktreeSetupState only flipped status→'complete' on the worker's mesh
|
|
1060
|
+
// object — the coordinator still holds the 'running' state it stamped from the
|
|
1061
|
+
// forwarded clone reply. Without this, the claim gate (agent:ready defer above +
|
|
1062
|
+
// mesh-queue-assignment) reads getMeshWithCache, sees 'running' forever, and
|
|
1063
|
+
// defers every claim — so this very re-fire loops against a gate that never opens
|
|
1064
|
+
// (claim never lands; idle session re-registered each tick; auto-launch spawns a
|
|
1065
|
+
// fresh session every cycle → runaway worktree-session multiplication). Stamping
|
|
1066
|
+
// the terminal state opens the gate so the deferred claim lands on this re-fire.
|
|
1067
|
+
const bootstrapNodeId = readNonEmptyString(args.nodeId) || readNonEmptyString(args.metadataEvent.meshNodeId);
|
|
1068
|
+
if (bootstrapNodeId) {
|
|
1069
|
+
try {
|
|
1070
|
+
(components.router as any)?.markWorktreeBootstrapTerminalState?.(
|
|
1071
|
+
args.meshId,
|
|
1072
|
+
bootstrapNodeId,
|
|
1073
|
+
args.event === 'worktree_bootstrap_failed' ? 'failed' : 'complete',
|
|
1074
|
+
);
|
|
1075
|
+
} catch (e: any) {
|
|
1076
|
+
LOG.warn('MeshQueue', `Failed to stamp terminal bootstrap state for ${bootstrapNodeId} (mesh ${args.meshId}): ${e?.message || e}`);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1056
1079
|
setImmediate(() => {
|
|
1057
1080
|
triggerMeshQueue(components, args.meshId).catch((e: any) => {
|
|
1058
1081
|
LOG.warn('MeshQueue', `Queue re-fire after ${args.event} failed (mesh ${args.meshId}): ${e?.message || e}`);
|
|
@@ -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,
|
|
@@ -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
|
|