@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.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +125 -7
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +120 -7
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-idle-reminder.d.ts +68 -0
- package/dist/mesh/mesh-runtime-store.d.ts +17 -0
- package/dist/repo-mesh-types.d.ts +10 -0
- package/package.json +3 -3
- package/src/index.ts +1 -0
- package/src/mesh/mesh-event-forwarding.ts +12 -0
- package/src/mesh/mesh-idle-reminder.ts +153 -0
- package/src/mesh/mesh-reconcile-loop.ts +15 -2
- package/src/mesh/mesh-remote-event-pull.ts +8 -1
- package/src/mesh/mesh-runtime-store.ts +26 -0
- package/src/repo-mesh-types.ts +13 -0
|
@@ -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.
|
|
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.
|
|
51
|
-
"@adhdev/session-host-core": "0.9.82-rc.
|
|
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)
|
|
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
|
-
|
|
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;
|
package/src/repo-mesh-types.ts
CHANGED
|
@@ -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) ──────────────────────────
|