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

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.
Files changed (67) hide show
  1. package/dist/cli-adapters/provider-cli-shared.d.ts +33 -0
  2. package/dist/commands/cli-manager.d.ts +9 -0
  3. package/dist/commands/upgrade-helper.d.ts +1 -0
  4. package/dist/commands/windows-atomic-upgrade.d.ts +1 -0
  5. package/dist/config/mesh-json-config.d.ts +62 -0
  6. package/dist/index.d.ts +4 -2
  7. package/dist/index.js +2205 -657
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +2173 -634
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/mesh/mesh-active-work.d.ts +1 -1
  12. package/dist/mesh/mesh-ledger.d.ts +1 -1
  13. package/dist/mesh/mesh-node-identity.d.ts +23 -0
  14. package/dist/mesh/mesh-refine-gates.d.ts +89 -0
  15. package/dist/mesh/mesh-remote-event-pull.d.ts +3 -0
  16. package/dist/mesh/mesh-work-queue.d.ts +24 -1
  17. package/dist/providers/auto-approve-modes.d.ts +14 -0
  18. package/dist/providers/chat-message-normalization.d.ts +22 -0
  19. package/dist/providers/cli-provider-instance-types.d.ts +2 -0
  20. package/dist/providers/cli-provider-instance.d.ts +76 -0
  21. package/dist/providers/contracts.d.ts +17 -0
  22. package/dist/providers/provider-schema.d.ts +1 -0
  23. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +14 -0
  24. package/dist/providers/types/interactive-prompt.d.ts +18 -0
  25. package/dist/repo-mesh-types.d.ts +38 -6
  26. package/dist/shared-types.d.ts +4 -2
  27. package/package.json +3 -3
  28. package/src/cli-adapters/cli-state-engine.ts +17 -1
  29. package/src/cli-adapters/provider-cli-adapter.ts +2 -6
  30. package/src/cli-adapters/provider-cli-shared.ts +48 -0
  31. package/src/commands/cli-manager.ts +72 -8
  32. package/src/commands/high-family/mesh-events.ts +13 -2
  33. package/src/commands/med-family/mesh-crud.ts +155 -0
  34. package/src/commands/med-family/mesh-queue.ts +74 -1
  35. package/src/commands/router-refine.ts +71 -4
  36. package/src/commands/router.ts +8 -0
  37. package/src/commands/upgrade-helper.ts +50 -1
  38. package/src/commands/windows-atomic-upgrade.ts +88 -23
  39. package/src/config/mesh-json-config.ts +103 -0
  40. package/src/index.ts +21 -1
  41. package/src/mesh/coordinator-prompt.ts +2 -1
  42. package/src/mesh/mesh-active-work.ts +11 -1
  43. package/src/mesh/mesh-completion-synthesis.ts +12 -1
  44. package/src/mesh/mesh-event-classify.ts +19 -0
  45. package/src/mesh/mesh-event-forwarding.ts +64 -6
  46. package/src/mesh/mesh-events-utils.ts +42 -0
  47. package/src/mesh/mesh-ledger.ts +5 -0
  48. package/src/mesh/mesh-node-identity.ts +49 -0
  49. package/src/mesh/mesh-queue-assignment.ts +84 -10
  50. package/src/mesh/mesh-reconcile-loop.ts +257 -3
  51. package/src/mesh/mesh-refine-gates.ts +300 -0
  52. package/src/mesh/mesh-remote-event-pull.ts +68 -47
  53. package/src/mesh/mesh-work-queue.ts +85 -2
  54. package/src/providers/auto-approve-modes.ts +97 -0
  55. package/src/providers/chat-message-normalization.ts +53 -0
  56. package/src/providers/cli-provider-instance-types.ts +28 -0
  57. package/src/providers/cli-provider-instance.ts +394 -28
  58. package/src/providers/contracts.ts +25 -1
  59. package/src/providers/provider-schema.ts +83 -0
  60. package/src/providers/sdk/v1/builders/cli/detect-status.ts +23 -0
  61. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +20 -0
  62. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +44 -0
  63. package/src/providers/types/interactive-prompt.ts +77 -0
  64. package/src/repo-mesh-types.ts +112 -12
  65. package/src/shared-types.ts +9 -1
  66. package/src/status/reporter.ts +1 -0
  67. package/src/status/snapshot.ts +2 -0
