@adhdev/daemon-core 0.9.82-rc.508 → 0.9.82-rc.509
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/config/mesh-config.d.ts +12 -0
- package/dist/index.js +246 -80
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +246 -80
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-node-slots.d.ts +22 -0
- package/dist/mesh/mesh-runtime-store.d.ts +2 -2
- package/dist/mesh/mesh-scheduling-runtime.d.ts +5 -1
- package/dist/mesh/mesh-work-queue.d.ts +3 -3
- package/dist/providers/cli-provider-instance-types.d.ts +1 -0
- package/dist/providers/cli-provider-instance.d.ts +54 -0
- package/dist/repo-mesh-types.d.ts +30 -29
- package/package.json +3 -3
- package/src/commands/med-family/mesh-crud.ts +24 -14
- package/src/config/mesh-config.ts +71 -24
- package/src/mesh/coordinator-prompt.ts +19 -14
- package/src/mesh/mesh-node-slots.ts +52 -0
- package/src/mesh/mesh-queue-assignment.ts +23 -23
- package/src/mesh/mesh-runtime-store.ts +3 -3
- package/src/mesh/mesh-scheduling-runtime.ts +24 -9
- package/src/mesh/mesh-work-queue.ts +3 -3
- package/src/providers/cli-provider-instance-types.ts +15 -0
- package/src/providers/cli-provider-instance.ts +174 -2
- package/src/repo-mesh-types.ts +39 -38
|
@@ -2,7 +2,7 @@ import { existsSync } from 'fs';
|
|
|
2
2
|
import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
|
|
3
3
|
import { MESH_CONNECT_TIMEOUT_MS } from '../runtime-defaults.js';
|
|
4
4
|
import { loadConfig } from '../config/config.js';
|
|
5
|
-
import { getMesh
|
|
5
|
+
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';
|
|
@@ -15,7 +15,8 @@ import { traceMeshEventDrop } from './mesh-event-trace.js';
|
|
|
15
15
|
import { awaitWithWarmupDeadline, resolveWarmupDeadlineOpts } from './mesh-warmup-deadline.js';
|
|
16
16
|
import { resolveDelegatedWorkerAutoApprove, resolveProviderMaxParallel, resolveNodeSchedulingPriority, normalizeMeshSchedulingStrategy, resolveMaxParallelTasks, resolveMaxReadonlyParallelTasks } from '../repo-mesh-types.js';
|
|
17
17
|
import type { RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
|
|
18
|
-
import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, canonicalDaemonId, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, sessionIdsEquivalent,
|
|
18
|
+
import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, canonicalDaemonId, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, sessionIdsEquivalent, normalizeNodeCapabilitySlots, isMeshTaskDifficulty, withStatusProbeMarker, type MeshNodeIdentified, type NodeCapabilitySlot, type MeshTaskDifficulty } from '@adhdev/mesh-shared';
|
|
19
|
+
import { resolveNodeCapabilitySlots } from './mesh-node-slots.js';
|
|
19
20
|
import { findTerminalLedgerEvidenceForTask, hasUnterminalDirectDispatchLedgerEntry } from './mesh-events-stale.js';
|
|
20
21
|
import { readNonEmptyString } from './mesh-events-utils.js';
|
|
21
22
|
import { readMeshNodeDaemonId } from './mesh-node-identity.js';
|
|
@@ -505,11 +506,12 @@ export function tryAssignQueueTask(
|
|
|
505
506
|
}
|
|
506
507
|
|
|
507
508
|
const capabilityTags = buildMeshNodeCapabilityTags(node, providerType);
|
|
508
|
-
// Per-(node, provider) maxParallel cap (
|
|
509
|
-
// on top of the global/taskMode caps — stricter wins. Resolved
|
|
510
|
-
// claiming session's providerType + node policy are both known,
|
|
511
|
-
// inside the atomic claim transaction so concurrent claims can't
|
|
512
|
-
|
|
509
|
+
// Per-(node, provider) maxParallel cap (summed across the node's slots for this
|
|
510
|
+
// provider) layers on top of the global/taskMode caps — stricter wins. Resolved
|
|
511
|
+
// here where the claiming session's providerType + node policy are both known,
|
|
512
|
+
// then enforced inside the atomic claim transaction so concurrent claims can't
|
|
513
|
+
// overshoot it.
|
|
514
|
+
const providerMaxParallel = resolveProviderMaxParallel(resolveNodeCapabilitySlots(node), providerType);
|
|
513
515
|
// WTDISPATCH-FANOUT: tell the atomic claim whether the claiming node is a worktree
|
|
514
516
|
// clone so a `convergence` task (base-only: merge → push → cleanup) is refused for
|
|
515
517
|
// worktree sessions. Without it, every sibling worktree session on this daemon could
|
|
@@ -1280,8 +1282,8 @@ export function __buildSchedulingPoolForTests(
|
|
|
1280
1282
|
//
|
|
1281
1283
|
// A node's capability slots are the single source of truth for routing. When a
|
|
1282
1284
|
// node has explicit `policy.slots` we use them; otherwise we derive slots from the
|
|
1283
|
-
// legacy providerPriority
|
|
1284
|
-
//
|
|
1285
|
+
// legacy providerPriority + the machine-global difficultyBrains so existing nodes
|
|
1286
|
+
// keep working (back-compat). The fitness scorer ranks a node for a
|
|
1285
1287
|
// specific task by how well its best slot matches the task's difficulty and
|
|
1286
1288
|
// required tags — with graceful fallback so a task is never blocked by a missing
|
|
1287
1289
|
// exact match.
|
|
@@ -1293,19 +1295,6 @@ interface FitnessTask {
|
|
|
1293
1295
|
requiredTags?: string[];
|
|
1294
1296
|
}
|
|
1295
1297
|
|
|
1296
|
-
/** Resolve a node's capability slots: explicit policy.slots, else derived from legacy. */
|
|
1297
|
-
function resolveNodeCapabilitySlots(node: any): NodeCapabilitySlot[] {
|
|
1298
|
-
const explicit = normalizeNodeCapabilitySlots(node?.policy?.slots);
|
|
1299
|
-
if (explicit.length) return explicit;
|
|
1300
|
-
let difficultyBrains: any;
|
|
1301
|
-
try { difficultyBrains = getDifficultyBrains(); } catch { difficultyBrains = undefined; }
|
|
1302
|
-
return deriveSlotsFromLegacy({
|
|
1303
|
-
providerPriority: normalizeProviderPriority(node?.policy),
|
|
1304
|
-
providerRoles: Array.isArray(node?.policy?.providerRoles) ? node.policy.providerRoles : undefined,
|
|
1305
|
-
difficultyBrains,
|
|
1306
|
-
});
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
1298
|
/**
|
|
1310
1299
|
* Score how well one slot fits a task. Higher = better. A slot whose difficulty
|
|
1311
1300
|
* range contains the task's difficulty scores highest; a general-purpose slot
|
|
@@ -1556,6 +1545,17 @@ function nodeHasLiveSessionPendingClaim(components: DaemonComponents, meshId: st
|
|
|
1556
1545
|
const state = inst.getState();
|
|
1557
1546
|
const settings = state.settings as Record<string, unknown> || {};
|
|
1558
1547
|
if (readNonEmptyString(settings.meshNodeFor) !== meshId) return false;
|
|
1548
|
+
// DISPATCH-DEADLOCK-COORD-SESSION-SLOT: a coordinator session for THIS mesh
|
|
1549
|
+
// (meshCoordinatorFor === meshId) is never a pending-claim worker — the idle→claim
|
|
1550
|
+
// drain (drainMeshQueue, isIdleSessionState + worker role) never picks it up, because
|
|
1551
|
+
// a coordinator is generating/non-idle and is the dispatcher, not a claimer. The claim
|
|
1552
|
+
// path excludes it structurally; the skip gate must apply the SAME exclusion. Without
|
|
1553
|
+
// this, a node whose only live mesh session is the coordinator makes this gate return
|
|
1554
|
+
// true, so no worker auto-launches and no session ever claims → the task pends forever
|
|
1555
|
+
// with no error/requeue (silent deadlock). The busy-set / non-idle guards below don't
|
|
1556
|
+
// help because the coordinator holds no *assigned* queue task, so it is neither busy
|
|
1557
|
+
// nor terminal here.
|
|
1558
|
+
if (readNonEmptyString(settings.meshCoordinatorFor) === meshId) return false;
|
|
1559
1559
|
const instNodeId = readNonEmptyString(settings.meshNodeId) || readNonEmptyString(settings.nodeId);
|
|
1560
1560
|
// Canonical-form match (see nodeHasActiveMeshWork / liveSessionCountForNode): a
|
|
1561
1561
|
// daemon-id form skew must not make a present session look absent and reopen the
|
|
@@ -2081,7 +2081,7 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
|
|
|
2081
2081
|
// Don't spawn a session for a (node, provider) already at its declared
|
|
2082
2082
|
// maxParallel cap — it would launch only to fail the claim. The claim
|
|
2083
2083
|
// transaction enforces the cap regardless; this just avoids a doomed launch.
|
|
2084
|
-
const providerCap = resolveProviderMaxParallel(node
|
|
2084
|
+
const providerCap = resolveProviderMaxParallel(resolveNodeCapabilitySlots(node), resolved.providerType);
|
|
2085
2085
|
if (
|
|
2086
2086
|
providerCap !== undefined
|
|
2087
2087
|
&& activeProviderAssignedCount(meshId, nodeId, resolved.providerType) >= providerCap
|
|
@@ -867,8 +867,8 @@ export class MeshRuntimeStore {
|
|
|
867
867
|
/**
|
|
868
868
|
* Count active (status='assigned') tasks on a (node, provider) combination,
|
|
869
869
|
* matched by the assignedProviderType stamped on the payload at claim time.
|
|
870
|
-
* Drives the per-(node, provider) maxParallel cap (
|
|
871
|
-
*
|
|
870
|
+
* Drives the per-(node, provider) maxParallel cap (summed across a provider's
|
|
871
|
+
* slots[].maxParallel). The active-assignment set for a single node is tiny, so
|
|
872
872
|
* parsing payloads here is cheap and avoids a schema migration. Pre-cap legacy
|
|
873
873
|
* rows (no provider stamp) and other providers on the same node do not consume
|
|
874
874
|
* this provider's budget, so the cap is fully backward compatible.
|
|
@@ -906,7 +906,7 @@ export class MeshRuntimeStore {
|
|
|
906
906
|
if (this.hasActiveSessionAssignment(meshId, sessionId)) return null;
|
|
907
907
|
const nodeBusy = this.hasActiveNodeAssignment(meshId, nodeId);
|
|
908
908
|
|
|
909
|
-
// Per-(node, provider) maxParallel cap (
|
|
909
|
+
// Per-(node, provider) maxParallel cap (summed slots[].maxParallel).
|
|
910
910
|
// Orthogonal to taskMode: this bounds the (node, provider) resource pool
|
|
911
911
|
// regardless of read-only vs write. When the cap is already met, this
|
|
912
912
|
// session cannot claim any candidate here — return null. This composes
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
resolveProviderMaxParallel,
|
|
22
22
|
} from '../repo-mesh-types.js';
|
|
23
23
|
import { normalizeMeshNodeId } from '@adhdev/mesh-shared';
|
|
24
|
+
import { resolveNodeCapabilitySlots } from './mesh-node-slots.js';
|
|
24
25
|
import type { MeshWorkQueueEntry } from './mesh-work-queue.js';
|
|
25
26
|
import { isTaskReadonly } from './mesh-work-queue.js';
|
|
26
27
|
|
|
@@ -44,7 +45,11 @@ export interface MeshNodeSchedulingRuntime {
|
|
|
44
45
|
schedulingPriority: number;
|
|
45
46
|
/** Per-node concurrent-session cap, when configured. */
|
|
46
47
|
maxConcurrentSessions?: number;
|
|
47
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Per-(node, provider) caps + consumption, when the node's slots declare a
|
|
50
|
+
* maxParallel for any provider. (Field name kept for dashboard back-compat;
|
|
51
|
+
* the cap source is now slots[].maxParallel, not the removed providerRoles.)
|
|
52
|
+
*/
|
|
48
53
|
providerRoles?: MeshNodeProviderSchedulingRuntime[];
|
|
49
54
|
/**
|
|
50
55
|
* True when the node currently cannot claim a NEW write (non-readonly) task —
|
|
@@ -147,17 +152,27 @@ export function buildMeshSchedulingRuntime(
|
|
|
147
152
|
// Write isolation: a node already holding an assigned write task can't take another.
|
|
148
153
|
if ((writeAssignedByNode.get(nodeId) ?? 0) > 0) capReasons.push('node_has_active_assignment');
|
|
149
154
|
|
|
150
|
-
// Per-(node, provider) caps, with live consumption.
|
|
155
|
+
// Per-(node, provider) caps, with live consumption. Derived from the node's
|
|
156
|
+
// resolved capability slots (explicit policy.slots, else legacy-derived): the
|
|
157
|
+
// distinct providers named by slots that declare a maxParallel cap, each cap
|
|
158
|
+
// summed across that provider's slots by resolveProviderMaxParallel.
|
|
151
159
|
let providerRoles: MeshNodeProviderSchedulingRuntime[] | undefined;
|
|
152
|
-
const
|
|
153
|
-
|
|
160
|
+
const slots = resolveNodeCapabilitySlots(rawNode);
|
|
161
|
+
const cappedProviders: string[] = [];
|
|
162
|
+
const seenProvider = new Set<string>();
|
|
163
|
+
for (const slot of slots) {
|
|
164
|
+
const providerType = typeof slot?.provider === 'string' ? slot.provider.trim() : '';
|
|
165
|
+
if (!providerType) continue;
|
|
166
|
+
const key = providerType.toLowerCase();
|
|
167
|
+
if (seenProvider.has(key)) continue;
|
|
168
|
+
seenProvider.add(key);
|
|
169
|
+
if (resolveProviderMaxParallel(slots, providerType) !== undefined) cappedProviders.push(providerType);
|
|
170
|
+
}
|
|
171
|
+
if (cappedProviders.length) {
|
|
154
172
|
const byProvider = providerCountByNode.get(nodeId);
|
|
155
173
|
providerRoles = [];
|
|
156
|
-
for (const
|
|
157
|
-
|
|
158
|
-
const providerType = typeof role.providerType === 'string' ? role.providerType.trim() : '';
|
|
159
|
-
if (!providerType) continue;
|
|
160
|
-
const maxParallel = resolveProviderMaxParallel(policy, providerType);
|
|
174
|
+
for (const providerType of cappedProviders) {
|
|
175
|
+
const maxParallel = resolveProviderMaxParallel(slots, providerType);
|
|
161
176
|
const activeAssigned = byProvider?.get(providerType) ?? 0;
|
|
162
177
|
const capReached = maxParallel !== undefined && activeAssigned >= maxParallel;
|
|
163
178
|
providerRoles.push({
|
|
@@ -612,8 +612,8 @@ export interface MeshWorkQueueEntry {
|
|
|
612
612
|
assignedSessionId?: string;
|
|
613
613
|
/**
|
|
614
614
|
* Provider type of the session that claimed the task. Recorded so the queue
|
|
615
|
-
* can enforce per-(node, provider) maxParallel caps (
|
|
616
|
-
*
|
|
615
|
+
* can enforce per-(node, provider) maxParallel caps (summed slots[].maxParallel)
|
|
616
|
+
* by counting active assignments grouped by node + provider.
|
|
617
617
|
*/
|
|
618
618
|
assignedProviderType?: string;
|
|
619
619
|
/** Human/operator reason for terminal cancellation. */
|
|
@@ -1095,7 +1095,7 @@ export function getMeshQueueRevision(meshId: string): string {
|
|
|
1095
1095
|
*
|
|
1096
1096
|
* `opts.providerType` is stamped onto the claimed entry (assignedProviderType) so
|
|
1097
1097
|
* per-(node, provider) caps can be counted. `opts.providerMaxParallel`, when set,
|
|
1098
|
-
* is the enforced per-(node, provider) cap
|
|
1098
|
+
* is the enforced per-(node, provider) cap (summed slots[].maxParallel):
|
|
1099
1099
|
* a task is not assigned to this (node, provider) once it already has that many
|
|
1100
1100
|
* active assignments. This composes with the global/taskMode caps (stricter wins).
|
|
1101
1101
|
*/
|
|
@@ -105,6 +105,21 @@ export const COMPLETED_FINALIZATION_MAX_WAIT_MS = 30_000;
|
|
|
105
105
|
// hold only covers the approval-resolved valley; widening this settle window to 4000ms
|
|
106
106
|
// covers that race AND the ~3s waiting_approval valley within the settle bound.
|
|
107
107
|
export const NATIVE_HISTORY_MESH_IDLE_SETTLE_MS = 4000;
|
|
108
|
+
// (FALSE-IDLE-MIDTURN codex/PTY) Minimum quiet dwell required after the LAST raw PTY
|
|
109
|
+
// output before a PTY-PARSED (non-native-history) provider's on-screen "final" assistant
|
|
110
|
+
// bubble may be trusted as a turn-complete reply. codex parses its assistant text from the
|
|
111
|
+
// terminal screen, so a completion-gate poll that lands MID-STREAM — while the screen still
|
|
112
|
+
// shows a partial sentence fragment ("...하겠습니다") and the FSM momentarily reads idle —
|
|
113
|
+
// satisfies completionHasFinalAssistantMessage (present=true) and would clean-emit an early
|
|
114
|
+
// completion. The busyEpoch/lastOutputAt continuity guard in the flush only CANCELS when new
|
|
115
|
+
// output ARRIVES during the settle; it cannot catch a turn that fell quiet just before the
|
|
116
|
+
// arm and is still mid-turn. Require instead that the screen has been QUIET for at least this
|
|
117
|
+
// long since the last output: a genuinely finished turn's tail is stable well past this bound,
|
|
118
|
+
// while a mid-stream fragment is by definition still receiving output (or just did). Bounded,
|
|
119
|
+
// non-terminal HOLD — a real completion re-passes the gate one retry later once the dwell is
|
|
120
|
+
// met. Scoped (at the call site) to autonomous mesh sessions, so interactive sessions are
|
|
121
|
+
// untouched.
|
|
122
|
+
export const PTY_PARSED_FINAL_ASSISTANT_QUIET_DWELL_MS = 1200;
|
|
108
123
|
// (FALSE-IDLE-BACKGROUND-CMD) Hard cap on how long a pending completion may be HELD
|
|
109
124
|
// solely because the claude-cli transcript still shows an unresolved run_in_background
|
|
110
125
|
// bash job (backgroundTaskActive). The hold is the correct behaviour while the job is
|
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
COMPLETED_FINALIZATION_RETRY_MS,
|
|
57
57
|
COMPLETED_FINALIZATION_MAX_WAIT_MS,
|
|
58
58
|
NATIVE_HISTORY_MESH_IDLE_SETTLE_MS,
|
|
59
|
+
PTY_PARSED_FINAL_ASSISTANT_QUIET_DWELL_MS,
|
|
59
60
|
BACKGROUND_TASK_HOLD_MAX_MS,
|
|
60
61
|
USER_INPUT_ACK_DEDUP_WINDOW_MS,
|
|
61
62
|
STARTUP_GRACE_IDLE_COLLAPSE_WINDOW_MS,
|
|
@@ -194,6 +195,40 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
194
195
|
*/
|
|
195
196
|
private static readonly AUTO_APPROVE_MASK_STALL_MS = 10500;
|
|
196
197
|
|
|
198
|
+
/**
|
|
199
|
+
* AUTOAPPROVE-FLAP-INBOX-MISSING: sticky-approval overlay window. Same time-tick
|
|
200
|
+
* hold idea as the FALSE-IDLE completion gate — an approval signal that was
|
|
201
|
+
* DOMINANT within this recent window is re-presented across a momentary busy blip
|
|
202
|
+
* instead of collapsing.
|
|
203
|
+
*
|
|
204
|
+
* RCA (live 2026-07-13): a claude-cli worker sitting at a Bash approval modal
|
|
205
|
+
* ("Do you want to proceed? ❯1.Yes") flaps waiting_approval↔busy on a ~2-3s period.
|
|
206
|
+
* The spec `approval→busy` transition fires whenever the footer/modal approval
|
|
207
|
+
* markers momentarily drop out of their parsed sections while the PRIOR command's
|
|
208
|
+
* residual spinner text ("✳ Checking vendor drift…") still matches the busy regex.
|
|
209
|
+
* On the busy frame the adapter reports status='generating', activeModal=null. That
|
|
210
|
+
* corrupts THREE consumers at once: (1) mesh_active_work samples 'generating' →
|
|
211
|
+
* collectPendingApprovals never sees 'awaiting_approval' → mesh_list_pending_approvals
|
|
212
|
+
* count:0 (inbox miss); (2) the auto-approve settle gate is torn down each busy phase
|
|
213
|
+
* so the 600ms settle never accrues → auto-approve never fires; (3) a mesh_approve
|
|
214
|
+
* landing on a busy frame hits "Not in approval state". The existing FLAP machinery
|
|
215
|
+
* (AUTO_APPROVE_FLAP_CONTINUITY_MS) only keeps the settle gate warm while status is
|
|
216
|
+
* STILL waiting_approval (buttons scrolled out) — it does nothing once the FSM fully
|
|
217
|
+
* commits to 'busy', and it never stabilises the status the inbox samples.
|
|
218
|
+
*
|
|
219
|
+
* Fix: when the raw adapter status flaps to generating/busy/idle but a
|
|
220
|
+
* waiting_approval WITH a concrete modal was observed within this window, overlay
|
|
221
|
+
* the cached modal and report status='waiting_approval'. This stabilized status
|
|
222
|
+
* feeds getState (→ inbox), detectStatusTransition (→ event emission), and
|
|
223
|
+
* maybeAutoApproveStatus (→ settle gate) uniformly, so the approval both registers
|
|
224
|
+
* in the inbox and settles for auto-approve across the flap. Bounded (a genuine
|
|
225
|
+
* resume that never returns to approval unmasks after this window) and scoped at the
|
|
226
|
+
* call site to autonomous mesh sessions. 4000ms bridges the observed ~2-3s flap with
|
|
227
|
+
* margin while staying well under AUTO_APPROVE_MASK_STALL_MS (a truly stalled/absent
|
|
228
|
+
* approval still surfaces).
|
|
229
|
+
*/
|
|
230
|
+
private static readonly APPROVAL_STICKY_FLAP_MS = 4000;
|
|
231
|
+
|
|
197
232
|
/**
|
|
198
233
|
* FALSE-IDLE (inter-approval quiet valley): grace window after an auto-approve
|
|
199
234
|
* (or mesh_approve) RESOLVES a modal during which a subsequent generating→idle
|
|
@@ -298,6 +333,14 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
298
333
|
// signature) while a genuinely closed modal — buttons empty continuously past
|
|
299
334
|
// the continuity window — is still recognised and resets the gate.
|
|
300
335
|
private autoApproveLastModalSeenAt = 0;
|
|
336
|
+
// AUTOAPPROVE-FLAP-INBOX-MISSING sticky-approval overlay (see APPROVAL_STICKY_FLAP_MS).
|
|
337
|
+
// The wall-clock of the last frame where the RAW adapter reported waiting_approval with
|
|
338
|
+
// a CONCRETE modal (buttons present), the cached modal to re-present across a busy blip,
|
|
339
|
+
// and the approvalEntrySeq at that frame (so a stabilized frame carries the right seq to
|
|
340
|
+
// the emission-dedup fingerprint). All zero/null when no recent concrete approval.
|
|
341
|
+
private approvalStickyLastConcreteAt = 0;
|
|
342
|
+
private approvalStickyModal: { message?: string; buttons?: unknown[]; kind?: string | null } | null = null;
|
|
343
|
+
private approvalStickyEntrySeq = 0;
|
|
301
344
|
// STATUS-MISMATCH: wall-clock when the CURRENT auto-approve episode (waiting_approval
|
|
302
345
|
// + shouldAutoApprove) first began wanting to mask. Unlike pendingAutoApprovalSince it
|
|
303
346
|
// is NOT reset when the modal signature changes (a still-streaming/flapping prompt) and
|
|
@@ -533,7 +576,11 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
533
576
|
// in cli-script-runner.ts (CliScriptRunner.detectStatus / parseApproval /
|
|
534
577
|
// parseSession), with provider-loader.ts updated to store script source strings
|
|
535
578
|
// alongside the loaded function references for extended-legacy providers.
|
|
536
|
-
|
|
579
|
+
// AUTOAPPROVE-FLAP-INBOX-MISSING: apply the same sticky-approval overlay the
|
|
580
|
+
// FSM path uses so the status this getState() surfaces to the mesh probe (and
|
|
581
|
+
// thus mesh_active_work → the pending-approval inbox) stays waiting_approval
|
|
582
|
+
// across a busy flap frame, instead of momentarily reading generating (count:0).
|
|
583
|
+
const adapterStatus = this.stabilizeFlappingApprovalStatus(this.adapter.getStatus());
|
|
537
584
|
if (Object.prototype.hasOwnProperty.call(adapterStatus, 'activeInteractivePrompt')) {
|
|
538
585
|
this.activeInteractivePrompt = adapterStatus.activeInteractivePrompt ?? null;
|
|
539
586
|
}
|
|
@@ -1753,7 +1800,27 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1753
1800
|
if (probe?.lastRole === 'assistant' && (probe.contentLen ?? 0) > 0) {
|
|
1754
1801
|
return null;
|
|
1755
1802
|
}
|
|
1803
|
+
// (FALSE-IDLE-MIDTURN antigravity) A native-history session whose
|
|
1804
|
+
// external-native transcript has NO in-turn final assistant bubble yet
|
|
1805
|
+
// (present=false AND the probe's tail is not an assistant reply) is NOT
|
|
1806
|
+
// proven done. antigravity previously returned null here — an IMMEDIATE
|
|
1807
|
+
// clean emit with zero transcript evidence — so a momentary PTY-parser
|
|
1808
|
+
// idle blip MID-TURN (the parser reads idle while the turn is still in
|
|
1809
|
+
// flight and the transcript's answer has not landed) fired an early
|
|
1810
|
+
// agent:generating_completed the coordinator could never correct. Instead
|
|
1811
|
+
// HOLD for the transcript: re-probe each retry (readExternalCompletionMessages
|
|
1812
|
+
// runs forceRefresh every call) and clear the block only once the assistant
|
|
1813
|
+
// turn actually lands (the probe branch above → genuine emit). Non-terminal
|
|
1814
|
+
// and bounded by COMPLETED_FINALIZATION_MAX_WAIT_MS (30s) so a turn that
|
|
1815
|
+
// genuinely produced no assistant bubble (tool-only) still force-emits a weak
|
|
1816
|
+
// completion rather than wedging — preserving the a0fb6b05 "antigravity always
|
|
1817
|
+
// eventually emits" fix while filtering the mid-turn false-idle. Scoped to
|
|
1818
|
+
// autonomous mesh sessions (allowMissingAssistantTimeout) so an interactive
|
|
1819
|
+
// antigravity session, which has no coordinator to misfire at, is untouched.
|
|
1756
1820
|
if (this.type === 'antigravity-cli') {
|
|
1821
|
+
if (allowMissingAssistantTimeout) {
|
|
1822
|
+
return { reason: 'missing_final_assistant', terminal: false, holdForTranscript: true };
|
|
1823
|
+
}
|
|
1757
1824
|
return null;
|
|
1758
1825
|
}
|
|
1759
1826
|
// (SETTLE-VALLEY) The inter-approval idle valley: a native-history mesh worker
|
|
@@ -1809,6 +1876,40 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1809
1876
|
}
|
|
1810
1877
|
} catch { /* defensive: screen text read is best-effort */ }
|
|
1811
1878
|
|
|
1879
|
+
// (FALSE-IDLE-MIDTURN codex/PTY) The turn-complete quiet-dwell gate. We only reach
|
|
1880
|
+
// here with finalAssistantEvidence.present === true. For a PTY-PARSED provider
|
|
1881
|
+
// (codex: !adapterOwnsMessagesElsewhere), that "present" verdict is derived from the
|
|
1882
|
+
// on-screen assistant text, which can be a PARTIAL sentence fragment captured mid-stream
|
|
1883
|
+
// when the FSM momentarily read idle. completionHasFinalAssistantMessage accepts the
|
|
1884
|
+
// fragment and hasAdapterPendingResponse can transiently read clean between chunks, so
|
|
1885
|
+
// present flips true mid-turn and this path would clean-emit an early completion. The
|
|
1886
|
+
// flush's lastOutputAt continuity guard only cancels when NEW output ARRIVES during the
|
|
1887
|
+
// settle — it cannot catch a turn that fell quiet just before the arm. Require instead a
|
|
1888
|
+
// minimum QUIET DWELL since the last raw PTY output: a genuinely finished turn's screen
|
|
1889
|
+
// has been stable well past this bound, whereas a mid-stream fragment either just received
|
|
1890
|
+
// output or is about to. Non-terminal HOLD (bounded by COMPLETED_FINALIZATION_MAX_WAIT_MS),
|
|
1891
|
+
// so a real completion re-passes the gate one retry later once the dwell is met; and it
|
|
1892
|
+
// force-emits at the 30s cap rather than wedging. Scoped to autonomous mesh sessions
|
|
1893
|
+
// (allowMissingAssistantTimeout) and PTY-parsed sources only — native-history providers
|
|
1894
|
+
// (antigravity/claude) resolve evidence from the authoritative transcript above, not the
|
|
1895
|
+
// screen, so this dwell does not apply to them and interactive sessions are untouched.
|
|
1896
|
+
if (allowMissingAssistantTimeout
|
|
1897
|
+
&& !adapterOwnsMessagesElsewhere
|
|
1898
|
+
&& finalAssistantEvidence.source === 'parsed') {
|
|
1899
|
+
try {
|
|
1900
|
+
const outStatus = this.adapter.getStatus({ allowParse: false }) as any;
|
|
1901
|
+
const lastOutputAt = typeof outStatus?.lastOutputAt === 'number' && Number.isFinite(outStatus.lastOutputAt)
|
|
1902
|
+
? outStatus.lastOutputAt as number
|
|
1903
|
+
: undefined;
|
|
1904
|
+
if (typeof lastOutputAt === 'number') {
|
|
1905
|
+
const quietMs = Date.now() - lastOutputAt;
|
|
1906
|
+
if (quietMs < PTY_PARSED_FINAL_ASSISTANT_QUIET_DWELL_MS) {
|
|
1907
|
+
return { reason: 'parsed_final_assistant_quiet_dwell', terminal: false };
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
} catch { /* defensive: dwell read is best-effort — fall through to emit */ }
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1812
1913
|
return null;
|
|
1813
1914
|
}
|
|
1814
1915
|
|
|
@@ -2342,6 +2443,72 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2342
2443
|
});
|
|
2343
2444
|
}
|
|
2344
2445
|
|
|
2446
|
+
/**
|
|
2447
|
+
* AUTOAPPROVE-FLAP-INBOX-MISSING sticky-approval overlay. Returns the adapterStatus a
|
|
2448
|
+
* flap-prone claude-cli approval SHOULD present this frame — either the raw status
|
|
2449
|
+
* unchanged, or, when the raw status has momentarily flapped OFF a recently-dominant
|
|
2450
|
+
* concrete approval, a synthetic `waiting_approval` re-presenting the cached modal.
|
|
2451
|
+
*
|
|
2452
|
+
* Records the concrete approval whenever the raw status is waiting_approval WITH
|
|
2453
|
+
* buttons. On a subsequent non-approval frame (the spec `approval→busy` flap), if that
|
|
2454
|
+
* concrete approval was seen within APPROVAL_STICKY_FLAP_MS AND the engine has NOT
|
|
2455
|
+
* resolved a modal since (lastApprovalResolvedAt not advanced past the sticky start),
|
|
2456
|
+
* overlay the cached modal + waiting_approval so the inbox / auto-approve / mesh_approve
|
|
2457
|
+
* all see the stable approval. A genuine resolution (auto-approve or mesh_approve fires
|
|
2458
|
+
* resolveModal → lastApprovalResolvedAt advances) clears the sticky immediately, so a
|
|
2459
|
+
* legitimate post-approval resume is NEVER masked as a lingering approval. Bounded by the
|
|
2460
|
+
* window, and scoped to autonomous mesh sessions (a foreground/attended or non-mesh
|
|
2461
|
+
* session, where a human answers the prompt, is returned untouched).
|
|
2462
|
+
*/
|
|
2463
|
+
private stabilizeFlappingApprovalStatus(adapterStatus: any, now = Date.now()): any {
|
|
2464
|
+
// Only autonomous auto-approving mesh sessions are subject to the delegated flap;
|
|
2465
|
+
// never overlay for attended/foreground/non-mesh sessions.
|
|
2466
|
+
if (!this.isAutonomousMeshSession() || !this.shouldAutoApprove()) return adapterStatus;
|
|
2467
|
+
|
|
2468
|
+
const rawStatus = adapterStatus?.status;
|
|
2469
|
+
const resolvedAt = typeof (this.adapter as any)?.lastApprovalResolvedAt === 'number'
|
|
2470
|
+
? (this.adapter as any).lastApprovalResolvedAt as number
|
|
2471
|
+
: 0;
|
|
2472
|
+
|
|
2473
|
+
if (rawStatus === 'waiting_approval') {
|
|
2474
|
+
// A concrete modal this frame refreshes the sticky anchor; an approval frame
|
|
2475
|
+
// with buttons momentarily scrolled out is left to the existing settle-gate
|
|
2476
|
+
// hysteresis (we do not touch it — status is already waiting_approval).
|
|
2477
|
+
if (hasNonEmptyCliModalButtons(adapterStatus?.activeModal)) {
|
|
2478
|
+
this.approvalStickyLastConcreteAt = now;
|
|
2479
|
+
this.approvalStickyModal = adapterStatus.activeModal;
|
|
2480
|
+
this.approvalStickyEntrySeq = typeof adapterStatus?.approvalEntrySeq === 'number'
|
|
2481
|
+
? adapterStatus.approvalEntrySeq
|
|
2482
|
+
: this.approvalStickyEntrySeq;
|
|
2483
|
+
}
|
|
2484
|
+
return adapterStatus;
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
// Non-approval frame. Overlay only if a concrete approval was dominant within the
|
|
2488
|
+
// window AND no resolution has happened since the sticky anchor (a resolveModal
|
|
2489
|
+
// advances lastApprovalResolvedAt to at/after the anchor → the flap is really a
|
|
2490
|
+
// genuine resume, so drop the sticky and report the raw status).
|
|
2491
|
+
if (this.approvalStickyLastConcreteAt > 0 && this.approvalStickyModal) {
|
|
2492
|
+
const withinWindow = (now - this.approvalStickyLastConcreteAt) < CliProviderInstance.APPROVAL_STICKY_FLAP_MS;
|
|
2493
|
+
const resolvedSinceAnchor = resolvedAt >= this.approvalStickyLastConcreteAt;
|
|
2494
|
+
if (withinWindow && !resolvedSinceAnchor) {
|
|
2495
|
+
return {
|
|
2496
|
+
...adapterStatus,
|
|
2497
|
+
status: 'waiting_approval',
|
|
2498
|
+
activeModal: this.approvalStickyModal,
|
|
2499
|
+
...(this.approvalStickyEntrySeq ? { approvalEntrySeq: this.approvalStickyEntrySeq } : {}),
|
|
2500
|
+
approvalStickyOverlay: true,
|
|
2501
|
+
};
|
|
2502
|
+
}
|
|
2503
|
+
// Window lapsed or a resolution landed — clear the sticky so a later approval
|
|
2504
|
+
// re-anchors from scratch and a genuine resume surfaces immediately.
|
|
2505
|
+
this.approvalStickyLastConcreteAt = 0;
|
|
2506
|
+
this.approvalStickyModal = null;
|
|
2507
|
+
this.approvalStickyEntrySeq = 0;
|
|
2508
|
+
}
|
|
2509
|
+
return adapterStatus;
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2345
2512
|
private maybeAutoApproveStatus(adapterStatus: any, now = Date.now()): boolean {
|
|
2346
2513
|
// Manual-attendance suppression (provider-common): when a human is
|
|
2347
2514
|
// Manual-attendance suppression (provider-common): when a human is
|
|
@@ -2777,7 +2944,12 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2777
2944
|
// Status-change handling is a hot path: PTY output can fire it many times
|
|
2778
2945
|
// during long-running CLI sessions. Keep this path on adapter-owned light
|
|
2779
2946
|
// state only; rich provider parsing is reserved for getState/read_chat.
|
|
2780
|
-
|
|
2947
|
+
// AUTOAPPROVE-FLAP-INBOX-MISSING: stabilize a flap-prone approval BEFORE it feeds
|
|
2948
|
+
// maybeAutoApproveStatus / newStatus / the waiting_approval emission branch below,
|
|
2949
|
+
// so a momentary busy blip during the flap re-presents the cached modal + status
|
|
2950
|
+
// rather than emitting generating (which would corrupt the inbox and tear down the
|
|
2951
|
+
// settle gate). No-op for non-mesh/foreground/non-approval frames.
|
|
2952
|
+
const adapterStatus = this.stabilizeFlappingApprovalStatus(this.adapter.getStatus({ allowParse: false }), now);
|
|
2781
2953
|
const adapterProviderSessionId = normalizeProviderSessionId(
|
|
2782
2954
|
this.provider,
|
|
2783
2955
|
typeof adapterStatus?.providerSessionId === 'string' ? adapterStatus.providerSessionId : '',
|
package/src/repo-mesh-types.ts
CHANGED
|
@@ -388,16 +388,15 @@ export interface RepoMeshRelatedRepo {
|
|
|
388
388
|
* must satisfy both. Omitting `maxParallel` means this provider is bounded only
|
|
389
389
|
* by the global/taskMode caps (full backward compatibility).
|
|
390
390
|
*
|
|
391
|
-
* Routing is governed exclusively by required_tags (see nodeSatisfiesRequiredTags)
|
|
392
|
-
*
|
|
393
|
-
*
|
|
391
|
+
* Routing is governed exclusively by required_tags (see nodeSatisfiesRequiredTags).
|
|
392
|
+
* To route work to a specific node, advertise an ordinary capability tag on the
|
|
393
|
+
* node and require it on the task.
|
|
394
|
+
*
|
|
395
|
+
* NOTE: the per-(node, provider) parallelism cap now lives on `slots[].maxParallel`
|
|
396
|
+
* (see NodeCapabilitySlot). The former `providerRoles` field has been removed; a
|
|
397
|
+
* persisted meshes.json that still carries it is migrated to `slots` on load
|
|
398
|
+
* (see migrateLoadedMeshConfig).
|
|
394
399
|
*/
|
|
395
|
-
export interface RepoMeshProviderRole {
|
|
396
|
-
/** Provider type this entry governs (e.g. 'claude-cli', 'codex-cli'). */
|
|
397
|
-
providerType: string;
|
|
398
|
-
/** Max concurrent active tasks for this (node, provider). Omit = no per-provider cap. */
|
|
399
|
-
maxParallel?: number;
|
|
400
|
-
}
|
|
401
400
|
|
|
402
401
|
export interface RepoMeshNodePolicy {
|
|
403
402
|
readOnly?: boolean;
|
|
@@ -413,26 +412,14 @@ export interface RepoMeshNodePolicy {
|
|
|
413
412
|
schedulingPriority?: number;
|
|
414
413
|
/** Ordered provider preference used when mesh_launch_session omits an explicit type. */
|
|
415
414
|
providerPriority?: string[];
|
|
416
|
-
/**
|
|
417
|
-
* Per-(node, provider) parallelism declarations. Each entry binds a
|
|
418
|
-
* providerType on THIS node to an optional maxParallel cap. maxParallel is
|
|
419
|
-
* enforced as an additional, stricter-wins constraint on top of the global
|
|
420
|
-
* maxParallelTasks/taskMode caps. Missing/empty: the node behaves exactly as
|
|
421
|
-
* before (global caps only). Routing is governed solely by required_tags.
|
|
422
|
-
*
|
|
423
|
-
* SUPERSEDED by `slots` (ORCHESTRATION_NODE_SLOTS.md). Kept for back-compat:
|
|
424
|
-
* when `slots` is absent, providerRoles + providerPriority + the machine-global
|
|
425
|
-
* difficultyBrains are auto-derived into slots via deriveSlotsFromLegacy.
|
|
426
|
-
*/
|
|
427
|
-
providerRoles?: RepoMeshProviderRole[];
|
|
428
415
|
/**
|
|
429
416
|
* Node capability slots (ORCHESTRATION_NODE_SLOTS.md) — the ordered "Preferred
|
|
430
417
|
* AI tools" profile that is the single source of truth for task routing, MAGI
|
|
431
418
|
* fan-out, and orchestrator-proposed edits. Each slot bundles provider + model
|
|
432
419
|
* + thinkingLevel + difficulty range + capability tags + per-slot maxParallel.
|
|
433
420
|
* Order = preference. When absent, the scheduler derives slots from the legacy
|
|
434
|
-
* providerPriority/
|
|
435
|
-
*
|
|
421
|
+
* providerPriority/difficultyBrains (deriveSlotsFromLegacy) so existing nodes
|
|
422
|
+
* keep working without reconfiguration.
|
|
436
423
|
*/
|
|
437
424
|
slots?: NodeCapabilitySlot[];
|
|
438
425
|
/**
|
|
@@ -696,29 +683,38 @@ export function resolveDelegatedWorkerAutoApprove(
|
|
|
696
683
|
}
|
|
697
684
|
|
|
698
685
|
/**
|
|
699
|
-
* Resolve the enforced per-(node, provider) maxParallel cap
|
|
700
|
-
*
|
|
701
|
-
* claim path as a stricter-wins constraint layered on top of the global
|
|
702
|
-
* Case-insensitive, trimmed match on
|
|
703
|
-
*
|
|
686
|
+
* Resolve the enforced per-(node, provider) maxParallel cap from a node's resolved
|
|
687
|
+
* capability slots, or undefined when no matching slot declares a finite cap. Used
|
|
688
|
+
* by the queue claim path as a stricter-wins constraint layered on top of the global
|
|
689
|
+
* caps. Case-insensitive, trimmed match on the slot's provider.
|
|
690
|
+
*
|
|
691
|
+
* When a node declares multiple slots for the same provider (e.g. distinct
|
|
692
|
+
* difficulty ranges), their caps SUM into a single per-(node, provider) pool — the
|
|
693
|
+
* provider can run up to the total across all its slots. Legacy-derived slots (via
|
|
694
|
+
* deriveSlotsFromLegacy) produce one slot per provider, so the sum equals that
|
|
695
|
+
* single slot's cap and behavior is preserved exactly.
|
|
696
|
+
*
|
|
697
|
+
* Callers pass the already-resolved slots (explicit policy.slots, else legacy-
|
|
698
|
+
* derived) — this keeps the resolver free of the difficultyBrains dependency and
|
|
699
|
+
* usable from any layer.
|
|
704
700
|
*/
|
|
705
701
|
export function resolveProviderMaxParallel(
|
|
706
|
-
|
|
702
|
+
slots: NodeCapabilitySlot[] | null | undefined,
|
|
707
703
|
providerType: string | null | undefined,
|
|
708
704
|
): number | undefined {
|
|
709
705
|
const wanted = typeof providerType === 'string' ? providerType.trim().toLowerCase() : '';
|
|
710
706
|
if (!wanted) return undefined;
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
for (const
|
|
714
|
-
if (!
|
|
715
|
-
const type = typeof
|
|
707
|
+
if (!Array.isArray(slots)) return undefined;
|
|
708
|
+
let total: number | undefined;
|
|
709
|
+
for (const slot of slots) {
|
|
710
|
+
if (!slot || typeof slot !== 'object') continue;
|
|
711
|
+
const type = typeof slot.provider === 'string' ? slot.provider.trim().toLowerCase() : '';
|
|
716
712
|
if (!type || type !== wanted) continue;
|
|
717
|
-
const raw = Number(
|
|
718
|
-
if (!Number.isFinite(raw) || raw < 0)
|
|
719
|
-
|
|
713
|
+
const raw = Number(slot.maxParallel);
|
|
714
|
+
if (!Number.isFinite(raw) || raw < 0) continue;
|
|
715
|
+
total = (total ?? 0) + Math.floor(raw);
|
|
720
716
|
}
|
|
721
|
-
return
|
|
717
|
+
return total;
|
|
722
718
|
}
|
|
723
719
|
|
|
724
720
|
// ─── Capabilities ───────────────────────────────
|
|
@@ -987,6 +983,11 @@ export interface RepoMeshNodeSchedulingStatus {
|
|
|
987
983
|
load: number;
|
|
988
984
|
schedulingPriority?: number;
|
|
989
985
|
maxConcurrentSessions?: number;
|
|
986
|
+
/**
|
|
987
|
+
* Per-(node, provider) caps + consumption. Field name kept for dashboard
|
|
988
|
+
* back-compat; the cap source is now slots[].maxParallel (the removed
|
|
989
|
+
* policy.providerRoles no longer exists).
|
|
990
|
+
*/
|
|
990
991
|
providerRoles?: RepoMeshNodeProviderSchedulingStatus[];
|
|
991
992
|
capReached: boolean;
|
|
992
993
|
capReasons?: string[];
|