@adhdev/daemon-core 1.0.28-rc.4 → 1.0.28-rc.6

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 (33) hide show
  1. package/dist/config/mesh-config.d.ts +2 -0
  2. package/dist/git/git-commands.d.ts +1 -0
  3. package/dist/index.js +7155 -7080
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +7158 -7083
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/mesh/mesh-completion-synthesis.d.ts +29 -0
  8. package/dist/mesh/mesh-node-identity.d.ts +2 -0
  9. package/dist/mesh/mesh-refine-gates.d.ts +32 -0
  10. package/dist/mesh/mesh-runtime-store.d.ts +1 -0
  11. package/dist/mesh/mesh-work-queue.d.ts +15 -0
  12. package/dist/mesh/node-facts.d.ts +17 -0
  13. package/dist/providers/cli-provider-instance.d.ts +30 -57
  14. package/dist/providers/transcript-evidence.d.ts +63 -0
  15. package/dist/repo-mesh-types.d.ts +23 -0
  16. package/package.json +5 -3
  17. package/src/cli-adapters/cli-state-engine.ts +4 -3
  18. package/src/commands/high-family/mesh-status.ts +5 -0
  19. package/src/commands/router-refine.ts +30 -0
  20. package/src/commands/upgrade-helper.ts +15 -1
  21. package/src/config/mesh-config.ts +7 -0
  22. package/src/git/git-commands.ts +11 -1
  23. package/src/mesh/mesh-completion-synthesis.ts +76 -50
  24. package/src/mesh/mesh-node-identity.ts +37 -3
  25. package/src/mesh/mesh-queue-assignment.ts +34 -0
  26. package/src/mesh/mesh-reconcile-loop.ts +62 -71
  27. package/src/mesh/mesh-refine-gates.ts +58 -0
  28. package/src/mesh/mesh-runtime-store.ts +9 -1
  29. package/src/mesh/mesh-work-queue.ts +20 -1
  30. package/src/mesh/node-facts.ts +54 -0
  31. package/src/providers/cli-provider-instance.ts +72 -152
  32. package/src/providers/transcript-evidence.ts +105 -0
  33. package/src/repo-mesh-types.ts +23 -0