@@ -16,7 +16,8 @@ import { resolveMeshHostStatus } from './mesh-host-ownership.js';
16
16
  import { enqueueUnresolvedDelegateForward, nudgeUnresolvedForwardRetry } from './mesh-unresolved-forward-outbox.js';
17
17
  import { traceMeshEventStage, traceMeshEventDrop } from './mesh-event-trace.js';
18
18
  import { getLastDisplayMessage } from '../status/snapshot.js';
19
- import { resolveDelegatedWorkerAutoApprove } from '../repo-mesh-types.js';
19
+ import { delegatedWorkerAutoApproveSettings } from '../repo-mesh-types.js';
20
+ import { loadRepoMeshJsonConfig } from '../config/mesh-json-config.js';
20
21
  import { meshNodeIdMatches, daemonIdsEquivalent, expandDaemonIdForms, sessionIdsEquivalent, withStatusProbeMarker, type MeshNodeIdentified } from '@adhdev/mesh-shared';
21
22
  import {
22
23
  findRecentTerminalLedgerEvidence,
@@ -1071,10 +1072,44 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1071
1072
  // C2: prefer an exact taskId match when the completion event carries one —
1072
1073
  // it's immune to coordinator↔worker clock skew that can hide the assigned row.
1073
1074
  const eventTaskId = readNonEmptyString(args.metadataEvent.taskId) || undefined;
1074
- const task = updateSessionTaskStatus(args.meshId, sessionId, outcome, {
1075
- occurredAt: occurredAtMs != null ? new Date(occurredAtMs).toISOString() : undefined,
1076
- taskId: eventTaskId,
1077
- });
1075
+ // WEAK-QUEUE-TENTATIVE (early-completed safety net, symmetric with the direct-dispatch
1076
+ // `tentativeIfDirect` guard below): a `completed` outcome whose evidence is WEAK — a
1077
+ // false-idle / missing_final_assistant emit (the CANON-C decoupled-immediate path that
1078
+ // stamps evidenceLevel=insufficient) — must NOT hard-flip a matched QUEUE row to
1079
+ // terminal. Until this net, only DIRECT dispatches were kept tentative on weak evidence;
1080
+ // a queue-claimed task's session flipped its row to 'completed' unconditionally, so a
1081
+ // worker that dropped to idle ~13s in with no final assistant closed the task early even
1082
+ // though it never produced an answer. Leaving the row 'assigned' hands it to the reconcile
1083
+ // loop's proven net (PHASE 4 records the GENUINE completion once the transcript lands via
1084
+ // findTerminalLedgerEvidenceForTask; PHASE 2.5 reclaims a truly-stranded row back to
1085
+ // 'pending' for re-dispatch, bounded by MAX_STRANDED_RECLAIMS) — never a permanent wedge.
1086
+ //
1087
+ // CRUCIAL SCOPE: this covers only the PREMATURE decoupled-immediate emit
1088
+ // (emittedAfterFinalizationTimeout !== true). A weak completion that already waited out the
1089
+ // full 30s COMPLETED_FINALIZATION_MAX_WAIT_MS window (emittedAfterFinalizationTimeout=true)
1090
+ // is a GENUINE — if answerless — terminal: the worker exhausted its finalization wait and
1091
+ // is done, so it flips 'completed' as before (a tool-only turn that never produced an
1092
+ // assistant bubble must still close its task). A `failed` outcome or a completion with
1093
+ // genuine evidence flips terminal as before too.
1094
+ const completionDiagnostic = args.metadataEvent.completionDiagnostic && typeof args.metadataEvent.completionDiagnostic === 'object'
1095
+ ? args.metadataEvent.completionDiagnostic as Record<string, unknown>
1096
+ : undefined;
1097
+ const emittedAfterFinalizationTimeout = completionDiagnostic?.emittedAfterFinalizationTimeout === true;
1098
+ const weakCompleted = outcome === 'completed'
1099
+ && isWeakCompletionEvidence(args.metadataEvent)
1100
+ && !emittedAfterFinalizationTimeout;
1101
+ const task = weakCompleted
1102
+ ? updateSessionTaskStatus(args.meshId, sessionId, 'assigned', {
1103
+ occurredAt: occurredAtMs != null ? new Date(occurredAtMs).toISOString() : undefined,
1104
+ taskId: eventTaskId,
1105
+ })
1106
+ : updateSessionTaskStatus(args.meshId, sessionId, outcome, {
1107
+ occurredAt: occurredAtMs != null ? new Date(occurredAtMs).toISOString() : undefined,
1108
+ taskId: eventTaskId,
1109
+ });
1110
+ if (weakCompleted && task) {
1111
+ LOG.info('MeshQueue', `Weak completion (${readNonEmptyString(args.metadataEvent.evidenceLevel) || 'missing_final_assistant'}) kept queue task ${task.id} tentative (session ${sessionId}); reconcile owns the genuine terminal`);
1112
+ }
1078
1113
  // Fix A (early-terminal prevention): a false-idle completion (no confirmed final
1079
1114
  // assistant) for a DIRECT dispatch — i.e. no work-queue row matched — must not flip the
1080
1115
  // dispatch row terminal. Leaving it active lets the reconcile loop (PHASE 4) re-read the
@@ -1580,7 +1615,22 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1580
1615
  spawnedSessionVisibility: mesh?.policy?.spawnedSessionVisibility || 'hidden',
1581
1616
  // Coordinator-dispatched recovery relaunch: same auto-approve
1582
1617
  // policy as the primary worker launch path.
1583
- autoApprove: resolveDelegatedWorkerAutoApprove(mesh?.policy, node?.policy),
1618
+ ...delegatedWorkerAutoApproveSettings(
1619
+ mesh?.policy,
1620
+ node?.policy,
1621
+ components.providerLoader?.getMeta(recoveryContext.failedProviderType),
1622
+ // Recovery relaunch: same repo-declared requested mode as the
1623
+ // primary path. node.workspace missing → null → provider default.
1624
+ (() => {
1625
+ const ws = typeof node?.workspace === 'string' && node.workspace.trim() ? node.workspace.trim() : '';
1626
+ if (!ws) return null;
1627
+ try {
1628
+ const r = loadRepoMeshJsonConfig(ws);
1629
+ return r.sourceType === 'repo_file' && r.config ? r.config : null;
1630
+ } catch { return null; }
1631
+ })(),
1632
+ recoveryContext.failedProviderType,
1633
+ ),
1584
1634
  launchedByCoordinator: true,
1585
1635
  }
