@adhdev/daemon-core 0.9.82-rc.469 → 0.9.82-rc.470
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 +2 -2
- package/dist/index.js +4166 -4020
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4166 -4021
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-active-work.d.ts +29 -0
- package/dist/mesh/mesh-ledger.d.ts +6 -0
- package/dist/mesh/mesh-missions.d.ts +37 -0
- package/dist/mesh/mesh-runtime-store.d.ts +11 -0
- package/package.json +3 -3
- package/src/index.ts +2 -2
- package/src/mesh/coordinator-prompt.ts +3 -0
- package/src/mesh/mesh-active-work.ts +50 -0
- package/src/mesh/mesh-event-classify.ts +5 -0
- package/src/mesh/mesh-ledger.ts +10 -0
- package/src/mesh/mesh-missions.ts +119 -0
- package/src/mesh/mesh-runtime-store.ts +36 -4
- package/src/mesh/mesh-work-queue.ts +79 -19
|
@@ -75,6 +75,35 @@ export interface BuildMeshActiveWorkOptions {
|
|
|
75
75
|
/** Include terminal direct rows (idle/failed) for handoff/recent-work surfaces. Defaults false. */
|
|
76
76
|
includeTerminalDirect?: boolean;
|
|
77
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* One session awaiting an approval decision — the derived row `mesh_list_pending_approvals`
|
|
80
|
+
* returns and the UI approvals inbox renders. A thin projection of the `awaiting_approval`
|
|
81
|
+
* MeshActiveWorkRecord: it carries exactly what a coordinator needs to route a follow-up
|
|
82
|
+
* mesh_approve(node_id, session_id) and what the inbox needs to label the item. No new
|
|
83
|
+
* store or data source — derived from the same buildMeshActiveWork records.
|
|
84
|
+
*/
|
|
85
|
+
export interface MeshPendingApproval {
|
|
86
|
+
nodeId?: string;
|
|
87
|
+
sessionId?: string;
|
|
88
|
+
providerType?: string;
|
|
89
|
+
taskId: string;
|
|
90
|
+
taskTitle: string;
|
|
91
|
+
/** Always 'awaiting_approval' — kept explicit so consumers can render/assert on it. */
|
|
92
|
+
status: 'awaiting_approval';
|
|
93
|
+
/** ISO timestamp the underlying task was created/dispatched — the "waiting since" anchor. */
|
|
94
|
+
waitingSince: string;
|
|
95
|
+
/** Milliseconds the record has been outstanding (elapsed from dispatch/create). */
|
|
96
|
+
waitingMs: number;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Derive the mesh-wide pending-approval inbox from already-built active-work records.
|
|
100
|
+
* Pure filter+projection over `status === 'awaiting_approval'` — no new store, no probe;
|
|
101
|
+
* the caller supplies the records (typically buildMeshActiveWork(...).activeWork so the
|
|
102
|
+
* enumeration reuses the exact classification `mesh_status` already computes). Records
|
|
103
|
+
* without a node/session are skipped: an approval that cannot be routed to a live
|
|
104
|
+
* node+session via mesh_approve is not actionable inbox content.
|
|
105
|
+
*/
|
|
106
|
+
export declare function collectPendingApprovals(activeWork: MeshActiveWorkRecord[]): MeshPendingApproval[];
|
|
78
107
|
export declare function buildMeshActiveWorkSummary(activeWork: MeshActiveWorkRecord[]): MeshActiveWorkSummary;
|
|
79
108
|
export declare function buildMeshActiveWork(opts: BuildMeshActiveWorkOptions): {
|
|
80
109
|
activeWork: MeshActiveWorkRecord[];
|
|
@@ -115,6 +115,12 @@ export interface ReadLedgerOptions {
|
|
|
115
115
|
tail?: number;
|
|
116
116
|
since?: string;
|
|
117
117
|
kind?: MeshLedgerKind[];
|
|
118
|
+
/**
|
|
119
|
+
* Filter to entries whose nodeId is equivalent to this daemon id. Matched via
|
|
120
|
+
* daemonIdsEquivalent (not raw ===) so caller-supplied identifiers in any form
|
|
121
|
+
* (mach_X vs daemon_mach_X) resolve correctly — see the canon-identity defect class.
|
|
122
|
+
*/
|
|
123
|
+
node?: string;
|
|
118
124
|
}
|
|
119
125
|
export interface ReadLedgerSliceOptions {
|
|
120
126
|
/** Return entries strictly after this entry id. If not found, starts from the beginning of the filtered set. */
|
|
@@ -35,6 +35,13 @@ export interface MeshMissionRecord {
|
|
|
35
35
|
* missions are bounded out of the default list once completed.
|
|
36
36
|
*/
|
|
37
37
|
source?: MeshMissionSource;
|
|
38
|
+
/**
|
|
39
|
+
* G3: idempotency marker for the mission_close_candidate nudge. ISO timestamp of
|
|
40
|
+
* the last emit; absent/undefined when the mission has not (or no longer) been in a
|
|
41
|
+
* fully-terminal state. Owned by maybeEmitMissionCloseCandidate — never set by a
|
|
42
|
+
* regular upsert. See summarizeMissionTasks / maybeEmitMissionCloseCandidate.
|
|
43
|
+
*/
|
|
44
|
+
closeCandidateEmittedAt?: string;
|
|
38
45
|
createdAt: string;
|
|
39
46
|
updatedAt: string;
|
|
40
47
|
}
|
|
@@ -96,6 +103,36 @@ export declare function getMeshMission(meshId: string, missionId: string): MeshM
|
|
|
96
103
|
/** Aggregate task statuses for a mission at query time (no stored progress). */
|
|
97
104
|
export declare function summarizeMissionTasks(meshId: string, missionId: string): MeshMissionTaskAggregate;
|
|
98
105
|
export declare function summarizeMeshMission(meshId: string, mission: MeshMissionRecord): MeshMissionSummary;
|
|
106
|
+
/**
|
|
107
|
+
* G3 — all-tasks-terminal detection: true when a mission has at least one task and
|
|
108
|
+
* every task has reached a terminal status (no pending, no assigned). This is the
|
|
109
|
+
* derived signal (never a stored flag) that a mission has no more work in flight and
|
|
110
|
+
* is a candidate for the coordinator to close. A mission with zero tasks is NOT a
|
|
111
|
+
* candidate — an empty mission is a freshly-created plan, not a finished one.
|
|
112
|
+
*/
|
|
113
|
+
export declare function isMissionAllTasksTerminal(aggregate: MeshMissionTaskAggregate): boolean;
|
|
114
|
+
/**
|
|
115
|
+
* G3 (step ①) — emit a `mission_close_candidate` coordinator nudge the first time an
|
|
116
|
+
* ACTIVE mission's tasks all become terminal, and reset the idempotency marker when a
|
|
117
|
+
* mission leaves the terminal state. Call this after any task-status mutation that can
|
|
118
|
+
* change a mission's aggregate (completion / failure / cancel / dependency-failure /
|
|
119
|
+
* new task). Fire-and-forget and fully best-effort — a throw here must never break the
|
|
120
|
+
* task mutation that triggered it.
|
|
121
|
+
*
|
|
122
|
+
* Design invariants (docs/MESH_PROMPT_ARCH_REVIEW_2026-07.md §9-1 G3):
|
|
123
|
+
* - NEVER transitions the mission status. This only publishes a "consider closing"
|
|
124
|
+
* hint; the coordinator/human decides via mesh_mission_upsert.
|
|
125
|
+
* - Idempotent per all-terminal EDGE. The mission's close_candidate_emitted_at marker
|
|
126
|
+
* guarantees exactly one emit per terminal transition; while the mission stays
|
|
127
|
+
* all-terminal, subsequent calls no-op (no per-tick spam). When the mission returns
|
|
128
|
+
* to non-terminal (a new/re-opened task), the marker is cleared so a later
|
|
129
|
+
* re-completion nudges again.
|
|
130
|
+
* - Only ACTIVE missions nudge. A paused/completed/abandoned mission is never a
|
|
131
|
+
* close candidate (already decided, or intentionally on hold).
|
|
132
|
+
*
|
|
133
|
+
* Returns true iff an event was emitted on this call.
|
|
134
|
+
*/
|
|
135
|
+
export declare function maybeEmitMissionCloseCandidate(meshId: string, missionId: string): boolean;
|
|
99
136
|
/** Active mission summaries for mesh_status / coordinator prompt injection. */
|
|
100
137
|
export declare function getActiveMeshMissionSummaries(meshId: string): MeshMissionSummary[];
|
|
101
138
|
/**
|
|
@@ -435,6 +435,7 @@ export declare class MeshRuntimeStore {
|
|
|
435
435
|
goal: string;
|
|
436
436
|
status: string;
|
|
437
437
|
source?: string;
|
|
438
|
+
closeCandidateEmittedAt?: string;
|
|
438
439
|
createdAt: string;
|
|
439
440
|
updatedAt: string;
|
|
440
441
|
} | null;
|
|
@@ -445,9 +446,19 @@ export declare class MeshRuntimeStore {
|
|
|
445
446
|
goal: string;
|
|
446
447
|
status: string;
|
|
447
448
|
source?: string;
|
|
449
|
+
closeCandidateEmittedAt?: string;
|
|
448
450
|
createdAt: string;
|
|
449
451
|
updatedAt: string;
|
|
450
452
|
}>;
|
|
453
|
+
/**
|
|
454
|
+
* G3: set/clear the mission_close_candidate idempotency marker. Passing an ISO
|
|
455
|
+
* timestamp records that the all-terminal nudge has been emitted for this mission;
|
|
456
|
+
* passing null clears it (mission returned to a non-terminal state, so a future
|
|
457
|
+
* re-completion may nudge again). Touches ONLY this column — never the mission's
|
|
458
|
+
* updated_at — so the marker write is invisible to updatedAt-ordered surfaces and
|
|
459
|
+
* does not masquerade as mission activity. Returns rows changed (0 if no such mission).
|
|
460
|
+
*/
|
|
461
|
+
setMissionCloseCandidateEmittedAt(meshId: string, missionId: string, emittedAt: string | null): number;
|
|
451
462
|
/** Remove all missions for a mesh — mesh deletion / test cleanup. */
|
|
452
463
|
clearMissionsForMesh(meshId: string): number;
|
|
453
464
|
/** Remove all pending-event rows (drained included) for a mesh — mesh deletion / test cleanup. */
|
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.470",
|
|
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",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"author": "vilmire",
|
|
48
48
|
"license": "AGPL-3.0-or-later",
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
51
|
-
"@adhdev/session-host-core": "0.9.82-rc.
|
|
50
|
+
"@adhdev/mesh-shared": "0.9.82-rc.470",
|
|
51
|
+
"@adhdev/session-host-core": "0.9.82-rc.470",
|
|
52
52
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
53
53
|
"ajv": "^8.20.0",
|
|
54
54
|
"ajv-formats": "^3.0.1",
|
package/src/index.ts
CHANGED
|
@@ -278,9 +278,9 @@ export type { AnyLedgerSlice, MeshLedgerReconciliationEvidence, MeshLedgerReplic
|
|
|
278
278
|
// ── Mesh Work Queue (GUPP) ──
|
|
279
279
|
export { enqueueTask, recordDirectDispatchTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest, isTaskReadonly, buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, normalizeMeshCapabilityTags, resolveConvergeRequiredTags, insertDirectDispatch, getActiveDirectDispatches, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, markStaleDirectDispatches, deleteDirectDispatchesByTaskId, recordMeshToolCall, assertNoDependencyCycle, hasPendingDependents, describeTaskDependencyState, taskDependenciesSatisfied, normalizeMeshTaskPriority, meshTaskPriorityRank, resolveNotBefore, meshTaskNotBeforeReady, MESH_TASK_PRIORITIES, NOT_BEFORE_RELATIVE_THRESHOLD_MS } from './mesh/mesh-work-queue.js';
|
|
280
280
|
export type { MeshWorkQueueEntry, MeshTaskStatus, MeshTaskMode, MeshTaskPriority, MeshWorkQueueStats, MeshQueueMutationOptions, MeshTaskModeValidationResult, DirectDispatchRecord, MeshToolCallRateResult } from './mesh/mesh-work-queue.js';
|
|
281
|
-
export { buildCompactStaleDirectWorkSummary, buildMeshActiveWork, buildMeshActiveWorkSummary, classifyStaleDirectForPrune, pruneStaleDirectDispatches, PRUNABLE_ORPHAN_STALE_REASONS } from './mesh/mesh-active-work.js';
|
|
281
|
+
export { buildCompactStaleDirectWorkSummary, buildMeshActiveWork, buildMeshActiveWorkSummary, collectPendingApprovals, classifyStaleDirectForPrune, pruneStaleDirectDispatches, PRUNABLE_ORPHAN_STALE_REASONS } from './mesh/mesh-active-work.js';
|
|
282
282
|
export type { StaleDirectPruneClassification, StaleDirectPruneResult, PruneStaleDirectDispatchesOptions } from './mesh/mesh-active-work.js';
|
|
283
|
-
export type { MeshActiveWorkRecord, MeshActiveWorkStatus, MeshActiveWorkSummary, MeshActiveWorkSource, MeshStaleDirectWorkSummary } from './mesh/mesh-active-work.js';
|
|
283
|
+
export type { MeshActiveWorkRecord, MeshActiveWorkStatus, MeshActiveWorkSummary, MeshActiveWorkSource, MeshStaleDirectWorkSummary, MeshPendingApproval } from './mesh/mesh-active-work.js';
|
|
284
284
|
export { buildMeshAsyncRefineJobs, summarizeMeshAsyncRefineJobs, STALE_TERMINAL_REFINE_WINDOW_MS, RECENT_TERMINAL_REFINE_CAP } from './mesh/mesh-refine-status.js';
|
|
285
285
|
export type { MeshAsyncRefineJobStatus, MeshAsyncRefineJobSummary, MeshAsyncRefineJobsSummary } from './mesh/mesh-refine-status.js';
|
|
286
286
|
export { buildMeshMagiActivity, summarizeMeshMagiActivity, getMeshMagiActivityByGroup, STALE_MAGI_WINDOW_MS, RECENT_MAGI_CAP, MAGI_NEEDS_VERIFICATION_PREVIEW_CAP } from './mesh/mesh-magi-status.js';
|
|
@@ -621,8 +621,10 @@ const TOOLS_SECTION = `## Available Tools
|
|
|
621
621
|
| \`mesh_read_chat\` | Read recent chat messages from a delegated agent session |
|
|
622
622
|
| \`mesh_read_debug\` | Collect a daemon-side chat/parser debug bundle for a session |
|
|
623
623
|
| \`mesh_task_history\` | Read the task ledger — dispatches, completions, failures. Use to understand what has been done before deciding next steps |
|
|
624
|
+
| \`mesh_ledger_query\` | Read-only ledger query along the kind/time/node axes (complement to task-axis mesh_task_history): filter by kind, since, node, tail — answer "what happened on node X / what failed since T" without scanning transcripts |
|
|
624
625
|
| \`mesh_reconcile_ledger\` | Reconcile daemon-local ledgers over P2P — import missing entries from remote nodes into the coordinator local ledger |
|
|
625
626
|
| \`mesh_requeue_held_events\` | Restore recoverable held coordinator events (T6 quarantine / pending-trim) back to the pending queue; lossless, no double-requeue |
|
|
627
|
+
| \`mesh_wait_events\` | Long-poll blocking wait for coordinator events (worker completions/approvals/nudges) up to timeoutMs — drains immediately if any are pending, else blocks until they arrive. Use this instead of busy-polling mesh_status/mesh_view_queue after dispatching work |
|
|
626
628
|
| \`mesh_review_inbox\` | List local worktree nodes needing human review — merge candidates and Refinery-blocked results with evidence/diff summaries |
|
|
627
629
|
| \`mesh_record_note\` | Record a durable, provider-neutral operating note (provider quirk / pattern to avoid / recovery lesson). Future coordinators see it under "## Operating Notes" at launch |
|
|
628
630
|
| \`mesh_forget_note\` | Retract a stale/wrong operating note by note_id or exact text so it stops riding into future coordinators' prompts (append-only tombstone; history preserved) |
|
|
@@ -632,6 +634,7 @@ const TOOLS_SECTION = `## Available Tools
|
|
|
632
634
|
| \`mesh_restart_daemon\` | Update a node's daemon to the latest published version on its channel and restart it (the dashboard "preview update" path, as a mesh command) |
|
|
633
635
|
| \`mesh_checkpoint\` | Create a git checkpoint on a node |
|
|
634
636
|
| \`mesh_approve\` | Approve/reject a pending agent action |
|
|
637
|
+
| \`mesh_list_pending_approvals\` | List every session across the mesh awaiting an approval decision (the approval inbox) — read-only; enumerate all blocked sessions at once, then drive a mesh_approve for each |
|
|
635
638
|
| \`mesh_clone_node\` | Create a worktree node for isolated parallel branch work |
|
|
636
639
|
| \`mesh_refine_node\` | Validate and merge a completed worktree node back into its base branch |
|
|
637
640
|
| \`mesh_refine_batch\` | Batch Refinery: converge multiple sibling worktree nodes onto the base branch in one conflict-aware sequential pipeline |
|
|
@@ -273,6 +273,56 @@ function buildLedgerDirectDispatchRecord(
|
|
|
273
273
|
return { record, terminalRow };
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
/**
|
|
277
|
+
* One session awaiting an approval decision — the derived row `mesh_list_pending_approvals`
|
|
278
|
+
* returns and the UI approvals inbox renders. A thin projection of the `awaiting_approval`
|
|
279
|
+
* MeshActiveWorkRecord: it carries exactly what a coordinator needs to route a follow-up
|
|
280
|
+
* mesh_approve(node_id, session_id) and what the inbox needs to label the item. No new
|
|
281
|
+
* store or data source — derived from the same buildMeshActiveWork records.
|
|
282
|
+
*/
|
|
283
|
+
export interface MeshPendingApproval {
|
|
284
|
+
nodeId?: string;
|
|
285
|
+
sessionId?: string;
|
|
286
|
+
providerType?: string;
|
|
287
|
+
taskId: string;
|
|
288
|
+
taskTitle: string;
|
|
289
|
+
/** Always 'awaiting_approval' — kept explicit so consumers can render/assert on it. */
|
|
290
|
+
status: 'awaiting_approval';
|
|
291
|
+
/** ISO timestamp the underlying task was created/dispatched — the "waiting since" anchor. */
|
|
292
|
+
waitingSince: string;
|
|
293
|
+
/** Milliseconds the record has been outstanding (elapsed from dispatch/create). */
|
|
294
|
+
waitingMs: number;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Derive the mesh-wide pending-approval inbox from already-built active-work records.
|
|
299
|
+
* Pure filter+projection over `status === 'awaiting_approval'` — no new store, no probe;
|
|
300
|
+
* the caller supplies the records (typically buildMeshActiveWork(...).activeWork so the
|
|
301
|
+
* enumeration reuses the exact classification `mesh_status` already computes). Records
|
|
302
|
+
* without a node/session are skipped: an approval that cannot be routed to a live
|
|
303
|
+
* node+session via mesh_approve is not actionable inbox content.
|
|
304
|
+
*/
|
|
305
|
+
export function collectPendingApprovals(activeWork: MeshActiveWorkRecord[]): MeshPendingApproval[] {
|
|
306
|
+
const approvals: MeshPendingApproval[] = [];
|
|
307
|
+
for (const record of activeWork) {
|
|
308
|
+
if (record.status !== 'awaiting_approval') continue;
|
|
309
|
+
if (!record.nodeId || !record.sessionId) continue;
|
|
310
|
+
approvals.push({
|
|
311
|
+
nodeId: record.nodeId,
|
|
312
|
+
sessionId: record.sessionId,
|
|
313
|
+
providerType: record.providerType,
|
|
314
|
+
taskId: record.taskId,
|
|
315
|
+
taskTitle: record.taskTitle,
|
|
316
|
+
status: 'awaiting_approval',
|
|
317
|
+
waitingSince: record.dispatchedAt || record.createdAt,
|
|
318
|
+
waitingMs: record.elapsedMs,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
// Longest-waiting first — the coordinator/inbox should address the most-stalled approval first.
|
|
322
|
+
approvals.sort((a, b) => b.waitingMs - a.waitingMs);
|
|
323
|
+
return approvals;
|
|
324
|
+
}
|
|
325
|
+
|
|
276
326
|
export function buildMeshActiveWorkSummary(activeWork: MeshActiveWorkRecord[]): MeshActiveWorkSummary {
|
|
277
327
|
const statusCounts: Record<MeshActiveWorkStatus, number> = {
|
|
278
328
|
pending: 0,
|
|
@@ -16,6 +16,11 @@ const MESH_COORDINATOR_EVENTS = new Set([
|
|
|
16
16
|
'refine:failed',
|
|
17
17
|
'worktree_bootstrap_complete',
|
|
18
18
|
'worktree_bootstrap_failed',
|
|
19
|
+
// G3: mission-hygiene nudge. Emitted once when all of a mission's tasks first
|
|
20
|
+
// become terminal — a "consider closing this mission" hint for the coordinator.
|
|
21
|
+
// Purely informational: NOT force-injected (no blocked coordinator waits on it)
|
|
22
|
+
// and NOT an approval; it never drives a mission status transition on its own.
|
|
23
|
+
'mission_close_candidate',
|
|
19
24
|
]);
|
|
20
25
|
|
|
21
26
|
export const EVENT_TO_LEDGER_KIND: Record<string, MeshLedgerKind> = {
|
package/src/mesh/mesh-ledger.ts
CHANGED
|
@@ -209,6 +209,12 @@ export interface ReadLedgerOptions {
|
|
|
209
209
|
tail?: number;
|
|
210
210
|
since?: string;
|
|
211
211
|
kind?: MeshLedgerKind[];
|
|
212
|
+
/**
|
|
213
|
+
* Filter to entries whose nodeId is equivalent to this daemon id. Matched via
|
|
214
|
+
* daemonIdsEquivalent (not raw ===) so caller-supplied identifiers in any form
|
|
215
|
+
* (mach_X vs daemon_mach_X) resolve correctly — see the canon-identity defect class.
|
|
216
|
+
*/
|
|
217
|
+
node?: string;
|
|
212
218
|
}
|
|
213
219
|
|
|
214
220
|
export interface ReadLedgerSliceOptions {
|
|
@@ -1088,6 +1094,10 @@ export function readLedgerEntries(meshId: string, opts?: ReadLedgerOptions): Mes
|
|
|
1088
1094
|
const kindSet = new Set(opts.kind);
|
|
1089
1095
|
entries = entries.filter(e => kindSet.has(e.kind));
|
|
1090
1096
|
}
|
|
1097
|
+
if (opts?.node && opts.node.trim()) {
|
|
1098
|
+
const node = opts.node.trim();
|
|
1099
|
+
entries = entries.filter(e => e.nodeId && daemonIdsEquivalent(e.nodeId, node));
|
|
1100
|
+
}
|
|
1091
1101
|
if (opts?.tail && opts.tail > 0 && entries.length > opts.tail) {
|
|
1092
1102
|
entries = entries.slice(-opts.tail);
|
|
1093
1103
|
}
|
|
@@ -13,10 +13,12 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { randomUUID } from 'crypto';
|
|
16
|
+
import { LOG } from '../logging/logger.js';
|
|
16
17
|
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
17
18
|
import { getQueue } from './mesh-work-queue.js';
|
|
18
19
|
import { computeMeshMissionStats, type MeshMissionStats } from './mesh-task-stats.js';
|
|
19
20
|
import { appendLedgerEntry } from './mesh-ledger.js';
|
|
21
|
+
import { queuePendingMeshCoordinatorEvent } from './mesh-events-pending.js';
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* Max chars of mission goal text written into a ledger entry payload. Mission
|
|
@@ -56,6 +58,13 @@ export interface MeshMissionRecord {
|
|
|
56
58
|
* missions are bounded out of the default list once completed.
|
|
57
59
|
*/
|
|
58
60
|
source?: MeshMissionSource;
|
|
61
|
+
/**
|
|
62
|
+
* G3: idempotency marker for the mission_close_candidate nudge. ISO timestamp of
|
|
63
|
+
* the last emit; absent/undefined when the mission has not (or no longer) been in a
|
|
64
|
+
* fully-terminal state. Owned by maybeEmitMissionCloseCandidate — never set by a
|
|
65
|
+
* regular upsert. See summarizeMissionTasks / maybeEmitMissionCloseCandidate.
|
|
66
|
+
*/
|
|
67
|
+
closeCandidateEmittedAt?: string;
|
|
59
68
|
createdAt: string;
|
|
60
69
|
updatedAt: string;
|
|
61
70
|
}
|
|
@@ -269,6 +278,116 @@ export function summarizeMeshMission(meshId: string, mission: MeshMissionRecord)
|
|
|
269
278
|
return { ...mission, tasks: summarizeMissionTasks(meshId, mission.id) };
|
|
270
279
|
}
|
|
271
280
|
|
|
281
|
+
/**
|
|
282
|
+
* G3 — all-tasks-terminal detection: true when a mission has at least one task and
|
|
283
|
+
* every task has reached a terminal status (no pending, no assigned). This is the
|
|
284
|
+
* derived signal (never a stored flag) that a mission has no more work in flight and
|
|
285
|
+
* is a candidate for the coordinator to close. A mission with zero tasks is NOT a
|
|
286
|
+
* candidate — an empty mission is a freshly-created plan, not a finished one.
|
|
287
|
+
*/
|
|
288
|
+
export function isMissionAllTasksTerminal(aggregate: MeshMissionTaskAggregate): boolean {
|
|
289
|
+
return aggregate.total > 0 && aggregate.pending === 0 && aggregate.assigned === 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* G3 (step ①) — emit a `mission_close_candidate` coordinator nudge the first time an
|
|
294
|
+
* ACTIVE mission's tasks all become terminal, and reset the idempotency marker when a
|
|
295
|
+
* mission leaves the terminal state. Call this after any task-status mutation that can
|
|
296
|
+
* change a mission's aggregate (completion / failure / cancel / dependency-failure /
|
|
297
|
+
* new task). Fire-and-forget and fully best-effort — a throw here must never break the
|
|
298
|
+
* task mutation that triggered it.
|
|
299
|
+
*
|
|
300
|
+
* Design invariants (docs/MESH_PROMPT_ARCH_REVIEW_2026-07.md §9-1 G3):
|
|
301
|
+
* - NEVER transitions the mission status. This only publishes a "consider closing"
|
|
302
|
+
* hint; the coordinator/human decides via mesh_mission_upsert.
|
|
303
|
+
* - Idempotent per all-terminal EDGE. The mission's close_candidate_emitted_at marker
|
|
304
|
+
* guarantees exactly one emit per terminal transition; while the mission stays
|
|
305
|
+
* all-terminal, subsequent calls no-op (no per-tick spam). When the mission returns
|
|
306
|
+
* to non-terminal (a new/re-opened task), the marker is cleared so a later
|
|
307
|
+
* re-completion nudges again.
|
|
308
|
+
* - Only ACTIVE missions nudge. A paused/completed/abandoned mission is never a
|
|
309
|
+
* close candidate (already decided, or intentionally on hold).
|
|
310
|
+
*
|
|
311
|
+
* Returns true iff an event was emitted on this call.
|
|
312
|
+
*/
|
|
313
|
+
export function maybeEmitMissionCloseCandidate(meshId: string, missionId: string): boolean {
|
|
314
|
+
try {
|
|
315
|
+
const mission = getMeshMission(meshId, missionId);
|
|
316
|
+
if (!mission) return false;
|
|
317
|
+
const store = MeshRuntimeStore.getInstance();
|
|
318
|
+
const aggregate = summarizeMissionTasks(meshId, missionId);
|
|
319
|
+
const allTerminal = isMissionAllTasksTerminal(aggregate);
|
|
320
|
+
const alreadyEmitted = typeof mission.closeCandidateEmittedAt === 'string' && mission.closeCandidateEmittedAt.length > 0;
|
|
321
|
+
|
|
322
|
+
// Reset edge: mission is no longer all-terminal (new/re-opened task) but still
|
|
323
|
+
// carries a stale marker → clear it so a future re-completion can nudge again.
|
|
324
|
+
// Applies regardless of mission status (a re-opened completed mission also resets).
|
|
325
|
+
if (!allTerminal) {
|
|
326
|
+
if (alreadyEmitted) store.setMissionCloseCandidateEmittedAt(meshId, missionId, null);
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// All-terminal, but only an ACTIVE mission is a close candidate. A paused mission
|
|
331
|
+
// is intentionally on hold; a completed/abandoned one is already decided. We do
|
|
332
|
+
// NOT mark in these cases — if the mission is later reactivated while still
|
|
333
|
+
// all-terminal, it should nudge then.
|
|
334
|
+
if (mission.status !== 'active') return false;
|
|
335
|
+
|
|
336
|
+
// Idempotency: already nudged for this terminal edge → no-op (no per-tick spam).
|
|
337
|
+
if (alreadyEmitted) return false;
|
|
338
|
+
|
|
339
|
+
const emittedAt = new Date().toISOString();
|
|
340
|
+
emitMissionCloseCandidateEvent(meshId, mission, aggregate, emittedAt);
|
|
341
|
+
// Mark AFTER a successful emit path so a mid-emit throw leaves the marker unset
|
|
342
|
+
// and the next mutation retries rather than silently dropping the only nudge.
|
|
343
|
+
store.setMissionCloseCandidateEmittedAt(meshId, missionId, emittedAt);
|
|
344
|
+
return true;
|
|
345
|
+
} catch (e: any) {
|
|
346
|
+
LOG.warn('MeshMissions', `maybeEmitMissionCloseCandidate failed for mission ${missionId} on mesh ${meshId}: ${e?.message || e}`);
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Build and queue the mission_close_candidate pending coordinator event. Broadcast
|
|
353
|
+
* scope (defaultScopeForEvent → 'broadcast' since it is neither a terminal-task nor a
|
|
354
|
+
* system event), so it reaches any coordinator on the mesh without needing an
|
|
355
|
+
* intendedFor identity — a mission-hygiene hint is not tied to a single dispatcher.
|
|
356
|
+
*/
|
|
357
|
+
function emitMissionCloseCandidateEvent(
|
|
358
|
+
meshId: string,
|
|
359
|
+
mission: MeshMissionRecord,
|
|
360
|
+
aggregate: MeshMissionTaskAggregate,
|
|
361
|
+
emittedAt: string,
|
|
362
|
+
): void {
|
|
363
|
+
const coordinatorMessage =
|
|
364
|
+
`Mission "${mission.title}" (id: ${mission.id}) has no tasks left in flight — all ${aggregate.total} `
|
|
365
|
+
+ `task(s) are terminal (${aggregate.completed} completed, ${aggregate.failed} failed, ${aggregate.cancelled} cancelled). `
|
|
366
|
+
+ `It is a candidate to close. Review its outcome and, if done, set its status with `
|
|
367
|
+
+ `mesh_mission_upsert(mission_id: "${mission.id}", status: "completed" | "abandoned"). `
|
|
368
|
+
+ `This is only a hint — the mission stays 'active' until you decide.`;
|
|
369
|
+
queuePendingMeshCoordinatorEvent({
|
|
370
|
+
event: 'mission_close_candidate',
|
|
371
|
+
meshId,
|
|
372
|
+
nodeLabel: '',
|
|
373
|
+
metadataEvent: {
|
|
374
|
+
missionId: mission.id,
|
|
375
|
+
title: mission.title,
|
|
376
|
+
status: mission.status,
|
|
377
|
+
aggregate: {
|
|
378
|
+
total: aggregate.total,
|
|
379
|
+
completed: aggregate.completed,
|
|
380
|
+
failed: aggregate.failed,
|
|
381
|
+
cancelled: aggregate.cancelled,
|
|
382
|
+
},
|
|
383
|
+
lastActivityAt: aggregate.lastActivityAt,
|
|
384
|
+
emittedAt,
|
|
385
|
+
},
|
|
386
|
+
coordinatorMessage,
|
|
387
|
+
queuedAt: Date.parse(emittedAt) || 0,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
272
391
|
/** Active mission summaries for mesh_status / coordinator prompt injection. */
|
|
273
392
|
export function getActiveMeshMissionSummaries(meshId: string): MeshMissionSummary[] {
|
|
274
393
|
return getMeshMissions(meshId, ['active']).map(mission => summarizeMeshMission(meshId, mission));
|
|
@@ -346,6 +346,14 @@ export class MeshRuntimeStore {
|
|
|
346
346
|
goal TEXT NOT NULL DEFAULT '',
|
|
347
347
|
status TEXT NOT NULL DEFAULT 'active',
|
|
348
348
|
source TEXT,
|
|
349
|
+
-- G3: idempotency marker for the mission_close_candidate coordinator
|
|
350
|
+
-- event. Set to the emit timestamp when all of a mission's tasks first
|
|
351
|
+
-- become terminal (so the "consider closing this" nudge fires exactly
|
|
352
|
+
-- once per all-terminal edge), and cleared back to NULL when the mission
|
|
353
|
+
-- returns to a non-terminal state (new/re-opened task) so a later
|
|
354
|
+
-- re-completion can nudge again. Never drives a status transition — the
|
|
355
|
+
-- coordinator/human still decides via mesh_mission_upsert.
|
|
356
|
+
close_candidate_emitted_at TEXT,
|
|
349
357
|
created_at TEXT NOT NULL,
|
|
350
358
|
updated_at TEXT NOT NULL
|
|
351
359
|
);
|
|
@@ -462,6 +470,13 @@ export class MeshRuntimeStore {
|
|
|
462
470
|
if (!missionCols.has('source')) {
|
|
463
471
|
this.db.exec(`ALTER TABLE mesh_missions ADD COLUMN source TEXT`);
|
|
464
472
|
}
|
|
473
|
+
// 3b. mesh_missions.close_candidate_emitted_at (G3): nullable idempotency
|
|
474
|
+
// marker for the mission_close_candidate coordinator nudge. Pre-existing
|
|
475
|
+
// rows keep it NULL — treated as "not yet emitted", so the first
|
|
476
|
+
// all-terminal detection after this migration emits once, then marks it.
|
|
477
|
+
if (!missionCols.has('close_candidate_emitted_at')) {
|
|
478
|
+
this.db.exec(`ALTER TABLE mesh_missions ADD COLUMN close_candidate_emitted_at TEXT`);
|
|
479
|
+
}
|
|
465
480
|
|
|
466
481
|
// 4. mesh_pending_events v2 envelope columns (B2a). A pre-v2 DB has the
|
|
467
482
|
// table (CREATE IF NOT EXISTS is a no-op) without these columns, so add
|
|
@@ -1988,6 +2003,9 @@ export class MeshRuntimeStore {
|
|
|
1988
2003
|
// with a non-null incoming value (COALESCE(excluded, existing)), so a later
|
|
1989
2004
|
// status/goal upsert that omits source never clears a previously-stamped
|
|
1990
2005
|
// 'magi'/'coordinator' tag.
|
|
2006
|
+
// close_candidate_emitted_at is deliberately NOT in the UPDATE set: the G3
|
|
2007
|
+
// idempotency marker is owned solely by setMissionCloseCandidateEmittedAt, so a
|
|
2008
|
+
// title/goal/status upsert here never clears or overwrites it.
|
|
1991
2009
|
this.db.prepare(
|
|
1992
2010
|
`INSERT INTO mesh_missions (id, mesh_id, title, goal, status, source, created_at, updated_at)
|
|
1993
2011
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
@@ -2010,15 +2028,15 @@ export class MeshRuntimeStore {
|
|
|
2010
2028
|
this.maybeCheckpointWal();
|
|
2011
2029
|
}
|
|
2012
2030
|
|
|
2013
|
-
getMission(meshId: string, missionId: string): { id: string; meshId: string; title: string; goal: string; status: string; source?: string; createdAt: string; updatedAt: string } | null {
|
|
2031
|
+
getMission(meshId: string, missionId: string): { id: string; meshId: string; title: string; goal: string; status: string; source?: string; closeCandidateEmittedAt?: string; createdAt: string; updatedAt: string } | null {
|
|
2014
2032
|
const row = this.db.prepare(
|
|
2015
2033
|
'SELECT * FROM mesh_missions WHERE mesh_id = ? AND id = ?'
|
|
2016
2034
|
).get(meshId, missionId) as Record<string, string> | undefined;
|
|
2017
2035
|
if (!row) return null;
|
|
2018
|
-
return { id: row.id, meshId: row.mesh_id, title: row.title, goal: row.goal, status: row.status, source: row.source ?? undefined, createdAt: row.created_at, updatedAt: row.updated_at };
|
|
2036
|
+
return { id: row.id, meshId: row.mesh_id, title: row.title, goal: row.goal, status: row.status, source: row.source ?? undefined, closeCandidateEmittedAt: row.close_candidate_emitted_at ?? undefined, createdAt: row.created_at, updatedAt: row.updated_at };
|
|
2019
2037
|
}
|
|
2020
2038
|
|
|
2021
|
-
getMissions(meshId: string, statuses?: string[]): Array<{ id: string; meshId: string; title: string; goal: string; status: string; source?: string; createdAt: string; updatedAt: string }> {
|
|
2039
|
+
getMissions(meshId: string, statuses?: string[]): Array<{ id: string; meshId: string; title: string; goal: string; status: string; source?: string; closeCandidateEmittedAt?: string; createdAt: string; updatedAt: string }> {
|
|
2022
2040
|
let rows: Array<Record<string, string>>;
|
|
2023
2041
|
if (statuses?.length) {
|
|
2024
2042
|
const placeholders = statuses.map(() => '?').join(', ');
|
|
@@ -2030,7 +2048,21 @@ export class MeshRuntimeStore {
|
|
|
2030
2048
|
'SELECT * FROM mesh_missions WHERE mesh_id = ? ORDER BY updated_at DESC'
|
|
2031
2049
|
).all(meshId) as Array<Record<string, string>>;
|
|
2032
2050
|
}
|
|
2033
|
-
return rows.map(row => ({ id: row.id, meshId: row.mesh_id, title: row.title, goal: row.goal, status: row.status, source: row.source ?? undefined, createdAt: row.created_at, updatedAt: row.updated_at }));
|
|
2051
|
+
return rows.map(row => ({ id: row.id, meshId: row.mesh_id, title: row.title, goal: row.goal, status: row.status, source: row.source ?? undefined, closeCandidateEmittedAt: row.close_candidate_emitted_at ?? undefined, createdAt: row.created_at, updatedAt: row.updated_at }));
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
/**
|
|
2055
|
+
* G3: set/clear the mission_close_candidate idempotency marker. Passing an ISO
|
|
2056
|
+
* timestamp records that the all-terminal nudge has been emitted for this mission;
|
|
2057
|
+
* passing null clears it (mission returned to a non-terminal state, so a future
|
|
2058
|
+
* re-completion may nudge again). Touches ONLY this column — never the mission's
|
|
2059
|
+
* updated_at — so the marker write is invisible to updatedAt-ordered surfaces and
|
|
2060
|
+
* does not masquerade as mission activity. Returns rows changed (0 if no such mission).
|
|
2061
|
+
*/
|
|
2062
|
+
setMissionCloseCandidateEmittedAt(meshId: string, missionId: string, emittedAt: string | null): number {
|
|
2063
|
+
return this.db.prepare(
|
|
2064
|
+
'UPDATE mesh_missions SET close_candidate_emitted_at = ? WHERE mesh_id = ? AND id = ?'
|
|
2065
|
+
).run(emittedAt, meshId, missionId).changes;
|
|
2034
2066
|
}
|
|
2035
2067
|
|
|
2036
2068
|
/** Remove all missions for a mesh — mesh deletion / test cleanup. */
|