@adhdev/daemon-core 0.9.82-rc.351 → 0.9.82-rc.352
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/upgrade-helper.d.ts +1 -1
- package/dist/index.js +424 -189
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +424 -189
- package/dist/index.mjs.map +1 -1
- package/dist/logging/logger.d.ts +1 -1
- package/dist/mesh/mesh-ledger.d.ts +1 -1
- package/dist/mesh/mesh-runtime-store.d.ts +9 -0
- package/dist/mesh/mesh-work-queue.d.ts +28 -0
- package/dist/providers/approval-utils.d.ts +7 -0
- package/dist/providers/cli-provider-instance.d.ts +15 -0
- package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +1 -0
- package/package.json +2 -2
- package/src/commands/upgrade-helper.ts +57 -14
- package/src/logging/command-log.ts +7 -5
- package/src/logging/logger.ts +12 -6
- package/src/mesh/mesh-events-coordinator.ts +165 -84
- package/src/mesh/mesh-events-pending.ts +14 -15
- package/src/mesh/mesh-ledger.ts +1 -0
- package/src/mesh/mesh-reconcile-loop.ts +67 -7
- package/src/mesh/mesh-runtime-store.ts +17 -0
- package/src/mesh/mesh-work-queue.ts +89 -0
- package/src/providers/approval-utils.d.ts +1 -0
- package/src/providers/approval-utils.ts +10 -0
- package/src/providers/cli-provider-instance.ts +73 -19
- package/src/providers/sdk/v1/builders/cli/detect-status.ts +51 -0
|
@@ -5,6 +5,7 @@ import { LOG } from '../logging/logger.js';
|
|
|
5
5
|
import { getLedgerDir, readLedgerEntries, appendLedgerEntry } from './mesh-ledger.js';
|
|
6
6
|
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
7
7
|
import { buildMeshSystemMessage, readNonEmptyString, readRecord, resolveEventSessionId, readMeshCompletionSummary } from './mesh-events-utils.js';
|
|
8
|
+
import { expandDaemonIdForms } from '@adhdev/mesh-shared';
|
|
8
9
|
|
|
9
10
|
// ---------------------------------------------------------------------------
|
|
10
11
|
// MCP coordinator pending-event queue — FILE-BASED PERSISTENCE
|
|
@@ -48,24 +49,22 @@ export interface PendingMeshCoordinatorEvent {
|
|
|
48
49
|
const REFINE_TERMINAL_EVENTS = new Set(['refine:completed', 'refine:failed']);
|
|
49
50
|
|
|
50
51
|
/** Normalise a coordinator-daemon-id argument (single id, list, or undefined) into a
|
|
51
|
-
* de-duplicated list of non-empty strings
|
|
52
|
-
*
|
|
52
|
+
* de-duplicated list of non-empty strings, EXPANDED to every equivalent daemon-id
|
|
53
|
+
* form (bare `mach_X` ≡ `daemon_mach_X` ≡ `standalone_mach_X`).
|
|
54
|
+
*
|
|
55
|
+
* A coordinator resolves its own id through one path (status instanceId, the config-
|
|
56
|
+
* form node daemonId, or the bare machineId) but a worker stamps a completion's
|
|
57
|
+
* `coordinator_daemon_id` through another, so the two are routinely in DIFFERENT
|
|
58
|
+
* forms of the SAME machine. The scope filter is an exact-string match, so without
|
|
59
|
+
* expansion a `daemon_mach_X`-scoped completion is silently skipped by a coordinator
|
|
60
|
+
* that only knows itself as bare `mach_X` (the base-node completion-surface bug).
|
|
61
|
+
* Expanding here fixes every drain/peek/surface caller uniformly. The first ORIGINAL
|
|
62
|
+
* id stays at [0] so per-daemon JSONL file naming keeps its primary; expansion stays
|
|
63
|
+
* within one machine core so a different coordinator's events are never claimed. */
|
|
53
64
|
function normalizeCoordinatorDaemonIds(
|
|
54
65
|
coordinatorDaemonId?: string | null | ReadonlyArray<string>,
|
|
55
66
|
): string[] {
|
|
56
|
-
|
|
57
|
-
? coordinatorDaemonId
|
|
58
|
-
: coordinatorDaemonId != null ? [coordinatorDaemonId] : [];
|
|
59
|
-
const seen = new Set<string>();
|
|
60
|
-
const out: string[] = [];
|
|
61
|
-
for (const id of raw) {
|
|
62
|
-
if (typeof id !== 'string') continue;
|
|
63
|
-
const trimmed = id.trim();
|
|
64
|
-
if (!trimmed || seen.has(trimmed)) continue;
|
|
65
|
-
seen.add(trimmed);
|
|
66
|
-
out.push(trimmed);
|
|
67
|
-
}
|
|
68
|
-
return out;
|
|
67
|
+
return expandDaemonIdForms(coordinatorDaemonId);
|
|
69
68
|
}
|
|
70
69
|
|
|
71
70
|
export function readRefineJobId(event: { metadataEvent?: Record<string, unknown> } | Record<string, unknown>): string {
|
package/src/mesh/mesh-ledger.ts
CHANGED
|
@@ -56,7 +56,8 @@ import {
|
|
|
56
56
|
expireStaleUnresolvedDelegateForwards,
|
|
57
57
|
} from './mesh-unresolved-forward-outbox.js';
|
|
58
58
|
import { readNonEmptyString, readMeshCompletionSummary } from './mesh-events-utils.js';
|
|
59
|
-
import {
|
|
59
|
+
import { expandDaemonIdForms } from '@adhdev/mesh-shared';
|
|
60
|
+
import { getActiveDirectDispatches, getQueue, reclaimStrandedAssignedTask } from './mesh-work-queue.js';
|
|
60
61
|
import { readLedgerEntries } from './mesh-ledger.js';
|
|
61
62
|
import { pruneStaleDirectDispatches } from './mesh-active-work.js';
|
|
62
63
|
import { reconcileDirectDispatchCompletionFromTranscript } from './mesh-events-stale.js';
|
|
@@ -119,16 +120,19 @@ interface LiveCoordinator {
|
|
|
119
120
|
// - the daemon's canonical status id (`standalone_<machineId>` / `daemon_<machineId>`),
|
|
120
121
|
// stamped by the MCP layer via ctx.localDaemonId (= getStatus().status.instanceId), or
|
|
121
122
|
// - the bare machineId, stamped by the local queue-assignment path (loadConfig().machineId).
|
|
123
|
+
// - the config-form node daemonId (`daemon_<machineId>`), which the MCP layer's
|
|
124
|
+
// resolveCoordinatorDaemonId prefers and stamps onto direct-dispatch workers.
|
|
122
125
|
// Draining with only one of these silently misses events stamped with the other —
|
|
123
|
-
// the exact reason a generating coordinator never self-received local completions
|
|
124
|
-
//
|
|
126
|
+
// the exact reason a generating coordinator never self-received local completions,
|
|
127
|
+
// and the base-node completion-surface bug (base completions land full-form
|
|
128
|
+
// `daemon_<machineId>` while a coordinator that only knows itself as bare
|
|
129
|
+
// `<machineId>` never matches them). We expand to EVERY equivalent form so the
|
|
130
|
+
// scope match (host gate, self-node detection, and the drain IN-filter downstream)
|
|
131
|
+
// succeeds regardless of which path stamped the event.
|
|
125
132
|
function resolveCoordinatorDaemonIds(components: DaemonComponents): string[] {
|
|
126
|
-
const ids = new Set<string>();
|
|
127
133
|
const statusInstanceId = readNonEmptyString((components as { statusInstanceId?: string }).statusInstanceId);
|
|
128
|
-
if (statusInstanceId) ids.add(statusInstanceId);
|
|
129
134
|
const machineId = readNonEmptyString(loadConfig().machineId);
|
|
130
|
-
|
|
131
|
-
return [...ids];
|
|
135
|
+
return expandDaemonIdForms([statusInstanceId, machineId]);
|
|
132
136
|
}
|
|
133
137
|
|
|
134
138
|
// Whether THIS daemon is the coordinator/host for a mesh — i.e. the daemon that
|
|
@@ -316,6 +320,47 @@ function recordHeldTerminalEventsToLedger(
|
|
|
316
320
|
// PHASE 2 — Live CLI inject. For each mesh that has a live CLI coordinator on
|
|
317
321
|
// THIS daemon, drain the local queue and inject pending events into the PTY.
|
|
318
322
|
// Unchanged from before.
|
|
323
|
+
// Bug B: how long a row may sit 'assigned' with an unconfirmed dispatch before the
|
|
324
|
+
// watchdog reclaims it. Must be comfortably larger than the per-dispatch confirm
|
|
325
|
+
// timeout (DISPATCH_CONFIRM_TIMEOUT_MS in mesh-events-coordinator) so a slow-but-live
|
|
326
|
+
// dispatch still inside its normal confirm window is never reclaimed early — this is
|
|
327
|
+
// the durable backstop for the case the in-process confirm timer can't cover (a timer
|
|
328
|
+
// lost to a daemon restart between claim and confirm).
|
|
329
|
+
const ASSIGNED_STRANDED_DEADLINE_MS = 5 * 60_000;
|
|
330
|
+
|
|
331
|
+
// PHASE 2.5 — assigned-stranded dispatch watchdog (Bug B). claimNextTask atomically
|
|
332
|
+
// flips a row to 'assigned' BEFORE the fire-and-forget dispatch runs. If that dispatch
|
|
333
|
+
// neither rejects (→ no .catch requeue) nor is confirmed delivered — a relay that hangs
|
|
334
|
+
// without acking, or a confirm timer lost across a restart — the row stays 'assigned'
|
|
335
|
+
// forever: it contributes 0 pending, so PHASE 3 (gated on pendingQueueTaskCount>0) never
|
|
336
|
+
// re-examines it, and nothing but a manual requeue clears it. This is that missing net.
|
|
337
|
+
//
|
|
338
|
+
// Regression guard: a row whose delivery IS confirmed (delivered/acked/completed) is a
|
|
339
|
+
// genuinely in-flight (or completion-lost) task — left to PHASE 4's completion reconcile,
|
|
340
|
+
// never reclaimed here. And the deadline is generous so a slow-but-live dispatch still in
|
|
341
|
+
// its normal confirm window is never reclaimed early. Reclaimed rows return to 'pending'
|
|
342
|
+
// with ownership cleared, so the PHASE 3 trigger below re-dispatches them this same tick.
|
|
343
|
+
function recoverStrandedAssignedDispatches(meshId: string, store: MeshRuntimeStore): void {
|
|
344
|
+
const assigned = getQueue(meshId, { status: ['assigned'] });
|
|
345
|
+
if (!assigned.length) return;
|
|
346
|
+
const nowMs = Date.now();
|
|
347
|
+
for (const row of assigned) {
|
|
348
|
+
const dispatchedAtMs = Date.parse(row.dispatchTimestamp ?? '');
|
|
349
|
+
if (!Number.isFinite(dispatchedAtMs)) continue; // no dispatch ts → can't age it
|
|
350
|
+
if (nowMs - dispatchedAtMs < ASSIGNED_STRANDED_DEADLINE_MS) continue; // still in confirm window
|
|
351
|
+
if (store.taskHasConfirmedDelivery(meshId, row.id)) continue; // dispatched → PHASE 4's job
|
|
352
|
+
const reclaimed = reclaimStrandedAssignedTask(meshId, row.id, {
|
|
353
|
+
reason: 'assigned_stranded_dispatch_unconfirmed',
|
|
354
|
+
ageMs: nowMs - dispatchedAtMs,
|
|
355
|
+
});
|
|
356
|
+
if (reclaimed) {
|
|
357
|
+
LOG.warn('MeshReconcile', `Reclaimed stranded assigned task ${row.id} on mesh ${meshId} `
|
|
358
|
+
+ `(node=${row.assignedNodeId ?? '?'} session=${row.assignedSessionId ?? '?'}, dispatched `
|
|
359
|
+
+ `${Math.round((nowMs - dispatchedAtMs) / 1000)}s ago, never confirmed delivered → ${reclaimed.status})`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
319
364
|
export async function runMeshReconcileTick(components: DaemonComponents): Promise<void> {
|
|
320
365
|
const localDaemonId = readNonEmptyString(loadConfig().machineId) || undefined;
|
|
321
366
|
// The id-set used to scope the local queue drain (status id + machineId). See
|
|
@@ -360,6 +405,21 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
|
|
|
360
405
|
}
|
|
361
406
|
}
|
|
362
407
|
|
|
408
|
+
// ── PHASE 2.5: assigned-stranded dispatch watchdog (Bug B) ─────────────────
|
|
409
|
+
// Runs before PHASE 3 so any row it returns to 'pending' is re-dispatched by the
|
|
410
|
+
// PHASE 3 trigger in this same tick. See recoverStrandedAssignedDispatches.
|
|
411
|
+
if (store) {
|
|
412
|
+
for (const mesh of listMeshes()) {
|
|
413
|
+
const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
|
|
414
|
+
if (!daemonHostsMesh(mesh, selfIds)) continue;
|
|
415
|
+
try {
|
|
416
|
+
recoverStrandedAssignedDispatches(mesh.id, store);
|
|
417
|
+
} catch (e: any) {
|
|
418
|
+
LOG.warn('MeshReconcile', `Assigned-stranded watchdog failed for mesh ${mesh.id}: ${e?.message || e}`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
363
423
|
// ── PHASE 3: recover pending queue claims for newly-idle sessions ──────────
|
|
364
424
|
// The event-driven claim paths (agent:ready / agent:generating_completed in
|
|
365
425
|
// mesh-events-coordinator) re-claim the queue the moment a session goes idle,
|
|
@@ -1075,6 +1075,23 @@ export class MeshRuntimeStore {
|
|
|
1075
1075
|
}));
|
|
1076
1076
|
}
|
|
1077
1077
|
|
|
1078
|
+
/**
|
|
1079
|
+
* Bug B watchdog support: true when at least one delivery record for the task has
|
|
1080
|
+
* reached a confirmed-handed-off status (delivered / acked / completed). The
|
|
1081
|
+
* assigned-stranded watchdog uses this to distinguish a dispatch that was never
|
|
1082
|
+
* confirmed (reclaimable) from one that WAS handed to the worker (a genuinely
|
|
1083
|
+
* in-flight or completion-lost task, which is PHASE 4's responsibility, not this
|
|
1084
|
+
* watchdog's). Indexed by (mesh_id, task_id).
|
|
1085
|
+
*/
|
|
1086
|
+
taskHasConfirmedDelivery(meshId: string, taskId: string): boolean {
|
|
1087
|
+
const row = this.db.prepare(`
|
|
1088
|
+
SELECT 1 FROM mesh_session_delivery
|
|
1089
|
+
WHERE mesh_id = ? AND task_id = ? AND status IN ('delivered','acked','completed')
|
|
1090
|
+
LIMIT 1
|
|
1091
|
+
`).get(meshId, taskId) as { 1: number } | undefined;
|
|
1092
|
+
return !!row;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1078
1095
|
expireStaleSessionDeliveries(meshId: string): void {
|
|
1079
1096
|
const now = new Date().toISOString();
|
|
1080
1097
|
this.db.prepare(`
|
|
@@ -5,6 +5,8 @@ import { MESH_CONVERGE_REFINE_TAG, resolveAutoConvergeCodeChange } from '../repo
|
|
|
5
5
|
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
6
6
|
import { getMesh } from '../config/mesh-config.js';
|
|
7
7
|
import { LOG } from '../logging/logger.js';
|
|
8
|
+
import { appendLedgerEntry } from './mesh-ledger.js';
|
|
9
|
+
import type { MeshLedgerKind } from './mesh-ledger.js';
|
|
8
10
|
|
|
9
11
|
export type MeshTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
|
|
10
12
|
export type MeshActiveTaskStatus = Extract<MeshTaskStatus, 'pending' | 'assigned'>;
|
|
@@ -339,6 +341,14 @@ export interface MeshWorkQueueEntry {
|
|
|
339
341
|
requeueCount?: number;
|
|
340
342
|
/** Max automatic requeue attempts. When requeueCount reaches this, task is auto-failed. */
|
|
341
343
|
maxRetries?: number;
|
|
344
|
+
/**
|
|
345
|
+
* Bug B: number of times the reconcile assigned-stranded watchdog has reclaimed this
|
|
346
|
+
* row from 'assigned' back to 'pending' because its dispatch was never confirmed
|
|
347
|
+
* delivered. Separate from requeueCount (operator/execution retries) and bounded by
|
|
348
|
+
* MAX_STRANDED_RECLAIMS so a permanently-undeliverable target auto-fails rather than
|
|
349
|
+
* cycling reclaim→re-dispatch→strand forever.
|
|
350
|
+
*/
|
|
351
|
+
strandedReclaimCount?: number;
|
|
342
352
|
/** Last automatic queue session spin-up attempt, for mesh_view_queue/debug visibility. */
|
|
343
353
|
autoLaunch?: {
|
|
344
354
|
status: 'skipped' | 'started' | 'failed' | 'completed';
|
|
@@ -887,6 +897,85 @@ export function requeueTask(
|
|
|
887
897
|
});
|
|
888
898
|
}
|
|
889
899
|
|
|
900
|
+
/**
|
|
901
|
+
* Max times the assigned-stranded watchdog will reclaim a single task before giving
|
|
902
|
+
* up and failing it. Bounds the reclaim→re-dispatch→strand cycle so a permanently
|
|
903
|
+
* undeliverable target (e.g. a node whose transport is wedged) eventually fails and
|
|
904
|
+
* unblocks its dependents instead of looping every reconcile tick.
|
|
905
|
+
*/
|
|
906
|
+
const MAX_STRANDED_RECLAIMS = 3;
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Bug B: reclaim a task stuck in 'assigned' because its dispatch was never confirmed.
|
|
910
|
+
*
|
|
911
|
+
* claimNextTask atomically marks a row 'assigned' BEFORE the fire-and-forget dispatch
|
|
912
|
+
* runs. If that dispatch neither rejects (→ no .catch requeue) nor is confirmed
|
|
913
|
+
* delivered — a relay that hangs without acking, or a confirm timer lost across a
|
|
914
|
+
* daemon restart — the row stays 'assigned' forever, contributing 0 pending so PHASE 3
|
|
915
|
+
* reconcile never re-examines it. This returns such a row to 'pending' and clears its
|
|
916
|
+
* dead assignment ownership (node / session / provider / dispatchTimestamp) — the same
|
|
917
|
+
* ownership-clear requeueTask applies — so PHASE 3 can re-dispatch it onto a fresh idle
|
|
918
|
+
* session.
|
|
919
|
+
*
|
|
920
|
+
* Guarded to 'assigned' rows only (a completion/cancel that already moved the row off
|
|
921
|
+
* 'assigned' must never be resurrected) and bounded by MAX_STRANDED_RECLAIMS (beyond
|
|
922
|
+
* which the task is failed so dependents unblock).
|
|
923
|
+
*/
|
|
924
|
+
export function reclaimStrandedAssignedTask(
|
|
925
|
+
meshId: string,
|
|
926
|
+
taskId: string,
|
|
927
|
+
opts?: { reason?: string; ageMs?: number } & MeshQueueMutationOptions,
|
|
928
|
+
): MeshWorkQueueEntry | null {
|
|
929
|
+
requireMeshHostQueueOwner(opts);
|
|
930
|
+
return withQueueLock(meshId, () => {
|
|
931
|
+
const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
|
|
932
|
+
if (!entry) return null;
|
|
933
|
+
// Only a still-assigned row is stranded. If a completion/cancel already moved it
|
|
934
|
+
// off 'assigned', there is nothing to reclaim — never resurrect a terminal row.
|
|
935
|
+
if (entry.status !== 'assigned') return null;
|
|
936
|
+
const now = new Date().toISOString();
|
|
937
|
+
const reason = opts?.reason || 'assigned_stranded_dispatch_unconfirmed';
|
|
938
|
+
const reclaims = (entry.strandedReclaimCount || 0) + 1;
|
|
939
|
+
const prevNode = entry.assignedNodeId;
|
|
940
|
+
const prevSession = entry.assignedSessionId;
|
|
941
|
+
// Always clear the dead assignment ownership so a re-claim starts clean and the
|
|
942
|
+
// assigned-counters (which filter status==='assigned') stop counting this row.
|
|
943
|
+
delete entry.assignedNodeId;
|
|
944
|
+
delete entry.assignedSessionId;
|
|
945
|
+
delete entry.assignedProviderType;
|
|
946
|
+
delete entry.dispatchTimestamp;
|
|
947
|
+
entry.strandedReclaimCount = reclaims;
|
|
948
|
+
entry.updatedAt = now;
|
|
949
|
+
if (reclaims > MAX_STRANDED_RECLAIMS) {
|
|
950
|
+
// Repeatedly undeliverable — stop cycling and fail it so dependents unblock.
|
|
951
|
+
entry.status = 'failed';
|
|
952
|
+
entry.cancelReason = `stranded_dispatch_unrecovered: reclaimed ${reclaims - 1} time(s) without a confirmed dispatch`;
|
|
953
|
+
MeshRuntimeStore.getInstance().updateQueueEntry(entry);
|
|
954
|
+
propagateDependencyFailure(meshId, taskId);
|
|
955
|
+
} else {
|
|
956
|
+
entry.status = 'pending';
|
|
957
|
+
entry.requeuedAt = now;
|
|
958
|
+
entry.requeueReason = reason;
|
|
959
|
+
MeshRuntimeStore.getInstance().updateQueueEntry(entry);
|
|
960
|
+
}
|
|
961
|
+
try {
|
|
962
|
+
appendLedgerEntry(meshId, {
|
|
963
|
+
kind: 'task_reclaimed' as MeshLedgerKind,
|
|
964
|
+
nodeId: prevNode,
|
|
965
|
+
sessionId: prevSession,
|
|
966
|
+
payload: {
|
|
967
|
+
taskId,
|
|
968
|
+
reason,
|
|
969
|
+
...(typeof opts?.ageMs === 'number' ? { ageMs: opts.ageMs } : {}),
|
|
970
|
+
reclaimCount: reclaims,
|
|
971
|
+
outcome: entry.status,
|
|
972
|
+
},
|
|
973
|
+
});
|
|
974
|
+
} catch { /* ledger write is best-effort */ }
|
|
975
|
+
return entry;
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
|
|
890
979
|
/**
|
|
891
980
|
* Update the status of the task currently assigned to a specific session.
|
|
892
981
|
*/
|
|
@@ -8,4 +8,5 @@ export declare function pickAutoApprovalButton(buttons: string[] | null | undefi
|
|
|
8
8
|
index: number;
|
|
9
9
|
label: string;
|
|
10
10
|
};
|
|
11
|
+
export declare function hasNegativeApprovalOption(buttons: string[] | null | undefined): boolean;
|
|
11
12
|
export declare function formatAutoApprovalMessage(modalMessage?: string, buttonLabel?: string): string;
|
|
@@ -31,6 +31,16 @@ function isNegativeApprovalLabel(value: string): boolean {
|
|
|
31
31
|
|| /\bdo not\b/.test(label);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* True when any of the given button labels reads as a decline/negative option
|
|
36
|
+
* (No / Deny / Cancel / Skip / …). Used as the second half of an approval-modal
|
|
37
|
+
* structural anchor: a real approval modal offers BOTH an affirmative and a
|
|
38
|
+
* decline, which distinguishes it from a generic numbered menu or prose list.
|
|
39
|
+
*/
|
|
40
|
+
export function hasNegativeApprovalOption(buttons: string[] | null | undefined): boolean {
|
|
41
|
+
return (buttons || []).some((button) => isNegativeApprovalLabel(String(button || '')));
|
|
42
|
+
}
|
|
43
|
+
|
|
34
44
|
export function getApprovalPositiveHints(provider?: Pick<ProviderModule, 'approvalPositiveHints'> | null): string[] {
|
|
35
45
|
const customHints = Array.isArray(provider?.approvalPositiveHints)
|
|
36
46
|
? provider.approvalPositiveHints
|
|
@@ -376,6 +376,21 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
376
376
|
*/
|
|
377
377
|
private static readonly AUTO_APPROVE_SETTLE_MS = 600;
|
|
378
378
|
|
|
379
|
+
/**
|
|
380
|
+
* Busy-side hysteresis for the settle gate. A momentary `generating` flip
|
|
381
|
+
* while the SAME approval modal's button block is still on screen (its
|
|
382
|
+
* question line scrolled out of the captured frame, only the buttons + a
|
|
383
|
+
* residual `esc to interrupt` spinner remain) briefly reports
|
|
384
|
+
* status!=waiting_approval. Without hysteresis that flip wipes the settle
|
|
385
|
+
* clock, and the modal→generating→modal flap restarts the 600ms window
|
|
386
|
+
* every time so auto-approve never fires. We keep the in-progress settle
|
|
387
|
+
* gate warm across an inactive blip up to this bound; only once the modal
|
|
388
|
+
* has genuinely stayed gone this long (a real resolution → idle) is the
|
|
389
|
+
* gate cleared. Bounded so a genuinely new, later approval still re-settles
|
|
390
|
+
* from scratch rather than firing on a stale timestamp.
|
|
391
|
+
*/
|
|
392
|
+
private static readonly AUTO_APPROVE_GATE_HYSTERESIS_MS = 1500;
|
|
393
|
+
|
|
379
394
|
private adapter: ProviderCliAdapter;
|
|
380
395
|
private context: InstanceContext | null = null;
|
|
381
396
|
private events: ProviderEvent[] = [];
|
|
@@ -397,6 +412,10 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
397
412
|
private pendingAutoApprovalSignature = '';
|
|
398
413
|
private pendingAutoApprovalSince = 0;
|
|
399
414
|
private autoApproveSettleTimer: NodeJS.Timeout | null = null;
|
|
415
|
+
// Wall-clock when auto-approve first observed status!=waiting_approval while
|
|
416
|
+
// a settle gate was in progress. Drives AUTO_APPROVE_GATE_HYSTERESIS_MS so a
|
|
417
|
+
// brief generating flip does not immediately wipe the settle clock.
|
|
418
|
+
private autoApproveInactiveSince = 0;
|
|
400
419
|
private controlValues: Record<string, string | number | boolean> = {};
|
|
401
420
|
private summaryMetadata: unknown = undefined;
|
|
402
421
|
private appliedEffectKeys = new Set<string>();
|
|
@@ -1471,13 +1490,37 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1471
1490
|
// still inside the short busy window.
|
|
1472
1491
|
if (!autoApproveActive) {
|
|
1473
1492
|
this.lastAutoApprovalSignature = '';
|
|
1493
|
+
// Hysteresis: if a settle gate is mid-progress, a momentary
|
|
1494
|
+
// status!=waiting_approval blip (a generating flip while the same
|
|
1495
|
+
// modal's button block is still on screen) must NOT wipe the settle
|
|
1496
|
+
// clock — otherwise the modal→generating→modal flap restarts the
|
|
1497
|
+
// 600ms window every time and auto-approve never fires. Keep the
|
|
1498
|
+
// gate warm for AUTO_APPROVE_GATE_HYSTERESIS_MS; re-arm a timer so
|
|
1499
|
+
// that if the modal does NOT come back the gate is cleared on the
|
|
1500
|
+
// re-check (a genuine resolution → idle frees the gate normally).
|
|
1501
|
+
if (this.pendingAutoApprovalSince) {
|
|
1502
|
+
if (!this.autoApproveInactiveSince) this.autoApproveInactiveSince = now;
|
|
1503
|
+
const goneForMs = now - this.autoApproveInactiveSince;
|
|
1504
|
+
if (goneForMs < CliProviderInstance.AUTO_APPROVE_GATE_HYSTERESIS_MS) {
|
|
1505
|
+
if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
|
|
1506
|
+
this.autoApproveSettleTimer = setTimeout(() => {
|
|
1507
|
+
this.autoApproveSettleTimer = null;
|
|
1508
|
+
this.recheckAutoApproveSettled();
|
|
1509
|
+
}, CliProviderInstance.AUTO_APPROVE_GATE_HYSTERESIS_MS - goneForMs + 20);
|
|
1510
|
+
return autoApproveActive;
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1474
1513
|
// Clear the settle gate so the next approval starts its own quiet
|
|
1475
1514
|
// window from scratch (a stale timestamp would let it fire instantly).
|
|
1476
1515
|
this.pendingAutoApprovalSignature = '';
|
|
1477
1516
|
this.pendingAutoApprovalSince = 0;
|
|
1517
|
+
this.autoApproveInactiveSince = 0;
|
|
1478
1518
|
if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
|
|
1479
1519
|
return autoApproveActive;
|
|
1480
1520
|
}
|
|
1521
|
+
// Active approval observed — reset the inactivity tracker so a later
|
|
1522
|
+
// blip starts its hysteresis window fresh.
|
|
1523
|
+
this.autoApproveInactiveSince = 0;
|
|
1481
1524
|
const modal = adapterStatus.activeModal;
|
|
1482
1525
|
// (fix) Do not auto-approve when no concrete modal/buttons are present.
|
|
1483
1526
|
// Claude TUI flaps between paints; without this guard adapterStatus
|
|
@@ -1497,34 +1540,44 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1497
1540
|
// surface the modal so the user can decide.
|
|
1498
1541
|
return autoApproveActive;
|
|
1499
1542
|
}
|
|
1500
|
-
//
|
|
1501
|
-
//
|
|
1502
|
-
//
|
|
1503
|
-
//
|
|
1543
|
+
// Modal *identity* signature — the question/button set only, NO volatile
|
|
1544
|
+
// counters. This is what the settle gate tracks: the FSM bumps
|
|
1545
|
+
// approvalEntrySeq on every fresh waiting_approval entry, and a
|
|
1546
|
+
// modal→generating→modal flap (the question line scrolled out of the
|
|
1547
|
+
// captured frame while the button block stays) re-enters and bumps it
|
|
1548
|
+
// again. Folding that seq into the settle signature made the 600ms
|
|
1549
|
+
// settle clock restart on every flap, so the modal never stayed stable
|
|
1550
|
+
// long enough to fire — the gate was never satisfied. Identity excludes
|
|
1551
|
+
// the seq so button/seq flap of the SAME modal keeps one settle clock.
|
|
1552
|
+
const modalSignature = [
|
|
1553
|
+
typeof modal?.message === 'string' ? modal.message.trim() : '',
|
|
1554
|
+
buttons.join('|'),
|
|
1555
|
+
buttonIndex,
|
|
1556
|
+
].join('::');
|
|
1557
|
+
// Busy-window re-entry guard still needs the seq: two DISTINCT
|
|
1558
|
+
// back-to-back approvals can carry identical message/buttons (common
|
|
1559
|
+
// with claude-cli). Without the seq their busy signatures collide and
|
|
1560
|
+
// the 5s busy-window guard below would swallow the second auto-approve,
|
|
1504
1561
|
// leaving it stuck. The seq is bumped by the FSM on every fresh
|
|
1505
|
-
// waiting_approval entry, so a new approval always yields a new
|
|
1562
|
+
// waiting_approval entry, so a new approval always yields a new busy
|
|
1506
1563
|
// signature and fires through.
|
|
1507
1564
|
const approvalEntrySeq = typeof adapterStatus?.approvalEntrySeq === 'number'
|
|
1508
1565
|
? adapterStatus.approvalEntrySeq
|
|
1509
1566
|
: 0;
|
|
1510
|
-
const
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
].join('::');
|
|
1516
|
-
// Already fired for this exact modal and still inside the busy window —
|
|
1517
|
-
// nothing to do (re-entry guard for repeated snapshots of one modal).
|
|
1518
|
-
if (this.autoApproveBusy && signature === this.lastAutoApprovalSignature) {
|
|
1567
|
+
const busySignature = `${approvalEntrySeq}::${modalSignature}`;
|
|
1568
|
+
// Already fired for this exact modal entry and still inside the busy
|
|
1569
|
+
// window — nothing to do (re-entry guard for repeated snapshots of one
|
|
1570
|
+
// modal).
|
|
1571
|
+
if (this.autoApproveBusy && busySignature === this.lastAutoApprovalSignature) {
|
|
1519
1572
|
return autoApproveActive;
|
|
1520
1573
|
}
|
|
1521
1574
|
|
|
1522
|
-
// Settle gate: only fire once this
|
|
1575
|
+
// Settle gate: only fire once this modal identity has been stable for
|
|
1523
1576
|
// AUTO_APPROVE_SETTLE_MS. A still-streaming prompt mutates its
|
|
1524
|
-
// message/buttons each frame → new
|
|
1577
|
+
// message/buttons each frame → new identity → clock restarts, so we
|
|
1525
1578
|
// never approve a half-rendered prompt (the "resolves too fast" bug).
|
|
1526
|
-
if (
|
|
1527
|
-
this.pendingAutoApprovalSignature =
|
|
1579
|
+
if (modalSignature !== this.pendingAutoApprovalSignature) {
|
|
1580
|
+
this.pendingAutoApprovalSignature = modalSignature;
|
|
1528
1581
|
this.pendingAutoApprovalSince = now;
|
|
1529
1582
|
}
|
|
1530
1583
|
const settledForMs = now - this.pendingAutoApprovalSince;
|
|
@@ -1543,9 +1596,10 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1543
1596
|
// Settled — fire the approve key.
|
|
1544
1597
|
if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
|
|
1545
1598
|
this.autoApproveBusy = true;
|
|
1546
|
-
this.lastAutoApprovalSignature =
|
|
1599
|
+
this.lastAutoApprovalSignature = busySignature;
|
|
1547
1600
|
this.pendingAutoApprovalSignature = '';
|
|
1548
1601
|
this.pendingAutoApprovalSince = 0;
|
|
1602
|
+
this.autoApproveInactiveSince = 0;
|
|
1549
1603
|
if (this.autoApproveBusyTimer) clearTimeout(this.autoApproveBusyTimer);
|
|
1550
1604
|
this.autoApproveBusyTimer = setTimeout(() => {
|
|
1551
1605
|
this.autoApproveBusy = false;
|
|
@@ -28,6 +28,10 @@ import {
|
|
|
28
28
|
applyVisibleRegion,
|
|
29
29
|
type VisibleRegionSpec,
|
|
30
30
|
} from './visible-region.js';
|
|
31
|
+
import {
|
|
32
|
+
pickApprovalButton,
|
|
33
|
+
hasNegativeApprovalOption,
|
|
34
|
+
} from '../../../../approval-utils.js';
|
|
31
35
|
|
|
32
36
|
// ─── Primitive spec shapes (mirror the JSON schemas) ───────────────────
|
|
33
37
|
|
|
@@ -69,6 +73,7 @@ interface ModalSpec {
|
|
|
69
73
|
questionVariants?: Array<{ regex: string; flags?: string; label?: string }>;
|
|
70
74
|
buttonPattern: string;
|
|
71
75
|
buttonFlags?: string;
|
|
76
|
+
buttonLabelGroup?: number;
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
export type DispatchGroup =
|
|
@@ -163,6 +168,48 @@ function settledPromptMatches(spec: SettledPromptSpec, input: CliStatusInput): b
|
|
|
163
168
|
return footers.every((f) => f.test(text));
|
|
164
169
|
}
|
|
165
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Extract approval-button labels from every line matching the modal spec's
|
|
173
|
+
* `buttonPattern`. Mirrors buildParseApprovalFromTui's per-line extraction but
|
|
174
|
+
* scans the whole screen with no question-line anchor, so the cue survives the
|
|
175
|
+
* question line scrolling out of the captured frame during long runs of
|
|
176
|
+
* consecutive approvals.
|
|
177
|
+
*/
|
|
178
|
+
function extractButtonLabels(spec: ModalSpec, text: string): string[] {
|
|
179
|
+
if (!text) return [];
|
|
180
|
+
const flags = spec.buttonFlags && spec.buttonFlags.includes('m')
|
|
181
|
+
? spec.buttonFlags
|
|
182
|
+
: `${spec.buttonFlags ?? ''}m`;
|
|
183
|
+
const buttonRe = compile(spec.buttonPattern, flags);
|
|
184
|
+
const labelGroup = Number.isInteger(spec.buttonLabelGroup) && (spec.buttonLabelGroup ?? 0) > 0
|
|
185
|
+
? spec.buttonLabelGroup!
|
|
186
|
+
: 1;
|
|
187
|
+
const out: string[] = [];
|
|
188
|
+
for (const line of text.split('\n')) {
|
|
189
|
+
buttonRe.lastIndex = 0;
|
|
190
|
+
const m = buttonRe.exec(line);
|
|
191
|
+
if (!m) continue;
|
|
192
|
+
const captured = m[labelGroup] ?? (labelGroup === 1 && m.length > 2 ? m[m.length - 1] : undefined);
|
|
193
|
+
if (captured && captured.trim()) out.push(captured.trim());
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* (fixB ①) The approval's selectable button block is itself a modal cue.
|
|
200
|
+
* Anchored on approval *verbs* so it generalizes across CLIs without trusting
|
|
201
|
+
* any single spec's `buttonPattern` specificity: a real approval modal offers
|
|
202
|
+
* BOTH an affirmative option (Yes/Allow/Continue/…) and a decline (No/Deny/
|
|
203
|
+
* Cancel/Skip/…). A generic numbered menu, a single prose "1. Yes …" line, or
|
|
204
|
+
* an assistant enumeration lacks that pair and does not fire the cue.
|
|
205
|
+
*/
|
|
206
|
+
function buttonBlockApprovalCue(spec: ModalSpec, text: string): boolean {
|
|
207
|
+
const labels = extractButtonLabels(spec, text);
|
|
208
|
+
if (labels.length < 2) return false;
|
|
209
|
+
if (pickApprovalButton(labels).index < 0) return false;
|
|
210
|
+
return hasNegativeApprovalOption(labels);
|
|
211
|
+
}
|
|
212
|
+
|
|
166
213
|
function modalMatches(spec: ModalSpec, input: CliStatusInput): boolean {
|
|
167
214
|
// Status-level modal detection is cue-only — does the question appear at all?
|
|
168
215
|
// Button extraction lives in buildParseApprovalFromTui.
|
|
@@ -173,6 +220,10 @@ function modalMatches(spec: ModalSpec, input: CliStatusInput): boolean {
|
|
|
173
220
|
const re = compile(variant.regex, variant.flags ?? 'i');
|
|
174
221
|
if (re.test(text)) return true;
|
|
175
222
|
}
|
|
223
|
+
// The question line can scroll out of the captured frame while the button
|
|
224
|
+
// block (and a residual spinner) remain. Hold the modal cue on the button
|
|
225
|
+
// block alone so waiting_approval does not flap to generating mid-approval.
|
|
226
|
+
if (buttonBlockApprovalCue(spec, text)) return true;
|
|
176
227
|
return false;
|
|
177
228
|
}
|
|
178
229
|
|