@adhdev/daemon-core 0.9.82-rc.421 → 0.9.82-rc.423
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/index.js +72 -40
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +72 -40
- package/dist/index.mjs.map +1 -1
- package/dist/providers/provider-instance-manager.d.ts +18 -3
- package/package.json +2 -2
- package/src/commands/cli-manager.ts +10 -2
- package/src/mesh/mesh-queue-assignment.ts +55 -0
- package/src/providers/provider-instance-manager.ts +54 -13
|
@@ -84,15 +84,30 @@ export declare class ProviderInstanceManager {
|
|
|
84
84
|
updateInstanceSettings(providerType: string, settings: Record<string, any>): number;
|
|
85
85
|
/** Stamp a mesh assignment on a single instance (used by mesh_send_task
|
|
86
86
|
* --direct so the worker's completion event has a coordinator routing
|
|
87
|
-
* marker in state.settings). Returns true
|
|
88
|
-
*
|
|
87
|
+
* marker in state.settings). Returns `{ stamped: true }` when the stamp was
|
|
88
|
+
* applied, or `{ stamped: false, reason }` when it was refused — the instance
|
|
89
|
+
* was missing / has no attach method, or the DOUBLE-DISPATCH idempotence guard
|
|
90
|
+
* fired (the same task is already running on another live session here). */
|
|
89
91
|
attachMeshAssignmentToInstance(instanceId: string, assignment: {
|
|
90
92
|
meshId: string;
|
|
91
93
|
nodeId?: string;
|
|
92
94
|
taskId?: string;
|
|
93
95
|
coordinatorDaemonId?: string;
|
|
94
96
|
coordinatorSessionId?: string;
|
|
95
|
-
}):
|
|
97
|
+
}): {
|
|
98
|
+
stamped: boolean;
|
|
99
|
+
reason?: string;
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* DOUBLE-DISPATCH support: the id of another LIVE, actively-working instance that already
|
|
103
|
+
* holds (meshId, taskId), or null. "Live working" = stamped with this exact mesh+task AND
|
|
104
|
+
* currently mid-turn / booting toward it (generating / waiting on approval-or-choice /
|
|
105
|
+
* starting) — NOT idle, stopped, or errored. A stale/dead/idle holder is deliberately
|
|
106
|
+
* ignored so a legitimate re-dispatch (e.g. after a dispatch failure) is never blocked.
|
|
107
|
+
* The instance being stamped (excludeInstanceId) is skipped so re-stamping the same
|
|
108
|
+
* session stays idempotent. O(n) over instances — the count is small.
|
|
109
|
+
*/
|
|
110
|
+
private findLiveWorkingTaskHolder;
|
|
96
111
|
/** Clear a mesh assignment after the dispatched task reaches a terminal
|
|
97
112
|
* state (generating_completed / stopped / failed). */
|
|
98
113
|
detachMeshAssignmentFromInstance(instanceId: string): 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.423",
|
|
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.423",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
|
@@ -1535,14 +1535,22 @@ export class DaemonCliManager {
|
|
|
1535
1535
|
const meshContext = (args as any)?.meshContext;
|
|
1536
1536
|
if (meshContext && typeof meshContext === 'object' && typeof meshContext.meshId === 'string' && meshContext.meshId) {
|
|
1537
1537
|
const targetInstanceId = key;
|
|
1538
|
+
let stampResult: { stamped: boolean; reason?: string } | undefined;
|
|
1538
1539
|
try {
|
|
1539
|
-
this.deps.getInstanceManager()?.attachMeshAssignmentToInstance(targetInstanceId, {
|
|
1540
|
+
stampResult = this.deps.getInstanceManager()?.attachMeshAssignmentToInstance(targetInstanceId, {
|
|
1540
1541
|
meshId: meshContext.meshId,
|
|
1541
1542
|
...(typeof meshContext.nodeId === 'string' && meshContext.nodeId ? { nodeId: meshContext.nodeId } : {}),
|
|
1542
1543
|
...(typeof meshContext.taskId === 'string' && meshContext.taskId ? { taskId: meshContext.taskId } : {}),
|
|
1543
1544
|
...(typeof meshContext.coordinatorDaemonId === 'string' && meshContext.coordinatorDaemonId ? { coordinatorDaemonId: meshContext.coordinatorDaemonId } : {}),
|
|
1544
1545
|
});
|
|
1545
|
-
} catch { /* best-effort */ }
|
|
1546
|
+
} catch { /* best-effort — stamping is a routing aid, not a hard requirement */ }
|
|
1547
|
+
// DOUBLE-DISPATCH stamp guard: the instance manager refused this stamp because
|
|
1548
|
+
// the SAME task is already running on another live session on this daemon.
|
|
1549
|
+
// Sending the prompt anyway would double-execute the task — fail closed so the
|
|
1550
|
+
// coordinator does not duplicate the work onto a second session.
|
|
1551
|
+
if (stampResult && stampResult.stamped === false && stampResult.reason === 'task_already_stamped_on_live_instance') {
|
|
1552
|
+
throw new Error(`Refusing duplicate mesh dispatch: task ${meshContext.taskId} is already being worked by a live session on this daemon`);
|
|
1553
|
+
}
|
|
1546
1554
|
}
|
|
1547
1555
|
const input = normalizeInputEnvelope(args?.input ? { input: args.input } : args);
|
|
1548
1556
|
const provider = this.providerLoader.resolve(agentType) || this.providerLoader.getMeta(agentType);
|
|
@@ -1098,6 +1098,47 @@ function liveSessionCountForNode(components: DaemonComponents, meshId: string, n
|
|
|
1098
1098
|
}).length;
|
|
1099
1099
|
}
|
|
1100
1100
|
|
|
1101
|
+
/**
|
|
1102
|
+
* DOUBLE-DISPATCH auto-launch gate: does this node already have a LIVE mesh session that
|
|
1103
|
+
* is NOT holding an assigned queue task — i.e. one that is idle, booting toward its first
|
|
1104
|
+
* claim, or in a momentary non-idle flip? Such a session WILL claim a still-pending task
|
|
1105
|
+
* on its own via the idle→claim / agent:ready drain, so spawning a NEW session here only
|
|
1106
|
+
* races it and yields a duplicate worker that double-stamps the same taskId (the
|
|
1107
|
+
* enqueue → drain-miss → auto-launch RCA: the drain skipped a momentarily-non-idle idle
|
|
1108
|
+
* session as a candidate, and the write-only nodeHasActiveAssignment gate — which only
|
|
1109
|
+
* inspects status='assigned' rows — could not see the about-to-claim session either).
|
|
1110
|
+
*
|
|
1111
|
+
* A session that already HOLDS an assigned queue task is genuine concurrent work, not a
|
|
1112
|
+
* free claimer, and is excluded — so a read-only auto-launch onto a busy-but-no-idle node
|
|
1113
|
+
* is still allowed. A node with NO live mesh session at all (dead, or never launched) does
|
|
1114
|
+
* not match, preserving the legitimate first-session spawn.
|
|
1115
|
+
*/
|
|
1116
|
+
function nodeHasLiveSessionPendingClaim(components: DaemonComponents, meshId: string, nodeId: string): boolean {
|
|
1117
|
+
// Session ids currently holding an assigned queue task on this node — those are busy,
|
|
1118
|
+
// not pending claimers, so they must NOT suppress a (read-only) launch.
|
|
1119
|
+
const busySessionIds = new Set(
|
|
1120
|
+
getQueue(meshId, { status: ['assigned'] as any })
|
|
1121
|
+
.filter(task => daemonIdsEquivalent(task.assignedNodeId, nodeId))
|
|
1122
|
+
.map(task => readNonEmptyString(task.assignedSessionId))
|
|
1123
|
+
.filter(Boolean),
|
|
1124
|
+
);
|
|
1125
|
+
return components.instanceManager.getByCategory('cli').some((inst: any) => {
|
|
1126
|
+
const state = inst.getState();
|
|
1127
|
+
const settings = state.settings as Record<string, unknown> || {};
|
|
1128
|
+
if (readNonEmptyString(settings.meshNodeFor) !== meshId) return false;
|
|
1129
|
+
const instNodeId = readNonEmptyString(settings.meshNodeId) || readNonEmptyString(settings.nodeId);
|
|
1130
|
+
// Canonical-form match (see nodeHasActiveMeshWork / liveSessionCountForNode): a
|
|
1131
|
+
// daemon-id form skew must not make a present session look absent and reopen the
|
|
1132
|
+
// duplicate-launch hole.
|
|
1133
|
+
if (!daemonIdsEquivalent(instNodeId, nodeId)) return false;
|
|
1134
|
+
const status = readNonEmptyString(state.status).toLowerCase();
|
|
1135
|
+
if (isTerminalSessionStatus(status)) return false; // dead → no claimer here, allow launch
|
|
1136
|
+
const sessionId = readNonEmptyString(state.instanceId);
|
|
1137
|
+
if (sessionId && busySessionIds.has(sessionId)) return false; // busy with its own assigned task
|
|
1138
|
+
return true; // live + unassigned → will claim the pending task itself
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1101
1142
|
function recordAutoLaunchEvent(meshId: string, args: {
|
|
1102
1143
|
phase: 'skipped' | 'started' | 'failed' | 'completed';
|
|
1103
1144
|
taskId: string;
|
|
@@ -1417,6 +1458,20 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
|
|
|
1417
1458
|
autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS); sweepExpiredCooldowns();
|
|
1418
1459
|
continue;
|
|
1419
1460
|
}
|
|
1461
|
+
// DOUBLE-DISPATCH auto-launch gate (see nodeHasLiveSessionPendingClaim): when this
|
|
1462
|
+
// node already has a live session on its way to claim (idle / booting / momentary
|
|
1463
|
+
// non-idle flip), do NOT spawn a second one — that session pulls the pending task
|
|
1464
|
+
// via the normal idle→claim / agent:ready drain. Launching here races it and yields
|
|
1465
|
+
// a duplicate worker that double-stamps the same taskId. Applies to read-only tasks
|
|
1466
|
+
// too: an idle session can claim either kind, while a genuinely BUSY session (holding
|
|
1467
|
+
// its own assigned task) is excluded by the helper, so a read-only launch onto a
|
|
1468
|
+
// busy-but-no-idle node is still allowed. Skip with a transient (non-actionable)
|
|
1469
|
+
// reason so the coordinator is not paged; the 4s reconcile retries, and once the
|
|
1470
|
+
// existing session goes terminal this gate clears and a legitimate launch proceeds.
|
|
1471
|
+
if (nodeHasLiveSessionPendingClaim(components, meshId, nodeId)) {
|
|
1472
|
+
markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'node_has_live_session_pending_claim', nodeId });
|
|
1473
|
+
continue;
|
|
1474
|
+
}
|
|
1420
1475
|
// Write tasks keep the one-active-per-node invariant (worktree isolation);
|
|
1421
1476
|
// read-only diagnoses may auto-launch onto a node that already has an active
|
|
1422
1477
|
// assignment. Classified by the shared isTaskReadonly predicate.
|
|
@@ -308,23 +308,64 @@ export class ProviderInstanceManager {
|
|
|
308
308
|
|
|
309
309
|
/** Stamp a mesh assignment on a single instance (used by mesh_send_task
|
|
310
310
|
* --direct so the worker's completion event has a coordinator routing
|
|
311
|
-
* marker in state.settings). Returns true
|
|
312
|
-
*
|
|
313
|
-
|
|
311
|
+
* marker in state.settings). Returns `{ stamped: true }` when the stamp was
|
|
312
|
+
* applied, or `{ stamped: false, reason }` when it was refused — the instance
|
|
313
|
+
* was missing / has no attach method, or the DOUBLE-DISPATCH idempotence guard
|
|
314
|
+
* fired (the same task is already running on another live session here). */
|
|
315
|
+
attachMeshAssignmentToInstance(instanceId: string, assignment: { meshId: string; nodeId?: string; taskId?: string; coordinatorDaemonId?: string; coordinatorSessionId?: string }): { stamped: boolean; reason?: string } {
|
|
314
316
|
const inst = this.instances.get(instanceId);
|
|
315
317
|
if (!inst || typeof inst.attachMeshAssignment !== 'function') {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
318
|
+
LOG.warn('MeshDispatch', `attachMeshAssignment skipped: instance ${instanceId} ${inst ? 'has no attach method' : 'not found'}`);
|
|
319
|
+
return { stamped: false, reason: inst ? 'instance_has_no_attach_method' : 'instance_not_found' };
|
|
320
|
+
}
|
|
321
|
+
// DOUBLE-DISPATCH stamp idempotence guard (defense in depth): refuse to stamp this
|
|
322
|
+
// (meshId, taskId) onto a SECOND instance when a DIFFERENT, still-live and actively
|
|
323
|
+
// working instance already holds the exact same task. Two sessions carrying one taskId
|
|
324
|
+
// double-execute the work (the auto-launch race RCA: a delayed claim by the original
|
|
325
|
+
// session plus the new session's post-boot claim sequentially stamp the same task —
|
|
326
|
+
// the atomic claim only blocks SIMULTANEOUS claims). A stale/dead/idle prior holder is
|
|
327
|
+
// NOT a conflict — a legitimate re-dispatch after a dispatch failure must still stamp.
|
|
328
|
+
if (assignment.taskId) {
|
|
329
|
+
const conflict = this.findLiveWorkingTaskHolder(assignment.meshId, assignment.taskId, instanceId);
|
|
330
|
+
if (conflict) {
|
|
331
|
+
LOG.warn('MeshDispatch', `attachMeshAssignment refused: task ${assignment.taskId} (mesh ${assignment.meshId}) is already being worked by live session ${conflict} — skipping duplicate stamp on ${instanceId}`);
|
|
332
|
+
return { stamped: false, reason: 'task_already_stamped_on_live_instance' };
|
|
333
|
+
}
|
|
321
334
|
}
|
|
322
335
|
inst.attachMeshAssignment(assignment);
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
336
|
+
LOG.info('MeshDispatch', `stamped mesh assignment on ${instanceId}: mesh=${assignment.meshId} node=${assignment.nodeId || ''} task=${assignment.taskId || ''} coordinator=${assignment.coordinatorDaemonId || ''}`);
|
|
337
|
+
return { stamped: true };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* DOUBLE-DISPATCH support: the id of another LIVE, actively-working instance that already
|
|
342
|
+
* holds (meshId, taskId), or null. "Live working" = stamped with this exact mesh+task AND
|
|
343
|
+
* currently mid-turn / booting toward it (generating / waiting on approval-or-choice /
|
|
344
|
+
* starting) — NOT idle, stopped, or errored. A stale/dead/idle holder is deliberately
|
|
345
|
+
* ignored so a legitimate re-dispatch (e.g. after a dispatch failure) is never blocked.
|
|
346
|
+
* The instance being stamped (excludeInstanceId) is skipped so re-stamping the same
|
|
347
|
+
* session stays idempotent. O(n) over instances — the count is small.
|
|
348
|
+
*/
|
|
349
|
+
private findLiveWorkingTaskHolder(meshId: string, taskId: string, excludeInstanceId: string): string | null {
|
|
350
|
+
// Mid-turn / booting statuses (top-level or activeChat). Anything else — idle, stopped,
|
|
351
|
+
// error — is not a live worker actively holding the task.
|
|
352
|
+
const working = new Set(['generating', 'waiting_approval', 'waiting_choice', 'starting', 'streaming', 'working', 'no_progress', 'long_generating']);
|
|
353
|
+
for (const [id, inst] of this.instances) {
|
|
354
|
+
if (id === excludeInstanceId) continue;
|
|
355
|
+
let state: ProviderState;
|
|
356
|
+
try {
|
|
357
|
+
state = inst.getState();
|
|
358
|
+
} catch {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
const settings = (state.settings as Record<string, unknown>) || {};
|
|
362
|
+
if (settings.meshNodeFor !== meshId) continue;
|
|
363
|
+
if (settings.meshActiveTaskId !== taskId) continue;
|
|
364
|
+
const status = (typeof state.status === 'string' ? state.status : '').toLowerCase();
|
|
365
|
+
const chatStatus = (typeof state.activeChat?.status === 'string' ? state.activeChat.status : '').toLowerCase();
|
|
366
|
+
if (working.has(status) || working.has(chatStatus)) return id;
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
328
369
|
}
|
|
329
370
|
|
|
330
371
|
/** Clear a mesh assignment after the dispatched task reaches a terminal
|