1586
1636
  }).catch((e: any) => LOG.error('MeshRecovery', `Failed to auto-relaunch session for ${node.id}: ${e?.message}`));
@@ -1754,6 +1804,14 @@ export function buildRelayMetadataEvent(payload: Record<string, unknown>): Recor
1754
1804
  retryOfJobId: readNonEmptyString(payload.retryOfJobId),
1755
1805
  ...(relayModalMessage ? { modalMessage: relayModalMessage } : {}),
1756
1806
  ...(relayModalButtons && relayModalButtons.length > 0 ? { modalButtons: relayModalButtons } : {}),
1807
+ // agent:waiting_choice (mission f1d25e11): carry the FULL structured question
1808
+ // payload across the machine boundary so a REMOTE worker's AskUserQuestion reaches
1809
+ // the coordinator with every question + option intact — the coordinator renders
1810
+ // these and answers with mesh_answer_question. The local in-process forward path
1811
+ // preserves the whole event for free; this mirrors the fields for the remote relay.
1812
+ ...(payload.interactivePrompt && typeof payload.interactivePrompt === 'object' && !Array.isArray(payload.interactivePrompt) ? { interactivePrompt: payload.interactivePrompt } : {}),
1813
+ ...(readNonEmptyString(payload.promptId) ? { promptId: readNonEmptyString(payload.promptId) } : {}),
1814
+ ...(payload.multiSelect === true ? { multiSelect: true } : {}),
1757
1815
  ...(payload.result && typeof payload.result === 'object' && !Array.isArray(payload.result) ? { result: payload.result } : {}),
