@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.
Files changed (64) hide show
  1. package/dist/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +2 -0
  2. package/dist/commands/router.d.ts +6 -0
  3. package/dist/git/git-commands.d.ts +2 -0
  4. package/dist/git/git-diff.d.ts +6 -0
  5. package/dist/index.d.ts +11 -5
  6. package/dist/index.js +5699 -3486
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +5679 -3483
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/mesh/coordinator-prompt.d.ts +6 -0
  11. package/dist/mesh/mesh-delivery-policy.d.ts +5 -0
  12. package/dist/mesh/mesh-events-coordinator.d.ts +151 -0
  13. package/dist/mesh/mesh-events-pending.d.ts +33 -0
  14. package/dist/mesh/mesh-events-stale.d.ts +40 -0
  15. package/dist/mesh/mesh-events-utils.d.ts +14 -0
  16. package/dist/mesh/mesh-events.d.ts +5 -198
  17. package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -3
  18. package/dist/mesh/mesh-ledger.d.ts +19 -0
  19. package/dist/mesh/mesh-missions.d.ts +58 -0
  20. package/dist/mesh/mesh-review-inbox.d.ts +90 -0
  21. package/dist/mesh/mesh-runtime-store.d.ts +175 -0
  22. package/dist/mesh/mesh-task-stats.d.ts +49 -0
  23. package/dist/mesh/mesh-work-queue.d.ts +82 -0
  24. package/dist/mesh/refine-config.d.ts +24 -2
  25. package/dist/mesh/worktree-bootstrap-config.d.ts +22 -0
  26. package/dist/providers/acp-provider-instance.d.ts +2 -0
  27. package/dist/providers/spec/driver.d.ts +8 -0
  28. package/dist/providers/spec/evaluator.d.ts +4 -5
  29. package/dist/providers/spec/loader.d.ts +1 -0
  30. package/dist/providers/spec/schema.gen.d.ts +1409 -6
  31. package/dist/providers/spec/types.d.ts +188 -175
  32. package/dist/repo-mesh-types.d.ts +1 -0
  33. package/package.json +1 -1
  34. package/src/boot/daemon-lifecycle.ts +3 -0
  35. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +20 -7
  36. package/src/commands/router.ts +594 -66
  37. package/src/git/git-commands.ts +5 -5
  38. package/src/git/git-diff.ts +53 -0
  39. package/src/index.ts +11 -5
  40. package/src/mesh/coordinator-prompt.ts +14 -1
  41. package/src/mesh/mesh-delivery-policy.ts +17 -0
  42. package/src/mesh/mesh-events-coordinator.ts +1404 -0
  43. package/src/mesh/mesh-events-pending.ts +371 -0
  44. package/src/mesh/mesh-events-stale.ts +283 -0
  45. package/src/mesh/mesh-events-utils.ts +161 -0
  46. package/src/mesh/mesh-events.ts +27 -2143
  47. package/src/mesh/mesh-ledger-reconciliation.ts +12 -5
  48. package/src/mesh/mesh-ledger.ts +134 -2
  49. package/src/mesh/mesh-missions.ts +151 -0
  50. package/src/mesh/mesh-review-inbox.ts +307 -0
  51. package/src/mesh/mesh-runtime-store.ts +539 -3
  52. package/src/mesh/mesh-task-stats.ts +154 -0
  53. package/src/mesh/mesh-work-queue.ts +233 -17
  54. package/src/mesh/refine-config.ts +42 -5
  55. package/src/mesh/worktree-bootstrap-config.ts +79 -0
  56. package/src/providers/acp-provider-instance.ts +15 -1
  57. package/src/providers/cli-provider-instance.ts +34 -13
  58. package/src/providers/spec/driver.ts +57 -29
  59. package/src/providers/spec/evaluator.ts +302 -112
  60. package/src/providers/spec/loader.ts +226 -37
  61. package/src/providers/spec/schema.gen.ts +450 -334
  62. package/src/providers/spec/schema.json +162 -75
  63. package/src/providers/spec/types.ts +234 -183
  64. 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?: { targetNodeId?: string; targetSessionId?: string; taskMode?: MeshTaskMode | string; requiredTags?: string[] } & MeshQueueMutationOptions,
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 entry: MeshWorkQueueEntry = {
173
- id: randomUUID(),
174
- meshId,
175
- message,
176
- status: 'pending',
177
- taskMode: modeValidation.taskMode,
178
- targetNodeId: opts?.targetNodeId,
179
- targetSessionId: opts?.targetSessionId,
180
- requiredTags: normalizeMeshCapabilityTags(opts?.requiredTags),
181
- createdAt: new Date().toISOString(),
182
- updatedAt: new Date().toISOString(),
183
- };
184
- MeshRuntimeStore.getInstance().insertQueueEntry(entry);
185
- return entry;
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 = (entry.requeueCount || 0) + 1;
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
- * Optional dependency/bootstrap commands that Refinery runs before
33
- * validation commands. Refinery never infers installs on its own.
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',