@adhdev/daemon-core 0.9.82-rc.468 → 0.9.82-rc.469

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.
@@ -189,6 +189,10 @@ function ledgerRecordQuarantinedEvent(event: PendingMeshCoordinatorEvent, reason
189
189
  ...(readNonEmptyString(event.eventId) ? { eventId: event.eventId } : {}),
190
190
  queuedAt: event.queuedAt,
191
191
  ...(finalSummary ? { finalSummary } : {}),
192
+ // Full original event so mesh_requeue_held_events can restore it
193
+ // losslessly (event_held→pending). The summary/label fields above stay
194
+ // for human-readable audit; `heldEvent` is the machine recovery copy.
195
+ heldEvent: event,
192
196
  },
193
197
  });
194
198
  } catch (e: any) {
@@ -746,8 +750,11 @@ function trimPendingEventsIfNeeded(path: string): void {
746
750
  nodeLabel: event.nodeLabel,
747
751
  ...(event.workspace ? { workspace: event.workspace } : {}),
748
752
  targetCoordinatorDaemonId: event.targetCoordinatorDaemonId ?? null,
753
+ ...(readNonEmptyString(event.eventId) ? { eventId: event.eventId } : {}),
749
754
  queuedAt: event.queuedAt,
750
755
  ...(finalSummary ? { finalSummary } : {}),
756
+ // Full original event for lossless mesh_requeue_held_events restore.
757
+ heldEvent: event,
751
758
  },
752
759
  });
753
760
  LOG.warn('MeshEvents', `Pending-events trim dropping undelivered ${event.event} for mesh ${event.meshId} — recorded to ledger (recoverable)`);
@@ -1303,6 +1310,175 @@ export function __persistUnstampedPendingEventForTests(event: PendingMeshCoordin
1303
1310
  return persistPendingMeshCoordinatorEvent(event);
1304
1311
  }
1305
1312
 
