@adhdev/daemon-core 0.9.82-rc.209 → 0.9.82-rc.210
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/terminal-backends/ghostty-vt-backend.d.ts +2 -0
- package/dist/commands/router.d.ts +6 -0
- package/dist/git/git-commands.d.ts +2 -0
- package/dist/git/git-diff.d.ts +6 -0
- package/dist/index.d.ts +11 -5
- package/dist/index.js +5699 -3486
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +5679 -3483
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/coordinator-prompt.d.ts +6 -0
- package/dist/mesh/mesh-delivery-policy.d.ts +5 -0
- package/dist/mesh/mesh-events-coordinator.d.ts +151 -0
- package/dist/mesh/mesh-events-pending.d.ts +33 -0
- package/dist/mesh/mesh-events-stale.d.ts +40 -0
- package/dist/mesh/mesh-events-utils.d.ts +14 -0
- package/dist/mesh/mesh-events.d.ts +5 -198
- package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -3
- package/dist/mesh/mesh-ledger.d.ts +19 -0
- package/dist/mesh/mesh-missions.d.ts +58 -0
- package/dist/mesh/mesh-review-inbox.d.ts +90 -0
- package/dist/mesh/mesh-runtime-store.d.ts +175 -0
- package/dist/mesh/mesh-task-stats.d.ts +49 -0
- package/dist/mesh/mesh-work-queue.d.ts +82 -0
- package/dist/mesh/refine-config.d.ts +24 -2
- package/dist/mesh/worktree-bootstrap-config.d.ts +22 -0
- package/dist/providers/acp-provider-instance.d.ts +2 -0
- package/dist/providers/spec/driver.d.ts +8 -0
- package/dist/providers/spec/evaluator.d.ts +4 -5
- package/dist/providers/spec/loader.d.ts +1 -0
- package/dist/providers/spec/schema.gen.d.ts +1409 -6
- package/dist/providers/spec/types.d.ts +188 -175
- package/dist/repo-mesh-types.d.ts +1 -0
- package/package.json +1 -1
- package/src/boot/daemon-lifecycle.ts +3 -0
- package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +20 -7
- package/src/commands/router.ts +594 -66
- package/src/git/git-commands.ts +5 -5
- package/src/git/git-diff.ts +53 -0
- package/src/index.ts +11 -5
- package/src/mesh/coordinator-prompt.ts +14 -1
- package/src/mesh/mesh-delivery-policy.ts +17 -0
- package/src/mesh/mesh-events-coordinator.ts +1404 -0
- package/src/mesh/mesh-events-pending.ts +371 -0
- package/src/mesh/mesh-events-stale.ts +283 -0
- package/src/mesh/mesh-events-utils.ts +161 -0
- package/src/mesh/mesh-events.ts +27 -2143
- package/src/mesh/mesh-ledger-reconciliation.ts +12 -5
- package/src/mesh/mesh-ledger.ts +134 -2
- package/src/mesh/mesh-missions.ts +151 -0
- package/src/mesh/mesh-review-inbox.ts +307 -0
- package/src/mesh/mesh-runtime-store.ts +539 -3
- package/src/mesh/mesh-task-stats.ts +154 -0
- package/src/mesh/mesh-work-queue.ts +233 -17
- package/src/mesh/refine-config.ts +42 -5
- package/src/mesh/worktree-bootstrap-config.ts +79 -0
- package/src/providers/acp-provider-instance.ts +15 -1
- package/src/providers/cli-provider-instance.ts +34 -13
- package/src/providers/spec/driver.ts +57 -29
- package/src/providers/spec/evaluator.ts +302 -112
- package/src/providers/spec/loader.ts +226 -37
- package/src/providers/spec/schema.gen.ts +450 -334
- package/src/providers/spec/schema.json +162 -75
- package/src/providers/spec/types.ts +234 -183
- package/src/repo-mesh-types.ts +1 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M7: Operational stats (time/attempts) derived from existing truth.
|
|
3
|
+
*
|
|
4
|
+
* No cost/token accounting — ADHDev observes PTY/CDP and cannot see API
|
|
5
|
+
* tokens (explicit non-goal). Everything here is derived at query time from
|
|
6
|
+
* the SQLite ledger and queue rows; there is no separate aggregate table.
|
|
7
|
+
* Tasks with missing ledger evidence report incompleteEvidence instead of
|
|
8
|
+
* estimated numbers.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readLedgerEntries } from './mesh-ledger.js';
|
|
12
|
+
import type { MeshLedgerEntry } from './mesh-ledger.js';
|
|
13
|
+
import { getQueue } from './mesh-work-queue.js';
|
|
14
|
+
|
|
15
|
+
export interface MeshTaskStats {
|
|
16
|
+
taskId: string;
|
|
17
|
+
status: string;
|
|
18
|
+
dispatchedAt: string | null;
|
|
19
|
+
terminalAt: string | null;
|
|
20
|
+
terminalKind: 'task_completed' | 'task_failed' | null;
|
|
21
|
+
/** dispatched → terminal wall clock; null when evidence is incomplete. */
|
|
22
|
+
durationMs: number | null;
|
|
23
|
+
/** Number of task_dispatched ledger entries observed for this task. */
|
|
24
|
+
dispatchCount: number;
|
|
25
|
+
/** Queue requeueCount (0 when the row is gone or never requeued). */
|
|
26
|
+
requeueCount: number;
|
|
27
|
+
/** True when dispatch or terminal ledger evidence is missing — numbers are withheld, never estimated. */
|
|
28
|
+
incompleteEvidence?: true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface MeshMissionStats {
|
|
32
|
+
missionId: string;
|
|
33
|
+
taskCount: number;
|
|
34
|
+
completed: number;
|
|
35
|
+
failed: number;
|
|
36
|
+
/** Sum of per-task durations with complete evidence. */
|
|
37
|
+
totalDurationMs: number;
|
|
38
|
+
/** First dispatch → last terminal across the mission's tasks; null without complete endpoints. */
|
|
39
|
+
wallClockMs: number | null;
|
|
40
|
+
/** Total requeue attempts across the mission's tasks. */
|
|
41
|
+
retries: number;
|
|
42
|
+
/** Task ids whose ledger evidence was incomplete (excluded from sums). */
|
|
43
|
+
incompleteTaskIds: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readPayloadTaskId(entry: MeshLedgerEntry): string {
|
|
47
|
+
const value = entry.payload?.taskId;
|
|
48
|
+
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseTime(value: string | null | undefined): number | null {
|
|
52
|
+
if (!value) return null;
|
|
53
|
+
const parsed = new Date(value).getTime();
|
|
54
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Compute per-task stats from ledger entries. Scans a bounded tail window —
|
|
59
|
+
* stats are an operational view of recent work, not a full historical report.
|
|
60
|
+
*/
|
|
61
|
+
export function computeMeshTaskStats(meshId: string, opts?: { taskIds?: string[]; missionId?: string; tail?: number }): MeshTaskStats[] {
|
|
62
|
+
const queue = getQueue(meshId);
|
|
63
|
+
const queueById = new Map(queue.map(task => [task.id, task]));
|
|
64
|
+
|
|
65
|
+
let targetIds: string[];
|
|
66
|
+
if (opts?.taskIds?.length) {
|
|
67
|
+
targetIds = [...new Set(opts.taskIds)];
|
|
68
|
+
} else if (opts?.missionId) {
|
|
69
|
+
targetIds = queue.filter(task => task.missionId === opts.missionId).map(task => task.id);
|
|
70
|
+
} else {
|
|
71
|
+
targetIds = queue.map(task => task.id);
|
|
72
|
+
}
|
|
73
|
+
if (targetIds.length === 0) return [];
|
|
74
|
+
const targetSet = new Set(targetIds);
|
|
75
|
+
|
|
76
|
+
const entries = readLedgerEntries(meshId, { tail: opts?.tail ?? 1000 });
|
|
77
|
+
const dispatches = new Map<string, { first: string; count: number }>();
|
|
78
|
+
const terminals = new Map<string, { at: string; kind: 'task_completed' | 'task_failed' }>();
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
const taskId = readPayloadTaskId(entry);
|
|
81
|
+
if (!taskId || !targetSet.has(taskId)) continue;
|
|
82
|
+
if (entry.kind === 'task_dispatched') {
|
|
83
|
+
const existing = dispatches.get(taskId);
|
|
84
|
+
if (existing) existing.count += 1;
|
|
85
|
+
else dispatches.set(taskId, { first: entry.timestamp, count: 1 });
|
|
86
|
+
} else if (entry.kind === 'task_completed' || entry.kind === 'task_failed') {
|
|
87
|
+
// Last terminal wins (requeued tasks can have multiple terminals).
|
|
88
|
+
terminals.set(taskId, { at: entry.timestamp, kind: entry.kind });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return targetIds.map(taskId => {
|
|
93
|
+
const queueEntry = queueById.get(taskId);
|
|
94
|
+
const status = queueEntry?.status ?? 'unknown';
|
|
95
|
+
const dispatch = dispatches.get(taskId);
|
|
96
|
+
const terminal = terminals.get(taskId);
|
|
97
|
+
const isTerminalStatus = status === 'completed' || status === 'failed' || status === 'cancelled';
|
|
98
|
+
const dispatchTime = parseTime(dispatch?.first ?? queueEntry?.dispatchTimestamp);
|
|
99
|
+
const terminalTime = parseTime(terminal?.at);
|
|
100
|
+
const stats: MeshTaskStats = {
|
|
101
|
+
taskId,
|
|
102
|
+
status,
|
|
103
|
+
dispatchedAt: dispatch?.first ?? queueEntry?.dispatchTimestamp ?? null,
|
|
104
|
+
terminalAt: terminal?.at ?? null,
|
|
105
|
+
terminalKind: terminal?.kind ?? null,
|
|
106
|
+
durationMs: null,
|
|
107
|
+
dispatchCount: dispatch?.count ?? 0,
|
|
108
|
+
requeueCount: queueEntry?.requeueCount ?? 0,
|
|
109
|
+
};
|
|
110
|
+
if (dispatchTime !== null && terminalTime !== null && terminalTime >= dispatchTime) {
|
|
111
|
+
stats.durationMs = terminalTime - dispatchTime;
|
|
112
|
+
} else if (isTerminalStatus) {
|
|
113
|
+
// Terminal task without complete dispatch+terminal ledger evidence:
|
|
114
|
+
// withhold numbers rather than estimate (M7 rule).
|
|
115
|
+
stats.incompleteEvidence = true;
|
|
116
|
+
}
|
|
117
|
+
return stats;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Mission rollup — derived from per-task stats, no stored aggregates. */
|
|
122
|
+
export function computeMeshMissionStats(meshId: string, missionId: string): MeshMissionStats {
|
|
123
|
+
const tasks = computeMeshTaskStats(meshId, { missionId });
|
|
124
|
+
const stats: MeshMissionStats = {
|
|
125
|
+
missionId,
|
|
126
|
+
taskCount: tasks.length,
|
|
127
|
+
completed: 0,
|
|
128
|
+
failed: 0,
|
|
129
|
+
totalDurationMs: 0,
|
|
130
|
+
wallClockMs: null,
|
|
131
|
+
retries: 0,
|
|
132
|
+
incompleteTaskIds: [],
|
|
133
|
+
};
|
|
134
|
+
let firstDispatch: number | null = null;
|
|
135
|
+
let lastTerminal: number | null = null;
|
|
136
|
+
for (const task of tasks) {
|
|
137
|
+
if (task.status === 'completed') stats.completed += 1;
|
|
138
|
+
else if (task.status === 'failed') stats.failed += 1;
|
|
139
|
+
stats.retries += task.requeueCount;
|
|
140
|
+
if (task.incompleteEvidence) {
|
|
141
|
+
stats.incompleteTaskIds.push(task.taskId);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (task.durationMs !== null) stats.totalDurationMs += task.durationMs;
|
|
145
|
+
const dispatchTime = parseTime(task.dispatchedAt);
|
|
146
|
+
const terminalTime = parseTime(task.terminalAt);
|
|
147
|
+
if (dispatchTime !== null && (firstDispatch === null || dispatchTime < firstDispatch)) firstDispatch = dispatchTime;
|
|
148
|
+
if (terminalTime !== null && (lastTerminal === null || terminalTime > lastTerminal)) lastTerminal = terminalTime;
|
|
149
|
+
}
|
|
150
|
+
if (firstDispatch !== null && lastTerminal !== null && lastTerminal >= firstDispatch) {
|
|
151
|
+
stats.wallClockMs = lastTerminal - firstDispatch;
|
|
152
|
+
}
|
|
153
|
+
return stats;
|
|
154
|
+
}
|
|
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto';
|
|
|
2
2
|
import { requireMeshHostQueueOwner } from './mesh-host-ownership.js';
|
|
3
3
|
import type { RepoMeshDaemonRole } from '../repo-mesh-types.js';
|
|
4
4
|
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
5
|
+
import { getMesh } from '../config/mesh-config.js';
|
|
5
6
|
|
|
6
7
|
export type MeshTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
|
|
7
8
|
export type MeshActiveTaskStatus = Extract<MeshTaskStatus, 'pending' | 'assigned'>;
|
|
@@ -71,6 +72,20 @@ export interface MeshWorkQueueEntry {
|
|
|
71
72
|
targetSessionId?: string;
|
|
72
73
|
/** If specified, a node must expose all tags before it can claim the task. */
|
|
73
74
|
requiredTags?: string[];
|
|
75
|
+
/**
|
|
76
|
+
* M1: ids of tasks that must reach 'completed' before this task is claimable.
|
|
77
|
+
* Forward references (ids not yet enqueued) are allowed for batch flows and
|
|
78
|
+
* simply keep the task waiting until the referenced task exists and completes.
|
|
79
|
+
*/
|
|
80
|
+
dependsOn?: string[];
|
|
81
|
+
/** M1/M3: mission this task belongs to (joins mesh_missions). */
|
|
82
|
+
missionId?: string;
|
|
83
|
+
/**
|
|
84
|
+
* M1: why this task is held back (e.g. "dependency_failed:<taskId>").
|
|
85
|
+
* Only set by the system on dependency failure under the 'block' policy;
|
|
86
|
+
* waiting-on-dependency state is computed at view time, not stored.
|
|
87
|
+
*/
|
|
88
|
+
blockedReason?: string;
|
|
74
89
|
/** The node that actually claimed and is executing the task */
|
|
75
90
|
assignedNodeId?: string;
|
|
76
91
|
/** The session currently executing the task */
|
|
@@ -82,6 +97,8 @@ export interface MeshWorkQueueEntry {
|
|
|
82
97
|
requeueReason?: string;
|
|
83
98
|
requeuedAt?: string;
|
|
84
99
|
requeueCount?: number;
|
|
100
|
+
/** Max automatic requeue attempts. When requeueCount reaches this, task is auto-failed. */
|
|
101
|
+
maxRetries?: number;
|
|
85
102
|
/** Last automatic queue session spin-up attempt, for mesh_view_queue/debug visibility. */
|
|
86
103
|
autoLaunch?: {
|
|
87
104
|
status: 'skipped' | 'started' | 'failed' | 'completed';
|
|
@@ -123,17 +140,24 @@ function firstProviderPriority(policy: unknown): string | undefined {
|
|
|
123
140
|
}
|
|
124
141
|
|
|
125
142
|
export function buildMeshNodeCapabilityTags(
|
|
126
|
-
node: { capabilities?: unknown; policy?: unknown } | undefined,
|
|
143
|
+
node: { capabilities?: unknown; policy?: unknown; isLocalWorktree?: unknown; worktreeBranch?: unknown } | undefined,
|
|
127
144
|
providerType?: string,
|
|
128
145
|
): string[] {
|
|
129
146
|
const provider = typeof providerType === 'string' && providerType.trim()
|
|
130
147
|
? providerType.trim()
|
|
131
148
|
: firstProviderPriority(node?.policy);
|
|
149
|
+
const worktreeBranch = typeof node?.worktreeBranch === 'string' && node.worktreeBranch.trim()
|
|
150
|
+
? node.worktreeBranch.trim()
|
|
151
|
+
: null;
|
|
132
152
|
return normalizeMeshCapabilityTags([
|
|
133
153
|
...(Array.isArray(node?.capabilities) ? node.capabilities : []),
|
|
134
154
|
`os=${process.platform}`,
|
|
135
155
|
`arch=${process.arch}`,
|
|
136
156
|
...(provider ? [`provider=${provider}`] : []),
|
|
157
|
+
// Worktree nodes automatically expose a "worktree=<branch>" tag so that
|
|
158
|
+
// mesh_enqueue_task with required_tags: ["worktree=<branch>"] routes
|
|
159
|
+
// only to the matching worktree node.
|
|
160
|
+
...(node?.isLocalWorktree === true && worktreeBranch ? [`worktree=${worktreeBranch}`] : []),
|
|
137
161
|
]);
|
|
138
162
|
}
|
|
139
163
|
|
|
@@ -156,33 +180,96 @@ function writeQueue(meshId: string, queue: MeshWorkQueueEntry[]): void {
|
|
|
156
180
|
MeshRuntimeStore.getInstance().replaceQueue(meshId, queue);
|
|
157
181
|
}
|
|
158
182
|
|
|
183
|
+
function normalizeDependsOn(value: unknown): string[] {
|
|
184
|
+
if (!Array.isArray(value)) return [];
|
|
185
|
+
const seen = new Set<string>();
|
|
186
|
+
return value
|
|
187
|
+
.map(id => typeof id === 'string' ? id.trim() : '')
|
|
188
|
+
.filter(Boolean)
|
|
189
|
+
.filter(id => {
|
|
190
|
+
if (seen.has(id)) return false;
|
|
191
|
+
seen.add(id);
|
|
192
|
+
return true;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* M1: detect dependency cycles before enqueue. Walks the dependency graph of
|
|
198
|
+
* existing queue entries plus the new task's edges. Fail-closed: a cycle
|
|
199
|
+
* rejects the enqueue entirely. Synchronous and bounded by queue size.
|
|
200
|
+
*/
|
|
201
|
+
export function assertNoDependencyCycle(meshId: string, newTaskId: string, dependsOn: string[]): void {
|
|
202
|
+
if (dependsOn.length === 0) return;
|
|
203
|
+
if (dependsOn.includes(newTaskId)) {
|
|
204
|
+
throw new Error(`dependency_cycle_detected: task '${newTaskId}' cannot depend on itself`);
|
|
205
|
+
}
|
|
206
|
+
const adjacency = new Map<string, string[]>();
|
|
207
|
+
for (const entry of readQueue(meshId)) {
|
|
208
|
+
adjacency.set(entry.id, normalizeDependsOn(entry.dependsOn));
|
|
209
|
+
}
|
|
210
|
+
adjacency.set(newTaskId, dependsOn);
|
|
211
|
+
// DFS from the new task: if we can reach newTaskId again, the edges form a cycle.
|
|
212
|
+
const stack = [...dependsOn];
|
|
213
|
+
const visited = new Set<string>();
|
|
214
|
+
while (stack.length > 0) {
|
|
215
|
+
const current = stack.pop()!;
|
|
216
|
+
if (current === newTaskId) {
|
|
217
|
+
throw new Error(`dependency_cycle_detected: task '${newTaskId}' is part of a dependency cycle via '${dependsOn.join(', ')}'`);
|
|
218
|
+
}
|
|
219
|
+
if (visited.has(current)) continue;
|
|
220
|
+
visited.add(current);
|
|
221
|
+
stack.push(...(adjacency.get(current) ?? []));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
159
225
|
/**
|
|
160
226
|
* Add a new task to the mesh queue.
|
|
161
227
|
*/
|
|
162
228
|
export function enqueueTask(
|
|
163
229
|
meshId: string,
|
|
164
230
|
message: string,
|
|
165
|
-
opts?: {
|
|
231
|
+
opts?: {
|
|
232
|
+
targetNodeId?: string;
|
|
233
|
+
targetSessionId?: string;
|
|
234
|
+
taskMode?: MeshTaskMode | string;
|
|
235
|
+
requiredTags?: string[];
|
|
236
|
+
/** M1: tasks that must complete before this one is claimable. */
|
|
237
|
+
dependsOn?: string[];
|
|
238
|
+
/** M1/M3: mission this task belongs to. */
|
|
239
|
+
missionId?: string;
|
|
240
|
+
/** Explicit task id for batch/template flows (M5). Random UUID when omitted. */
|
|
241
|
+
id?: string;
|
|
242
|
+
} & MeshQueueMutationOptions,
|
|
166
243
|
): MeshWorkQueueEntry {
|
|
167
244
|
requireMeshHostQueueOwner(opts);
|
|
168
245
|
const modeValidation = validateMeshTaskModeRequest(opts?.taskMode, message);
|
|
169
246
|
if (!modeValidation.valid) {
|
|
170
247
|
throw new Error(`live_debug_readonly_guardrail_violation: forbidden operations (${modeValidation.violations.join(', ')})`);
|
|
171
248
|
}
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
249
|
+
const id = typeof opts?.id === 'string' && opts.id.trim() ? opts.id.trim() : randomUUID();
|
|
250
|
+
const dependsOn = normalizeDependsOn(opts?.dependsOn);
|
|
251
|
+
return withQueueLock(meshId, () => {
|
|
252
|
+
if (MeshRuntimeStore.getInstance().findQueueEntryById(meshId, id)) {
|
|
253
|
+
throw new Error(`duplicate_task_id: task '${id}' already exists in mesh '${meshId}'`);
|
|
254
|
+
}
|
|
255
|
+
assertNoDependencyCycle(meshId, id, dependsOn);
|
|
256
|
+
const entry: MeshWorkQueueEntry = {
|
|
257
|
+
id,
|
|
258
|
+
meshId,
|
|
259
|
+
message,
|
|
260
|
+
status: 'pending',
|
|
261
|
+
taskMode: modeValidation.taskMode,
|
|
262
|
+
targetNodeId: opts?.targetNodeId,
|
|
263
|
+
targetSessionId: opts?.targetSessionId,
|
|
264
|
+
requiredTags: normalizeMeshCapabilityTags(opts?.requiredTags),
|
|
265
|
+
...(dependsOn.length > 0 ? { dependsOn } : {}),
|
|
266
|
+
...(typeof opts?.missionId === 'string' && opts.missionId.trim() ? { missionId: opts.missionId.trim() } : {}),
|
|
267
|
+
createdAt: new Date().toISOString(),
|
|
268
|
+
updatedAt: new Date().toISOString(),
|
|
269
|
+
};
|
|
270
|
+
MeshRuntimeStore.getInstance().insertQueueEntry(entry);
|
|
271
|
+
return entry;
|
|
272
|
+
});
|
|
186
273
|
}
|
|
187
274
|
|
|
188
275
|
/**
|
|
@@ -203,6 +290,57 @@ export function claimNextTask(meshId: string, nodeId: string, sessionId: string,
|
|
|
203
290
|
return MeshRuntimeStore.getInstance().claimNextQueueTask(meshId, nodeId, sessionId, capabilityTags);
|
|
204
291
|
}
|
|
205
292
|
|
|
293
|
+
// ─── M1: Dependency Failure Propagation ─────────
|
|
294
|
+
|
|
295
|
+
export type DependencyFailurePolicy = 'block' | 'cancel';
|
|
296
|
+
|
|
297
|
+
function resolveDependencyFailurePolicy(meshId: string): DependencyFailurePolicy {
|
|
298
|
+
try {
|
|
299
|
+
const policy = (getMesh(meshId)?.policy ?? {}) as Record<string, unknown>;
|
|
300
|
+
return policy.onDependencyFailure === 'cancel' ? 'cancel' : 'block';
|
|
301
|
+
} catch {
|
|
302
|
+
return 'block';
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Apply the mesh's onDependencyFailure policy to pending dependents of a task
|
|
308
|
+
* that just reached a failed/cancelled terminal state.
|
|
309
|
+
*
|
|
310
|
+
* - 'block' (default): dependents stay pending with blockedReason
|
|
311
|
+
* "dependency_failed:<taskId>" so an operator can requeue/cancel them.
|
|
312
|
+
* - 'cancel': dependents are cancelled (cascading to their own dependents).
|
|
313
|
+
*
|
|
314
|
+
* Must be called inside the queue lock of the triggering transition.
|
|
315
|
+
*/
|
|
316
|
+
function propagateDependencyFailure(meshId: string, failedTaskId: string): void {
|
|
317
|
+
const policy = resolveDependencyFailurePolicy(meshId);
|
|
318
|
+
const store = MeshRuntimeStore.getInstance();
|
|
319
|
+
const frontier = [failedTaskId];
|
|
320
|
+
const seen = new Set<string>(frontier);
|
|
321
|
+
while (frontier.length > 0) {
|
|
322
|
+
const currentId = frontier.pop()!;
|
|
323
|
+
const dependents = store.getQueueEntries(meshId, ['pending'])
|
|
324
|
+
.filter(entry => Array.isArray(entry.dependsOn) && entry.dependsOn.includes(currentId));
|
|
325
|
+
for (const dependent of dependents) {
|
|
326
|
+
if (seen.has(dependent.id)) continue;
|
|
327
|
+
seen.add(dependent.id);
|
|
328
|
+
if (policy === 'cancel') {
|
|
329
|
+
dependent.status = 'cancelled';
|
|
330
|
+
dependent.cancelledAt = new Date().toISOString();
|
|
331
|
+
dependent.cancelReason = `dependency_failed:${currentId}`;
|
|
332
|
+
store.updateQueueEntry(dependent);
|
|
333
|
+
frontier.push(dependent.id); // cascade to transitive dependents
|
|
334
|
+
} else {
|
|
335
|
+
dependent.blockedReason = `dependency_failed:${currentId}`;
|
|
336
|
+
store.updateQueueEntry(dependent);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const DEPENDENCY_FAILURE_TERMINALS = new Set<MeshTaskStatus>(['failed', 'cancelled']);
|
|
343
|
+
|
|
206
344
|
/**
|
|
207
345
|
* Update the status of a specific task.
|
|
208
346
|
* Used when a session completes, fails, or stalls.
|
|
@@ -219,6 +357,7 @@ export function updateTaskStatus(
|
|
|
219
357
|
if (!entry) return null;
|
|
220
358
|
entry.status = status;
|
|
221
359
|
MeshRuntimeStore.getInstance().updateQueueEntry(entry);
|
|
360
|
+
if (DEPENDENCY_FAILURE_TERMINALS.has(status)) propagateDependencyFailure(meshId, taskId);
|
|
222
361
|
return entry;
|
|
223
362
|
});
|
|
224
363
|
}
|
|
@@ -255,6 +394,7 @@ export function cancelTask(
|
|
|
255
394
|
entry.cancelledAt = now;
|
|
256
395
|
if (opts?.reason) entry.cancelReason = opts.reason;
|
|
257
396
|
MeshRuntimeStore.getInstance().updateQueueEntry(entry);
|
|
397
|
+
propagateDependencyFailure(meshId, taskId);
|
|
258
398
|
return entry;
|
|
259
399
|
});
|
|
260
400
|
}
|
|
@@ -263,6 +403,11 @@ export function cancelTask(
|
|
|
263
403
|
* Return a queue task to pending for retry. By default, dead session targeting
|
|
264
404
|
* and assigned ownership are cleared so stale assignments do not strand again.
|
|
265
405
|
*/
|
|
406
|
+
export type RequeueResult =
|
|
407
|
+
| { status: 'requeued'; entry: MeshWorkQueueEntry }
|
|
408
|
+
| { status: 'failed_max_retries'; entry: MeshWorkQueueEntry; maxRetries: number; requeueCount: number }
|
|
409
|
+
| { status: 'not_found' };
|
|
410
|
+
|
|
266
411
|
export function requeueTask(
|
|
267
412
|
meshId: string,
|
|
268
413
|
taskId: string,
|
|
@@ -272,13 +417,34 @@ export function requeueTask(
|
|
|
272
417
|
targetSessionId?: string;
|
|
273
418
|
clearTargetNode?: boolean;
|
|
274
419
|
clearTargetSession?: boolean;
|
|
420
|
+
/**
|
|
421
|
+
* Override the retry cap for this call. Use only for explicit operator actions.
|
|
422
|
+
* If true, the task is requeued even when requeueCount >= maxRetries.
|
|
423
|
+
*/
|
|
424
|
+
force?: boolean;
|
|
425
|
+
/** Per-task retry cap override. Falls back to mesh policy maxTaskRetries (default 1). */
|
|
426
|
+
maxRetries?: number;
|
|
275
427
|
} & MeshQueueMutationOptions,
|
|
276
428
|
): MeshWorkQueueEntry | null {
|
|
277
429
|
requireMeshHostQueueOwner(opts);
|
|
278
430
|
return withQueueLock(meshId, () => {
|
|
279
431
|
const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
|
|
280
432
|
if (!entry) return null;
|
|
433
|
+
const currentCount = entry.requeueCount || 0;
|
|
434
|
+
const maxRetries = opts?.maxRetries ?? entry.maxRetries ?? 1;
|
|
435
|
+
if (!opts?.force && currentCount >= maxRetries) {
|
|
436
|
+
// Auto-fail: cap exceeded without explicit force override.
|
|
437
|
+
entry.status = 'failed';
|
|
438
|
+
entry.cancelReason = `max_retries_exceeded: requeued ${currentCount} time(s), limit is ${maxRetries}`;
|
|
439
|
+
entry.updatedAt = new Date().toISOString();
|
|
440
|
+
MeshRuntimeStore.getInstance().updateQueueEntry(entry);
|
|
441
|
+
propagateDependencyFailure(meshId, taskId);
|
|
442
|
+
return entry;
|
|
443
|
+
}
|
|
281
444
|
entry.status = 'pending';
|
|
445
|
+
// Operator requeue clears a dependency-failure block — the operator is
|
|
446
|
+
// explicitly overriding the held-back state.
|
|
447
|
+
delete entry.blockedReason;
|
|
282
448
|
delete entry.assignedNodeId;
|
|
283
449
|
delete entry.assignedSessionId;
|
|
284
450
|
delete entry.cancelledAt;
|
|
@@ -288,7 +454,7 @@ export function requeueTask(
|
|
|
288
454
|
if (opts?.clearTargetSession !== false) delete entry.targetSessionId;
|
|
289
455
|
if (typeof opts?.targetSessionId === 'string') entry.targetSessionId = opts.targetSessionId;
|
|
290
456
|
entry.requeuedAt = new Date().toISOString();
|
|
291
|
-
entry.requeueCount =
|
|
457
|
+
entry.requeueCount = currentCount + 1;
|
|
292
458
|
if (opts?.reason) entry.requeueReason = opts.reason;
|
|
293
459
|
MeshRuntimeStore.getInstance().updateQueueEntry(entry);
|
|
294
460
|
return entry;
|
|
@@ -310,10 +476,37 @@ export function updateSessionTaskStatus(
|
|
|
310
476
|
if (!entry) return null;
|
|
311
477
|
entry.status = status;
|
|
312
478
|
MeshRuntimeStore.getInstance().updateQueueEntry(entry);
|
|
479
|
+
if (DEPENDENCY_FAILURE_TERMINALS.has(status)) propagateDependencyFailure(meshId, entry.id);
|
|
313
480
|
return entry;
|
|
314
481
|
});
|
|
315
482
|
}
|
|
316
483
|
|
|
484
|
+
/**
|
|
485
|
+
* M1-3: true when at least one pending task is waiting on the given task.
|
|
486
|
+
* Used by the completion event path to decide whether to wake the queue.
|
|
487
|
+
*/
|
|
488
|
+
export function hasPendingDependents(meshId: string, taskId: string): boolean {
|
|
489
|
+
return MeshRuntimeStore.getInstance().getQueueEntries(meshId, ['pending'])
|
|
490
|
+
.some(entry => Array.isArray(entry.dependsOn) && entry.dependsOn.includes(taskId));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* M1-4: view-time dependency state for a task — unmet dependency ids and
|
|
495
|
+
* whether the task is currently claimable from a dependency standpoint.
|
|
496
|
+
* Not stored (truth stays in task statuses).
|
|
497
|
+
*/
|
|
498
|
+
export function describeTaskDependencyState(
|
|
499
|
+
entry: Pick<MeshWorkQueueEntry, 'dependsOn' | 'blockedReason'>,
|
|
500
|
+
statusById: Map<string, MeshTaskStatus | string>,
|
|
501
|
+
): { waitingOn: string[]; dependenciesSatisfied: boolean } {
|
|
502
|
+
const deps = Array.isArray(entry.dependsOn) ? entry.dependsOn : [];
|
|
503
|
+
const waitingOn = deps.filter(depId => statusById.get(depId) !== 'completed');
|
|
504
|
+
return {
|
|
505
|
+
waitingOn,
|
|
506
|
+
dependenciesSatisfied: waitingOn.length === 0 && !entry.blockedReason,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
317
510
|
export interface MeshWorkQueueStats {
|
|
318
511
|
total: number;
|
|
319
512
|
active: number;
|
|
@@ -436,3 +629,26 @@ export function markStaleDirectDispatches(meshId: string, olderThanMs = 60 * 60_
|
|
|
436
629
|
MeshRuntimeStore.getInstance().markStaleDirectDispatches(meshId, olderThanMs);
|
|
437
630
|
} catch { /* best-effort */ }
|
|
438
631
|
}
|
|
632
|
+
|
|
633
|
+
export type MeshToolCallRateResult = { rateLimitExceeded: boolean; callsInWindow: number; advisory: string | null };
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Record a coordinator tool call and return a rate-limit advisory when the
|
|
637
|
+
* call rate for that tool exceeds the allowed threshold.
|
|
638
|
+
*
|
|
639
|
+
* Defaults: 10-second sliding window, max 5 calls before advisory is raised.
|
|
640
|
+
* Returns { rateLimitExceeded: false } on any store error so callers are not blocked.
|
|
641
|
+
*/
|
|
642
|
+
export function recordMeshToolCall(opts: {
|
|
643
|
+
meshId: string;
|
|
644
|
+
tool: string;
|
|
645
|
+
sessionId?: string | null;
|
|
646
|
+
windowMs?: number;
|
|
647
|
+
maxCalls?: number;
|
|
648
|
+
}): MeshToolCallRateResult {
|
|
649
|
+
try {
|
|
650
|
+
return MeshRuntimeStore.getInstance().recordMeshToolCall(opts);
|
|
651
|
+
} catch {
|
|
652
|
+
return { rateLimitExceeded: false, callsInWindow: 0, advisory: null };
|
|
653
|
+
}
|
|
654
|
+
}
|
|
@@ -29,8 +29,18 @@ export interface RepoMeshRefineConfig {
|
|
|
29
29
|
validation?: {
|
|
30
30
|
required?: boolean;
|
|
31
31
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
32
|
+
* M2-2 (v2): how Refinery sources its bootstrap stage.
|
|
33
|
+
* 'inherit' (default) — consume the worktree_bootstrap config/state:
|
|
34
|
+
* skip when the node's bootstrap is 'ready' (staleInputs unchanged),
|
|
35
|
+
* run the worktree_bootstrap definition when stale/never-ran.
|
|
36
|
+
* 'skip' — no bootstrap stage at all (validation commands run as-is).
|
|
37
|
+
*/
|
|
38
|
+
bootstrap?: 'inherit' | 'skip';
|
|
39
|
+
/**
|
|
40
|
+
* DEPRECATED (M2-2): define bootstrap once in
|
|
41
|
+
* .adhdev/worktree_bootstrap.json instead. Still honored when no
|
|
42
|
+
* worktree_bootstrap config exists, with a deprecation warning; when
|
|
43
|
+
* both exist the worktree_bootstrap config wins.
|
|
34
44
|
*/
|
|
35
45
|
bootstrapCommands?: RepoMeshRefineValidationCommandConfig[];
|
|
36
46
|
commands?: RepoMeshRefineValidationCommandConfig[];
|
|
@@ -60,9 +70,13 @@ export interface MeshRefineConfigLoadResult {
|
|
|
60
70
|
export interface MeshRefineValidationPlan {
|
|
61
71
|
source: string;
|
|
62
72
|
sourceType: MeshRefineConfigLoadResult['sourceType'];
|
|
73
|
+
/** M2-2: how the bootstrap stage is sourced ('inherit' consumes worktree_bootstrap; 'skip' disables it). */
|
|
74
|
+
bootstrapMode: 'inherit' | 'skip';
|
|
63
75
|
bootstrapCommands: MeshRefineValidationCommandPlan[];
|
|
64
76
|
commands: MeshRefineValidationCommandPlan[];
|
|
65
77
|
rejectedCommands: Array<Record<string, unknown>>;
|
|
78
|
+
/** M2-2: deprecation notices (e.g. validation.bootstrapCommands present). */
|
|
79
|
+
deprecationWarnings: string[];
|
|
66
80
|
suggestions: RepoMeshRefineValidationCommandConfig[];
|
|
67
81
|
suggestedConfig?: RepoMeshRefineConfig;
|
|
68
82
|
unavailableReason?: string;
|
|
@@ -98,6 +112,11 @@ export const MESH_REFINE_CONFIG_SCHEMA = {
|
|
|
98
112
|
additionalProperties: false,
|
|
99
113
|
properties: {
|
|
100
114
|
required: { type: 'boolean', default: true },
|
|
115
|
+
bootstrap: {
|
|
116
|
+
enum: ['inherit', 'skip'],
|
|
117
|
+
default: 'inherit',
|
|
118
|
+
description: "M2-2 (v2): 'inherit' consumes the worktree_bootstrap config/state (skip when ready, rerun when stale); 'skip' disables the bootstrap stage entirely.",
|
|
119
|
+
},
|
|
101
120
|
commands: {
|
|
102
121
|
type: 'array',
|
|
103
122
|
minItems: 1,
|
|
@@ -120,6 +139,7 @@ export const MESH_REFINE_CONFIG_SCHEMA = {
|
|
|
120
139
|
bootstrapCommands: {
|
|
121
140
|
type: 'array',
|
|
122
141
|
maxItems: 4,
|
|
142
|
+
description: 'DEPRECATED: define bootstrap once in .adhdev/worktree_bootstrap.json. Honored only when no worktree_bootstrap config exists (with a deprecation warning); worktree_bootstrap wins when both exist.',
|
|
123
143
|
items: {
|
|
124
144
|
type: 'object',
|
|
125
145
|
additionalProperties: false,
|
|
@@ -217,23 +237,36 @@ export function normalizeMeshCommandConfig(entry: unknown, source: string): { co
|
|
|
217
237
|
|
|
218
238
|
const isRecord = isMeshConfigRecord;
|
|
219
239
|
|
|
220
|
-
export function validateMeshRefineConfig(config: unknown, source = 'inline'): { valid: boolean; errors: string[]; bootstrapCommands: MeshRefineValidationCommandPlan[]; commands: MeshRefineValidationCommandPlan[]; rejectedCommands: Array<Record<string, unknown
|
|
240
|
+
export function validateMeshRefineConfig(config: unknown, source = 'inline'): { valid: boolean; errors: string[]; bootstrapCommands: MeshRefineValidationCommandPlan[]; commands: MeshRefineValidationCommandPlan[]; rejectedCommands: Array<Record<string, unknown>>; bootstrapMode: 'inherit' | 'skip'; deprecationWarnings: string[] } {
|
|
221
241
|
const errors: string[] = [];
|
|
222
242
|
const bootstrapCommands: MeshRefineValidationCommandPlan[] = [];
|
|
223
243
|
const commands: MeshRefineValidationCommandPlan[] = [];
|
|
224
244
|
const rejectedCommands: Array<Record<string, unknown>> = [];
|
|
245
|
+
const deprecationWarnings: string[] = [];
|
|
246
|
+
let bootstrapMode: 'inherit' | 'skip' = 'inherit';
|
|
225
247
|
|
|
226
|
-
if (!isRecord(config)) return { valid: false, errors: ['config must be an object'], bootstrapCommands, commands, rejectedCommands };
|
|
248
|
+
if (!isRecord(config)) return { valid: false, errors: ['config must be an object'], bootstrapCommands, commands, rejectedCommands, bootstrapMode, deprecationWarnings };
|
|
227
249
|
if (config.version !== 1) errors.push('version must be 1');
|
|
228
250
|
if (config.allowAutoPublishSubmoduleMainCommits !== undefined && typeof config.allowAutoPublishSubmoduleMainCommits !== 'boolean') {
|
|
229
251
|
errors.push('allowAutoPublishSubmoduleMainCommits must be a boolean when provided');
|
|
230
252
|
}
|
|
231
253
|
const validation = config.validation;
|
|
232
254
|
if (validation !== undefined && !isRecord(validation)) errors.push('validation must be an object');
|
|
255
|
+
const rawBootstrapMode = isRecord(validation) ? validation.bootstrap : undefined;
|
|
256
|
+
if (rawBootstrapMode !== undefined) {
|
|
257
|
+
if (rawBootstrapMode === 'inherit' || rawBootstrapMode === 'skip') {
|
|
258
|
+
bootstrapMode = rawBootstrapMode;
|
|
259
|
+
} else {
|
|
260
|
+
errors.push("validation.bootstrap must be 'inherit' or 'skip' when provided");
|
|
261
|
+
}
|
|
262
|
+
}
|
|
233
263
|
const rawCommands = isRecord(validation) ? validation.commands : undefined;
|
|
234
264
|
const rawBootstrapCommands = isRecord(validation) ? validation.bootstrapCommands : undefined;
|
|
235
265
|
if (rawCommands !== undefined && !Array.isArray(rawCommands)) errors.push('validation.commands must be an array');
|
|
236
266
|
if (rawBootstrapCommands !== undefined && !Array.isArray(rawBootstrapCommands)) errors.push('validation.bootstrapCommands must be an array');
|
|
267
|
+
if (Array.isArray(rawBootstrapCommands) && rawBootstrapCommands.length > 0) {
|
|
268
|
+
deprecationWarnings.push('validation.bootstrapCommands is deprecated: define bootstrap once in .adhdev/worktree_bootstrap.json. It still runs when no worktree_bootstrap config exists; when both exist the worktree_bootstrap config wins.');
|
|
269
|
+
}
|
|
237
270
|
if (Array.isArray(rawBootstrapCommands)) {
|
|
238
271
|
rawBootstrapCommands.forEach((entry, index) => {
|
|
239
272
|
const normalized = normalizeMeshCommandConfig(entry, `${source}:validation.bootstrapCommands[${index}]`);
|
|
@@ -249,7 +282,7 @@ export function validateMeshRefineConfig(config: unknown, source = 'inline'): {
|
|
|
249
282
|
});
|
|
250
283
|
}
|
|
251
284
|
if (rejectedCommands.length) errors.push('one or more validation commands are invalid');
|
|
252
|
-
return { valid: errors.length === 0, errors, bootstrapCommands, commands, rejectedCommands };
|
|
285
|
+
return { valid: errors.length === 0, errors, bootstrapCommands, commands, rejectedCommands, bootstrapMode, deprecationWarnings };
|
|
253
286
|
}
|
|
254
287
|
|
|
255
288
|
function parseConfigText(path: string, text: string): unknown {
|
|
@@ -343,9 +376,11 @@ export function resolveMeshRefineValidationPlan(mesh: any, workspace: string): M
|
|
|
343
376
|
return {
|
|
344
377
|
source: loaded.source,
|
|
345
378
|
sourceType: loaded.sourceType,
|
|
379
|
+
bootstrapMode: 'inherit',
|
|
346
380
|
bootstrapCommands: [],
|
|
347
381
|
commands: [],
|
|
348
382
|
rejectedCommands: loaded.error ? [{ source: loaded.source, reason: loaded.error }] : [],
|
|
383
|
+
deprecationWarnings: [],
|
|
349
384
|
suggestions: suggestion.suggestions,
|
|
350
385
|
suggestedConfig: suggestion.suggestedConfig,
|
|
351
386
|
unavailableReason: loaded.error || 'validation_unavailable: repo mesh/refine config missing',
|
|
@@ -356,9 +391,11 @@ export function resolveMeshRefineValidationPlan(mesh: any, workspace: string): M
|
|
|
356
391
|
return {
|
|
357
392
|
source: loaded.path || loaded.source,
|
|
358
393
|
sourceType: loaded.sourceType,
|
|
394
|
+
bootstrapMode: validation.bootstrapMode,
|
|
359
395
|
bootstrapCommands: validation.bootstrapCommands,
|
|
360
396
|
commands: validation.commands,
|
|
361
397
|
rejectedCommands: validation.rejectedCommands,
|
|
398
|
+
deprecationWarnings: validation.deprecationWarnings,
|
|
362
399
|
suggestions: suggestion.suggestions,
|
|
363
400
|
suggestedConfig: suggestion.suggestedConfig,
|
|
364
401
|
unavailableReason: validation.commands.length ? undefined : 'validation_unavailable: repo mesh/refine config has no validation.commands',
|