@adhdev/daemon-core 1.0.8 → 1.0.10-rc.1
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.js +81 -24
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +81 -24
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-events-pending.d.ts +2 -0
- package/dist/repo-mesh-types.d.ts +36 -0
- package/dist/status/builders.d.ts +12 -3
- package/package.json +3 -3
- package/src/commands/cli-manager.ts +20 -0
- package/src/mesh/coordinator-prompt.ts +4 -0
- package/src/mesh/mesh-events-pending.ts +26 -1
- package/src/mesh/mesh-queue-assignment.ts +14 -1
- package/src/mesh/mesh-reconcile-loop.ts +16 -9
- package/src/providers/cli-provider-instance.ts +9 -0
- package/src/repo-mesh-types.ts +58 -0
- package/src/status/builders.ts +51 -7
|
@@ -122,6 +122,8 @@ export declare function getMeshV2DrainCounters(): Readonly<typeof meshV2DrainCou
|
|
|
122
122
|
export declare function __resetMeshV2DrainCountersForTests(): void;
|
|
123
123
|
/** Test helper: clear the one-shot WARN dedup set. */
|
|
124
124
|
export declare function __resetMeshV2WarnDedupForTests(): void;
|
|
125
|
+
/** Test helper: reset the one-shot SQLite-drain-failure WARN guard. */
|
|
126
|
+
export declare function __resetSqlitePendingDrainWarnForTests(): void;
|
|
125
127
|
export declare function readRefineJobId(event: {
|
|
126
128
|
metadataEvent?: Record<string, unknown>;
|
|
127
129
|
} | Record<string, unknown>): string;
|
|
@@ -304,6 +304,27 @@ export interface RepoMeshPolicy {
|
|
|
304
304
|
* Set to false to suppress the reminder entirely.
|
|
305
305
|
*/
|
|
306
306
|
idleActiveMissionReminder?: boolean;
|
|
307
|
+
/**
|
|
308
|
+
* Whether a coordinator-dispatched worker's routine idle/completion push
|
|
309
|
+
* notification is delivered to the owner every time ('always', the default —
|
|
310
|
+
* historical behavior, byte-for-byte unchanged), or auto-silenced for the single
|
|
311
|
+
* completion that follows a coordinator dispatch ('auto_silent_on_dispatch').
|
|
312
|
+
*
|
|
313
|
+
* When 'auto_silent_on_dispatch', dispatching a task to a worker arms a ONE-SHOT
|
|
314
|
+
* transient mute (settings.silentNextIdlePush) on that worker session. The next
|
|
315
|
+
* agent:generating_completed rides a muted status snapshot, so the server push
|
|
316
|
+
* gate (which already honors session.muted) suppresses ONLY that routine
|
|
317
|
+
* completion push; the flag auto-clears afterward so subsequent turns notify
|
|
318
|
+
* normally, and it is status-gated to `idle` so an approval-needed / long-running
|
|
319
|
+
* / failure notification in the SAME turn is NEVER suppressed. A stale one-shot
|
|
320
|
+
* self-expires (SILENT_IDLE_PUSH_TTL_MS) so a worker that never completes cannot
|
|
321
|
+
* strand its session permanently muted.
|
|
322
|
+
*
|
|
323
|
+
* Coordinator-spawned HIDDEN workers already default muted (they emit no owner
|
|
324
|
+
* push), so this only changes behavior for VISIBLE / manually-unmuted delegated
|
|
325
|
+
* workers. Defaults to 'always' — zero behavior change unless the mesh opts in.
|
|
326
|
+
*/
|
|
327
|
+
coordinatorIdlePushPolicy?: 'always' | 'auto_silent_on_dispatch';
|
|
307
328
|
}
|
|
308
329
|
export interface RepoMeshRelatedRepo {
|
|
309
330
|
/** Stable display label for an explicitly configured associated checkout. */
|
|
@@ -413,6 +434,21 @@ export interface RepoMeshNodePolicy {
|
|
|
413
434
|
initSubmodulesOnClone?: boolean;
|
|
414
435
|
}
|
|
415
436
|
export declare const DEFAULT_MESH_POLICY: RepoMeshPolicy;
|
|
437
|
+
/**
|
|
438
|
+
* TTL for the one-shot silent-idle-push arm (settings.silentNextIdlePushArmedAt).
|
|
439
|
+
* A dispatched worker whose completion never arrives (crash, offline, dropped event)
|
|
440
|
+
* must not leave its session muted forever — after this window the arm is treated as
|
|
441
|
+
* expired and stops muting, so the session self-heals to normal notification behavior.
|
|
442
|
+
* Ten minutes comfortably covers a long delegated turn while still bounding the leak.
|
|
443
|
+
*/
|
|
444
|
+
export declare const SILENT_IDLE_PUSH_TTL_MS: number;
|
|
445
|
+
/**
|
|
446
|
+
* Resolve the effective coordinator idle-push policy from a mesh policy, defaulting
|
|
447
|
+
* to 'always' (notify) for a missing/invalid value so a typo can never silently
|
|
448
|
+
* disable owner completion notifications. Mirrors the other policy resolvers so the
|
|
449
|
+
* arm site and any future consumer read one source of truth.
|
|
450
|
+
*/
|
|
451
|
+
export declare function resolveCoordinatorIdlePushPolicy(meshPolicy?: Pick<RepoMeshPolicy, 'coordinatorIdlePushPolicy'> | null): 'always' | 'auto_silent_on_dispatch';
|
|
416
452
|
/**
|
|
417
453
|
* Resolve a magiSessionCleanup policy value (string mode, boolean shorthand,
|
|
418
454
|
* or unset) to a canonical mode. Unset → the default ('stop_and_delete', ON).
|
|
@@ -29,10 +29,19 @@ export declare function isCoordinatorSpawnedHiddenWorker(settings: Record<string
|
|
|
29
29
|
export declare function resolveSurfaceHidden(settings: Record<string, any> | undefined): boolean;
|
|
30
30
|
/**
|
|
31
31
|
* A session is muted (attention side-effects suppressed, but still shown in the
|
|
32
|
-
* list) when the user muted it, OR a coordinator-spawned worker defaults muted
|
|
33
|
-
*
|
|
32
|
+
* list) when the user muted it, OR a coordinator-spawned worker defaults muted, OR
|
|
33
|
+
* a one-shot silent-idle-push arm is active for this completion snapshot (see
|
|
34
|
+
* isSilentIdlePushArmActive — status-gated + TTL-bounded so approval/failure pushes
|
|
35
|
+
* and never-completing workers are unaffected). userMuted === false is an explicit
|
|
36
|
+
* un-mute overriding the worker/policy default — but NOT the one-shot arm, which is a
|
|
37
|
+
* coordinator-driven per-completion decision that a stale manual un-mute must not
|
|
38
|
+
* defeat.
|
|
39
|
+
*
|
|
40
|
+
* `status` is the session's resolved status for the snapshot being built; omit it
|
|
41
|
+
* (undefined) for callers that only have mesh-attribution fields (the cloud mirror),
|
|
42
|
+
* where the one-shot never applies.
|
|
34
43
|
*/
|
|
35
|
-
export declare function resolveMuted(settings: Record<string, any> | undefined): boolean;
|
|
44
|
+
export declare function resolveMuted(settings: Record<string, any> | undefined, status?: string): boolean;
|
|
36
45
|
/**
|
|
37
46
|
* Pure resolver for a mesh-spawned worker session's dashboard hide+mute state,
|
|
38
47
|
* shared by the standalone/local `buildSessionEntries` path (via the wrappers
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.10-rc.1",
|
|
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": "
|
|
51
|
-
"@adhdev/session-host-core": "
|
|
50
|
+
"@adhdev/mesh-shared": "1.0.10-rc.1",
|
|
51
|
+
"@adhdev/session-host-core": "1.0.10-rc.1",
|
|
52
52
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
53
53
|
"ajv": "^8.20.0",
|
|
54
54
|
"ajv-formats": "^3.0.1",
|
|
@@ -1739,6 +1739,26 @@ export class DaemonCliManager {
|
|
|
1739
1739
|
if (stampResult && stampResult.stamped === false && stampResult.reason === 'task_already_stamped_on_live_instance') {
|
|
1740
1740
|
throw new Error(`Refusing duplicate mesh dispatch: task ${meshContext.taskId} is already being worked by a live session on this daemon`);
|
|
1741
1741
|
}
|
|
1742
|
+
// COORDINATOR-SILENT-IDLE (opt-in): the coordinator's mesh policy is
|
|
1743
|
+
// 'auto_silent_on_dispatch', so arm a ONE-SHOT transient mute on THIS
|
|
1744
|
+
// worker session for the single completion that follows this dispatch.
|
|
1745
|
+
// resolveMuted honors it only for an idle snapshot within
|
|
1746
|
+
// SILENT_IDLE_PUSH_TTL_MS, so the routine completion push is suppressed
|
|
1747
|
+
// while approval/failure/long-running notifications (non-idle status) and
|
|
1748
|
+
// a worker that never completes (TTL expiry) are unaffected. Re-armed on
|
|
1749
|
+
// every dispatch (fresh armedAt) and one-shot-cleared at the completion
|
|
1750
|
+
// emission (emitGeneratingCompleted) so subsequent turns notify normally.
|
|
1751
|
+
if ((meshContext as any).silentIdlePush === true) {
|
|
1752
|
+
try {
|
|
1753
|
+
const workerInst = this.deps.getInstanceManager()?.getInstance(targetInstanceId);
|
|
1754
|
+
if (workerInst && typeof workerInst.updateSettings === 'function') {
|
|
1755
|
+
workerInst.updateSettings({
|
|
1756
|
+
silentNextIdlePush: true,
|
|
1757
|
+
silentNextIdlePushArmedAt: Date.now(),
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1760
|
+
} catch { /* best-effort — silent-idle is a notification nicety, never fail the dispatch */ }
|
|
1761
|
+
}
|
|
1742
1762
|
}
|
|
1743
1763
|
const input = normalizeInputEnvelope(args?.input ? { input: args.input } : args);
|
|
1744
1764
|
const provider = this.providerLoader.resolve(agentType) || this.providerLoader.getMeta(agentType);
|
|
@@ -724,6 +724,10 @@ function buildPolicySection(policy: RepoMeshPolicy): string {
|
|
|
724
724
|
|
|
725
725
|
rules.push(`- Maximum **${policy.maxParallelTasks}** tasks running in parallel`);
|
|
726
726
|
|
|
727
|
+
if (policy.coordinatorIdlePushPolicy === 'auto_silent_on_dispatch') {
|
|
728
|
+
rules.push('- Delegated-worker completions are **auto-silenced**: the routine idle/completion push for a task you dispatch is suppressed once (approval-needed, failure, and long-running alerts still notify the owner normally)');
|
|
729
|
+
}
|
|
730
|
+
|
|
727
731
|
return `## Policy\n${rules.join('\n')}`;
|
|
728
732
|
}
|
|
729
733
|
|
|
@@ -272,6 +272,22 @@ export function __resetMeshV2WarnDedupForTests(): void {
|
|
|
272
272
|
warnedV2Violations.clear();
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
// Log-once guard for the SQLite pending-event drain failure. When better-sqlite3 is
|
|
276
|
+
// unavailable (native load failure on a clean npx install with no build tools), the
|
|
277
|
+
// SQLite drain throws every drain call — once per mesh, every reconcile tick (~4s) —
|
|
278
|
+
// while the JSONL fallback below keeps delivering events correctly. That is the
|
|
279
|
+
// intended degraded path, so the operator only needs to be told ONCE that SQLite is
|
|
280
|
+
// down; repeating the WARN every cycle floods the logs (mirrors MeshRuntimeStore's
|
|
281
|
+
// loggedGetInstanceFailure guard). New/different drain errors still surface: the
|
|
282
|
+
// guard keys on nothing beyond "already warned", but the first occurrence is always
|
|
283
|
+
// shown at WARN and every occurrence stays at debug.
|
|
284
|
+
let loggedSqlitePendingDrainFailure = false;
|
|
285
|
+
|
|
286
|
+
/** Test helper: reset the one-shot SQLite-drain-failure WARN guard. */
|
|
287
|
+
export function __resetSqlitePendingDrainWarnForTests(): void {
|
|
288
|
+
loggedSqlitePendingDrainFailure = false;
|
|
289
|
+
}
|
|
290
|
+
|
|
275
291
|
/**
|
|
276
292
|
* Resolve the drainer's CoordinatorIdentity for v2 routing from the daemon-id
|
|
277
293
|
* argument the (untouchable) reconcile-loop already passes. The daemon ids are the
|
|
@@ -1216,7 +1232,16 @@ export function drainPendingMeshCoordinatorEvents(
|
|
|
1216
1232
|
// failure here means the JSONL copy is emptied while the SQLite rows
|
|
1217
1233
|
// survive undrained, so the next drain re-delivers the same events to the
|
|
1218
1234
|
// coordinator (duplicate refine:completed etc.) with no diagnostic trail.
|
|
1219
|
-
|
|
1235
|
+
// Log-once (then debug): when better-sqlite3 is unavailable this throws every
|
|
1236
|
+
// tick × mesh count, and the JSONL fallback below is the intended degraded
|
|
1237
|
+
// path — so warn ONCE, then keep the per-cycle repetition at debug to stop the
|
|
1238
|
+
// log flood without hiding the diagnosis (see loggedSqlitePendingDrainFailure).
|
|
1239
|
+
if (!loggedSqlitePendingDrainFailure) {
|
|
1240
|
+
loggedSqlitePendingDrainFailure = true;
|
|
1241
|
+
LOG.warn('MeshEvents', `SQLite pending-event drain failed for mesh ${meshId}; JSONL fallback only (further occurrences at debug): ${e?.message || e}`);
|
|
1242
|
+
} else {
|
|
1243
|
+
LOG.debug('MeshEvents', `SQLite pending-event drain failed for mesh ${meshId}; JSONL fallback only: ${e?.message || e}`);
|
|
1244
|
+
}
|
|
1220
1245
|
}
|
|
1221
1246
|
|
|
1222
1247
|
// JSONL (legacy / migration path) — always drained alongside SQLite.
|
|
@@ -13,7 +13,7 @@ import { createSessionDelivery, updateSessionDeliveryStatus } from './mesh-deliv
|
|
|
13
13
|
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
14
14
|
import { traceMeshEventDrop } from './mesh-event-trace.js';
|
|
15
15
|
import { awaitWithWarmupDeadline, resolveWarmupDeadlineOpts } from './mesh-warmup-deadline.js';
|
|
16
|
-
import { resolveDelegatedWorkerAutoApprove, resolveProviderMaxParallel, resolveNodeSchedulingPriority, normalizeMeshSchedulingStrategy, resolveMaxParallelTasks, resolveMaxReadonlyParallelTasks } from '../repo-mesh-types.js';
|
|
16
|
+
import { resolveDelegatedWorkerAutoApprove, resolveProviderMaxParallel, resolveNodeSchedulingPriority, normalizeMeshSchedulingStrategy, resolveMaxParallelTasks, resolveMaxReadonlyParallelTasks, resolveCoordinatorIdlePushPolicy } from '../repo-mesh-types.js';
|
|
17
17
|
import type { RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
|
|
18
18
|
import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, canonicalDaemonId, expandDaemonIdForms, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, sessionIdsEquivalent, normalizeNodeCapabilitySlots, isMeshTaskDifficulty, withStatusProbeMarker, type MeshNodeIdentified, type NodeCapabilitySlot, type MeshTaskDifficulty } from '@adhdev/mesh-shared';
|
|
19
19
|
import { resolveNodeCapabilitySlots } from './mesh-node-slots.js';
|
|
@@ -612,6 +612,17 @@ export function tryAssignQueueTask(
|
|
|
612
612
|
// failure / cancel / reclaim).
|
|
613
613
|
beginTaskDispatchInFlight(meshId, task.id);
|
|
614
614
|
|
|
615
|
+
// COORDINATOR-SILENT-IDLE (opt-in): when the mesh policy is
|
|
616
|
+
// 'auto_silent_on_dispatch', carry a one-shot silent-idle-push signal in the
|
|
617
|
+
// dispatch meshContext. The worker stamps settings.silentNextIdlePush on its own
|
|
618
|
+
// live session (cli-manager send_chat), so the SINGLE completion that follows this
|
|
619
|
+
// dispatch rides a muted status snapshot and the server suppresses ONLY that
|
|
620
|
+
// routine idle push. Status-gated + TTL-bounded downstream (see resolveMuted), so
|
|
621
|
+
// approval/failure/long-running notifications and never-completing workers are
|
|
622
|
+
// unaffected. Default 'always' → this stays undefined and nothing changes.
|
|
623
|
+
const silentIdlePushOnDispatch =
|
|
624
|
+
resolveCoordinatorIdlePushPolicy(mesh?.policy) === 'auto_silent_on_dispatch';
|
|
625
|
+
|
|
615
626
|
// CANON-IDENTITY: read the remote daemon id through the normalizing helper so a node
|
|
616
627
|
// whose daemonId arrives in a non-top-level-camelCase serialization form (daemon_id /
|
|
617
628
|
// machine.daemonId / lastProbe.machine.daemon_id / …) is still recognized as remote.
|
|
@@ -648,6 +659,7 @@ export function tryAssignQueueTask(
|
|
|
648
659
|
...(typeof task.dispatchNonce === 'number' ? { dispatchNonce: task.dispatchNonce } : {}),
|
|
649
660
|
...(localDaemonIdForDispatch ? { coordinatorDaemonId: localDaemonIdForDispatch } : {}),
|
|
650
661
|
...(sourceCoordinatorSessionId ? { coordinatorSessionId: sourceCoordinatorSessionId } : {}),
|
|
662
|
+
...(silentIdlePushOnDispatch ? { silentIdlePush: true } : {}),
|
|
651
663
|
},
|
|
652
664
|
}),
|
|
653
665
|
{
|
|
@@ -730,6 +742,7 @@ export function tryAssignQueueTask(
|
|
|
730
742
|
...(typeof task.dispatchNonce === 'number' ? { dispatchNonce: task.dispatchNonce } : {}),
|
|
731
743
|
...(localCoordinatorDaemonId() ? { coordinatorDaemonId: localCoordinatorDaemonId() } : {}),
|
|
732
744
|
...(readNonEmptyString(task.sourceCoordinatorSessionId) ? { coordinatorSessionId: readNonEmptyString(task.sourceCoordinatorSessionId) } : {}),
|
|
745
|
+
...(silentIdlePushOnDispatch ? { silentIdlePush: true } : {}),
|
|
733
746
|
},
|
|
734
747
|
}),
|
|
735
748
|
{
|
|
@@ -1277,18 +1277,25 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
|
|
|
1277
1277
|
// indexed status column, so an idle mesh costs one cheap query per tick.
|
|
1278
1278
|
// claimNextQueueTask is atomic, so racing the event-driven path can only have
|
|
1279
1279
|
// one winner; double-claiming is impossible.
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1280
|
+
// The mesh work-queue is SQLite-only (claimNextQueueTask/countQueueStatus all go
|
|
1281
|
+
// through MeshRuntimeStore — there is no JSONL fallback for the QUEUE, only for
|
|
1282
|
+
// pending EVENTS drained in PHASE 1/2). So when SQLite is unavailable (store
|
|
1283
|
+
// undefined, e.g. better-sqlite3 native load failure on a clean install),
|
|
1284
|
+
// triggerMeshQueue can do no useful work — it would only re-throw inside the
|
|
1285
|
+
// store and emit the WARN below every tick × mesh count, flooding the logs. Skip
|
|
1286
|
+
// the phase entirely in that case; the JSONL event-delivery path is unaffected.
|
|
1287
|
+
if (store) {
|
|
1288
|
+
for (const mesh of listMeshes()) {
|
|
1289
|
+
const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
|
|
1290
|
+
if (!daemonHostsMesh(mesh, selfIds)) continue;
|
|
1284
1291
|
try {
|
|
1285
1292
|
if (store.pendingQueueTaskCount(mesh.id) === 0) continue;
|
|
1286
1293
|
} catch { /* fall through and let triggerMeshQueue decide */ }
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1294
|
+
try {
|
|
1295
|
+
await triggerMeshQueue(components, mesh.id);
|
|
1296
|
+
} catch (e: any) {
|
|
1297
|
+
LOG.warn('MeshReconcile', `Pending-claim recovery trigger failed for mesh ${mesh.id}: ${e?.message || e}`);
|
|
1298
|
+
}
|
|
1292
1299
|
}
|
|
1293
1300
|
}
|
|
1294
1301
|
|
|
@@ -2881,6 +2881,15 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2881
2881
|
...(opts.evidenceLevel !== undefined ? { evidenceLevel: opts.evidenceLevel } : {}),
|
|
2882
2882
|
...(opts.completionDiagnostic !== undefined ? { completionDiagnostic: opts.completionDiagnostic } : {}),
|
|
2883
2883
|
});
|
|
2884
|
+
// COORDINATOR-SILENT-IDLE one-shot consume: this completion's snapshot rides the
|
|
2885
|
+
// armed mute (resolveMuted honors settings.silentNextIdlePush for the idle status
|
|
2886
|
+
// above), so the routine idle push is suppressed for THIS completion only. Clear
|
|
2887
|
+
// the arm now — AFTER the completion event was pushed — so the NEXT turn notifies
|
|
2888
|
+
// normally. Redundant with the TTL leak-guard, but the deterministic clear is the
|
|
2889
|
+
// primary one-shot mechanism; the TTL only covers a worker that never completes.
|
|
2890
|
+
if (this.settings?.silentNextIdlePush === true) {
|
|
2891
|
+
this.updateSettings({ silentNextIdlePush: undefined, silentNextIdlePushArmedAt: undefined });
|
|
2892
|
+
}
|
|
2884
2893
|
}
|
|
2885
2894
|
|
|
2886
2895
|
/**
|
package/src/repo-mesh-types.ts
CHANGED
|
@@ -399,6 +399,27 @@ export interface RepoMeshPolicy {
|
|
|
399
399
|
* Set to false to suppress the reminder entirely.
|
|
400
400
|
*/
|
|
401
401
|
idleActiveMissionReminder?: boolean;
|
|
402
|
+
/**
|
|
403
|
+
* Whether a coordinator-dispatched worker's routine idle/completion push
|
|
404
|
+
* notification is delivered to the owner every time ('always', the default —
|
|
405
|
+
* historical behavior, byte-for-byte unchanged), or auto-silenced for the single
|
|
406
|
+
* completion that follows a coordinator dispatch ('auto_silent_on_dispatch').
|
|
407
|
+
*
|
|
408
|
+
* When 'auto_silent_on_dispatch', dispatching a task to a worker arms a ONE-SHOT
|
|
409
|
+
* transient mute (settings.silentNextIdlePush) on that worker session. The next
|
|
410
|
+
* agent:generating_completed rides a muted status snapshot, so the server push
|
|
411
|
+
* gate (which already honors session.muted) suppresses ONLY that routine
|
|
412
|
+
* completion push; the flag auto-clears afterward so subsequent turns notify
|
|
413
|
+
* normally, and it is status-gated to `idle` so an approval-needed / long-running
|
|
414
|
+
* / failure notification in the SAME turn is NEVER suppressed. A stale one-shot
|
|
415
|
+
* self-expires (SILENT_IDLE_PUSH_TTL_MS) so a worker that never completes cannot
|
|
416
|
+
* strand its session permanently muted.
|
|
417
|
+
*
|
|
418
|
+
* Coordinator-spawned HIDDEN workers already default muted (they emit no owner
|
|
419
|
+
* push), so this only changes behavior for VISIBLE / manually-unmuted delegated
|
|
420
|
+
* workers. Defaults to 'always' — zero behavior change unless the mesh opts in.
|
|
421
|
+
*/
|
|
422
|
+
coordinatorIdlePushPolicy?: 'always' | 'auto_silent_on_dispatch';
|
|
402
423
|
}
|
|
403
424
|
|
|
404
425
|
export interface RepoMeshRelatedRepo {
|
|
@@ -540,8 +561,36 @@ export const DEFAULT_MESH_POLICY: RepoMeshPolicy = {
|
|
|
540
561
|
// Nudge the coordinator when the mesh is fully idle but active missions linger,
|
|
541
562
|
// so a mission is never left drifting in `active` after its work is really done.
|
|
542
563
|
idleActiveMissionReminder: true,
|
|
564
|
+
// Conservative default: every coordinator-dispatched worker completion still
|
|
565
|
+
// notifies the owner. Opt into 'auto_silent_on_dispatch' to one-shot-silence the
|
|
566
|
+
// routine idle push for a coordinator-driven task (approval/failure notifications
|
|
567
|
+
// are never affected — see coordinatorIdlePushPolicy).
|
|
568
|
+
coordinatorIdlePushPolicy: 'always',
|
|
543
569
|
};
|
|
544
570
|
|
|
571
|
+
/**
|
|
572
|
+
* TTL for the one-shot silent-idle-push arm (settings.silentNextIdlePushArmedAt).
|
|
573
|
+
* A dispatched worker whose completion never arrives (crash, offline, dropped event)
|
|
574
|
+
* must not leave its session muted forever — after this window the arm is treated as
|
|
575
|
+
* expired and stops muting, so the session self-heals to normal notification behavior.
|
|
576
|
+
* Ten minutes comfortably covers a long delegated turn while still bounding the leak.
|
|
577
|
+
*/
|
|
578
|
+
export const SILENT_IDLE_PUSH_TTL_MS = 10 * 60 * 1000;
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Resolve the effective coordinator idle-push policy from a mesh policy, defaulting
|
|
582
|
+
* to 'always' (notify) for a missing/invalid value so a typo can never silently
|
|
583
|
+
* disable owner completion notifications. Mirrors the other policy resolvers so the
|
|
584
|
+
* arm site and any future consumer read one source of truth.
|
|
585
|
+
*/
|
|
586
|
+
export function resolveCoordinatorIdlePushPolicy(
|
|
587
|
+
meshPolicy?: Pick<RepoMeshPolicy, 'coordinatorIdlePushPolicy'> | null,
|
|
588
|
+
): 'always' | 'auto_silent_on_dispatch' {
|
|
589
|
+
return meshPolicy?.coordinatorIdlePushPolicy === 'auto_silent_on_dispatch'
|
|
590
|
+
? 'auto_silent_on_dispatch'
|
|
591
|
+
: 'always';
|
|
592
|
+
}
|
|
593
|
+
|
|
545
594
|
// ─── Policy normalization (single source of truth) ──────────────────────────
|
|
546
595
|
//
|
|
547
596
|
// Every mesh policy passes through mergeAndNormalizePolicy exactly once on write
|
|
@@ -727,6 +776,15 @@ export function mergeAndNormalizePolicy(
|
|
|
727
776
|
} else {
|
|
728
777
|
delete policy.autoConvergeCodeChange;
|
|
729
778
|
}
|
|
779
|
+
// Coordinator idle-push policy: strict opt-in. Only persist the explicit
|
|
780
|
+
// 'auto_silent_on_dispatch' value; any other/invalid value normalizes to the
|
|
781
|
+
// 'always' default and is dropped so existing meshes.json stays byte-for-byte
|
|
782
|
+
// untouched (a typo cannot silently disable owner completion notifications).
|
|
783
|
+
if (policy.coordinatorIdlePushPolicy === 'auto_silent_on_dispatch') {
|
|
784
|
+
policy.coordinatorIdlePushPolicy = 'auto_silent_on_dispatch';
|
|
785
|
+
} else {
|
|
786
|
+
delete policy.coordinatorIdlePushPolicy;
|
|
787
|
+
}
|
|
730
788
|
return policy;
|
|
731
789
|
}
|
|
732
790
|
|
package/src/status/builders.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type NormalizeActiveChatOptions,
|
|
26
26
|
} from './normalize.js';
|
|
27
27
|
import { getMeshQueueStats } from '../mesh/mesh-work-queue.js';
|
|
28
|
+
import { SILENT_IDLE_PUSH_TTL_MS } from '../repo-mesh-types.js';
|
|
28
29
|
import { getCoordinatorForSession } from '../mesh/coordinator-registry.js';
|
|
29
30
|
import { normalizeProviderStateControlValues } from '../providers/provider-patch-state.js';
|
|
30
31
|
import { normalizeProviderSummaryMetadata } from '../providers/summary-metadata.js';
|
|
@@ -63,13 +64,51 @@ export function resolveSurfaceHidden(settings: Record<string, any> | undefined):
|
|
|
63
64
|
return settings.spawnedSessionVisibility === 'hidden' || isCoordinatorSpawnedHiddenWorker(settings);
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Whether a one-shot silent-idle-push arm is currently ACTIVE for this session's
|
|
69
|
+
* completion snapshot. Set on a coordinator-dispatched worker when the mesh policy
|
|
70
|
+
* is `coordinatorIdlePushPolicy: 'auto_silent_on_dispatch'` (see arm sites in
|
|
71
|
+
* mesh-queue-assignment). Guardrails baked in here:
|
|
72
|
+
* - status-gated to `idle`: the arm mutes ONLY the routine completion snapshot, so
|
|
73
|
+
* an approval-needed / failure / long-running notification in the SAME turn (which
|
|
74
|
+
* the worker emits with a non-idle status) is NEVER suppressed.
|
|
75
|
+
* - TTL leak-guard: an arm older than SILENT_IDLE_PUSH_TTL_MS is treated as expired,
|
|
76
|
+
* so a worker whose completion never arrives cannot strand its session muted.
|
|
77
|
+
* The arm itself is one-shot: emitGeneratingCompleted clears it after the completion
|
|
78
|
+
* so the following turn notifies normally.
|
|
79
|
+
*/
|
|
80
|
+
function isSilentIdlePushArmActive(
|
|
81
|
+
settings: Record<string, any> | undefined,
|
|
82
|
+
status: string | undefined,
|
|
83
|
+
): boolean {
|
|
84
|
+
if (!settings || settings.silentNextIdlePush !== true) return false;
|
|
85
|
+
if (status !== 'idle') return false;
|
|
86
|
+
const armedAt = Number(settings.silentNextIdlePushArmedAt);
|
|
87
|
+
if (!Number.isFinite(armedAt) || armedAt <= 0) {
|
|
88
|
+
// No/invalid arm timestamp: honor the flag (fail-safe toward the explicit
|
|
89
|
+
// arm) but it will be one-shot-cleared at the completion emission.
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
return (Date.now() - armedAt) <= SILENT_IDLE_PUSH_TTL_MS;
|
|
93
|
+
}
|
|
94
|
+
|
|
66
95
|
/**
|
|
67
96
|
* A session is muted (attention side-effects suppressed, but still shown in the
|
|
68
|
-
* list) when the user muted it, OR a coordinator-spawned worker defaults muted
|
|
69
|
-
*
|
|
97
|
+
* list) when the user muted it, OR a coordinator-spawned worker defaults muted, OR
|
|
98
|
+
* a one-shot silent-idle-push arm is active for this completion snapshot (see
|
|
99
|
+
* isSilentIdlePushArmActive — status-gated + TTL-bounded so approval/failure pushes
|
|
100
|
+
* and never-completing workers are unaffected). userMuted === false is an explicit
|
|
101
|
+
* un-mute overriding the worker/policy default — but NOT the one-shot arm, which is a
|
|
102
|
+
* coordinator-driven per-completion decision that a stale manual un-mute must not
|
|
103
|
+
* defeat.
|
|
104
|
+
*
|
|
105
|
+
* `status` is the session's resolved status for the snapshot being built; omit it
|
|
106
|
+
* (undefined) for callers that only have mesh-attribution fields (the cloud mirror),
|
|
107
|
+
* where the one-shot never applies.
|
|
70
108
|
*/
|
|
71
|
-
export function resolveMuted(settings: Record<string, any> | undefined): boolean {
|
|
109
|
+
export function resolveMuted(settings: Record<string, any> | undefined, status?: string): boolean {
|
|
72
110
|
if (!settings) return false;
|
|
111
|
+
if (isSilentIdlePushArmActive(settings, status)) return true;
|
|
73
112
|
if (settings.userMuted === true) return true;
|
|
74
113
|
if (settings.userMuted === false) return false;
|
|
75
114
|
return isCoordinatorSpawnedHiddenWorker(settings);
|
|
@@ -365,6 +404,7 @@ function buildCliSession(state: CliProviderState, options: SessionEntryBuildOpti
|
|
|
365
404
|
const effectiveMeshId = meshCoordinatorFor || registryEntry?.meshId;
|
|
366
405
|
const coordinator = effectiveMeshId ? { meshId: effectiveMeshId, role: 'coordinator' as const } : undefined;
|
|
367
406
|
const meshQueueStats = effectiveMeshId ? getMeshQueueStats(effectiveMeshId) : undefined;
|
|
407
|
+
const resolvedStatus = resolveSessionStatus(activeChat, state.status);
|
|
368
408
|
return {
|
|
369
409
|
id: state.instanceId,
|
|
370
410
|
parentId: null,
|
|
@@ -373,7 +413,7 @@ function buildCliSession(state: CliProviderState, options: SessionEntryBuildOpti
|
|
|
373
413
|
providerSessionId: state.providerSessionId,
|
|
374
414
|
kind: 'agent',
|
|
375
415
|
transport: 'pty',
|
|
376
|
-
status:
|
|
416
|
+
status: resolvedStatus,
|
|
377
417
|
title: activeChat?.title || state.name,
|
|
378
418
|
workspace,
|
|
379
419
|
...(git && { git }),
|
|
@@ -412,7 +452,9 @@ function buildCliSession(state: CliProviderState, options: SessionEntryBuildOpti
|
|
|
412
452
|
// `!== undefined` fields, so an absent field on false never overwrote a prior true —
|
|
413
453
|
// the toggle-off direction silently stuck. See session-entry-merge.ts.
|
|
414
454
|
surfaceHidden: resolveSurfaceHidden(state.settings),
|
|
415
|
-
|
|
455
|
+
// status-gated so a one-shot silent-idle arm mutes ONLY the idle/completion
|
|
456
|
+
// snapshot, never an approval/generating frame in the same turn.
|
|
457
|
+
muted: resolveMuted(state.settings, resolvedStatus),
|
|
416
458
|
};
|
|
417
459
|
}
|
|
418
460
|
|
|
@@ -430,6 +472,7 @@ function buildAcpSession(state: AcpProviderState, options: SessionEntryBuildOpti
|
|
|
430
472
|
const effectiveMeshId = meshCoordinatorFor || registryEntry?.meshId;
|
|
431
473
|
const coordinator = effectiveMeshId ? { meshId: effectiveMeshId, role: 'coordinator' as const } : undefined;
|
|
432
474
|
const meshQueueStats = effectiveMeshId ? getMeshQueueStats(effectiveMeshId) : undefined;
|
|
475
|
+
const resolvedStatus = resolveSessionStatus(activeChat, state.status);
|
|
433
476
|
return {
|
|
434
477
|
id: state.instanceId,
|
|
435
478
|
parentId: null,
|
|
@@ -437,7 +480,7 @@ function buildAcpSession(state: AcpProviderState, options: SessionEntryBuildOpti
|
|
|
437
480
|
providerName: state.name,
|
|
438
481
|
kind: 'agent',
|
|
439
482
|
transport: 'acp',
|
|
440
|
-
status:
|
|
483
|
+
status: resolvedStatus,
|
|
441
484
|
title: activeChat?.title || state.name,
|
|
442
485
|
workspace,
|
|
443
486
|
...(git && { git }),
|
|
@@ -457,7 +500,8 @@ function buildAcpSession(state: AcpProviderState, options: SessionEntryBuildOpti
|
|
|
457
500
|
// Emit explicitly (including false) so un-hide/un-mute clears a prior true downstream —
|
|
458
501
|
// see buildCliSession above and session-entry-merge.ts.
|
|
459
502
|
surfaceHidden: resolveSurfaceHidden(state.settings),
|
|
460
|
-
|
|
503
|
+
// status-gated one-shot silent-idle arm — see buildCliSession above.
|
|
504
|
+
muted: resolveMuted(state.settings, resolvedStatus),
|
|
461
505
|
};
|
|
462
506
|
}
|
|
463
507
|
|