1313
+ // ---------------------------------------------------------------------------
1314
+ // event_held → pending requeue (T6 recovery path)
1315
+ // ---------------------------------------------------------------------------
1316
+ // T6 quarantine (v2 enforce) and the pending-events trim both mirror a
1317
+ // destructively-drained-but-undelivered event into the ledger as a recoverable
1318
+ // `event_held` entry. Until now that recovery channel was audit-only: the comment
1319
+ // said "an operator can requeue it" but no code path did. This restores those held
1320
+ // events to the pending queue so a coordinator drains them on its next poll.
1321
+ //
1322
+ // No-loss + no-double-requeue invariants:
1323
+ // • The full original event rides on the held payload as `heldEvent`, so the
1324
+ // restore is byte-for-byte (metadataEvent, coordinatorMessage, v2 envelope).
1325
+ // • queuePendingMeshCoordinatorEvent runs the normal dedup (fingerprint / eventId),
1326
+ // so an event still live in the queue is not duplicated.
1327
+ // • Every held entry that has ALREADY been requeued is marked with an
1328
+ // `event_held_requeued` ledger entry keyed by the source held-entry id; a later
1329
+ // pass skips those ids, so calling the tool twice does not requeue the same held
1330
+ // event twice.
1331
+
1332
+ /** Narrowing filter for {@link requeueHeldMeshCoordinatorEvents}, scoped within one mesh. */
1333
+ export interface MeshHeldEventRequeueFilter {
1334
+ /** Restore only held events whose worker task id matches (from the held event's metadata/taskId). */
1335
+ taskId?: string;
1336
+ /** Restore only held events originating from this node. */
1337
+ nodeId?: string;
1338
+ /** Restore only held events of this event name (e.g. 'session:completed'). */
1339
+ event?: string;
1340
+ /** Restore only held entries recorded at/after this ISO timestamp. */
1341
+ since?: string;
1342
+ /** Restore only held entries with this hold reason (e.g. 'pending_trim_dropped'). */
1343
+ reason?: string;
1344
+ }
1345
+
1346
+ export interface MeshHeldEventRequeueResult {
1347
+ meshId: string;
1348
+ /** event_held entries considered after the filter. */
1349
+ matched: number;
1350
+ /** entries skipped because a prior requeue already recovered them. */
1351
+ alreadyRequeued: number;
1352
+ /** entries skipped because they carried no restorable original event / were not recoverable. */
1353
+ unrecoverable: number;
1354
+ /** entries handed to the pending queue (some may have been dedup-suppressed downstream). */
1355
+ requeued: number;
1356
+ /** of `requeued`, how many the pending-queue dedup collapsed onto a live event. */
1357
+ dedupSuppressed: number;
1358
+ entries: Array<{
1359
+ heldEntryId: string;
1360
+ event: string;
1361
+ nodeId?: string;
1362
+ taskId?: string;
1363
+ reason?: string;
1364
+ outcome: 'requeued' | 'already_requeued' | 'unrecoverable';
1365
+ }>;
1366
+ }
1367
+
1368
+ /** Read the taskId a held event carried, checking the restored event then the audit payload. */
1369
+ function readHeldTaskId(restored: PendingMeshCoordinatorEvent | undefined, payload: Record<string, unknown>): string {
1370
+ const fromMeta = restored?.metadataEvent && typeof restored.metadataEvent === 'object'
1371
+ ? readNonEmptyString((restored.metadataEvent as Record<string, unknown>).taskId)
1372
+ : '';
1373
+ return fromMeta || readNonEmptyString(payload.taskId) || '';
1374
+ }
1375
+
1376
+ /**
1377
+ * Restore recoverable `event_held` ledger entries back to the pending coordinator
1378
+ * queue for `meshId`. See the block comment above for the no-loss / no-double-requeue
1379
+ * invariants. Returns per-entry outcomes for the caller to surface.
1380
+ */
1381
+ export function requeueHeldMeshCoordinatorEvents(
1382
+ meshId: string,
1383
+ filter?: MeshHeldEventRequeueFilter,
1384
+ ): MeshHeldEventRequeueResult {
1385
+ const result: MeshHeldEventRequeueResult = {
1386
+ meshId,
1387
+ matched: 0,
1388
+ alreadyRequeued: 0,
1389
+ unrecoverable: 0,
1390
+ requeued: 0,
1391
+ dedupSuppressed: 0,
1392
+ entries: [],
1393
+ };
1394
+
1395
+ const all = readLedgerEntries(meshId);
1396
+ // held-entry ids already recovered by a prior requeue pass (dedup key = source id).
1397
+ const requeuedIds = new Set<string>();
1398
+ for (const entry of all) {
1399
+ if (entry.kind !== 'event_held_requeued') continue;
1400
+ const id = readNonEmptyString(entry.payload?.heldEntryId);
1401
+ if (id) requeuedIds.add(id);
1402
+ }
1403
+
1404
+ const sinceMs = filter?.since ? new Date(filter.since).getTime() : NaN;
1405
+ const wantEvent = readNonEmptyString(filter?.event);
1406
+ const wantNode = readNonEmptyString(filter?.nodeId);
1407
+ const wantTask = readNonEmptyString(filter?.taskId);
1408
+ const wantReason = readNonEmptyString(filter?.reason);
1409
+
1410
+ for (const entry of all) {
1411
+ if (entry.kind !== 'event_held') continue;
1412
+ const payload = (entry.payload && typeof entry.payload === 'object') ? entry.payload : {};
1413
+ if (payload.recoverable !== true) continue;
1414
+
1415
+ // Reconstruct the original event: prefer the full `heldEvent` copy; fall back to
1416
+ // the flat audit fields for entries written before the copy was embedded.
1417
+ const restored: PendingMeshCoordinatorEvent | undefined =
1418
+ (payload.heldEvent && typeof payload.heldEvent === 'object')
1419
+ ? { ...(payload.heldEvent as PendingMeshCoordinatorEvent) }
1420
+ : undefined;
1421
+
1422
+ const eventName = restored?.event || readNonEmptyString(payload.event);
1423
+ const nodeId = restored?.nodeId || entry.nodeId || readNonEmptyString((payload as any).nodeId) || undefined;
1424
+ const taskId = readHeldTaskId(restored, payload as Record<string, unknown>);
1425
+ const reason = readNonEmptyString(payload.reason) || undefined;
1426
+
1427
+ // Apply the caller filter within the mesh scope.
1428
+ if (wantEvent && eventName !== wantEvent) continue;
1429
+ if (wantNode && nodeId !== wantNode) continue;
1430
+ if (wantTask && taskId !== wantTask) continue;
1431
+ if (wantReason && reason !== wantReason) continue;
1432
+ if (filter?.since && !Number.isNaN(sinceMs) && new Date(entry.timestamp).getTime() < sinceMs) continue;
1433
+
1434
+ result.matched++;
1435
+
1436
+ if (requeuedIds.has(entry.id)) {
1437
+ result.alreadyRequeued++;
1438
+ result.entries.push({ heldEntryId: entry.id, event: eventName, ...(nodeId ? { nodeId } : {}), ...(taskId ? { taskId } : {}), ...(reason ? { reason } : {}), outcome: 'already_requeued' });
1439
+ continue;
1440
+ }
1441
+
1442
+ if (!restored || !readNonEmptyString(restored.event) || !readNonEmptyString(restored.meshId)) {
1443
+ result.unrecoverable++;
1444
+ result.entries.push({ heldEntryId: entry.id, event: eventName, ...(nodeId ? { nodeId } : {}), ...(taskId ? { taskId } : {}), ...(reason ? { reason } : {}), outcome: 'unrecoverable' });
1445
+ continue;
1446
+ }
1447
+
1448
+ // Restore to pending. queuePendingMeshCoordinatorEvent re-stamps/dedups; a live
1449
+ // duplicate is suppressed there (returns true) so we never double-deliver.
1450
+ const beforeDup = hasPendingCoordinatorEventDuplicate(restored);
1451
+ let ok = false;
1452
+ try {
1453
+ ok = queuePendingMeshCoordinatorEvent(restored);
1454
+ } catch (e: any) {
1455
+ LOG.warn('MeshEvents', `Requeue of held ${eventName} for mesh ${meshId} failed: ${e?.message || e}`);
1456
+ }
1457
+
1458
+ // Mark the source held entry so a second pass skips it, regardless of whether the
1459
+ // queue dedup collapsed it (the recovery attempt is what we dedup on, not delivery).
1460
+ appendLedgerEntry(meshId, {
1461
+ kind: 'event_held_requeued',
1462
+ ...(nodeId ? { nodeId } : {}),
1463
+ payload: {
1464
+ heldEntryId: entry.id,
1465
+ event: eventName,
1466
+ requeued: ok,
1467
+ ...(taskId ? { taskId } : {}),
1468
+ ...(reason ? { reason } : {}),
1469
+ ...(beforeDup ? { dedupSuppressed: true } : {}),
1470
+ },
1471
+ });
1472
+ requeuedIds.add(entry.id);
1473
+
1474
+ result.requeued++;
1475
+ if (beforeDup) result.dedupSuppressed++;
1476
+ result.entries.push({ heldEntryId: entry.id, event: eventName, ...(nodeId ? { nodeId } : {}), ...(taskId ? { taskId } : {}), ...(reason ? { reason } : {}), outcome: 'requeued' });
1477
+ }
1478
+
1479
+ return result;
1480
+ }
1481
+
1306
1482
  /** Explicitly clear all pending coordinator events for a mesh (and coordinator if scoped). */
