@adhdev/daemon-core 0.9.82-rc.495 → 0.9.82-rc.497

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.
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Idle-active-mission reminder.
3
+ *
4
+ * When a coordinator session becomes fully idle — no queue/direct work in flight,
5
+ * no pending coordinator events to drain — yet the mesh still carries `active`
6
+ * missions, inject a one-shot "[System] Coordinator idle with N active mission(s)"
7
+ * reminder into that coordinator session. This nudges the coordinator to decide a
8
+ * lingering mission's real outcome (close it, or continue it) rather than leaving it
9
+ * drifting in `active` after its work is actually done.
10
+ *
11
+ * Precedent: mesh-missions.ts `maybeEmitMissionCloseCandidate` — a terminal-edge,
12
+ * once-per-edge coordinator nudge with an idempotency marker. This mirrors that
13
+ * shape, but the "edge" is the coordinator's idle transition (fired by the fast-path
14
+ * in mesh-event-forwarding and the slow-path idle tick in mesh-reconcile-loop) and
15
+ * the idempotency marker is a time+mission-set debounce held in MeshRuntimeStore
16
+ * (getIdleReminderState / setIdleReminderState). Fully best-effort: a throw here must
17
+ * never break the drain/injection path that called it.
18
+ *
19
+ * Design invariants:
20
+ * - Only fires when the mesh has ≥1 `active` mission AND is fully idle. Fully idle =
21
+ * buildMeshActiveWork over queue + direct dispatches reports totalActiveCount === 0
22
+ * && generatingCount === 0. We intentionally do NOT probe remote node sessions here
23
+ * (expensive per-tick RPC): any non-terminal queue/direct work already makes
24
+ * totalActiveCount > 0, so the idle check is conservative — it suppresses the
25
+ * reminder whenever any work is outstanding, which is the safe direction.
26
+ * - NEVER transitions a mission's status. It only surfaces a hint; the coordinator
27
+ * decides via mesh_mission_upsert.
28
+ * - Debounced per mission-set. Re-fires only when the debounce window has elapsed OR
29
+ * the set of active mission ids changed (a mission was closed/added), so a mesh that
30
+ * stays idle with the same active missions is nudged at most once per window.
31
+ * - Opt-out via policy.idleActiveMissionReminder === false.
32
+ */
33
+ import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
34
+ import type { RepoMeshPolicy } from '../repo-mesh-types.js';
35
+ import { type MeshMissionRecord } from './mesh-missions.js';
36
+ /** Coordinator instance the reminder is injected into (the idle CLI session). */
37
+ type CoordinatorInstance = ReturnType<DaemonComponents['instanceManager']['getInstance']>;
38
+ /**
39
+ * Debounce window: while the mesh stays idle with the SAME active-mission set, re-fire
40
+ * the reminder at most once per this interval. A changed mission set bypasses the window.
41
+ */
42
+ export declare const IDLE_REMINDER_DEBOUNCE_MS = 300000;
43
+ /** Stable hash of the active-mission id set (sorted, joined) for debounce comparison. */
44
+ export declare function missionSetHash(missions: MeshMissionRecord[]): string;
45
+ /**
46
+ * Decide whether a reminder should fire this call, given the debounce marker. Pure so it
47
+ * is independently testable. Re-fires when there is no prior marker, the debounce window
48
+ * has elapsed, or the active-mission set changed since the last reminder.
49
+ */
50
+ export declare function shouldFireIdleReminder(last: {
51
+ emittedAt: number;
52
+ missionSetHash: string;
53
+ } | null, hash: string, now: number, debounceMs?: number): boolean;
54
+ /**
55
+ * Build the coordinator-facing reminder text. Deliberately terse — one line per mission
56
+ * (`title (id)`), NEVER the full goal (goals can be thousands of chars). When there are
57
+ * more than MISSION_LIST_CAP missions, name the first N and fold the remainder.
58
+ */
59
+ export declare function buildIdleReminderMessage(missions: MeshMissionRecord[]): string;
60
+ /**
61
+ * Fire-and-forget idle-active-mission reminder. Call this at a coordinator idle edge
62
+ * once the pending-event queue is empty (nothing else to inject). `coordinator` is the
63
+ * idle CLI session to inject into; `policy` is the mesh's policy (for the opt-out flag).
64
+ *
65
+ * Returns true iff a reminder was injected on this call.
66
+ */
67
+ export declare function maybeInjectIdleActiveMissionReminder(meshId: string, coordinator: CoordinatorInstance, policy: RepoMeshPolicy | undefined, now?: number): boolean;
68
+ export {};
@@ -13,6 +13,7 @@ export declare class MeshRuntimeStore {
13
13
  private readonly db;
14
14
  private readonly dbPath;
15
15
  private readonly migratedMeshIds;
16
+ private readonly idleReminderState;
16
17
  private fingerprintSweepCounter;
17
18
  private walWriteCounter;
18
19
  private toolCallLogCounter;
@@ -503,6 +504,22 @@ export declare class MeshRuntimeStore {
503
504
  * does not masquerade as mission activity. Returns rows changed (0 if no such mission).
504
505
  */
505
506
  setMissionCloseCandidateEmittedAt(meshId: string, missionId: string, emittedAt: string | null): number;
507
+ /**
508
+ * Read the last idle-active-mission-reminder debounce marker for a mesh, or null if
509
+ * none has fired this process. In-memory only (see idleReminderState) — best-effort
510
+ * spam guard for a coordinator nudge, intentionally not SQLite-backed.
511
+ */
512
+ getIdleReminderState(meshId: string): {
513
+ emittedAt: number;
514
+ missionSetHash: string;
515
+ } | null;
516
+ /** Record that an idle-active-mission reminder just fired for a mesh (debounce marker). */
517
+ setIdleReminderState(meshId: string, state: {
518
+ emittedAt: number;
519
+ missionSetHash: string;
520
+ }): void;
521
+ /** Clear the idle-reminder debounce marker for a mesh — mesh deletion / test cleanup. */
522
+ clearIdleReminderState(meshId: string): void;
506
523
  /** Remove all missions for a mesh — mesh deletion / test cleanup. */
507
524
  clearMissionsForMesh(meshId: string): number;
508
525
  /** Remove all pending-event rows (drained included) for a mesh — mesh deletion / test cleanup. */
@@ -264,6 +264,16 @@ export interface RepoMeshPolicy {
264
264
  * Defaults to 1 (allow one retry). Set to 0 to disable auto-recovery advice.
265
265
  */
266
266
  maxTaskRetries?: number;
267
+ /**
268
+ * When true (default), the daemon injects a one-shot "[System] Coordinator idle
269
+ * with N active mission(s)" reminder into an idle coordinator session whenever the
270
+ * mesh is fully idle (no queue/direct work in flight, no pending coordinator events)
271
+ * yet still has `active` missions. This nudges the coordinator to close or continue
272
+ * missions that would otherwise drift in `active` while their real outcome is decided.
273
+ * Idempotent/debounced per mission-set (see maybeInjectIdleActiveMissionReminder).
274
+ * Set to false to suppress the reminder entirely.
275
+ */
276
+ idleActiveMissionReminder?: boolean;
267
277
  }
268
278
  export interface RepoMeshRelatedRepo {
269
279
  /** Stable display label for an explicitly configured associated checkout. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.495",
3
+ "version": "0.9.82-rc.497",
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.495",
51
- "@adhdev/session-host-core": "0.9.82-rc.495",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.497",
51
+ "@adhdev/session-host-core": "0.9.82-rc.497",
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
@@ -280,6 +280,7 @@ export type { MeshWorkQueueEntry, MeshTaskStatus, MeshTaskMode, MeshTaskPriority
280
280
  export { buildCompactStaleDirectWorkSummary, buildMeshActiveWork, buildMeshActiveWorkSummary, collectPendingApprovals, classifyStaleDirectForPrune, pruneStaleDirectDispatches, PRUNABLE_ORPHAN_STALE_REASONS } from './mesh/mesh-active-work.js';
281
281
  export type { StaleDirectPruneClassification, StaleDirectPruneResult, PruneStaleDirectDispatchesOptions } from './mesh/mesh-active-work.js';
282
282
  export type { MeshActiveWorkRecord, MeshActiveWorkStatus, MeshActiveWorkSummary, MeshActiveWorkSource, MeshStaleDirectWorkSummary, MeshPendingApproval } from './mesh/mesh-active-work.js';
283
+ export { maybeInjectIdleActiveMissionReminder, shouldFireIdleReminder, buildIdleReminderMessage, missionSetHash, IDLE_REMINDER_DEBOUNCE_MS } from './mesh/mesh-idle-reminder.js';
283
284
  export { buildMeshAsyncRefineJobs, summarizeMeshAsyncRefineJobs, STALE_TERMINAL_REFINE_WINDOW_MS, RECENT_TERMINAL_REFINE_CAP } from './mesh/mesh-refine-status.js';
284
285
  export type { MeshAsyncRefineJobStatus, MeshAsyncRefineJobSummary, MeshAsyncRefineJobsSummary } from './mesh/mesh-refine-status.js';
285
286
  export { buildMeshMagiActivity, summarizeMeshMagiActivity, getMeshMagiActivityByGroup, STALE_MAGI_WINDOW_MS, RECENT_MAGI_CAP, MAGI_NEEDS_VERIFICATION_PREVIEW_CAP } from './mesh/mesh-magi-status.js';
@@ -7,6 +7,7 @@ import type { SessionRecoveryContext } from './mesh-ledger.js';
7
7
  import { updateSessionTaskStatus, enqueueTask, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, getActiveDirectDispatches, hasPendingDependents, getQueue } from './mesh-work-queue.js';
8
8
  import { markSessionDeliveriesTerminal, updateSessionDeliveryStatus } from './mesh-delivery-policy.js';
9
9
  import { MeshRuntimeStore, pruneMeshRuntimeRetention } from './mesh-runtime-store.js';
10
+ import { maybeInjectIdleActiveMissionReminder } from './mesh-idle-reminder.js';
10
11
  import { queuePendingMeshCoordinatorEvent, drainPendingMeshCoordinatorEvents, prunePendingMeshCoordinatorEventsRetention, readV2EnvelopeFromWire, type PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
11
12
  import type { ProviderInstance } from '../providers/provider-instance.js';
12
13
  import { resolveWorkerDelegateRouting, recordUnroutableDelegateEvent, isUnroutableDelegateRejection } from './mesh-routing.js';
@@ -1948,6 +1949,17 @@ export function setupMeshEventForwarding(components: DaemonComponents) {
1948
1949
  ...(forcePending ? { force: true } : {}),
1949
1950
  });
1950
1951
  }
1952
+ } else {
1953
+ // Nothing to flush → this idle edge left the coordinator with an
1954
+ // empty inbox. If the mesh is fully idle but still has active
1955
+ // missions, nudge (once/debounced) so a lingering mission is not
1956
+ // left drifting in 'active'. Suppressed when events WERE injected,
1957
+ // so we never pile a reminder on top of real completion traffic.
1958
+ maybeInjectIdleActiveMissionReminder(
1959
+ coordinatorMeshId,
1960
+ flushSource,
1961
+ getMesh(coordinatorMeshId)?.policy,
1962
+ );
1951
1963
  }
1952
1964
  } catch (e: any) {
1953
1965
  LOG.warn('MeshEvents', `Failed to auto-flush pending coordinator events: ${e?.message || e}`);
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Idle-active-mission reminder.
3
+ *
4
+ * When a coordinator session becomes fully idle — no queue/direct work in flight,
5
+ * no pending coordinator events to drain — yet the mesh still carries `active`
6
+ * missions, inject a one-shot "[System] Coordinator idle with N active mission(s)"
7
+ * reminder into that coordinator session. This nudges the coordinator to decide a
8
+ * lingering mission's real outcome (close it, or continue it) rather than leaving it
9
+ * drifting in `active` after its work is actually done.
10
+ *
11
+ * Precedent: mesh-missions.ts `maybeEmitMissionCloseCandidate` — a terminal-edge,
12
+ * once-per-edge coordinator nudge with an idempotency marker. This mirrors that
13
+ * shape, but the "edge" is the coordinator's idle transition (fired by the fast-path
14
+ * in mesh-event-forwarding and the slow-path idle tick in mesh-reconcile-loop) and
15
+ * the idempotency marker is a time+mission-set debounce held in MeshRuntimeStore
16
+ * (getIdleReminderState / setIdleReminderState). Fully best-effort: a throw here must
17
+ * never break the drain/injection path that called it.
18
+ *
19
+ * Design invariants:
20
+ * - Only fires when the mesh has ≥1 `active` mission AND is fully idle. Fully idle =
21
+ * buildMeshActiveWork over queue + direct dispatches reports totalActiveCount === 0
22
+ * && generatingCount === 0. We intentionally do NOT probe remote node sessions here
23
+ * (expensive per-tick RPC): any non-terminal queue/direct work already makes
24
+ * totalActiveCount > 0, so the idle check is conservative — it suppresses the
25
+ * reminder whenever any work is outstanding, which is the safe direction.
26
+ * - NEVER transitions a mission's status. It only surfaces a hint; the coordinator
27
+ * decides via mesh_mission_upsert.
28
+ * - Debounced per mission-set. Re-fires only when the debounce window has elapsed OR
29
+ * the set of active mission ids changed (a mission was closed/added), so a mesh that
30
+ * stays idle with the same active missions is nudged at most once per window.
31
+ * - Opt-out via policy.idleActiveMissionReminder === false.
32
+ */
33
+
34
+ import { LOG } from '../logging/logger.js';
35
+ import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
36
+ import type { RepoMeshPolicy } from '../repo-mesh-types.js';
37
+ import { MeshRuntimeStore } from './mesh-runtime-store.js';
38
+ import { getMeshMissions, type MeshMissionRecord } from './mesh-missions.js';
39
+ import { getQueue, getActiveDirectDispatches } from './mesh-work-queue.js';
40
+ import { readLedgerEntries } from './mesh-ledger.js';
41
+ import { buildMeshActiveWork } from './mesh-active-work.js';
42
+
43
+ /** Coordinator instance the reminder is injected into (the idle CLI session). */
44
+ type CoordinatorInstance = ReturnType<DaemonComponents['instanceManager']['getInstance']>;
45
+
46
+ /**
47
+ * Debounce window: while the mesh stays idle with the SAME active-mission set, re-fire
48
+ * the reminder at most once per this interval. A changed mission set bypasses the window.
49
+ */
50
+ export const IDLE_REMINDER_DEBOUNCE_MS = 300_000; // 5 minutes
51
+
52
+ /** How many missions to name individually before folding the rest into "and M more". */
53
+ const MISSION_LIST_CAP = 10;
54
+
55
+ /** Stable hash of the active-mission id set (sorted, joined) for debounce comparison. */
56
+ export function missionSetHash(missions: MeshMissionRecord[]): string {
57
+ return missions.map(m => m.id).sort().join(',');
58
+ }
59
+
60
+ /**
61
+ * Decide whether a reminder should fire this call, given the debounce marker. Pure so it
62
+ * is independently testable. Re-fires when there is no prior marker, the debounce window
63
+ * has elapsed, or the active-mission set changed since the last reminder.
64
+ */
65
+ export function shouldFireIdleReminder(
66
+ last: { emittedAt: number; missionSetHash: string } | null,
67
+ hash: string,
68
+ now: number,
69
+ debounceMs: number = IDLE_REMINDER_DEBOUNCE_MS,
70
+ ): boolean {
71
+ if (!last) return true;
72
+ if (now - last.emittedAt > debounceMs) return true;
73
+ return hash !== last.missionSetHash;
74
+ }
75
+
76
+ /**
77
+ * Build the coordinator-facing reminder text. Deliberately terse — one line per mission
78
+ * (`title (id)`), NEVER the full goal (goals can be thousands of chars). When there are
79
+ * more than MISSION_LIST_CAP missions, name the first N and fold the remainder.
80
+ */
81
+ export function buildIdleReminderMessage(missions: MeshMissionRecord[]): string {
82
+ const shown = missions.slice(0, MISSION_LIST_CAP);
83
+ const lines = shown.map(m => `- ${m.title} (${m.id})`);
84
+ const overflow = missions.length - shown.length;
85
+ if (overflow > 0) lines.push(`- …and ${overflow} more`);
86
+ return (
87
+ `[System] Coordinator idle with ${missions.length} active mission(s):\n`
88
+ + `${lines.join('\n')}\n`
89
+ + `The mesh has no work in flight. For each mission, decide its outcome: continue it `
90
+ + `(enqueue/dispatch the remaining work) or close it with `
91
+ + `mesh_mission_upsert(mission_id, status: "completed" | "abandoned"). `
92
+ + `Do not leave a finished mission in 'active'. This is a one-time reminder.`
93
+ );
94
+ }
95
+
96
+ /**
97
+ * Fire-and-forget idle-active-mission reminder. Call this at a coordinator idle edge
98
+ * once the pending-event queue is empty (nothing else to inject). `coordinator` is the
99
+ * idle CLI session to inject into; `policy` is the mesh's policy (for the opt-out flag).
100
+ *
101
+ * Returns true iff a reminder was injected on this call.
102
+ */
103
+ export function maybeInjectIdleActiveMissionReminder(
104
+ meshId: string,
105
+ coordinator: CoordinatorInstance,
106
+ policy: RepoMeshPolicy | undefined,
107
+ now: number = Date.now(),
108
+ ): boolean {
109
+ try {
110
+ if (!coordinator) return false;
111
+ // Opt-out: default is ON; only an explicit false disables it.
112
+ if (policy?.idleActiveMissionReminder === false) return false;
113
+
114
+ // Active missions gate — no active mission, nothing to remind about.
115
+ const activeMissions = getMeshMissions(meshId, ['active']);
116
+ if (activeMissions.length === 0) return false;
117
+
118
+ // Fully-idle gate — any non-terminal queue/direct work suppresses the reminder.
119
+ // We pass no `nodes`: totalActiveCount already counts pending/assigned queue tasks
120
+ // and un-acknowledged direct dispatches from the store alone, so the check stays
121
+ // cheap (no per-node status RPC) and conservative.
122
+ const summary = buildMeshActiveWork({
123
+ meshId,
124
+ queue: getQueue(meshId),
125
+ directDispatches: getActiveDirectDispatches(meshId),
126
+ ledgerEntries: readLedgerEntries(meshId, { tail: 200 }),
127
+ now,
128
+ }).summary;
129
+ if (summary.totalActiveCount !== 0 || summary.generatingCount !== 0) return false;
130
+
131
+ // Debounce — same mission set within the window is nudged at most once.
132
+ const store = MeshRuntimeStore.getInstance();
133
+ const hash = missionSetHash(activeMissions);
134
+ const last = store.getIdleReminderState(meshId);
135
+ if (!shouldFireIdleReminder(last, hash, now)) return false;
136
+
137
+ const message = buildIdleReminderMessage(activeMissions);
138
+ coordinator.onEvent('send_message', {
139
+ input: { text: message, textFallback: message },
140
+ });
141
+ // Mark AFTER a successful inject so a mid-inject throw retries next edge rather
142
+ // than silently swallowing the only reminder.
143
+ store.setIdleReminderState(meshId, { emittedAt: now, missionSetHash: hash });
144
+ LOG.info(
145
+ 'MeshIdleReminder',
146
+ `Injected idle reminder for mesh ${meshId} (${activeMissions.length} active mission(s), fully idle)`,
147
+ );
148
+ return true;
149
+ } catch (e: any) {
150
+ LOG.warn('MeshIdleReminder', `maybeInjectIdleActiveMissionReminder failed for mesh ${meshId}: ${e?.message || e}`);
151
+ return false;
152
+ }
153
+ }
@@ -42,7 +42,8 @@
42
42
 
43
43
  import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
44
44
  import { loadConfig } from '../config/config.js';
45
- import { listMeshes } from '../config/mesh-config.js';
45
+ import { listMeshes, getMesh } from '../config/mesh-config.js';
46
+ import { maybeInjectIdleActiveMissionReminder } from './mesh-idle-reminder.js';
46
47
  import { LOG, getLogLevel } from '../logging/logger.js';
47
48
  import { drainPendingMeshCoordinatorEvents, getPendingMeshCoordinatorEvents, buildPendingEventFingerprint, queuePendingMeshCoordinatorEvent } from './mesh-events-pending.js';
48
49
  import type { PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
@@ -1407,7 +1408,19 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
1407
1408
  // O(1) guard: skip the drain entirely when the queue is empty.
1408
1409
  if (store) {
1409
1410
  try {
1410
- if (store.pendingEventCount(meshId) === 0) continue;
1411
+ if (store.pendingEventCount(meshId) === 0) {
1412
+ // An idle coordinator is present (the no-idle short-circuit already
1413
+ // `continue`d above) and the pending queue is empty → this mesh is at a
1414
+ // fully-idle edge with nothing to inject. Nudge (once/debounced) if it
1415
+ // still has active missions but no work in flight, so a lingering mission
1416
+ // is not left drifting in 'active'. Best-effort; never blocks the loop.
1417
+ maybeInjectIdleActiveMissionReminder(
1418
+ meshId,
1419
+ targetCoordinators[0].instance,
1420
+ getMesh(meshId)?.policy,
1421
+ );
1422
+ continue;
1423
+ }
1411
1424
  } catch { /* fall through to drain */ }
1412
1425
  }
1413
1426
 
@@ -198,7 +198,14 @@ export async function collectLiveNodesWithSessions(
198
198
  let statusResult: unknown;
199
199
  try {
200
200
  if (isLocalNode) {
201
- statusResult = await components.commandHandler.handle('get_status_metadata', {});
201
+ // get_status_metadata is a LOW-family registry command, not a
202
+ // DaemonCommandHandler switch case — so it must be dispatched
203
+ // through the router (which consults lowFamilyRegistry before
204
+ // delegating to commandHandler). Calling commandHandler.handle()
205
+ // directly falls through to `Unknown command: get_status_metadata`
206
+ // and leaves the local node's live-session list empty in the mesh
207
+ // graph. See router.execute() / low-family/index.ts.
208
+ statusResult = await components.router.execute('get_status_metadata', {}, 'mesh');
202
209
  } else if (dispatchMeshCommand) {
203
210
  statusResult = await dispatchMeshCommand(nodeDaemonId, 'get_status_metadata', {});
204
211
  } else {
@@ -116,6 +116,13 @@ export class MeshRuntimeStore {
116
116
  private readonly db: DatabaseHandle;
117
117
  private readonly dbPath: string;
118
118
  private readonly migratedMeshIds = new Set<string>();
119
+ // Idle-active-mission-reminder debounce (mesh-idle-reminder.ts). In-memory only:
120
+ // this is a spam guard for a best-effort coordinator nudge, so a daemon restart
121
+ // resetting it (at most one extra reminder) is harmless — no SQLite persistence
122
+ // is warranted. Keyed by meshId; the value records when the last reminder fired
123
+ // and the hash of the active-mission id set it named, so a changed mission set
124
+ // re-fires before the time window elapses.
125
+ private readonly idleReminderState = new Map<string, { emittedAt: number; missionSetHash: string }>();
119
126
  private fingerprintSweepCounter = 0;
120
127
  private walWriteCounter = 0;
121
128
  // Independent cadence for the tool-call-log sweep. Must NOT share walWriteCounter:
@@ -2164,6 +2171,25 @@ export class MeshRuntimeStore {
2164
2171
  ).run(emittedAt, meshId, missionId).changes;
2165
2172
  }
2166
2173
 
2174
+ /**
2175
+ * Read the last idle-active-mission-reminder debounce marker for a mesh, or null if
2176
+ * none has fired this process. In-memory only (see idleReminderState) — best-effort
2177
+ * spam guard for a coordinator nudge, intentionally not SQLite-backed.
2178
+ */
2179
+ getIdleReminderState(meshId: string): { emittedAt: number; missionSetHash: string } | null {
2180
+ return this.idleReminderState.get(meshId) ?? null;
2181
+ }
2182
+
2183
+ /** Record that an idle-active-mission reminder just fired for a mesh (debounce marker). */
2184
+ setIdleReminderState(meshId: string, state: { emittedAt: number; missionSetHash: string }): void {
2185
+ this.idleReminderState.set(meshId, state);
2186
+ }
2187
+
2188
+ /** Clear the idle-reminder debounce marker for a mesh — mesh deletion / test cleanup. */
2189
+ clearIdleReminderState(meshId: string): void {
2190
+ this.idleReminderState.delete(meshId);
2191
+ }
2192
+
2167
2193
  /** Remove all missions for a mesh — mesh deletion / test cleanup. */
2168
2194
  clearMissionsForMesh(meshId: string): number {
2169
2195
  return this.db.prepare('DELETE FROM mesh_missions WHERE mesh_id = ?').run(meshId).changes;
@@ -359,6 +359,16 @@ export interface RepoMeshPolicy {
359
359
  * Defaults to 1 (allow one retry). Set to 0 to disable auto-recovery advice.
360
360
  */
361
361
  maxTaskRetries?: number;
362
+ /**
363
+ * When true (default), the daemon injects a one-shot "[System] Coordinator idle
364
+ * with N active mission(s)" reminder into an idle coordinator session whenever the
365
+ * mesh is fully idle (no queue/direct work in flight, no pending coordinator events)
366
+ * yet still has `active` missions. This nudges the coordinator to close or continue
367
+ * missions that would otherwise drift in `active` while their real outcome is decided.
368
+ * Idempotent/debounced per mission-set (see maybeInjectIdleActiveMissionReminder).
369
+ * Set to false to suppress the reminder entirely.
370
+ */
371
+ idleActiveMissionReminder?: boolean;
362
372
  }
363
373
 
364
374
  export interface RepoMeshRelatedRepo {
@@ -478,6 +488,9 @@ export const DEFAULT_MESH_POLICY: RepoMeshPolicy = {
478
488
  magiSessionCleanup: 'stop_and_delete',
479
489
  autoFastForward: { enabled: true },
480
490
  maxTaskRetries: 1,
491
+ // Nudge the coordinator when the mesh is fully idle but active missions linger,
492
+ // so a mission is never left drifting in `active` after its work is really done.
493
+ idleActiveMissionReminder: true,
481
494
  };
482
495
 
483
496
  // ─── Policy normalization (single source of truth) ──────────────────────────