@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.
- package/dist/commands/windows-atomic-upgrade.d.ts +4 -0
- package/dist/index.js +245 -59
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +245 -59
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/coordinator-prompt.d.ts +57 -13
- package/dist/providers/cli-provider-instance.d.ts +1 -0
- package/package.json +3 -3
- package/src/commands/high-family/mesh-coordinator-launch.ts +12 -2
- package/src/commands/upgrade-helper.ts +28 -14
- package/src/commands/windows-atomic-upgrade.ts +61 -18
- package/src/config/mesh-json-config.ts +5 -1
- package/src/mesh/coordinator-prompt.ts +206 -31
- package/src/mesh/mesh-queue-assignment.ts +126 -1
- package/src/providers/cli-provider-instance.ts +51 -0
|
@@ -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
|