1307
1483
  export function clearPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string): void {
1308
1484
  if (!meshId) return;
@@ -5,12 +5,17 @@
5
5
  // New code should import directly from the relevant sub-module.
6
6
  // ---------------------------------------------------------------------------
7
7
 
8
- export type { PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
8
+ export type {
9
+ PendingMeshCoordinatorEvent,
10
+ MeshHeldEventRequeueFilter,
11
+ MeshHeldEventRequeueResult,
12
+ } from './mesh-events-pending.js';
9
13
  export {
10
14
  queuePendingMeshCoordinatorEvent,
11
15
  drainPendingMeshCoordinatorEvents,
12
16
  getPendingMeshCoordinatorEvents,
13
17
  clearPendingMeshCoordinatorEvents,
18
+ requeueHeldMeshCoordinatorEvents,
14
19
  serializeV2EnvelopeToWire,
15
20
  readV2EnvelopeFromWire,
16
21
  getMeshV2DrainCounters,
@@ -49,6 +49,11 @@ export type MeshLedgerKind =
49
49
  | 'delivery_unroutable'
50
50
  | 'direct_dispatch_pruned'
51
51
  | 'event_held'
52
+ // Audit marker written by mesh_requeue_held_events when a recoverable `event_held`
53
+ // entry is restored to the pending queue (event_held→pending). Keyed by the source
54
+ // held-ledger-entry id so a second requeue pass skips already-recovered entries
55
+ // (no double-requeue). payload: { heldEntryId, event, requeued: boolean, reason?, dedupSuppressed?: boolean }
56
+ | 'event_held_requeued'
52
57
  | 'task_reclaimed'
53
58
  // Gap2-A: a coordinator-recorded operating note — a runtime-accumulated
54
59
  // lesson (provider quirk, pattern to avoid, recovery lesson) persisted in
@@ -6,7 +6,7 @@ import { getMesh } from '../config/mesh-config.js';
6
6
  import { detectCLI } from '../detection/cli-detector.js';
7
7
  import { LOG } from '../logging/logger.js';
8
8
  import { appendLedgerEntry } from './mesh-ledger.js';
9
- import { buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, claimNextTask, updateTaskStatus, getQueue, recordTaskAutoLaunch, getActiveDirectDispatches, isTaskReadonly, taskDependenciesSatisfied } from './mesh-work-queue.js';
9
+ import { buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, claimNextTask, updateTaskStatus, getQueue, recordTaskAutoLaunch, getActiveDirectDispatches, isTaskReadonly, taskDependenciesSatisfied, meshTaskNotBeforeReady, meshTaskPriorityRank } from './mesh-work-queue.js';
10
10
  import type { MeshWorkQueueEntry } from './mesh-work-queue.js';
11
11
  import { fastForwardMeshNode } from './mesh-fast-forward.js';
12
12
  import { createSessionDelivery, updateSessionDeliveryStatus } from './mesh-delivery-policy.js';
@@ -1563,7 +1563,13 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
1563
1563
  // so the dependency gate below sees the terminal state of every referenced
1564
1564
  // dependency, not just the still-active rows.
1565
1565
  const statusById = new Map(queue.map(task => [task.id, task.status] as const));
1566
- const pending = queue.filter(task => task.status === 'pending');
1566
+ // G6: scan higher task-level priority first so a high-priority task auto-launches its
1567
+ // session ahead of an older normal/low task (getQueue is FIFO; a stable sort by priority
1568
+ // rank descending keeps created_at order within a priority band). The claim path applies
1569
+ // the same ordering, so the launched session pulls the same task the scan chose.
1570
+ const pending = queue
1571
+ .filter(task => task.status === 'pending')
1572
+ .sort((a, b) => meshTaskPriorityRank(b.priority) - meshTaskPriorityRank(a.priority));
1567
1573
  // AUTOLAUNCH-CLAIM-CHURN: prune await-claim backoff state for tasks of this mesh that are no
1568
1574
  // longer pending (claimed/completed/cancelled) so the map cannot grow without bound.
1569
1575
  {
@@ -1595,6 +1601,14 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
1595
1601
  markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'dependencies_unsatisfied' });
1596
1602
  continue;
1597
1603
  }
1604
+ // G7: never spawn a session for a task still held by its notBefore gate — the launched
1605
+ // session would idle→claim and be refused by the SAME gate in claimNextQueueTask,
1606
+ // producing orphan-session churn. Skip it so a later tick (after not_before passes)
1607
+ // launches it. Tasks with no notBefore pass through unchanged.
1608
+ if (!meshTaskNotBeforeReady(task)) {
1609
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'not_before_delayed' });
1610
+ continue;
1611
+ }
1598
1612
  const isReadonly = isTaskReadonly(task);
