@adhdev/daemon-core 0.9.82-rc.380 → 0.9.82-rc.381
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.d.ts +4 -2
- package/dist/index.js +222 -59
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +215 -59
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-runtime-store.d.ts +12 -0
- package/dist/mesh/mesh-scheduling-runtime.d.ts +78 -0
- package/dist/repo-mesh-types.d.ts +81 -0
- package/package.json +2 -2
- package/src/config/mesh-config.ts +6 -58
- package/src/index.ts +13 -0
- package/src/mesh/coordinator-prompt.ts +3 -3
- package/src/mesh/mesh-event-forwarding.ts +17 -1
- package/src/mesh/mesh-runtime-store.ts +23 -0
- package/src/mesh/mesh-scheduling-runtime.ts +199 -0
- package/src/repo-mesh-types.ts +164 -0
|
@@ -128,6 +128,18 @@ export declare class MeshRuntimeStore {
|
|
|
128
128
|
updatedAt: string;
|
|
129
129
|
}>;
|
|
130
130
|
updateDirectDispatchStatus(meshId: string, sessionId: string, status: 'acked' | 'completed' | 'failed' | 'stale', taskId?: string): void;
|
|
131
|
+
/**
|
|
132
|
+
* MESH-DISPATCH-MISROUTE (fix 3, consumer residual): resolve the task_id of the SINGLE
|
|
133
|
+
* non-terminal direct dispatch a session owns. Returns the task_id only when the session
|
|
134
|
+
* holds exactly ONE active ('dispatched'/'acked') row — the case where a taskId-less
|
|
135
|
+
* lifecycle event (a legacy/relayed worker whose producer never stamped meshActiveTaskId)
|
|
136
|
+
* unambiguously belongs to that one dispatch. With zero rows there is nothing to ack; with
|
|
137
|
+
* two or more (a re-dispatch/nudge sibling) the firing event's owner is ambiguous, so we
|
|
138
|
+
* return null and the caller MUST NOT fall back to the session_id sweep that would flip a
|
|
139
|
+
* sibling row ("may flip a sibling dispatch row"). This narrows the legacy fallback to the
|
|
140
|
+
* only safe case instead of removing the producer-side TASKIDLESS stamp's safety net.
|
|
141
|
+
*/
|
|
142
|
+
getSoleActiveDirectDispatchTaskId(meshId: string, sessionId: string): string | null;
|
|
131
143
|
cleanupTerminalDirectDispatches(olderThanMs: number): void;
|
|
132
144
|
deleteDirectDispatches(meshId: string): void;
|
|
133
145
|
/**
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { RepoMeshNodePolicy, RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
|
|
2
|
+
import type { MeshWorkQueueEntry } from './mesh-work-queue.js';
|
|
3
|
+
/** Per-(node, provider) cap and its current consumption. */
|
|
4
|
+
export interface MeshNodeProviderSchedulingRuntime {
|
|
5
|
+
providerType: string;
|
|
6
|
+
/** Declared maxParallel cap for this (node, provider). Omitted when uncapped. */
|
|
7
|
+
maxParallel?: number;
|
|
8
|
+
/** Active (status='assigned') tasks on this node claimed by this provider. */
|
|
9
|
+
activeAssigned: number;
|
|
10
|
+
/** True when activeAssigned has reached maxParallel (a further claim is refused). */
|
|
11
|
+
capReached: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** Per-node scheduling-runtime projection. */
|
|
14
|
+
export interface MeshNodeSchedulingRuntime {
|
|
15
|
+
nodeId: string;
|
|
16
|
+
/** Active (status='assigned') task count on this node — the least-loaded rank key. */
|
|
17
|
+
load: number;
|
|
18
|
+
/** Soft scheduling priority (PRIORITY rank key; higher = preferred). */
|
|
19
|
+
schedulingPriority: number;
|
|
20
|
+
/** Per-node concurrent-session cap, when configured. */
|
|
21
|
+
maxConcurrentSessions?: number;
|
|
22
|
+
/** Per-(node, provider) caps + consumption, when providerRoles declares any. */
|
|
23
|
+
providerRoles?: MeshNodeProviderSchedulingRuntime[];
|
|
24
|
+
/**
|
|
25
|
+
* True when the node currently cannot claim a NEW write (non-readonly) task —
|
|
26
|
+
* either the global write cap is exhausted or a node-local gate (active write
|
|
27
|
+
* assignment / a fully-consumed provider cap / session cap) blocks it.
|
|
28
|
+
*/
|
|
29
|
+
capReached: boolean;
|
|
30
|
+
/** Structured reasons backing capReached (empty when the node can take work). */
|
|
31
|
+
capReasons: string[];
|
|
32
|
+
}
|
|
33
|
+
/** Mesh-level scheduling-runtime projection. */
|
|
34
|
+
export interface MeshSchedulingRuntime {
|
|
35
|
+
/** Resolved tie-break strategy (defaults to 'first_eligible'). */
|
|
36
|
+
strategy: RepoMeshSchedulingStrategy;
|
|
37
|
+
/** Effective global write-task parallel cap (clamped). */
|
|
38
|
+
maxParallelTasks: number;
|
|
39
|
+
/** Derived read-only diagnosis cap (max(2, 2× write cap)) — mirrors the claim path. */
|
|
40
|
+
maxReadonlyParallelTasks: number;
|
|
41
|
+
/** Current global write (non-readonly) assigned-task load. */
|
|
42
|
+
activeWriteAssigned: number;
|
|
43
|
+
/** Current global read-only assigned-task load. */
|
|
44
|
+
activeReadonlyAssigned: number;
|
|
45
|
+
/** True when the global write cap is exhausted (no new write task can launch). */
|
|
46
|
+
globalWriteCapReached: boolean;
|
|
47
|
+
/** True when the read-only diagnosis cap is exhausted. */
|
|
48
|
+
globalReadonlyCapReached: boolean;
|
|
49
|
+
/** Per-node projections, in mesh config order. */
|
|
50
|
+
nodes: MeshNodeSchedulingRuntime[];
|
|
51
|
+
}
|
|
52
|
+
interface MeshLike {
|
|
53
|
+
policy?: {
|
|
54
|
+
schedulingStrategy?: unknown;
|
|
55
|
+
maxParallelTasks?: unknown;
|
|
56
|
+
} | null;
|
|
57
|
+
nodes?: Array<{
|
|
58
|
+
id?: string;
|
|
59
|
+
nodeId?: string;
|
|
60
|
+
node_id?: string;
|
|
61
|
+
policy?: RepoMeshNodePolicy | null;
|
|
62
|
+
isLocalWorktree?: boolean;
|
|
63
|
+
}> | null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Build the scheduling-runtime projection for a mesh from its config + a snapshot of
|
|
67
|
+
* its work queue. Pure and side-effect free — safe to call on any read path.
|
|
68
|
+
*
|
|
69
|
+
* The derivation deliberately mirrors maybeAutoLaunchOneQueueSession's gates so the
|
|
70
|
+
* exposed "capReached/capReasons" match why a real claim would be refused:
|
|
71
|
+
* • global write cap → activeWriteAssigned >= maxParallelTasks
|
|
72
|
+
* • node write conflict→ a node already holding an assigned write task (worktree isolation)
|
|
73
|
+
* • provider cap → a (node, provider) at its declared maxParallel
|
|
74
|
+
* It does NOT re-evaluate eligibility/required-tags (those are task-specific); it only
|
|
75
|
+
* reports the capacity picture, which is what an operator needs to read load balance.
|
|
76
|
+
*/
|
|
77
|
+
export declare function buildMeshSchedulingRuntime(mesh: MeshLike | null | undefined, queue: MeshWorkQueueEntry[]): MeshSchedulingRuntime;
|
|
78
|
+
export {};
|
|
@@ -279,6 +279,34 @@ export interface RepoMeshNodePolicy {
|
|
|
279
279
|
initSubmodulesOnClone?: boolean;
|
|
280
280
|
}
|
|
281
281
|
export declare const DEFAULT_MESH_POLICY: RepoMeshPolicy;
|
|
282
|
+
/** Min/max bounds for the global write-task parallel cap. */
|
|
283
|
+
export declare const MESH_MAX_PARALLEL_TASKS_MIN = 1;
|
|
284
|
+
export declare const MESH_MAX_PARALLEL_TASKS_MAX = 8;
|
|
285
|
+
/**
|
|
286
|
+
* Resolve the effective global write-task parallel cap from a raw policy value,
|
|
287
|
+
* clamped to [MESH_MAX_PARALLEL_TASKS_MIN, MESH_MAX_PARALLEL_TASKS_MAX] and
|
|
288
|
+
* defaulting to DEFAULT_MESH_POLICY.maxParallelTasks for a missing/NaN value.
|
|
289
|
+
* Both the config write path and the runtime scheduler read the cap through this
|
|
290
|
+
* helper so they can never disagree on what "max parallel" means.
|
|
291
|
+
*/
|
|
292
|
+
export declare function resolveMaxParallelTasks(value: unknown): number;
|
|
293
|
+
/**
|
|
294
|
+
* Normalize an autoFastForward sub-policy, filling defaults and dropping an
|
|
295
|
+
* invalid maxBehind. Mirrors the (previously mesh-config-local) shape so the merge
|
|
296
|
+
* always emits a fully-populated, valid autoFastForward object.
|
|
297
|
+
*/
|
|
298
|
+
export declare function normalizeAutoFastForwardPolicy(value: unknown): NonNullable<RepoMeshPolicy['autoFastForward']>;
|
|
299
|
+
/**
|
|
300
|
+
* Canonical merge+normalize for a RepoMeshPolicy. Layers (lowest→highest):
|
|
301
|
+
* DEFAULT_MESH_POLICY → base (existing persisted policy) → patch (incoming change),
|
|
302
|
+
* then applies every per-field normalizer so the result is always valid regardless
|
|
303
|
+
* of what a hand-edited meshes.json or a partial patch contained.
|
|
304
|
+
*
|
|
305
|
+
* Persistence economy is preserved: schedulingStrategy is dropped when it
|
|
306
|
+
* normalizes to the 'first_eligible' default, and autoConvergeCodeChange is dropped
|
|
307
|
+
* unless explicitly true — so an untouched meshes.json stays byte-for-byte the same.
|
|
308
|
+
*/
|
|
309
|
+
export declare function mergeAndNormalizePolicy(base: RepoMeshPolicy | undefined, patch: Partial<RepoMeshPolicy> | undefined): RepoMeshPolicy;
|
|
282
310
|
/**
|
|
283
311
|
* Resolve whether a delegated worker session launched onto `nodePolicy` (within a mesh
|
|
284
312
|
* governed by `meshPolicy`) should auto-approve. Precedence: node override → mesh policy
|
|
@@ -464,6 +492,41 @@ export interface LocalMeshNodeEntry {
|
|
|
464
492
|
relatedRepos?: RepoMeshRelatedRepo[];
|
|
465
493
|
role?: RepoMeshDaemonRole;
|
|
466
494
|
}
|
|
495
|
+
/**
|
|
496
|
+
* Per-(node, provider) cap + consumption, as surfaced on a node's scheduling
|
|
497
|
+
* status. Wire-shape mirror of MeshNodeProviderSchedulingRuntime.
|
|
498
|
+
*/
|
|
499
|
+
export interface RepoMeshNodeProviderSchedulingStatus {
|
|
500
|
+
providerType: string;
|
|
501
|
+
maxParallel?: number;
|
|
502
|
+
activeAssigned: number;
|
|
503
|
+
capReached: boolean;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Per-node scheduling runtime exposed on RepoMeshNodeStatus.scheduling. Carried in
|
|
507
|
+
* full by verbose mesh_status; compact mesh_status sends only {load, capReached}.
|
|
508
|
+
*/
|
|
509
|
+
export interface RepoMeshNodeSchedulingStatus {
|
|
510
|
+
load: number;
|
|
511
|
+
schedulingPriority?: number;
|
|
512
|
+
maxConcurrentSessions?: number;
|
|
513
|
+
providerRoles?: RepoMeshNodeProviderSchedulingStatus[];
|
|
514
|
+
capReached: boolean;
|
|
515
|
+
capReasons?: string[];
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Mesh-level scheduling rollup exposed on RepoMeshStatus.scheduling: which tie-break
|
|
519
|
+
* strategy is live and how much of the global parallel caps is consumed.
|
|
520
|
+
*/
|
|
521
|
+
export interface RepoMeshSchedulingStatus {
|
|
522
|
+
strategy: RepoMeshSchedulingStrategy;
|
|
523
|
+
maxParallelTasks: number;
|
|
524
|
+
maxReadonlyParallelTasks: number;
|
|
525
|
+
activeWriteAssigned: number;
|
|
526
|
+
activeReadonlyAssigned: number;
|
|
527
|
+
globalWriteCapReached: boolean;
|
|
528
|
+
globalReadonlyCapReached: boolean;
|
|
529
|
+
}
|
|
467
530
|
export interface RepoMeshStatus {
|
|
468
531
|
meshId: string;
|
|
469
532
|
meshName: string;
|
|
@@ -474,6 +537,11 @@ export interface RepoMeshStatus {
|
|
|
474
537
|
nodes: RepoMeshNodeStatus[];
|
|
475
538
|
queue?: RepoMeshQueueStatus;
|
|
476
539
|
ledger?: RepoMeshLedgerStatus;
|
|
540
|
+
/**
|
|
541
|
+
* Mesh-level scheduling rollup (strategy + global cap consumption). Omitted by
|
|
542
|
+
* daemons predating the scheduling-runtime exposure — treat as optional.
|
|
543
|
+
*/
|
|
544
|
+
scheduling?: RepoMeshSchedulingStatus;
|
|
477
545
|
/**
|
|
478
546
|
* Mission summaries for the dashboard overview. Active/paused missions plus a
|
|
479
547
|
* capped, newest-first slice of completed/abandoned history. Omitted by older
|
|
@@ -545,6 +613,19 @@ export interface RepoMeshNodeStatus {
|
|
|
545
613
|
lastSeenAt?: string;
|
|
546
614
|
updatedAt?: string;
|
|
547
615
|
connection?: RepoMeshPeerConnectionStatus;
|
|
616
|
+
/**
|
|
617
|
+
* Per-node scheduling runtime (load / priority / provider caps / claim-block
|
|
618
|
+
* reasons). Verbose mesh_status carries the full shape; compact carries only
|
|
619
|
+
* {load, capReached}. Omitted by daemons predating the exposure.
|
|
620
|
+
*/
|
|
621
|
+
scheduling?: RepoMeshNodeSchedulingStatus;
|
|
622
|
+
/**
|
|
623
|
+
* Stale-daemon-build marker: the live daemon's build commit is a strict ancestor
|
|
624
|
+
* of this node's workspace HEAD (merged code not yet live). Best-effort, set by
|
|
625
|
+
* mesh_status when the git probe reports daemonBuildBehind; shape is daemon-defined
|
|
626
|
+
* (scope/isDaemonAffecting flags). Omitted when the build is current.
|
|
627
|
+
*/
|
|
628
|
+
staleDaemonBuild?: Record<string, unknown>;
|
|
548
629
|
error?: string;
|
|
549
630
|
}
|
|
550
631
|
export type RepoMeshQueueTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
|
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.381",
|
|
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.381",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
|
@@ -22,7 +22,7 @@ import type {
|
|
|
22
22
|
RepoMeshHostMetadata,
|
|
23
23
|
RepoMeshDaemonRole,
|
|
24
24
|
} from '../repo-mesh-types.js';
|
|
25
|
-
import {
|
|
25
|
+
import { mergeAndNormalizePolicy } from '../repo-mesh-types.js';
|
|
26
26
|
import { createDefaultMeshHostMetadata } from '../mesh/mesh-host-ownership.js';
|
|
27
27
|
|
|
28
28
|
// ─── Persistence ────────────────────────────────
|
|
@@ -151,63 +151,11 @@ export function normalizeRepoIdentity(remoteUrl: string): string {
|
|
|
151
151
|
|
|
152
152
|
// ─── CRUD Operations ────────────────────────────
|
|
153
153
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
...DEFAULT_MESH_POLICY.autoFastForward,
|
|
160
|
-
...((base?.autoFastForward && typeof base.autoFastForward === 'object') ? base.autoFastForward : {}),
|
|
161
|
-
...((patch?.autoFastForward && typeof patch.autoFastForward === 'object') ? patch.autoFastForward : {}),
|
|
162
|
-
});
|
|
163
|
-
const policy: RepoMeshPolicy = {
|
|
164
|
-
...DEFAULT_MESH_POLICY,
|
|
165
|
-
...(base || {}),
|
|
166
|
-
...(patch || {}),
|
|
167
|
-
autoFastForward,
|
|
168
|
-
};
|
|
169
|
-
if (!['block', 'warn', 'checkpoint_then_continue'].includes(policy.dirtyWorkspaceBehavior)) {
|
|
170
|
-
policy.dirtyWorkspaceBehavior = 'warn';
|
|
171
|
-
}
|
|
172
|
-
const maxParallelTasks = Number(policy.maxParallelTasks);
|
|
173
|
-
policy.maxParallelTasks = Number.isFinite(maxParallelTasks) ? Math.max(1, Math.min(8, Math.floor(maxParallelTasks))) : 2;
|
|
174
|
-
policy.allowAutoPublishSubmoduleMainCommits = policy.allowAutoPublishSubmoduleMainCommits === true;
|
|
175
|
-
if (!SESSION_CLEANUP_MODES.has(String(policy.sessionCleanupOnNodeRemove))) {
|
|
176
|
-
policy.sessionCleanupOnNodeRemove = 'preserve';
|
|
177
|
-
}
|
|
178
|
-
if (!SPAWNED_SESSION_VISIBILITY_MODES.has(String(policy.spawnedSessionVisibility))) {
|
|
179
|
-
policy.spawnedSessionVisibility = DEFAULT_MESH_POLICY.spawnedSessionVisibility;
|
|
180
|
-
}
|
|
181
|
-
// Load-balancing: normalize the scheduling strategy so an invalid/blank value
|
|
182
|
-
// falls back to 'first_eligible' (strict no-change). Only persist the field when
|
|
183
|
-
// it is explicitly a non-default value to keep existing meshes.json untouched.
|
|
184
|
-
const normalizedStrategy = normalizeMeshSchedulingStrategy(policy.schedulingStrategy);
|
|
185
|
-
if (normalizedStrategy === 'first_eligible') {
|
|
186
|
-
delete policy.schedulingStrategy;
|
|
187
|
-
} else {
|
|
188
|
-
policy.schedulingStrategy = normalizedStrategy;
|
|
189
|
-
}
|
|
190
|
-
// Convergence routing: strict opt-in (default false). Only persist when explicitly
|
|
191
|
-
// enabled so existing meshes.json stays byte-for-byte untouched.
|
|
192
|
-
if (policy.autoConvergeCodeChange === true) {
|
|
193
|
-
policy.autoConvergeCodeChange = true;
|
|
194
|
-
} else {
|
|
195
|
-
delete policy.autoConvergeCodeChange;
|
|
196
|
-
}
|
|
197
|
-
return policy;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function normalizeAutoFastForwardPolicy(value: unknown): NonNullable<RepoMeshPolicy['autoFastForward']> {
|
|
201
|
-
const record = value && typeof value === 'object' && !Array.isArray(value)
|
|
202
|
-
? value as Record<string, unknown>
|
|
203
|
-
: {};
|
|
204
|
-
const maxBehind = Number(record.maxBehind);
|
|
205
|
-
return {
|
|
206
|
-
enabled: record.enabled !== false,
|
|
207
|
-
...(Number.isFinite(maxBehind) && maxBehind >= 0 ? { maxBehind: Math.floor(maxBehind) } : {}),
|
|
208
|
-
requireCleanSubmodules: record.requireCleanSubmodules !== false,
|
|
209
|
-
};
|
|
210
|
-
}
|
|
154
|
+
// Single source of truth for default+merge+per-field normalization is
|
|
155
|
+
// mergeAndNormalizePolicy in repo-mesh-types.ts. This thin alias keeps the local
|
|
156
|
+
// call sites (createMesh/updateMesh) reading naturally while ensuring config
|
|
157
|
+
// writes go through the exact same normalizer the scheduler/display paths use.
|
|
158
|
+
const mergeMeshPolicy = mergeAndNormalizePolicy;
|
|
211
159
|
|
|
212
160
|
export function listMeshes(): LocalMeshEntry[] {
|
|
213
161
|
return loadMeshConfig().meshes;
|
package/src/index.ts
CHANGED
|
@@ -135,6 +135,9 @@ export type {
|
|
|
135
135
|
RepoMeshLedgerStatus,
|
|
136
136
|
MeshAsyncJobLifecycle,
|
|
137
137
|
RepoMeshSchedulingStrategy,
|
|
138
|
+
RepoMeshSchedulingStatus,
|
|
139
|
+
RepoMeshNodeSchedulingStatus,
|
|
140
|
+
RepoMeshNodeProviderSchedulingStatus,
|
|
138
141
|
} from './repo-mesh-types.js';
|
|
139
142
|
export {
|
|
140
143
|
DEFAULT_MESH_POLICY,
|
|
@@ -143,6 +146,12 @@ export {
|
|
|
143
146
|
DEFAULT_MESH_SCHEDULING_STRATEGY,
|
|
144
147
|
normalizeMeshSchedulingStrategy,
|
|
145
148
|
resolveNodeSchedulingPriority,
|
|
149
|
+
resolveProviderMaxParallel,
|
|
150
|
+
mergeAndNormalizePolicy,
|
|
151
|
+
normalizeAutoFastForwardPolicy,
|
|
152
|
+
resolveMaxParallelTasks,
|
|
153
|
+
MESH_MAX_PARALLEL_TASKS_MIN,
|
|
154
|
+
MESH_MAX_PARALLEL_TASKS_MAX,
|
|
146
155
|
MESH_CONVERGE_REFINE_TAG,
|
|
147
156
|
MESH_CONVERGE_FAST_FORWARD_TAG,
|
|
148
157
|
resolveAutoConvergeCodeChange,
|
|
@@ -246,6 +255,10 @@ export type { MeshActiveWorkRecord, MeshActiveWorkStatus, MeshActiveWorkSummary,
|
|
|
246
255
|
export { buildMeshAsyncRefineJobs, summarizeMeshAsyncRefineJobs, STALE_TERMINAL_REFINE_WINDOW_MS, RECENT_TERMINAL_REFINE_CAP } from './mesh/mesh-refine-status.js';
|
|
247
256
|
export type { MeshAsyncRefineJobStatus, MeshAsyncRefineJobSummary, MeshAsyncRefineJobsSummary } from './mesh/mesh-refine-status.js';
|
|
248
257
|
|
|
258
|
+
// ── Mesh Scheduling Runtime (observability projection) ──
|
|
259
|
+
export { buildMeshSchedulingRuntime } from './mesh/mesh-scheduling-runtime.js';
|
|
260
|
+
export type { MeshSchedulingRuntime, MeshNodeSchedulingRuntime, MeshNodeProviderSchedulingRuntime } from './mesh/mesh-scheduling-runtime.js';
|
|
261
|
+
|
|
249
262
|
// ── Mesh Host Ownership ──
|
|
250
263
|
export { buildMeshHostRequiredFailure, createDefaultMeshHostMetadata, isMeshHostOwner, normalizeMeshDaemonRole, requireMeshHostQueueOwner, resolveMeshHostStatus } from './mesh/mesh-host-ownership.js';
|
|
251
264
|
|
|
@@ -31,7 +31,7 @@ import type {
|
|
|
31
31
|
RepoMeshStatus,
|
|
32
32
|
RepoMeshNodeStatus,
|
|
33
33
|
} from '../repo-mesh-types.js';
|
|
34
|
-
import {
|
|
34
|
+
import { mergeAndNormalizePolicy } from '../repo-mesh-types.js';
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
37
|
* Cheap, locally-derived "what just happened" snapshot for the coordinator
|
|
@@ -196,7 +196,7 @@ Repository: \`${mesh.repoIdentity}\`${mesh.defaultBranch ? `\nDefault branch: \`
|
|
|
196
196
|
if (operatingNotes) sections.push(operatingNotes);
|
|
197
197
|
|
|
198
198
|
// ── Policy ──
|
|
199
|
-
sections.push(buildPolicySection(
|
|
199
|
+
sections.push(buildPolicySection(mergeAndNormalizePolicy(undefined, mesh.policy)));
|
|
200
200
|
|
|
201
201
|
// ── Tools ──
|
|
202
202
|
sections.push(TOOLS_SECTION);
|
|
@@ -278,7 +278,7 @@ function expandPromptPlaceholders(template: string, ctx: CoordinatorPromptContex
|
|
|
278
278
|
mission: ctx.missionSection?.trim() || '',
|
|
279
279
|
recentActivity: buildRecentActivitySection(ctx.recentActivity) || '',
|
|
280
280
|
operatingNotes: buildOperatingNotesSection(ctx.operatingNotes) || '',
|
|
281
|
-
policy: buildPolicySection(
|
|
281
|
+
policy: buildPolicySection(mergeAndNormalizePolicy(undefined, mesh.policy)),
|
|
282
282
|
tools: TOOLS_SECTION,
|
|
283
283
|
workflow: WORKFLOW_SECTION,
|
|
284
284
|
rules: buildRulesSection(coordinatorCliType),
|
|
@@ -886,8 +886,24 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
886
886
|
// sibling/stale dispatch row this event does not own, marking it 'acked' prematurely and
|
|
887
887
|
// hiding a genuine non-delivery. Skip the dispatch ack for that ghost case (the delivery
|
|
888
888
|
// acks below are bound to actual deliveries and stay a no-op for a warmup session).
|
|
889
|
-
|
|
889
|
+
//
|
|
890
|
+
// MESH-DISPATCH-MISROUTE (fix 3, consumer residual): when the event carries no taskId
|
|
891
|
+
// (a legacy/relayed worker whose producer never stamped meshActiveTaskId) but the
|
|
892
|
+
// session owns EXACTLY ONE active dispatch, resolve that row's taskId and flip it by PK
|
|
893
|
+
// instead of the session_id sweep — the sweep flips every non-terminal row for the
|
|
894
|
+
// session ("may flip a sibling dispatch row"). With ≥2 active rows the owner is
|
|
895
|
+
// ambiguous, so resolvedAckTaskId stays undefined and we DROP the ack rather than
|
|
896
|
+
// mis-flip a sibling (the genuine ack arrives once the producer/reconcile names a task).
|
|
897
|
+
if (startedTaskId) {
|
|
890
898
|
updateDirectDispatchStatus(args.meshId, sessionId, 'acked', startedTaskId);
|
|
899
|
+
} else if (sessionHasActiveAssignment(args.meshId, sessionId)) {
|
|
900
|
+
const soleTaskId = (() => {
|
|
901
|
+
try { return MeshRuntimeStore.getInstance().getSoleActiveDirectDispatchTaskId(args.meshId, sessionId); }
|
|
902
|
+
catch { return null; }
|
|
903
|
+
})();
|
|
904
|
+
if (soleTaskId) {
|
|
905
|
+
updateDirectDispatchStatus(args.meshId, sessionId, 'acked', soleTaskId);
|
|
906
|
+
}
|
|
891
907
|
}
|
|
892
908
|
const activeDeliveries = ((): { id: string; taskId: string | null }[] => {
|
|
893
909
|
try { return MeshRuntimeStore.getInstance().getActiveSessionDeliveries(args.meshId, sessionId); }
|
|
@@ -903,6 +903,29 @@ export class MeshRuntimeStore {
|
|
|
903
903
|
`).run({ status, meshId, sessionId, updatedAt: now });
|
|
904
904
|
}
|
|
905
905
|
|
|
906
|
+
/**
|
|
907
|
+
* MESH-DISPATCH-MISROUTE (fix 3, consumer residual): resolve the task_id of the SINGLE
|
|
908
|
+
* non-terminal direct dispatch a session owns. Returns the task_id only when the session
|
|
909
|
+
* holds exactly ONE active ('dispatched'/'acked') row — the case where a taskId-less
|
|
910
|
+
* lifecycle event (a legacy/relayed worker whose producer never stamped meshActiveTaskId)
|
|
911
|
+
* unambiguously belongs to that one dispatch. With zero rows there is nothing to ack; with
|
|
912
|
+
* two or more (a re-dispatch/nudge sibling) the firing event's owner is ambiguous, so we
|
|
913
|
+
* return null and the caller MUST NOT fall back to the session_id sweep that would flip a
|
|
914
|
+
* sibling row ("may flip a sibling dispatch row"). This narrows the legacy fallback to the
|
|
915
|
+
* only safe case instead of removing the producer-side TASKIDLESS stamp's safety net.
|
|
916
|
+
*/
|
|
917
|
+
getSoleActiveDirectDispatchTaskId(meshId: string, sessionId: string): string | null {
|
|
918
|
+
if (!sessionId) return null;
|
|
919
|
+
const rows = this.db.prepare(`
|
|
920
|
+
SELECT task_id FROM mesh_direct_dispatches
|
|
921
|
+
WHERE mesh_id = ? AND session_id = ?
|
|
922
|
+
AND status NOT IN ('completed', 'failed', 'stale')
|
|
923
|
+
`).all(meshId, sessionId) as Array<{ task_id: string }>;
|
|
924
|
+
if (rows.length !== 1) return null;
|
|
925
|
+
const taskId = typeof rows[0]?.task_id === 'string' ? rows[0].task_id.trim() : '';
|
|
926
|
+
return taskId || null;
|
|
927
|
+
}
|
|
928
|
+
|
|
906
929
|
cleanupTerminalDirectDispatches(olderThanMs: number): void {
|
|
907
930
|
const cutoff = new Date(Date.now() - olderThanMs).toISOString();
|
|
908
931
|
this.db.prepare(`
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Mesh scheduling-runtime view — a read-only, derived projection of how the queue
|
|
2
|
+
// scheduler currently sees the mesh: which tie-break strategy is live, the global
|
|
3
|
+
// parallel caps and how much of them is consumed, and per-node load / priority /
|
|
4
|
+
// per-(node, provider) caps plus a structured "why this node can't take more write
|
|
5
|
+
// work right now" reason set.
|
|
6
|
+
//
|
|
7
|
+
// This is OBSERVABILITY ONLY. It re-derives the same numbers the live claim path
|
|
8
|
+
// (maybeAutoLaunchOneQueueSession → orderEligibleNodes → claimNextQueueTask) acts
|
|
9
|
+
// on, but never mutates anything and never drives a scheduling decision. It is built
|
|
10
|
+
// from the in-memory mesh config + the queue snapshot, so callers (mesh_status) get a
|
|
11
|
+
// consistent picture without touching the SQLite claim transaction. Keeping the
|
|
12
|
+
// derivation here — next to the constants it mirrors — means the exposed numbers and
|
|
13
|
+
// the enforced numbers stay defined in one module family.
|
|
14
|
+
|
|
15
|
+
import type { RepoMeshNodePolicy, RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
|
|
16
|
+
import {
|
|
17
|
+
normalizeMeshSchedulingStrategy,
|
|
18
|
+
resolveMaxParallelTasks,
|
|
19
|
+
resolveNodeSchedulingPriority,
|
|
20
|
+
resolveProviderMaxParallel,
|
|
21
|
+
} from '../repo-mesh-types.js';
|
|
22
|
+
import { normalizeMeshNodeId } from '@adhdev/mesh-shared';
|
|
23
|
+
import type { MeshWorkQueueEntry } from './mesh-work-queue.js';
|
|
24
|
+
|
|
25
|
+
/** Per-(node, provider) cap and its current consumption. */
|
|
26
|
+
export interface MeshNodeProviderSchedulingRuntime {
|
|
27
|
+
providerType: string;
|
|
28
|
+
/** Declared maxParallel cap for this (node, provider). Omitted when uncapped. */
|
|
29
|
+
maxParallel?: number;
|
|
30
|
+
/** Active (status='assigned') tasks on this node claimed by this provider. */
|
|
31
|
+
activeAssigned: number;
|
|
32
|
+
/** True when activeAssigned has reached maxParallel (a further claim is refused). */
|
|
33
|
+
capReached: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Per-node scheduling-runtime projection. */
|
|
37
|
+
export interface MeshNodeSchedulingRuntime {
|
|
38
|
+
nodeId: string;
|
|
39
|
+
/** Active (status='assigned') task count on this node — the least-loaded rank key. */
|
|
40
|
+
load: number;
|
|
41
|
+
/** Soft scheduling priority (PRIORITY rank key; higher = preferred). */
|
|
42
|
+
schedulingPriority: number;
|
|
43
|
+
/** Per-node concurrent-session cap, when configured. */
|
|
44
|
+
maxConcurrentSessions?: number;
|
|
45
|
+
/** Per-(node, provider) caps + consumption, when providerRoles declares any. */
|
|
46
|
+
providerRoles?: MeshNodeProviderSchedulingRuntime[];
|
|
47
|
+
/**
|
|
48
|
+
* True when the node currently cannot claim a NEW write (non-readonly) task —
|
|
49
|
+
* either the global write cap is exhausted or a node-local gate (active write
|
|
50
|
+
* assignment / a fully-consumed provider cap / session cap) blocks it.
|
|
51
|
+
*/
|
|
52
|
+
capReached: boolean;
|
|
53
|
+
/** Structured reasons backing capReached (empty when the node can take work). */
|
|
54
|
+
capReasons: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Mesh-level scheduling-runtime projection. */
|
|
58
|
+
export interface MeshSchedulingRuntime {
|
|
59
|
+
/** Resolved tie-break strategy (defaults to 'first_eligible'). */
|
|
60
|
+
strategy: RepoMeshSchedulingStrategy;
|
|
61
|
+
/** Effective global write-task parallel cap (clamped). */
|
|
62
|
+
maxParallelTasks: number;
|
|
63
|
+
/** Derived read-only diagnosis cap (max(2, 2× write cap)) — mirrors the claim path. */
|
|
64
|
+
maxReadonlyParallelTasks: number;
|
|
65
|
+
/** Current global write (non-readonly) assigned-task load. */
|
|
66
|
+
activeWriteAssigned: number;
|
|
67
|
+
/** Current global read-only assigned-task load. */
|
|
68
|
+
activeReadonlyAssigned: number;
|
|
69
|
+
/** True when the global write cap is exhausted (no new write task can launch). */
|
|
70
|
+
globalWriteCapReached: boolean;
|
|
71
|
+
/** True when the read-only diagnosis cap is exhausted. */
|
|
72
|
+
globalReadonlyCapReached: boolean;
|
|
73
|
+
/** Per-node projections, in mesh config order. */
|
|
74
|
+
nodes: MeshNodeSchedulingRuntime[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface MeshLike {
|
|
78
|
+
policy?: { schedulingStrategy?: unknown; maxParallelTasks?: unknown } | null;
|
|
79
|
+
nodes?: Array<{ id?: string; nodeId?: string; node_id?: string; policy?: RepoMeshNodePolicy | null; isLocalWorktree?: boolean }> | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isReadonly(task: MeshWorkQueueEntry): boolean {
|
|
83
|
+
return task.taskMode === 'live_debug_readonly';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isAssigned(task: MeshWorkQueueEntry): boolean {
|
|
87
|
+
return task.status === 'assigned';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build the scheduling-runtime projection for a mesh from its config + a snapshot of
|
|
92
|
+
* its work queue. Pure and side-effect free — safe to call on any read path.
|
|
93
|
+
*
|
|
94
|
+
* The derivation deliberately mirrors maybeAutoLaunchOneQueueSession's gates so the
|
|
95
|
+
* exposed "capReached/capReasons" match why a real claim would be refused:
|
|
96
|
+
* • global write cap → activeWriteAssigned >= maxParallelTasks
|
|
97
|
+
* • node write conflict→ a node already holding an assigned write task (worktree isolation)
|
|
98
|
+
* • provider cap → a (node, provider) at its declared maxParallel
|
|
99
|
+
* It does NOT re-evaluate eligibility/required-tags (those are task-specific); it only
|
|
100
|
+
* reports the capacity picture, which is what an operator needs to read load balance.
|
|
101
|
+
*/
|
|
102
|
+
export function buildMeshSchedulingRuntime(
|
|
103
|
+
mesh: MeshLike | null | undefined,
|
|
104
|
+
queue: MeshWorkQueueEntry[],
|
|
105
|
+
): MeshSchedulingRuntime {
|
|
106
|
+
const strategy = normalizeMeshSchedulingStrategy(mesh?.policy?.schedulingStrategy);
|
|
107
|
+
const maxParallelTasks = resolveMaxParallelTasks(mesh?.policy?.maxParallelTasks);
|
|
108
|
+
// Read-only diagnoses are exempt from the write cap and get their own higher
|
|
109
|
+
// safety cap (2× the write cap, floor 2) — identical to the claim path.
|
|
110
|
+
const maxReadonlyParallelTasks = Math.max(2, maxParallelTasks * 2);
|
|
111
|
+
|
|
112
|
+
const assignedTasks = (Array.isArray(queue) ? queue : []).filter(isAssigned);
|
|
113
|
+
const activeWriteAssigned = assignedTasks.filter(t => !isReadonly(t)).length;
|
|
114
|
+
const activeReadonlyAssigned = assignedTasks.filter(isReadonly).length;
|
|
115
|
+
const globalWriteCapReached = activeWriteAssigned >= maxParallelTasks;
|
|
116
|
+
const globalReadonlyCapReached = activeReadonlyAssigned >= maxReadonlyParallelTasks;
|
|
117
|
+
|
|
118
|
+
// Pre-bucket assigned tasks by node so per-node load and per-provider counts are
|
|
119
|
+
// a single pass rather than O(nodes × queue).
|
|
120
|
+
const writeAssignedByNode = new Map<string, number>();
|
|
121
|
+
const assignedByNode = new Map<string, number>();
|
|
122
|
+
const providerCountByNode = new Map<string, Map<string, number>>();
|
|
123
|
+
for (const task of assignedTasks) {
|
|
124
|
+
const nodeId = typeof task.assignedNodeId === 'string' ? task.assignedNodeId.trim() : '';
|
|
125
|
+
if (!nodeId) continue;
|
|
126
|
+
assignedByNode.set(nodeId, (assignedByNode.get(nodeId) ?? 0) + 1);
|
|
127
|
+
if (!isReadonly(task)) {
|
|
128
|
+
writeAssignedByNode.set(nodeId, (writeAssignedByNode.get(nodeId) ?? 0) + 1);
|
|
129
|
+
}
|
|
130
|
+
const provider = typeof task.assignedProviderType === 'string' ? task.assignedProviderType : '';
|
|
131
|
+
if (provider) {
|
|
132
|
+
let byProvider = providerCountByNode.get(nodeId);
|
|
133
|
+
if (!byProvider) { byProvider = new Map(); providerCountByNode.set(nodeId, byProvider); }
|
|
134
|
+
byProvider.set(provider, (byProvider.get(provider) ?? 0) + 1);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const nodes: MeshNodeSchedulingRuntime[] = [];
|
|
139
|
+
for (const rawNode of (Array.isArray(mesh?.nodes) ? mesh!.nodes! : [])) {
|
|
140
|
+
const nodeId = normalizeMeshNodeId(rawNode);
|
|
141
|
+
if (!nodeId) continue;
|
|
142
|
+
const policy = (rawNode?.policy || undefined) as RepoMeshNodePolicy | undefined;
|
|
143
|
+
const load = assignedByNode.get(nodeId) ?? 0;
|
|
144
|
+
const schedulingPriority = resolveNodeSchedulingPriority(policy);
|
|
145
|
+
|
|
146
|
+
const capReasons: string[] = [];
|
|
147
|
+
if (globalWriteCapReached) capReasons.push('global_max_parallel_tasks_reached');
|
|
148
|
+
// Write isolation: a node already holding an assigned write task can't take another.
|
|
149
|
+
if ((writeAssignedByNode.get(nodeId) ?? 0) > 0) capReasons.push('node_has_active_assignment');
|
|
150
|
+
|
|
151
|
+
// Per-(node, provider) caps, with live consumption.
|
|
152
|
+
let providerRoles: MeshNodeProviderSchedulingRuntime[] | undefined;
|
|
153
|
+
const declaredRoles = Array.isArray(policy?.providerRoles) ? policy!.providerRoles! : [];
|
|
154
|
+
if (declaredRoles.length) {
|
|
155
|
+
const byProvider = providerCountByNode.get(nodeId);
|
|
156
|
+
providerRoles = [];
|
|
157
|
+
for (const role of declaredRoles) {
|
|
158
|
+
if (!role || typeof role !== 'object') continue;
|
|
159
|
+
const providerType = typeof role.providerType === 'string' ? role.providerType.trim() : '';
|
|
160
|
+
if (!providerType) continue;
|
|
161
|
+
const maxParallel = resolveProviderMaxParallel(policy, providerType);
|
|
162
|
+
const activeAssigned = byProvider?.get(providerType) ?? 0;
|
|
163
|
+
const capReached = maxParallel !== undefined && activeAssigned >= maxParallel;
|
|
164
|
+
providerRoles.push({
|
|
165
|
+
providerType,
|
|
166
|
+
...(maxParallel !== undefined ? { maxParallel } : {}),
|
|
167
|
+
activeAssigned,
|
|
168
|
+
capReached,
|
|
169
|
+
});
|
|
170
|
+
if (capReached) capReasons.push(`max_provider_parallel_reached:${providerType}`);
|
|
171
|
+
}
|
|
172
|
+
if (!providerRoles.length) providerRoles = undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const maxConcurrentSessions = Number(policy?.maxConcurrentSessions);
|
|
176
|
+
const hasSessionCap = Number.isFinite(maxConcurrentSessions) && maxConcurrentSessions >= 0;
|
|
177
|
+
|
|
178
|
+
nodes.push({
|
|
179
|
+
nodeId,
|
|
180
|
+
load,
|
|
181
|
+
schedulingPriority,
|
|
182
|
+
...(hasSessionCap ? { maxConcurrentSessions: Math.floor(maxConcurrentSessions) } : {}),
|
|
183
|
+
...(providerRoles ? { providerRoles } : {}),
|
|
184
|
+
capReached: capReasons.length > 0,
|
|
185
|
+
capReasons,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
strategy,
|
|
191
|
+
maxParallelTasks,
|
|
192
|
+
maxReadonlyParallelTasks,
|
|
193
|
+
activeWriteAssigned,
|
|
194
|
+
activeReadonlyAssigned,
|
|
195
|
+
globalWriteCapReached,
|
|
196
|
+
globalReadonlyCapReached,
|
|
197
|
+
nodes,
|
|
198
|
+
};
|
|
199
|
+
}
|