1758
1816
  ...(payload.completionDiagnostic && typeof payload.completionDiagnostic === 'object' && !Array.isArray(payload.completionDiagnostic) ? { completionDiagnostic: payload.completionDiagnostic } : {}),
1759
1817
  ...(payload.workerResult && typeof payload.workerResult === 'object' && !Array.isArray(payload.workerResult) ? { workerResult: payload.workerResult } : {}),
@@ -330,6 +330,48 @@ export function buildMeshSystemMessage(args: {
330
330
  if (args.event === 'agent:waiting_approval') {
331
331
  return `[System] ${args.nodeLabel} is waiting for approval to proceed${metadata}. You may use mesh_read_chat and mesh_approve to handle it.`;
332
332
  }
333
+ if (args.event === 'agent:waiting_choice') {
334
+ // A multi-choice QUESTION (AskUserQuestion) — NOT an approval. It is answered with
335
+ // mesh_answer_question (never mesh_approve). Surface the question text + choices
336
+ // inline so the coordinator can decide without a mesh_read_chat round-trip, and
337
+ // name the correct tool + promptId (mission f1d25e11).
338
+ const prompt = args.metadataEvent.interactivePrompt as
339
+ | { promptId?: unknown; questions?: Array<Record<string, unknown>> }
340
+ | undefined;
341
+ const promptId = readNonEmptyString(args.metadataEvent.promptId)
342
+ || (prompt && readNonEmptyString(prompt.promptId));
343
+ const lines: string[] = [
344
+ `[System] ${args.nodeLabel} is asking a question and is waiting for your answer${metadata}.`,
345
+ ];
346
+ const questions = Array.isArray(prompt?.questions) ? prompt!.questions! : [];
347
+ if (questions.length > 0) {
348
+ for (const q of questions) {
349
+ const header = readNonEmptyString(q.header);
350
+ const question = readNonEmptyString(q.question);
351
+ const multiSelect = q.multiSelect === true;
352
+ if (question) {
353
+ lines.push(`\n**${header ? `${header}: ` : ''}${question}**${multiSelect ? ' (select one or more)' : ''}`);
354
+ }
355
+ const options = Array.isArray(q.options) ? q.options : [];
356
+ options.forEach((opt, i) => {
357
+ const record = (opt && typeof opt === 'object') ? opt as Record<string, unknown> : {};
358
+ const label = readNonEmptyString(record.label);
359
+ if (!label) return;
360
+ const description = readNonEmptyString(record.description);
361
+ lines.push(` ${i + 1}. ${label}${description ? ` — ${description}` : ''}`);
362
+ });
363
+ }
364
+ } else {
365
+ const modalMessage = readNonEmptyString(args.metadataEvent.modalMessage);
366
+ if (modalMessage) lines.push(`\n${modalMessage}`);
367
+ }
368
+ lines.push(
369
+ `\nAnswer with mesh_answer_question(node_id, session_id${promptId ? `, promptId: "${promptId}"` : ''}, answers). ` +
370
+ `Do NOT use mesh_approve — that only resolves yes/no consent modals, not a question. ` +
371
+ `Use mesh_read_chat once if you need the full context first.`,
372
+ );
373
+ return lines.join('\n');
374
+ }
333
375
  if (args.event === 'agent:stopped') {
334
376
  const rc = args.recoveryContext;
335
377
  if (rc && rc.consecutiveNodeFailures > 0) {
@@ -33,6 +33,11 @@ export type MeshLedgerKind =
33
33
  | 'task_failed'
34
34
  | 'task_stalled'
35
35
  | 'task_approval_needed'
36
+ // A worker is parked on an AskUserQuestion multi-choice prompt (waiting_choice) —
37
+ // distinct from task_approval_needed (a yes/no tool-consent modal). The coordinator
38
+ // answers a question with mesh_answer_question, never mesh_approve (mission f1d25e11).
39
+ // payload carries the full InteractivePrompt (promptId + questions + options).
40
+ | 'task_question_pending'
36
41
  | 'p2p_dispatch_failed'
37
42
  | 'session_launched'
38
43
  | 'session_auto_launch'
@@ -907,6 +907,55 @@ export function isMeshNodeHealthLaunchable(node: any): boolean {
907
907
  return health === 'online' || health === 'unknown';
908
908
  }
909
909
 
910
+ /** Resolve the git telemetry object a node carries, in the same precedence
911
+ * resolveEffectiveMeshNodeHealth uses: a fresh `node.git`, else the cached inline
912
+ * status git (`node.cachedStatus.git`). Returns an empty record when neither is
913
+ * present (no telemetry). */
914
+ function resolveEffectiveNodeGit(node: any): Record<string, any> {
915
+ const git = readObjectRecord(node?.git);
916
+ if (Object.keys(git).length > 0) return git;
917
+ const cachedStatus = readObjectRecord(node?.cachedStatus);
918
+ return readObjectRecord(cachedStatus.git);
919
+ }
920
+
921
+ /**
922
+ * Launch FRESHNESS gate — distinct from the health gate above.
923
+ *
924
+ * `deriveMeshNodeHealthFromGit` reports a clean-tree node with `behind > 0` as
925
+ * 'online', so the health gate (isMeshNodeHealthLaunchable) happily lets a STALE
926
+ * node — one whose branch is N commits behind its upstream — win auto-launch fitness
927
+ * routing and run a fresh worker against out-of-date code. `behind` is deliberately
928
+ * NOT folded into deriveMeshNodeHealthFromGit because "behind" is not universally
929
+ * unhealthy (the MAGI planner and other callers share that resolver); it is only
930
+ * unhealthy for *spawning new work*. This gate encodes exactly that launch-time axis.
931
+ *
932
+ * Returns false (NOT fresh → skip / de-rank) only when git telemetry is PRESENT and
933
+ * proves staleness:
934
+ * - behind count exceeds `maxBehind` (default 0 — any behind blocks), OR
935
+ * - a submodule is out of sync (gitlink points off upstream — cannot be caught up
936
+ * by a simple worktree ff and would launch against a mismatched submodule).
937
+ *
938
+ * When telemetry is absent it returns true (fresh), preserving the online/unknown-pass
939
+ * philosophy: we never block on missing data, only on data that proves the node stale.
940
+ */
941
+ export function isMeshNodeFreshEnoughToLaunch(node: any, opts?: { maxBehind?: number }): boolean {
942
+ const git = resolveEffectiveNodeGit(node);
943
+ // No git telemetry at all → cannot prove stale → do not block.
944
+ if (Object.keys(git).length === 0) return true;
945
+ // Not a git repo / no branch: leave this to the health gate (it returns 'degraded'
946
+ // and blocks there); freshness has nothing to add.
947
+ if (readBooleanValue(git.isGitRepo) === false) return true;
948
+ const submoduleDrift = getGitSubmoduleDriftState(git);
949
+ if (submoduleDrift.outOfSync) return false;
950
+ const behind = readNumberValue(git.behind);
951
+ // No behind datum reported → treat as fresh (don't infer staleness from absence).
952
+ if (behind === undefined) return true;
953
+ const maxBehind = Number.isFinite(opts?.maxBehind as number) && (opts?.maxBehind as number) >= 0
954
+ ? Math.floor(opts!.maxBehind as number)
955
+ : 0;
956
+ return behind <= maxBehind;
957
+ }
958
+
910
959
  function readMeshNodeLabel(status: Record<string, unknown>, node: any): string {
911
960
  return readStringValue(status.nodeId, normalizeMeshNodeId(node)) ?? 'unknown';
912
961
  }
@@ -13,13 +13,15 @@ import { createSessionDelivery, updateSessionDeliveryStatus } from './mesh-deliv
13
13
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
14
14
  import { traceMeshEventDrop } from './mesh-event-trace.js';
15
15
  import { awaitWithWarmupDeadline, resolveWarmupDeadlineOpts } from './mesh-warmup-deadline.js';
16
- import { resolveDelegatedWorkerAutoApprove, resolveProviderMaxParallel, resolveNodeSchedulingPriority, normalizeMeshSchedulingStrategy, resolveMaxParallelTasks, resolveMaxReadonlyParallelTasks, resolveCoordinatorIdlePushPolicy } from '../repo-mesh-types.js';
16
+ import { delegatedWorkerAutoApproveSettings, resolveProviderMaxParallel, resolveNodeSchedulingPriority, normalizeMeshSchedulingStrategy, resolveMaxParallelTasks, resolveMaxReadonlyParallelTasks, resolveCoordinatorIdlePushPolicy } from '../repo-mesh-types.js';
17
+ import { loadRepoMeshJsonConfig } from '../config/mesh-json-config.js';
18
+ import type { RepoMeshDeclarativeConfig } from '../config/mesh-json-config.js';
17
19
  import type { RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
18
20
  import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, canonicalDaemonId, expandDaemonIdForms, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, sessionIdsEquivalent, normalizeNodeCapabilitySlots, isMeshTaskDifficulty, withStatusProbeMarker, type MeshNodeIdentified, type NodeCapabilitySlot, type MeshTaskDifficulty } from '@adhdev/mesh-shared';
19
21
  import { resolveNodeCapabilitySlots } from './mesh-node-slots.js';
20
22
  import { findTerminalLedgerEvidenceForTask, hasUnterminalDirectDispatchLedgerEntry } from './mesh-events-stale.js';
21
23
  import { readNonEmptyString } from './mesh-events-utils.js';
22
- import { readMeshNodeDaemonId, isMeshNodeHealthLaunchable } from './mesh-node-identity.js';
24
+ import { readMeshNodeDaemonId, isMeshNodeHealthLaunchable, isMeshNodeFreshEnoughToLaunch } from './mesh-node-identity.js';
23
25
  import { queuePendingMeshCoordinatorEvent, retractPendingDispatchBlockedEvent, drainPendingMeshCoordinatorEvents } from './mesh-events-pending.js';
24
26
  import { isWorktreeBootstrapStaleRunning, shouldDeferDispatchForBootstrap } from './worktree-bootstrap-config.js';
25
27
  import { isWithinCloneBootstrapGrace } from './mesh-clone-grace.js';
@@ -90,6 +92,25 @@ export function __resetIdleAutoFastForwardForTests(): void {
90
92
  autoFastForwardWorkspaceLease.clear();
91
93
  }
92
94
 
95
+ /**
96
+ * Load the repo-shared `.adhdev/mesh.json` for a node's workspace, tolerating a
97
+ * missing/invalid file (returns null → resolver falls back to provider-spec
98
+ * defaults, i.e. exactly the pre-providerDefaults behavior). Only the
99
+ * `providerDefaults` zone influences the delegated-worker MODE selection; it never
100
+ * touches the ENABLE decision. When a node carries no workspace path (should not
101
+ * happen for a launchable node, but be defensive), we skip the read entirely.
102
+ */
103
+ function loadRepoConfigForNode(node: any): RepoMeshDeclarativeConfig | null {
104
+ const workspace = typeof node?.workspace === 'string' && node.workspace.trim() ? node.workspace.trim() : '';
105
+ if (!workspace) return null;
106
+ try {
107
+ const result = loadRepoMeshJsonConfig(workspace);
108
+ return result.sourceType === 'repo_file' && result.config ? result.config : null;
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
93
114
  export function getMeshWithCache(components: DaemonComponents, meshId: string): any | undefined {
94
115
  const localMesh = getMesh(meshId);
95
116
  const cachedMesh = components.router?.getCachedInlineMesh(meshId);
@@ -690,7 +711,7 @@ export function tryAssignQueueTask(
690
711
  if (inst && typeof inst.updateSettings === 'function') {
691
712
  // Adopting a (possibly manually-opened) local session as a worker: apply the
692
713
  // delegated-worker auto-approve policy here too, so a session that was launched
693
- // without autoApprove still auto-approves once the coordinator dispatches a task
714
+ // without an auto-approve boolean/mode still resolves the delegated policy once dispatched
694
715
  // to it (the "approval notification fires only for certain delegated sessions"
695
716
  // case). updateSettings preserves runtime mesh keys; passing autoApprove keeps it.
696
717
  //
@@ -704,7 +725,13 @@ export function tryAssignQueueTask(
704
725
  meshNodeFor: meshId,
705
726
  meshNodeId: nodeId,
706
727
  launchedByCoordinator: true,
707
- autoApprove: resolveDelegatedWorkerAutoApprove(mesh?.policy, node?.policy),
728
+ ...delegatedWorkerAutoApproveSettings(
729
+ mesh?.policy,
730
+ node?.policy,
731
+ components.providerLoader?.getMeta(providerType),
732
+ loadRepoConfigForNode(node),
733
+ providerType,
734
+ ),
708
735
  ...(localDaemonId ? { meshCoordinatorDaemonId: localDaemonId } : {}),
709
736
  // COMPLETION-PROPAGATION F5: (re)stamp the coordinator SESSION anchor from THIS
710
737
  // task's sourceCoordinatorSessionId with PRIORITY — a manually-launched (or reused)
@@ -1694,8 +1721,20 @@ function liveSessionCountForNode(components: DaemonComponents, meshId: string, n
1694
1721
  * free claimer, and is excluded — so a read-only auto-launch onto a busy-but-no-idle node
1695
1722
  * is still allowed. A node with NO live mesh session at all (dead, or never launched) does
1696
1723
  * not match, preserving the legitimate first-session spawn.
1724
+ *
1725
+ * PROVIDER-MATCH gate (DISPATCH-DEADLOCK-PROVIDER-MISMATCH): a live session only suppresses
1726
+ * this launch if it could ACTUALLY claim THIS task. A session whose providerType does not
1727
+ * satisfy the task's requiredTags (e.g. a claude-cli coordinator/worker session on a node,
1728
+ * while the pending task is required_tags: [provider=codex-cli]) is NOT a pending claimer for
1729
+ * this task — claimNextQueueTask's nodeSatisfiesRequiredTags gate would reject its claim. Left
1730
+ * unchecked, such a mismatched session made this gate return true for a task it can never claim,
1731
+ * so the required-provider worker never auto-launched AND no session could claim → nobody made
1732
+ * progress → permanent silent deadlock. Mirror the claim path: build the session's own
1733
+ * capability tags (its providerType pinned onto the node) and only count it as a pending claimer
1734
+ * when those tags satisfy task.requiredTags. `node` is passed in so the tag set reflects this
1735
+ * node's os/arch/worktree/converge context, matching claimNextQueueTask exactly.
1697
1736
  */
1698
- function nodeHasLiveSessionPendingClaim(components: DaemonComponents, meshId: string, nodeId: string): boolean {
1737
+ function nodeHasLiveSessionPendingClaim(components: DaemonComponents, meshId: string, nodeId: string, task: MeshWorkQueueEntry, node: any): boolean {
1699
1738
  // (A) AUTOLAUNCH-CLAIM-CHURN remote-awareness: a task whose auto-launch record targets this
1700
1739
  // node and is still inside its await-claim window (base or backoff) already has a session on
1701
1740
  // its way to claim — even when that session is REMOTE and thus invisible to the local
@@ -1734,7 +1773,20 @@ function nodeHasLiveSessionPendingClaim(components: DaemonComponents, meshId: st
1734
1773
  if (isTerminalSessionStatus(status)) return false; // dead → no claimer here, allow launch
1735
1774
  const sessionId = readNonEmptyString(state.instanceId);
1736
1775
  if (sessionId && busySessionIds.has(sessionId)) return false; // busy with its own assigned task
1737
- return true; // live + unassigned will claim the pending task itself
1776
+ // PROVIDER-MATCH gate (DISPATCH-DEADLOCK-PROVIDER-MISMATCH): only count this session as a
1777
+ // pending claimer if its own provider could satisfy THIS task's requiredTags. A session
1778
+ // whose providerType does not match (e.g. a claude-cli session while task requires
1779
+ // provider=codex-cli) can never claim this task via claimNextQueueTask's
1780
+ // nodeSatisfiesRequiredTags gate, so it must not suppress the required-provider launch —
1781
+ // otherwise the task deadlocks (mismatched session blocks launch, yet cannot claim). Mirror
1782
+ // the claim path: pin the session's providerType onto this node and check the tags.
1783
+ if (task.requiredTags?.length) {
1784
+ const sessionProviderType = state.type || readNonEmptyString(settings.providerType);
1785
+ if (sessionProviderType && !nodeSatisfiesRequiredTags(task.requiredTags, buildMeshNodeCapabilityTags(node, sessionProviderType))) {
1786
+ return false; // provider mismatch → this session can't claim this task; not a pending claimer
1787
+ }
1788
+ }
1789
+ return true; // live + unassigned + provider-capable → will claim the pending task itself
1738
1790
  });
1739
1791
  }
1740
1792
 
@@ -1987,6 +2039,11 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
1987
2039
  }
1988
2040
  if (!pending.length) return false;
1989
2041
 
2042
+ // Launch-freshness threshold: reuse the auto-fast-forward policy's maxBehind (default
2043
+ // 0 → any behind blocks) so the launch gate and the repair path agree on how far
2044
+ // behind is tolerable. Resolved once per pass and shared across every candidate node.
2045
+ const freshnessGate = { maxBehind: resolveAutoFastForwardPolicy(mesh).maxBehind };
2046
+
1990
2047
  // Write cap + read-only cap resolved through the shared helpers from the
1991
2048
  // MACHINE-LOCAL stored mesh policy (no repo-file overlay). These are the same
1992
2049
  // resolvers the observability projection uses, so the enforced and exposed
@@ -2210,6 +2267,18 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
2210
2267
  markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'node_not_launch_ready', nodeId });
2211
2268
  continue;
2212
2269
  }
2270
+ // FRESHNESS gate (distinct from the health gate above): a clean-tree node that
2271
+ // is `behind` its upstream reads as 'online' and passes isLaunchableNode, so
2272
+ // without this it could win fitness routing and run a fresh worker against
2273
+ // stale code. Skip a node whose git telemetry proves it stale (behind >
2274
+ // maxBehind, or a submodule out of sync). Reuse the auto-fast-forward policy's
2275
+ // maxBehind threshold so "how far behind is tolerable" is configured in ONE
2276
+ // place. Telemetry-absent nodes pass (never block on missing data). The 4s
2277
+ // reconcile retries once the node's auto-ff repair path catches it up.
2278
+ if (!isMeshNodeFreshEnoughToLaunch(node, freshnessGate)) {
2279
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'node_stale_behind_upstream', nodeId });
2280
+ continue;
2281
+ }
2213
2282
  const launchTarget = resolveAutoLaunchTarget(components, node);
2214
2283
  if (launchTarget.mode === 'skip') {
2215
2284
  // Remote node we can't reach (no transport / no coordinator daemonId).
@@ -2229,7 +2298,7 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
2229
2298
  // busy-but-no-idle node is still allowed. Skip with a transient (non-actionable)
2230
2299
  // reason so the coordinator is not paged; the 4s reconcile retries, and once the
2231
2300
  // existing session goes terminal this gate clears and a legitimate launch proceeds.
2232
- if (nodeHasLiveSessionPendingClaim(components, meshId, nodeId)) {
2301
+ if (nodeHasLiveSessionPendingClaim(components, meshId, nodeId, task, node)) {
2233
2302
  markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'node_has_live_session_pending_claim', nodeId });
2234
2303
  continue;
2235
2304
  }
@@ -2301,8 +2370,14 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
2301
2370
  spawnedSessionVisibility: mesh?.policy?.spawnedSessionVisibility || 'hidden',
2302
2371
  // Coordinator-dispatched worker: auto-approve unless mesh/node policy
2303
2372
  // opts out (default true). Lands in settingsOverride and beats the
2304
- // global per-provider-type autoApprove config (see shouldAutoApprove).
2305
- autoApprove: resolveDelegatedWorkerAutoApprove(mesh?.policy, node?.policy),
2373
+ // global per-provider-type boolean/mode through explicit opposite-key clearing.
2374
+ ...delegatedWorkerAutoApproveSettings(
2375
+ mesh?.policy,
2376
+ node?.policy,
2377
+ components.providerLoader?.getMeta(resolved.providerType),
2378
+ loadRepoConfigForNode(node),
2379
+ resolved.providerType,
2380
+ ),
2306
2381
  launchedByCoordinator: true,
2307
2382
  autoLaunchedForQueueTaskId: task.id,
2308
2383
  };
@@ -2967,4 +3042,3 @@ export function runIdleMaintenanceThenAssignQueue(components: DaemonComponents,
2967
3042
  });
2968
3043
  });
2969
3044
  }
2970
-