1599
1613
  if (isReadonly) {
1600
1614
  if (activeReadonlyAssignedCount(meshId) >= maxReadonlyParallelTasks) {
@@ -0,0 +1,66 @@
1
+ // ---------------------------------------------------------------------------
2
+ // mesh-reconcile-config — reconcile-loop timing tunables + env resolvers
3
+ // ---------------------------------------------------------------------------
4
+ // Extracted from mesh-reconcile-loop.ts (A-3 god-module decomposition, pure move,
5
+ // no behavior change). Holds the loop-cadence tunables and their env-override
6
+ // resolvers. Each resolver reads MESH_*_MS from the environment and clamps the
7
+ // value so a mis-set env cannot make the loop pathological. Single-consumer
8
+ // deadline constants that live next to their sole reader (e.g.
9
+ // ASSIGNED_STRANDED_DEADLINE_MS, STRICT_SESSION_MATCH_TTL_MS) intentionally stay
10
+ // in mesh-reconcile-loop.ts — only the shared loop-cadence tunables move here.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ import { readNonEmptyString } from './mesh-events-utils.js';
14
+ import { resolveTunedReconcileMs } from './mesh-reconcile-acked-hold.js';
15
+
16
+ // Default reconcile cadence. approval/completion notifications to a live CLI
17
+ // coordinator land within at most one interval. Overridable via env for tuning.
18
+ export const DEFAULT_RECONCILE_INTERVAL_MS = 4_000;
19
+
20
+ // PHASE 5 (auto-prune) conservative age gate. A direct dispatch whose node/session is
21
+ // orphaned (no longer in the live mesh) is only auto-pruned once it is at least this old,
22
+ // measured from its dispatch time. This protects against a node/session that is only
23
+ // *transiently* invisible (a momentary probe failure, a daemon restart) being pruned the
24
+ // instant it disappears. The MANUAL prune (mesh_prune_stale_direct) has no age gate — an
25
+ // operator pruning explicitly wants the orphan gone now. Overridable via env for tuning.
26
+ export const DEFAULT_AUTO_PRUNE_MIN_AGE_MS = 24 * 60 * 60_000; // 24h
27
+
28
+ export function resolveAutoPruneMinAgeMs(): number {
29
+ const raw = readNonEmptyString(process.env.MESH_AUTO_PRUNE_MIN_AGE_MS);
30
+ if (raw) {
31
+ const parsed = Number.parseInt(raw, 10);
32
+ // Clamp to [1h, 30d] so a mis-set env can't make the gate pathologically aggressive
33
+ // (prune the moment something blinks) or effectively disable it forever.
34
+ if (Number.isFinite(parsed) && parsed >= 60 * 60_000 && parsed <= 30 * 24 * 60 * 60_000) return parsed;
35
+ }
36
+ return DEFAULT_AUTO_PRUNE_MIN_AGE_MS;
37
+ }
38
+
39
+ // PTY-OVERTRUST-DRAIN (Defect B, fix B). Age-based escape for the
40
+ // `generating_no_idle_coordinator` hold. Fix A makes the drain predicate read the RAW
41
+ // adapter (mask-stripped), so the common mask-driven false-busy is gone. But a hold can
42
+ // still arise from a genuine status-source desync that fix A does not reach (e.g. the
43
+ // adapter raw itself momentarily reads generating while the coordinator is actually at a
44
+ // turn end). This is a TIME-BASED BACKSTOP: when a mesh's pending terminal events have
45
+ // been held this long, re-confirm the coordinator's RAW adapter idle on the tick and, if
46
+ // it is genuinely idle, drain ONCE. It NEVER injects into a genuinely-generating PTY —
47
+ // the re-confirmation gates on raw adapter idle, so the intentional removal of
48
+ // force-inject-into-generating (data-loss) is preserved. Default 12s = 3 reconcile ticks
49
+ // at the 4s cadence: long enough that a normal mid-turn settle is not pre-empted, short
50
+ // enough that a desync-stranded completion is not held for minutes. Env-tunable.
51
+ export const DEFAULT_PENDING_HELD_DRAIN_ESCALATE_MS = 12_000;
52
+
53
+ export function resolvePendingHeldDrainEscalateMs(): number {
54
+ // Floor 4s (one tick) so a mis-set env cannot make the escape race a normal settle;
55
+ // ceiling 5min so it cannot be disabled into a permanent strand.
56
+ return resolveTunedReconcileMs('MESH_PENDING_HELD_DRAIN_ESCALATE_MS', DEFAULT_PENDING_HELD_DRAIN_ESCALATE_MS, 4_000, 5 * 60_000);
57
+ }
58
+
59
+ export function resolveReconcileIntervalMs(): number {
60
+ const raw = readNonEmptyString(process.env.MESH_RECONCILE_INTERVAL_MS);
61
+ if (raw) {
62
+ const parsed = Number.parseInt(raw, 10);
63
+ if (Number.isFinite(parsed) && parsed >= 1_000 && parsed <= 60_000) return parsed;
64
+ }
65
+ return DEFAULT_RECONCILE_INTERVAL_MS;
66
+ }