@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.
@@ -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.469",
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.469",
51
- "@adhdev/session-host-core": "0.9.82-rc.469",
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> = {
@@ -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. */