@adhdev/daemon-core 1.0.18-rc.7 → 1.0.18-rc.9

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.
@@ -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, meshTaskNotBeforeReady, meshTaskPriorityRank } from './mesh-work-queue.js';
9
+ import { buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, claimNextTask, updateTaskStatus, getQueue, recordTaskAutoLaunch, getActiveDirectDispatches, isTaskReadonly, taskDependenciesSatisfied, meshTaskNotBeforeReady, meshTaskPriorityRank, requeueTask } 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';
@@ -931,6 +931,105 @@ function isTargetNodeTransientlyUnresolved(mesh: any, task: MeshWorkQueueEntry):
931
931
  return isWithinCloneBootstrapGrace(targetNodeId);
932
932
  }
933
933
 
934
+ // ---------------------------------------------------------------------------
935
+ // DEAD-TARGET-SELFHEAL: unpin a queue task hard-pinned to a session/node that has
936
+ // died (absent from the live mesh) so a live idle session can claim it, instead of
937
+ // leaving it stranded 'pending' forever behind the target_session_constraint skip.
938
+ // ---------------------------------------------------------------------------
939
+
940
+ // Conservative age gate before a pinned-but-dead target is unpinned. A target that is
941
+ // merely briefly unassigned or momentarily reconnecting must not be reclaimed on the
942
+ // tick it drops out of view; we require the task to have been idle (no updatedAt bump)
943
+ // for at least this window first. Sized to comfortably outlast a transient P2P blip /
944
+ // reconnect while staying well inside the reclaim cadence of the rest of the file
945
+ // (AUTO_LAUNCH_AWAIT_CLAIM_MS is 90s; the stranded-reclaim watchdog fires on similar
946
+ // scales), so a real reconnect wins the race and the self-heal only fires on a target
947
+ // that is genuinely gone.
948
+ const DEAD_TARGET_GRACE_MS = 60_000;
949
+
950
+ interface DeadTargetVerdict {
951
+ /** The pinned target is confirmed dead and past the grace window → safe to unpin. */
952
+ dead: boolean;
953
+ /** True when the target NODE itself is absent from the live mesh (clear targetNodeId too). */
954
+ nodeDead: boolean;
955
+ /** Short reason string for the ledger/requeue. */
956
+ reason: string;
957
+ }
958
+
959
+ /**
960
+ * Decide whether a task's hard target pin (targetSessionId and/or targetNodeId) points at
961
+ * something that has DIED — i.e. is absent from the live mesh snapshot — and has been so
962
+ * long enough (DEAD_TARGET_GRACE_MS since the task's last update) that unpinning is safe.
963
+ *
964
+ * Two definitive death signals, deliberately conservative to never race a reconnecting node:
965
+ *
966
+ * (1) NODE dead — the task pins a targetNodeId that matches NO node in the live mesh
967
+ * (the same `meshNodeIdMatches`-over-mesh.nodes signal the targetPinUnmatched relabel
968
+ * uses at the empty-candidate site). A pinned session on an absent node is unreachable
969
+ * regardless, so the session pin is dead too. `nodeDead` ⇒ clear targetNodeId as well.
970
+ * Excluded: a target that is only TRANSIENTLY unresolved (a freshly-cloned worktree
971
+ * still propagating / bootstrapping) — isTargetNodeTransientlyUnresolved gates it out.
972
+ *
973
+ * (2) SESSION dead on a LIVE LOCAL node — the target node IS present and is THIS daemon's
974
+ * node, but the pinned session is absent from the local instance manager
975
+ * (resolveSessionBusyVerdict === 'UNKNOWN'). Local session visibility is complete, so
976
+ * absence here is genuine death, not a busy/generating flip. We KEEP targetNodeId (only
977
+ * the session died; the node is healthy and can host a replacement claim).
978
+ *
979
+ * A live REMOTE node whose session is not in our idle view is NOT treated as dead: absence
980
+ * from the remote-idle mirror is explicitly UNKNOWN liveness (the session may be busy or its
981
+ * agent:ready pull merely lost), so unpinning it could race healthy in-flight work. Returns
982
+ * dead=false in that case, leaving the existing skip in place.
983
+ */
984
+ function resolveDeadTargetVerdict(components: DaemonComponents, meshId: string, mesh: any, task: MeshWorkQueueEntry): DeadTargetVerdict {
985
+ const NOT_DEAD: DeadTargetVerdict = { dead: false, nodeDead: false, reason: '' };
986
+ const targetSessionId = readNonEmptyString(task.targetSessionId);
987
+ const targetNodeId = readNonEmptyString(task.targetNodeId);
988
+ if (!targetSessionId && !targetNodeId) return NOT_DEAD;
989
+
990
+ // Age gate: never reclaim a pin younger than the grace window (guards against a target
991
+ // that has only just dropped out of view for a momentary reconnect).
992
+ const lastUpdateMs = Date.parse(task.updatedAt || task.createdAt || '');
993
+ if (Number.isFinite(lastUpdateMs) && Date.now() - lastUpdateMs < DEAD_TARGET_GRACE_MS) return NOT_DEAD;
994
+
995
+ const nodes: any[] = Array.isArray(mesh?.nodes) ? mesh.nodes : [];
996
+
997
+ // (1) NODE-dead — a pinned node absent from the live mesh, and NOT merely transiently
998
+ // unresolved (a propagating/bootstrapping clone). This is a permanent routing miss.
999
+ if (targetNodeId) {
1000
+ const nodePresent = nodes.some(n => meshNodeIdMatches(n, targetNodeId));
1001
+ if (!nodePresent) {
1002
+ if (isTargetNodeTransientlyUnresolved(mesh, task)) return NOT_DEAD;
1003
+ return { dead: true, nodeDead: true, reason: 'dead_target_node_absent' };
1004
+ }
1005
+ }
1006
+
1007
+ // (2) SESSION-dead on a LIVE LOCAL node. Only meaningful when a session is pinned.
1008
+ if (targetSessionId) {
1009
+ // Resolve the pinned target's node (if any) to decide whether we can trust local
1010
+ // absence. Without a targetNodeId, fall back to whichever live node hosts the session
1011
+ // is unknowable here; treat that as a LOCAL check only (a session id we cannot see
1012
+ // locally on a node we cannot resolve remotely stays UNKNOWN → not dead).
1013
+ const node = targetNodeId
1014
+ ? nodes.find(n => meshNodeIdMatches(n, targetNodeId))
1015
+ : undefined;
1016
+ // A pinned session on a REMOTE live node: absence from our view is UNKNOWN, not death.
1017
+ // Only a LOCAL node (or no node pin at all — same-daemon assumption) lets us conclude
1018
+ // death from local instance-manager absence.
1019
+ const nodeIsLocal = node ? isLocalAutoLaunchNode(node) : true;
1020
+ if (nodeIsLocal) {
1021
+ const verdict = resolveSessionBusyVerdict(components, targetSessionId);
1022
+ if (verdict === 'UNKNOWN') {
1023
+ // Session absent from the complete local session view → genuinely gone.
1024
+ return { dead: true, nodeDead: false, reason: 'dead_target_session_absent' };
1025
+ }
1026
+ // GENERATING / IDLE_CONFIRMED → the session is alive (possibly busy). Never disturb.
1027
+ }
1028
+ }
1029
+
1030
+ return NOT_DEAD;
1031
+ }
1032
+
934
1033
  /**
935
1034
  * FALSE-BLOCKER-CLONE-QUEUE (stale-event clear): once a task whose actionable blocker we
936
1035
  * previously paged either gets claimed or transitions to a self-resolving state, re-arm the
@@ -1929,6 +2028,32 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
1929
2028
  continue;
1930
2029
  }
1931
2030
  if (task.targetSessionId) {
2031
+ // DEAD-TARGET-SELFHEAL: before the unconditional target_session_constraint skip,
2032
+ // check whether the pinned session/node has DIED (absent from the live mesh). A
2033
+ // hard-pinned task whose target is gone can NEVER re-enter 'assigned' (the claim
2034
+ // gate refuses every non-matching session) and this skip fires forever with no
2035
+ // liveness check — the triple-walled stranded-pending defect. If the pin is
2036
+ // confirmed dead past the grace window, requeue it (clearing the dead session
2037
+ // pin, and the node pin too when the NODE itself is gone) so a live idle session
2038
+ // can claim it. requeueTask counts toward maxTaskRetries → bounded self-heal that
2039
+ // auto-fails past the cap (the desired terminal state, unblocking dependents).
2040
+ const deadTarget = resolveDeadTargetVerdict(components, meshId, mesh, task);
2041
+ if (deadTarget.dead) {
2042
+ const requeued = requeueTask(meshId, task.id, {
2043
+ reason: deadTarget.reason,
2044
+ clearTargetSession: true,
2045
+ // Keep the node pin if only the SESSION died on a still-live node; clear it
2046
+ // when the NODE itself is absent (nothing to pin to).
2047
+ clearTargetNode: deadTarget.nodeDead,
2048
+ });
2049
+ if (requeued) {
2050
+ LOG.warn('MeshQueue', `DEAD-TARGET-SELFHEAL: task ${task.id} (mesh ${meshId}) was pinned to a dead target (${deadTarget.reason}); requeued${deadTarget.nodeDead ? ' and unpinned node' : ''} (requeueCount=${requeued.requeueCount ?? '?'}, status=${requeued.status}).`);
2051
+ }
2052
+ // Keep the skip for THIS tick (the requeue already flipped the row to
2053
+ // pending/failed); a later tick assigns/launches the now-unpinned task.
2054
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'target_session_dead_requeued' });
2055
+ continue;
2056
+ }
1932
2057
  markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'target_session_constraint' });
1933
2058
  continue;
1934
2059
  }
@@ -371,6 +371,16 @@ export class CliProviderInstance implements ProviderInstance {
371
371
  private generatingDebounceTimer: NodeJS.Timeout | null = null;
372
372
  private generatingDebouncePending: { chatTitle: string; timestamp: number } | null = null;
373
373
  private lastApprovalEventFingerprint = '';
374
+ // INTERACTIVE-PROMPT-PUSH: edge-trigger key for the AskUserQuestion / waiting_choice
375
+ // notification. An AskUserQuestion prompt is surfaced only as a display-only
376
+ // `waiting_choice` overlay in getState(); the raw adapter status stays idle/generating,
377
+ // so detectStatusTransition()'s status-keyed arms never fire and no push-worthy
378
+ // agent:* event is emitted — the owner misses the "ACTION REQUIRED" prompt when the
379
+ // app is backgrounded. We emit one agent:waiting_approval on ENTRY into the prompt
380
+ // state (reusing the existing server push path, no cloud change) and dedupe on this
381
+ // key so repeated status ticks with the same prompt do not re-fire. '' means no
382
+ // prompt is currently active (cleared when the prompt is answered/gone).
383
+ private lastInteractivePromptEventKey = '';
374
384
  private autoApproveBusy = false;
375
385
  private autoApproveBusyTimer: NodeJS.Timeout | null = null;
376
386
  private lastAutoApprovalSignature = '';
@@ -3950,6 +3960,47 @@ export class CliProviderInstance implements ProviderInstance {
3950
3960
  this.lastStatus = newStatus;
3951
3961
  }
3952
3962
 
3963
+ // INTERACTIVE-PROMPT-PUSH: fire a push-worthy notification when the session
3964
+ // ENTERS an AskUserQuestion / waiting_choice state. This is orthogonal to the
3965
+ // status-change block above: an AskUserQuestion prompt is surfaced only as a
3966
+ // display-only `waiting_choice` overlay in getState() (mirrored here off
3967
+ // adapterStatus.activeInteractivePrompt, the exact signal getState uses), while
3968
+ // the raw adapter status stays idle/generating — so none of the status-keyed
3969
+ // arms above ever emit an agent:* event for it and the owner gets no web-push.
3970
+ //
3971
+ // We REUSE agent:waiting_approval (the question message as modalMessage, the
3972
+ // choice labels as modalButtons) so the existing server push path fires with
3973
+ // zero cloud change — the semantics ("the agent needs your input") match.
3974
+ //
3975
+ // Edge-triggered: emit exactly once on entry, keyed on promptId + question text,
3976
+ // and reset the key when the prompt clears so a fresh prompt re-fires and a later
3977
+ // real completion still flows through the idle arm normally. We do NOT emit when
3978
+ // the session is ALSO in a genuine approval state (newStatus === 'waiting_approval'):
3979
+ // that arm already emits agent:waiting_approval, and firing here too would double
3980
+ // up on the same modal.
3981
+ const interactivePrompt = adapterStatus.activeInteractivePrompt ?? null;
3982
+ if (interactivePrompt && newStatus !== 'waiting_approval') {
3983
+ const firstQuestion = interactivePrompt.questions?.[0];
3984
+ const promptKey = `${interactivePrompt.promptId}::${firstQuestion?.question ?? ''}`;
3985
+ if (promptKey !== this.lastInteractivePromptEventKey) {
3986
+ this.lastInteractivePromptEventKey = promptKey;
3987
+ const modalMessage = firstQuestion
3988
+ ? (firstQuestion.header
3989
+ ? `${firstQuestion.header}: ${firstQuestion.question}`
3990
+ : firstQuestion.question)
3991
+ : undefined;
3992
+ const modalButtons = firstQuestion?.options?.map((option: { label: string }) => option.label);
3993
+ this.pushEvent({
3994
+ event: 'agent:waiting_approval', chatTitle, timestamp: now,
3995
+ modalMessage,
3996
+ modalButtons,
3997
+ });
3998
+ }
3999
+ } else if (!interactivePrompt && this.lastInteractivePromptEventKey) {
4000
+ // Prompt answered / gone — reset so the next AskUserQuestion re-fires.
4001
+ this.lastInteractivePromptEventKey = '';
4002
+ }
4003
+
3953
4004
  // GENERATING-BOUNDARY idle-stayed collapse (R4b): the starting→idle
3954
4005
  // fast-collapse arm above only fires when the FIRST turn itself drives the
3955
4006
  // starting→idle transition. When the launch settle already drained