@@ -471,19 +471,26 @@ export interface AssignedTaskTerminalEvidence {
471
471
  // dispatch timestamp, or a tail containing nothing dated at/after dispatch all yield false, so the
472
472
  // caller falls through to its normal re-drive decision. The poll can only PREVENT a re-drive of a
473
473
  // working session, never create or suppress a completion.
474
- export async function pollAssignedTaskInTurnProgress(
474
+ // ---------------------------------------------------------------------------
475
+ // Shared assigned-task read_chat skeleton (P1 of the transcript-authority
476
+ // unification — root repo docs/design/2026-07-25-transcript-authority-unification.md).
477
+ // pollAssignedTaskInTurnProgress ('turn-progress') and
478
+ // pollAssignedTaskTerminalEvidence ('terminal-evidence') previously duplicated
479
+ // this fetch verbatim; both are delegation shells over this one skeleton so a
480
+ // future purpose (or the reconcile-loop consumers in P3) cannot fork the
481
+ // transport semantics again. Inconclusive — no worker, no transport, transport
482
+ // error, or an explicit success:false — is null; callers keep their
483
+ // conservative fallbacks ("couldn't tell ≠ idle").
484
+ // ---------------------------------------------------------------------------
485
+ async function fetchAssignedTaskChatTail(
475
486
  components: DaemonComponents,
476
487
  mesh: { id: string; nodes?: Array<{ id: string; daemonId?: string; workspace?: string }> },
477
- row: { id: string; assignedSessionId?: string; assignedNodeId?: string; assignedProviderType?: string; dispatchTimestamp?: string },
478
- ): Promise<boolean> {
488
+ row: { assignedSessionId?: string; assignedNodeId?: string; assignedProviderType?: string },
489
+ tailLimit = 10,
490
+ ): Promise<Record<string, unknown> | null> {
479
491
  const sessionId = readNonEmptyString(row.assignedSessionId);
480
492
  const nodeId = readNonEmptyString(row.assignedNodeId);
481
- if (!sessionId || !nodeId) return false; // no worker to read
482
-
483
- // A dispatch boundary is REQUIRED: without it a reused session's prior-task tail would read as
484
- // progress on THIS task and suppress the re-drive forever.
485
- const dispatchedAtMs = Date.parse(readNonEmptyString(row.dispatchTimestamp));
486
- if (!Number.isFinite(dispatchedAtMs)) return false;
493
+ if (!sessionId || !nodeId) return null; // no worker to read
487
494
 
488
495
  const node = (mesh.nodes ?? []).find(n => n.id === nodeId);
489
496
  const nodeDaemonId = readNonEmptyString(node?.daemonId);
@@ -496,27 +503,76 @@ export async function pollAssignedTaskInTurnProgress(
496
503
  const readArgs: Record<string, unknown> = {
497
504
  sessionId,
498
505
  targetSessionId: sessionId,
499
- tailLimit: 10,
506
+ tailLimit,
500
507
  ...(node?.workspace ? { workspace: node.workspace } : {}),
501
508
  ...(providerType ? { agentType: providerType, providerType } : {}),
502
509
  };
503
510
 
504
- let payload: Record<string, unknown> | null = null;
505
511
  try {
506
512
  if (isLocalNode) {
507
513
  const result = await components.commandHandler?.handle('read_chat', readArgs);
508
- if (result && (result as { success?: boolean }).success === false) return false;
509
- payload = unwrapReadChatPayload(result);
510
- } else if (components.dispatchMeshCommand) {
514
+ if (result && (result as { success?: boolean }).success === false) return null;
515
+ return unwrapReadChatPayload(result);
516
+ }
517
+ if (components.dispatchMeshCommand) {
511
518
  const result = await components.dispatchMeshCommand(nodeDaemonId, 'read_chat', readArgs);
512
- payload = unwrapReadChatPayload(result);
513
- if (payload && (payload as { success?: boolean }).success === false) return false;
514
- } else {
515
- return false; // remote node, no P2P transport — can't read this tick
519
+ const payload = unwrapReadChatPayload(result);
520
+ if (payload && (payload as { success?: boolean }).success === false) return null;
521
+ return payload;
516
522
  }
523
+ return null; // remote node, no P2P transport — can't read this tick
517
524
  } catch {
518
- return false; // transport error / session gone → inconclusive, let the caller decide
525
+ return null; // transport error / session gone → inconclusive, let the caller decide
519
526
  }
527
+ }
528
+
529
+ /**
530
+ * Purpose-tagged coordinator-side evidence query — the remote half of the
531
+ * transcript-authority choke point (P1). Existing callers keep the original
532
+ * poll* shells below; new consumers (the reconcile loop's early-arm / redrive
533
+ * sites in P3) should enter here so the purpose vocabulary — not another
534
+ * ad-hoc gate — expresses what is being asked of the worker transcript.
535
+ */
536
+ export type AssignedTaskEvidencePurpose = 'turn-progress' | 'terminal-evidence';
537
+
538
+ export interface AssignedTaskCompletionEvidence {
539
+ purpose: AssignedTaskEvidencePurpose;
540
+ /** "Did the worker START this task's turn" — post-dispatch agent bubble. */
541
+ inTurnProgress: boolean;
542
+ /** "Did the worker FINISH this task's turn" — populated for 'terminal-evidence'. */
543
+ terminal: AssignedTaskTerminalEvidence | null;
544
+ }
545
+
546
+ export async function resolveAssignedTaskCompletionEvidence(
547
+ components: DaemonComponents,
548
+ mesh: { id: string; nodes?: Array<{ id: string; daemonId?: string; workspace?: string }> },
549
+ row: { id: string; assignedSessionId?: string; assignedNodeId?: string; assignedProviderType?: string; dispatchTimestamp?: string },
550
+ purpose: AssignedTaskEvidencePurpose,
551
+ ): Promise<AssignedTaskCompletionEvidence> {
552
+ if (purpose === 'turn-progress') {
553
+ const inTurnProgress = await pollAssignedTaskInTurnProgress(components, mesh, row);
554
+ return { purpose, inTurnProgress, terminal: null };
555
+ }
556
+ const terminal = await pollAssignedTaskTerminalEvidence(components, mesh, row);
557
+ // A proven turn-end implies the turn also started.
558
+ return { purpose, inTurnProgress: terminal != null, terminal };
559
+ }
560
+
561
+ export async function pollAssignedTaskInTurnProgress(
562
+ components: DaemonComponents,
563
+ mesh: { id: string; nodes?: Array<{ id: string; daemonId?: string; workspace?: string }> },
564
+ row: { id: string; assignedSessionId?: string; assignedNodeId?: string; assignedProviderType?: string; dispatchTimestamp?: string },
565
+ ): Promise<boolean> {
566
+ const sessionId = readNonEmptyString(row.assignedSessionId);
567
+ const nodeId = readNonEmptyString(row.assignedNodeId);
568
+ if (!sessionId || !nodeId) return false; // no worker to read
569
+
570
+ // A dispatch boundary is REQUIRED: without it a reused session's prior-task tail would read as
571
+ // progress on THIS task and suppress the re-drive forever.
572
+ const dispatchedAtMs = Date.parse(readNonEmptyString(row.dispatchTimestamp));
573
+ if (!Number.isFinite(dispatchedAtMs)) return false;
574
+
575
+ const payload = await fetchAssignedTaskChatTail(components, mesh, row);
520
576
  if (!payload) return false;
521
577
 
522
578
  const messages = Array.isArray(payload.messages) ? payload.messages as ChatMessage[] : [];
@@ -542,38 +598,8 @@ export async function pollAssignedTaskTerminalEvidence(
542
598
  const nodeId = readNonEmptyString(row.assignedNodeId);
543
599
  if (!sessionId || !nodeId) return null; // no worker to read
544
600
 
545
- const node = (mesh.nodes ?? []).find(n => n.id === nodeId);
546
- const nodeDaemonId = readNonEmptyString(node?.daemonId);
547
- const localDaemonId = readNonEmptyString((components as { statusInstanceId?: string }).statusInstanceId);
548
- const isLocalNode = !nodeDaemonId
549
- || daemonIdsEquivalent(nodeDaemonId, localDaemonId)
550
- || !!components.instanceManager.getInstance(sessionId);
551
-
552
601
  const providerType = readNonEmptyString(row.assignedProviderType);
553
- const readArgs: Record<string, unknown> = {
554
- sessionId,
555
- targetSessionId: sessionId,
556
- tailLimit: 10,
557
- ...(node?.workspace ? { workspace: node.workspace } : {}),
558
- ...(providerType ? { agentType: providerType, providerType } : {}),
559
- };
560
-
561
- let payload: Record<string, unknown> | null = null;
562
- try {
563
- if (isLocalNode) {
564
- const result = await components.commandHandler?.handle('read_chat', readArgs);
565
- if (result && (result as { success?: boolean }).success === false) return null;
566
- payload = unwrapReadChatPayload(result);
567
- } else if (components.dispatchMeshCommand) {
568
- const result = await components.dispatchMeshCommand(nodeDaemonId, 'read_chat', readArgs);
569
- payload = unwrapReadChatPayload(result);
570
- if (payload && (payload as { success?: boolean }).success === false) return null;
571
- } else {
572
- return null; // remote node, no P2P transport — can't read this tick
573
- }
574
- } catch {
575
- return null; // transport error / session gone → inconclusive, let the caller decide
576
- }
602
+ const payload = await fetchAssignedTaskChatTail(components, mesh, row);
577
603
  if (!payload) return null;
578
604
 
579
605
  // Only a settled-idle session is a turn-end; a generating/waiting session is mid-turn and
@@ -15,7 +15,8 @@ import type { ProviderLoader } from '../providers/provider-loader.js';
15
15
  import { detectCLI, getCachedProviderVersions } from '../detection/cli-detector.js';
16
16
  import { getDaemonBuildInfo } from '../build-info.js';
17
17
  import { getGitRepoStatus } from '../git/git-status.js';
18
- import { normalizeGitStatus as sharedNormalizeGitStatus, pickBestTransitGitStatus as sharedPickBestTransitGitStatus, summarizeGitShape as sharedSummarizeGitShape, normalizeMeshNodeId, daemonIdsEquivalent, meshWorkspacesEquivalent, sessionIdsEquivalent, withStatusProbeMarker } from '@adhdev/mesh-shared';
18
+ import { normalizeGitStatus as sharedNormalizeGitStatus, pickBestTransitGitStatus as sharedPickBestTransitGitStatus, summarizeGitShape as sharedSummarizeGitShape, normalizeMeshNodeId, normalizeMeshNodeFacts, daemonIdsEquivalent, meshWorkspacesEquivalent, sessionIdsEquivalent, withStatusProbeMarker } from '@adhdev/mesh-shared';
19
+ import { buildLocalNodeFacts } from './node-facts.js';
19
20
  import type { MeshReportedMemberState } from '../repo-mesh-types.js';
20
21
  import { LOG } from '../logging/logger.js';
21
22
  import { getSessionHostSurfaceKind } from '../session-host/runtime-surface.js';
@@ -240,7 +241,7 @@ export function buildMeshNodeMachineIdentity(node: Record<string, unknown>, opts
240
241
  const hostname = readMeshNodeHostname(node);
241
242
  const machineName = readMeshNodeDisplayMachineName(node);
242
243
  const coordinatorHostname = readStringValue(opts.coordinatorHostname);
243
- const machineIdMatches = Boolean(opts.localMachineId && machineId && opts.localMachineId === machineId);
244
+ const machineIdMatches = Boolean(opts.localMachineId && machineId && daemonIdsEquivalent(opts.localMachineId, machineId));
244
245
  const daemonIdMatches = Boolean(opts.localDaemonId && daemonId && daemonIdsEquivalent(opts.localDaemonId, daemonId));
245
246
  const hostnameMatches = Boolean(
246
247
  normalizeMeshHostname(hostname)
@@ -328,6 +329,7 @@ export function recordInlineMeshDirectGitTruth(
328
329
  reporterProviderVersions: Record<string, string> | null;
329
330
  reporterDaemonBuildVersion: string | null;
330
331
  reportedMemberState: MeshReportedMemberState | null;
332
+ nodeFacts: import('@adhdev/mesh-shared').MeshNodeFacts | null;
331
333
  } {
332
334
  if (!node || typeof node !== 'object' || Array.isArray(node)) {
333
335
  return {
@@ -337,6 +339,7 @@ export function recordInlineMeshDirectGitTruth(
337
339
  reporterProviderVersions: null,
338
340
  reporterDaemonBuildVersion: null,
339
341
  reportedMemberState: null,
342
+ nodeFacts: null,
340
343
  };
341
344
  }
342
345
  const checkedAt = readNumberValue(git.lastCheckedAt) ?? Date.now();
@@ -433,6 +436,20 @@ export function recordInlineMeshDirectGitTruth(
433
436
  node.reportedMemberState = mirrored;
434
437
  }
435
438
  }
439
+ // Versioned facts bundle (deploy-lag visibility design §a). Remote: ingest the
440
+ // envelope's reporterNodeFacts WHOLESALE (opaque — unknown future fields ride
441
+ // through). Self/worktree: the local probe bypasses handleGitCommand, so build
442
+ // the bundle with the SAME producer the envelope uses — the two paths cannot
443
+ // drift on a per-field basis anymore. Skipped (no stamp churn) when a remote
444
+ // envelope predates the bundle.
445
+ if (!isLocalSource) {
446
+ const remoteFacts = normalizeMeshNodeFacts((git as { reporterNodeFacts?: unknown }).reporterNodeFacts);
447
+ if (remoteFacts) node.nodeFacts = remoteFacts;
448
+ } else {
449
+ try {
450
+ node.nodeFacts = buildLocalNodeFacts({ providerVersions: reporterProviderVersions ?? null });
451
+ } catch { /* facts stamp is best-effort observability */ }
452
+ }
436
453
  return {
437
454
  reporterPlatform,
438
455
  reporterArch,
@@ -440,6 +457,9 @@ export function recordInlineMeshDirectGitTruth(
440
457
  reporterProviderVersions,
441
458
  reporterDaemonBuildVersion,
442
459
  reportedMemberState: (!isLocalSource ? (node.reportedMemberState ?? null) : null) as MeshReportedMemberState | null,
460
+ // Bundle rides to persistence so the facts (incl. build COMMIT — the
461
+ // deploy-lag anchor) survive a config reload / coordinator restart.
462
+ nodeFacts: (node.nodeFacts ?? null) as import('@adhdev/mesh-shared').MeshNodeFacts | null,
443
463
  };
444
464
  }
445
465
 
@@ -555,6 +575,7 @@ export function persistNodeReporterPlatform(
555
575
  reporterProviderVersions?: Record<string, string> | null;
556
576
  reporterDaemonBuildVersion?: string | null;
557
577
  reportedMemberState?: MeshReportedMemberState | null;
578
+ nodeFacts?: import('@adhdev/mesh-shared').MeshNodeFacts | null;
558
579
  },
559
580
  ): void {
560
581
  if (meshSource !== 'local_config') return;
@@ -570,13 +591,15 @@ export function persistNodeReporterPlatform(
570
591
  // per-machine runtime facts only (versions + build) — slots are coordinator-owned
571
592
  // config, not reported (REMOTE-NODE-SLOTS-COORDINATOR-LOCAL fix).
572
593
  const reportedMemberState = reporter.reportedMemberState ?? undefined;
594
+ const nodeFacts = reporter.nodeFacts ?? undefined;
573
595
  if (
574
596
  !reportedPlatform &&
575
597
  !reportedArch &&
576
598
  !reportedMachineNickname &&
577
599
  !reportedProviderVersions &&
578
600
  !reportedDaemonBuildVersion &&
579
- !reportedMemberState
601
+ !reportedMemberState &&
602
+ !nodeFacts
580
603
  ) {
581
604
  return;
582
605
  }
@@ -588,6 +611,7 @@ export function persistNodeReporterPlatform(
588
611
  reportedProviderVersions,
589
612
  reportedDaemonBuildVersion,
590
613
  reportedMemberState,
614
+ nodeFacts,
591
615
  }))
592
616
  .catch(() => { /* best-effort self-heal; never block status assembly */ });
593
617
  }
@@ -1516,6 +1540,16 @@ export function finalizeMeshNodeStatus(args: {
1516
1540
  liveTruthProbed,
1517
1541
  directTruthUnavailable,
1518
1542
  });
1543
+ // Deploy-lag visibility: surface the git probe's daemonBuildBehind as the
1544
+ // top-level staleDaemonBuild node field — same contract as the MCP
1545
+ // mesh_status surface (mesh-tools-status.ts). The dashboard Status tab's
1546
+ // stale-build badge reads this field; before this stamp the dashboard
1547
+ // surface never received it (and normalizeGitStatus additionally dropped
1548
+ // it from relayed remote statuses), so the badge was a dead path.
1549
+ const gitForBuildLag = readObjectRecord(status.git);
1550
+ if (gitForBuildLag.daemonBuildBehind && typeof gitForBuildLag.daemonBuildBehind === 'object') {
1551
+ status.staleDaemonBuild = gitForBuildLag.daemonBuildBehind;
1552
+ }
1519
1553
  const bootstrap = readObjectRecord(node?.worktreeBootstrap);
1520
1554
  if (node?.isLocalWorktree && readStringValue(bootstrap.status)) {
1521
1555
  status.worktreeBootstrap = bootstrap;
@@ -8,6 +8,7 @@ import { LOG } from '../logging/logger.js';
8
8
  import { appendLedgerEntry } from './mesh-ledger.js';
9
9
  import { buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, claimNextTask, updateTaskStatus, getQueue, recordTaskAutoLaunch, getActiveDirectDispatches, isTaskReadonly, taskDependenciesSatisfied, meshTaskNotBeforeReady, meshTaskPriorityRank, requeueTask } from './mesh-work-queue.js';
10
10
  import type { MeshWorkQueueEntry } from './mesh-work-queue.js';
11
+ import { resolveTranscriptAuthorityProfile } from '../providers/transcript-evidence.js';
11
12
  import { fastForwardMeshNode } from './mesh-fast-forward.js';
12
13
  import { createSessionDelivery, updateSessionDeliveryStatus } from './mesh-delivery-policy.js';
13
14
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
@@ -433,6 +434,33 @@ function deliverTaskToSession(
433
434
  // the mesh_status per-node session filter, and the read_chat node scope guard all
434
435
  // share one comparison rule instead of drifting module-private copies.
435
436
 
437
+ /**
438
+ * Resolve the transcript-authority profile of the LIVE claiming session's
439
+ * provider module (runtime capability, not manifest — a manifest may declare
440
+ * nativeHistory the live-loaded session doesn't have). Row-lean subset: the
441
+ * nativeHistory config itself stays local. Undefined when the session isn't
442
+ * locally observable — the row simply carries no stamp and consumers fall
443
+ * back to local resolution (older-daemon rows look the same).
444
+ */
445
+ function resolveClaimingSessionTranscriptProfile(
446
+ components: DaemonComponents,
447
+ sessionId: string,
448
+ ): MeshWorkQueueEntry['assignedTranscriptProfile'] {
449
+ try {
450
+ const instances = components.instanceManager?.getByCategory?.('cli') || [];
451
+ const inst = instances.find((i: any) => {
452
+ const sid = i?.getState?.().instanceId;
453
+ return typeof sid === 'string' && sid && sessionIdsEquivalent(sid, sessionId);
454
+ }) as { provider?: unknown } | undefined;
455
+ const provider = inst?.provider;
456
+ if (!provider || typeof provider !== 'object') return undefined;
457
+ const profile = resolveTranscriptAuthorityProfile(provider as Parameters<typeof resolveTranscriptAuthorityProfile>[0]);
458
+ return { class: profile.class, timing: profile.timing, emitsPtyTurnEvents: profile.emitsPtyTurnEvents };
459
+ } catch {
460
+ return undefined;
461
+ }
462
+ }
463
+
436
464
  export function tryAssignQueueTask(
437
465
  components: DaemonComponents,
438
466
  meshId: string,
@@ -589,10 +617,16 @@ export function tryAssignQueueTask(
589
617
  // worktree sessions. Without it, every sibling worktree session on this daemon could
590
618
  // claim the same convergence intent and race push/production-deploy (the 4-way fan-out).
591
619
  const nodeIsWorktree = node?.isLocalWorktree === true;
620
+ // P1 transcript-authority stamp: the claim runs on the daemon that owns the
621
+ // session, so the LIVE provider module (runtime capability, not manifest) is
622
+ // resolvable here — classify once and persist it on the row so coordinator-side
623
+ // gates (early-arm / redrive) can classify this worker without local access.
624
+ const assignedTranscriptProfile = resolveClaimingSessionTranscriptProfile(components, sessionId);
592
625
  const task = claimNextTask(meshId, nodeId, sessionId, capabilityTags, {
593
626
  providerType,
594
627
  ...(providerMaxParallel !== undefined ? { providerMaxParallel } : {}),
595
628
  nodeIsWorktree,
629
+ ...(assignedTranscriptProfile ? { assignedTranscriptProfile } : {}),
596
630
  });
597
631
  if (!task) {
598
632
  return false;
@@ -77,8 +77,7 @@ import {
77
77
  resolveReconcileIntervalMs,
78
78
  } from './mesh-reconcile-config.js';
79
79
  import { pullRemoteNodeQueues, pullPendingEventsFromNode, reprobeWorkerStatus } from './mesh-remote-event-pull.js';
80
- import { isPurePtyTranscriptProvider } from '../cli-adapters/provider-cli-shared.js';
81
- import { isNativeSourceCanonicalHistory } from '../config/chat-history.js';
80
+ import { resolveTranscriptAuthorityProfile } from '../providers/transcript-evidence.js';
82
81
  import { runDiskRetentionSweep, detectAndSignalOrphanWorktrees } from './mesh-disk-retention.js';
83
82
  import {
84
83
  reconcileUnterminatedDirectDispatches,
@@ -792,53 +791,40 @@ function assignedRowLiveStatusIsAwaitingApproval(
792
791
  }
793
792
  }
794
793
 
795
- // EARLY-IDLE-COMPLETION-FALSE-POSITIVE — resolve whether the assigned session's provider is
796
- // the pure-PTY transcript class (kimi and kin — no native history, no provider authority), from
797
- // the LOCAL instance when it is present. Returns:
798
- // true → local instance is a pure-PTY provider (the class the early rescue exists for)
799
- // false → local instance is NOT pure-PTY (native / provider-authoritative)
800
- // undefined no local instance (remote worker / gone / id-form skew) — pure-PTY unknowable here
801
- // A remote pure-PTY worker is handled by the reprobe path (a fresh-idle read positively confirms
802
- // the session is settled before the streak accrues), not by this local-only capability check.
803
- function resolveLocalSessionPurePty(components: DaemonComponents, sessionId: string): boolean | undefined {
804
- try {
805
- const instances = components.instanceManager?.getByCategory?.('cli') || [];
806
- const inst = instances.find((i: any) => {
807
- const sid = readNonEmptyString(i?.getState?.().instanceId);
808
- return sid && sessionIdsEquivalent(sid, sessionId);
809
- }) as { provider?: unknown } | undefined;
810
- if (!inst) return undefined; // remote / gone / id-form skew — not locally observable
811
- const provider = inst.provider;
812
- if (!provider || typeof provider !== 'object') return undefined;
813
- return isPurePtyTranscriptProvider(provider as Parameters<typeof isPurePtyTranscriptProvider>[0]);
814
- } catch {
815
- return undefined;
794
+ /**
795
+ * P3 of the transcript-authority unification (root repo docs/design/
796
+ * 2026-07-25-transcript-authority-unification.md): resolve the assigned
797
+ * worker's transcript-authority profile. The claim-time row stamp comes FIRST
798
+ * it was written by the daemon that OWNS the session from its LIVE provider
799
+ * module, so it classifies a REMOTE worker this coordinator cannot resolve
800
+ * locally (the structural fix for the "remote class unknowable → reprobe-only"
801
+ * blind spot). Falls back to the local instance for pre-stamp rows / direct
802
+ * dispatches; undefined for an older-daemon remote row — callers keep their
803
+ * conservative reprobe fallbacks.
804
+ */
805
+ function resolveAssignedTranscriptProfile(
806
+ components: DaemonComponents,
807
+ row: {
808
+ assignedSessionId?: string;
809
+ assignedTranscriptProfile?: { class: string; timing: string; emitsPtyTurnEvents: boolean };
810
+ },
811
+ ): { class: string; timing: string; emitsPtyTurnEvents: boolean } | undefined {
812
+ const stamped = row.assignedTranscriptProfile;
813
+ if (stamped && typeof stamped === 'object' && typeof stamped.emitsPtyTurnEvents === 'boolean') {
814
+ return stamped;
816
815
  }
817
- }
818
-
819
- // KIMI-NATIVE-SOURCE early-idle arm — resolve whether the assigned session's provider is a
820
- // NATIVE-SOURCE provider (transcriptAuthority=provider + on-disk nativeHistory — e.g. kimi's
821
- // wire.jsonl), from the LOCAL instance when it is present. Returns:
822
- // true → local instance is a native-source provider (kimi and kin)
823
- // false → local instance is NOT native-source (pure-PTY / daemon-owned transcript)
824
- // undefined → no local instance (remote worker / gone / id-form skew) — unknowable here
825
- // This is the mirror of resolveLocalSessionPurePty for the class that DOES have an
826
- // authoritative on-disk transcript: a native-source worker that collapses idle→idle (never
827
- // emitting generating_started so taskDeliveryConsumed stays false) would otherwise be blocked
828
- // by the turn-not-started hold, leaving a finished turn 'assigned' until the 15-min reclaim.
829
- // Allowing it to arm lets the transcript-evidence poll complete it promptly; the poll's
830
- // idle + final-assistant-after-dispatch guards remain the finality net.
831
- function resolveLocalSessionNativeSource(components: DaemonComponents, sessionId: string): boolean | undefined {
816
+ const sessionId = readNonEmptyString(row.assignedSessionId);
817
+ if (!sessionId) return undefined;
832
818
  try {
833
819
  const instances = components.instanceManager?.getByCategory?.('cli') || [];
834
820
  const inst = instances.find((i: any) => {
835
821
  const sid = readNonEmptyString(i?.getState?.().instanceId);
836
822
  return sid && sessionIdsEquivalent(sid, sessionId);
837
- }) as { provider?: { nativeHistory?: unknown } } | undefined;
838
- if (!inst) return undefined; // remote / gone / id-form skew — not locally observable
839
- const provider = inst.provider;
823
+ }) as { provider?: unknown } | undefined;
824
+ const provider = inst?.provider;
840
825
  if (!provider || typeof provider !== 'object') return undefined;
841
- return isNativeSourceCanonicalHistory((provider as { nativeHistory?: any }).nativeHistory);
826
+ const profile = resolveTranscriptAuthorityProfile(provider as Parameters<typeof resolveTranscriptAuthorityProfile>[0]);
827
+ return { class: profile.class, timing: profile.timing, emitsPtyTurnEvents: profile.emitsPtyTurnEvents };
842
828
  } catch {
843
829
  return undefined;
844
830
  }
@@ -870,7 +856,13 @@ async function evaluateEarlyIdleTranscriptArm(
870
856
  components: DaemonComponents,
871
857
  mesh: { id: string; nodes?: Array<{ id: string; daemonId?: string; workspace?: string }> },
872
858
  store: MeshRuntimeStore,
873
- row: { id: string; assignedSessionId?: string; assignedNodeId?: string; assignedProviderType?: string },
859
+ row: {
860
+ id: string;
861
+ assignedSessionId?: string;
862
+ assignedNodeId?: string;
863
+ assignedProviderType?: string;
864
+ assignedTranscriptProfile?: { class: string; timing: string; emitsPtyTurnEvents: boolean };
865
+ },
874
866
  localDaemonId?: string,
875
867
  selfIds: string[] = [],
876
868
  ): Promise<boolean> {
@@ -921,26 +913,21 @@ async function evaluateEarlyIdleTranscriptArm(
921
913
 
922
914
  // (b) TURN-START evidence (guards the boot-window / preamble false positive).
923
915
  if (store.taskDeliveryConsumed(mesh.id, row.id)) return true; // worker emitted generating_started
924
- // Delivery never consumed. Preserve the kimi rescue ONLY for the LOCAL pure-PTY class (which
925
- // never emits generating_started); a native/provider-authoritative local worker that hasn't
926
- // started its turn must NOT be armed off a boot-window idle. For a remote worker (pure-PTY
927
- // unknowable here) the positive-idle reprobe in (a) is the gate — allow accrual and let the poll
928
- // enforce post-dispatch + trailing-tool-activity finality.
929
- const localPurePty = resolveLocalSessionPurePty(components, sessionId);
930
- if (localPurePty === true) return true; // local pure-PTY the class this rescue targets
931
- if (localPurePty === false) {
932
- // Not the pure-PTY class. A NATIVE-SOURCE provider (transcriptAuthority=provider +
933
- // nativeHistory e.g. kimi) can ALSO collapse idle→idle and never emit
934
- // generating_started (so taskDeliveryConsumed stays false), yet its finished turn is
935
- // provable from its authoritative on-disk transcript. Arm it too so it completes via
936
- // the transcript-evidence poll instead of sitting 'assigned' until the 15-min
937
- // DELIVERED_NO_TURN_DEADLINE_MS reclaim. A genuinely daemon-owned / non-native worker
938
- // that hasn't started its turn is still held (returns false).
939
- const localNativeSource = resolveLocalSessionNativeSource(components, sessionId);
940
- if (localNativeSource === true) return true; // native-source — provable via its transcript
941
- return false; // daemon-owned, turn not started — hold
942
- }
943
- return verdict === 'UNKNOWN'; // remote, unknowable class — trust the (a) reprobe
916
+ // Delivery never consumed. Classify via the transcript-authority profile (P3):
917
+ // a class that runs turns WITHOUT reliable PTY turn events (pure-PTY, or a
918
+ // native-source floor/hold provider emitsPtyTurnEvents=false) legitimately
919
+ // never emits generating_started, and its finished turn is provable from its
920
+ // transcript arm it and let the transcript-evidence poll enforce the
921
+ // post-dispatch + trailing-tool finality net. A class with RELIABLE PTY turn
922
+ // events (daemon-owned, or a write-lag native source like claude-cli) that
923
+ // hasn't started its turn is HELD — arming it off a boot-window idle is
924
+ // exactly the preamble false positive this gate exists to prevent. The
925
+ // claim-time row stamp classifies REMOTE workers too; an unstamped remote
926
+ // row (older claiming daemon) keeps the historical fallback of trusting the
927
+ // (a) positive-idle reprobe.
928
+ const profile = resolveAssignedTranscriptProfile(components, row);
929
+ if (profile) return profile.emitsPtyTurnEvents === false;
930
+ return verdict === 'UNKNOWN'; // no stamp, no local instance trust the (a) reprobe
944
931
  }
945
932
 
946
933
  // WATCHDOG-FINALSUMMARY-LOST. When the assigned-stranded watchdog / delivered-no-turn deadline
@@ -1210,16 +1197,20 @@ async function recoverStrandedAssignedDispatches(
1210
1197
  // grace at all. Live 2026-07-25: a kimi worker was re-injected the same prompt at
1211
1198
  // 35s/28s/27s/43s (answering it each time) and the task then marked failed.
1212
1199
  //
1213
- // So for that class ONLY, replace the missing event with the evidence the provider
1200
+ // So for that class, replace the missing event with the evidence the provider
1214
1201
  // actually produces: any post-dispatch agent bubble in its transcript. Found → the
1215
1202
  // prompt WAS consumed and the turn is under way; hold the row (and clear the streak,
1216
1203
  // since progress is positive evidence, not a deferral). Not found / unreadable → fall
1217
- // through unchanged. PTY-event providers never enter this branch
1218
- // (resolveLocalSessionNativeSource false/undefined), so their behaviour is untouched.
1219
- const nativeSourceRedriveCandidate = row.assignedSessionId
1220
- ? resolveLocalSessionNativeSource(components, row.assignedSessionId) === true
1221
- : false;
1222
- if (nativeSourceRedriveCandidate
1204
+ // through unchanged. P3 generalization: the gate is the transcript-authority
1205
+ // profile's emitsPtyTurnEvents=false (pure-PTY AND native-source floor/hold
1206
+ // every class whose turn start is not a PTY event), resolved from the claim-time
1207
+ // row stamp FIRST so a REMOTE worker of this class is finally covered too (the
1208
+ // original fix was local-only). Reliable-PTY-event providers never enter this
1209
+ // branch, so their behaviour is untouched.
1210
+ const redriveProfile = row.assignedSessionId
1211
+ ? resolveAssignedTranscriptProfile(components, row)
1212
+ : undefined;
1213
+ if (redriveProfile?.emitsPtyTurnEvents === false
1223
1214
  && await pollAssignedTaskInTurnProgress(components, { id: meshId, nodes: mesh.nodes }, row)) {
1224
1215
  deliveredUnconsumedUnknownStreak.delete(shortStreakKey);
1225
1216
  traceMeshEventDrop('short_redrive_deferred_native_source_progress', {
@@ -1228,7 +1219,7 @@ async function recoverStrandedAssignedDispatches(
1228
1219
  nodeId: row.assignedNodeId,
1229
1220
  meshId,
1230
1221
  event: 'agent:generating_started',
1231
- }, `native_source_in_turn_progress (verdict ${verdict})`);
1222
+ }, `${redriveProfile.class}_in_turn_progress (verdict ${verdict})`);
1232
1223
  continue;
1233
1224
  }
1234
1225
  if (verdict === 'IDLE_CONFIRMED') {
@@ -1200,6 +1200,64 @@ export function collectFastForwardGitlinkPaths(repoRoot: string, baseHead: strin
1200
1200
  });
1201
1201
  }
1202
1202
 
1203
+ /**
1204
+ * Collect gitlink resolutions for the *trivial fast-forward* case, so the
1205
+ * gitlink-aware root rebase ({@link rootRebaseResolvingGitlinks}) can drive a
1206
+ * behind>0 rebase whose changed submodule pointer would otherwise make a plain
1207
+ * `git rebase baseHead` abort on the gitlink.
1208
+ *
1209
+ * When base has advanced the SAME submodule as the branch, git's recursive merge
1210
+ * refuses to auto-merge the gitlink even when the two commits are in a strict
1211
+ * ancestor/descendant relationship (a real fast-forward). That is fine for the
1212
+ * patch-equivalence gate (it synthesizes the merged tree), but the sync_base
1213
+ * *rebase* still runs `git rebase baseHead`, which stops on the same gitlink
1214
+ * conflict and aborts → the branch is wrongly blocked. This helper produces the
1215
+ * per-path resolution the root rebase needs so those paths take the gitlink-aware
1216
+ * path instead of the plain rebase.
1217
+ *
1218
+ * Direction rule (kept consistent with the diverged path, which always resolves to
1219
+ * the linear descendant): pick whichever of base/branch commit is the DESCENDANT
1220
+ * of the other and resolve the gitlink to it — the more-advanced commit wins.
1221
+ * - base ancestor-of branch → branch is more advanced → resolve to branch-side.
1222
+ * - branch ancestor-of base → base is more advanced → resolve to base-side.
1223
+ * - neither ancestor (diverged) or ambiguous → excluded (left to the diverged
1224
+ * converge path / patch-equivalence gate).
1225
+ *
1226
+ * The base-side submodule commit is committed in the base workspace and may be
1227
+ * missing from the worktree's submodule object store; a best-effort local fetch
1228
+ * (identical to {@link convergeDivergedSubmoduleGitlinks}) brings it in so the
1229
+ * ancestry checks and the root rebase's `checkout --detach` can see it.
1230
+ */
1231
+ export function collectTrivialFastForwardGitlinkResolutions(
1232
+ worktreeRoot: string,
1233
+ baseRepoRoot: string,
1234
+ baseHead: string,
1235
+ branchHead: string,
1236
+ ): Array<{ path: string; rebasedCommit: string }> {
1237
+ const resolutions: Array<{ path: string; rebasedCommit: string }> = [];
1238
+ for (const path of readChangedGitlinkPaths(worktreeRoot, baseHead, branchHead)) {
1239
+ const baseCommit = readTreeObject(baseRepoRoot, baseHead, path);
1240
+ const branchCommit = readTreeObject(worktreeRoot, branchHead, path);
1241
+ if (!baseCommit || !branchCommit) continue;
1242
+ const submoduleRepoPath = pathResolve(worktreeRoot, path);
1243
+ // Make the base-side commit available locally (it may only live in base/<path>).
1244
+ ensureSubmoduleCommitLocal(submoduleRepoPath, pathResolve(baseRepoRoot, path), baseCommit);
1245
+ if (baseCommit === branchCommit) {
1246
+ // Identical pointer — no gitlink conflict to resolve; skip.
1247
+ continue;
1248
+ }
1249
+ if (isSubmoduleFastForward(submoduleRepoPath, baseCommit, branchCommit)) {
1250
+ // base ancestor-of branch → branch-side is the descendant (more advanced).
1251
+ resolutions.push({ path, rebasedCommit: branchCommit });
1252
+ } else if (isSubmoduleFastForward(submoduleRepoPath, branchCommit, baseCommit)) {
1253
+ // branch ancestor-of base → base-side is the descendant (more advanced).
1254
+ resolutions.push({ path, rebasedCommit: baseCommit });
1255
+ }
1256
+ // else: diverged / ambiguous → leave to convergeDivergedSubmoduleGitlinks.
1257
+ }
1258
+ return resolutions;
1259
+ }
1260
+
1203
1261
  /**
1204
1262
  * Whether both commits exist locally in the submodule repo AND neither is an
1205
1263
  * ancestor of the other — i.e. a genuine sibling divergence off a shared merge
@@ -915,7 +915,12 @@ export class MeshRuntimeStore {
915
915
  nodeId: string,
916
916
  sessionId: string,
917
917
  capabilityTags: string[] = [],
918
- opts?: { providerType?: string; providerMaxParallel?: number; nodeIsWorktree?: boolean },
918
+ opts?: {
919
+ providerType?: string;
920
+ providerMaxParallel?: number;
921
+ nodeIsWorktree?: boolean;
922
+ assignedTranscriptProfile?: MeshWorkQueueEntry['assignedTranscriptProfile'];
923
+ },
919
924
  ): MeshWorkQueueEntry | null {
920
925
  return this.transaction(() => {
921
926
  this.ensureLegacyQueueMigrated(meshId);
@@ -1070,6 +1075,9 @@ export class MeshRuntimeStore {
1070
1075
  entry.assignedNodeId = nodeId;
1071
1076
  entry.assignedSessionId = sessionId;
1072
1077
  if (providerType) entry.assignedProviderType = providerType;
1078
+ // P1 transcript-authority stamp (write-only for now): lets the
1079
+ // coordinator classify this worker without local provider access.
1080
+ if (opts?.assignedTranscriptProfile) entry.assignedTranscriptProfile = opts.assignedTranscriptProfile;
1073
1081
  entry.dispatchTimestamp = now;
1074
1082
  // REDRIVE-DUP: bump the per-task dispatch nonce on every claim so this dispatch
1075
1083
  // carries a nonce strictly greater than any prior (reclaimed) dispatch of the same