@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.
@@ -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, getDifficultyBrains } from '../config/mesh-config.js';
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, deriveSlotsFromLegacy, normalizeNodeCapabilitySlots, isMeshTaskDifficulty, withStatusProbeMarker, type MeshNodeIdentified, type NodeCapabilitySlot, type MeshTaskDifficulty } from '@adhdev/mesh-shared';
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 (RepoMeshNodePolicy.providerRoles) layers
509
- // on top of the global/taskMode caps — stricter wins. Resolved here where the
510
- // claiming session's providerType + node policy are both known, then enforced
511
- // inside the atomic claim transaction so concurrent claims can't overshoot it.
512
- const providerMaxParallel = resolveProviderMaxParallel(node?.policy, providerType);
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/providerRoles + the machine-global difficultyBrains so
1284
- // existing nodes keep working (back-compat). The fitness scorer ranks a node for a
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?.policy, resolved.providerType);
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 (RepoMeshNodePolicy
871
- * providerRoles). The active-assignment set for a single node is tiny, so
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 (RepoMeshNodePolicy providerRoles).
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
- /** Per-(node, provider) caps + consumption, when providerRoles declares any. */
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 declaredRoles = Array.isArray(policy?.providerRoles) ? policy!.providerRoles! : [];
153
- if (declaredRoles.length) {
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 role of declaredRoles) {
157
- if (!role || typeof role !== 'object') continue;
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 (RepoMeshNodePolicy
616
- * providerRoles) by counting active assignments grouped by node + provider.
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 from RepoMeshNodePolicy.providerRoles:
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
- const adapterStatus = this.adapter.getStatus();
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
- const adapterStatus = this.adapter.getStatus({ allowParse: false });
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 : '',
@@ -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
- * this entry carries no routing role. To route work to a specific node, advertise an
393
- * ordinary capability tag on the node and require it on the task.
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/providerRoles/difficultyBrains (deriveSlotsFromLegacy) so
435
- * existing nodes keep working without reconfiguration.
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, or undefined when
700
- * no finite, non-negative cap is declared for this provider. Used by the queue
701
- * claim path as a stricter-wins constraint layered on top of the global caps.
702
- * Case-insensitive, trimmed match on providerType. Defensive against malformed
703
- * config — non-object entries and blank providerTypes are skipped rather than throwing.
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
- nodePolicy: Pick<RepoMeshNodePolicy, 'providerRoles'> | null | undefined,
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
- const roles = nodePolicy?.providerRoles;
712
- if (!Array.isArray(roles)) return undefined;
713
- for (const entry of roles) {
714
- if (!entry || typeof entry !== 'object') continue;
715
- const type = typeof entry.providerType === 'string' ? entry.providerType.trim().toLowerCase() : '';
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(entry.maxParallel);
718
- if (!Number.isFinite(raw) || raw < 0) return undefined;
719
- return Math.floor(raw);
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 undefined;
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[];