@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.
- package/dist/config/mesh-config.d.ts +2 -0
- package/dist/git/git-commands.d.ts +1 -0
- package/dist/index.js +7155 -7080
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +7158 -7083
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-completion-synthesis.d.ts +29 -0
- package/dist/mesh/mesh-node-identity.d.ts +2 -0
- package/dist/mesh/mesh-refine-gates.d.ts +32 -0
- package/dist/mesh/mesh-runtime-store.d.ts +1 -0
- package/dist/mesh/mesh-work-queue.d.ts +15 -0
- package/dist/mesh/node-facts.d.ts +17 -0
- package/dist/providers/cli-provider-instance.d.ts +30 -57
- package/dist/providers/transcript-evidence.d.ts +63 -0
- package/dist/repo-mesh-types.d.ts +23 -0
- package/package.json +5 -3
- package/src/cli-adapters/cli-state-engine.ts +4 -3
- package/src/commands/high-family/mesh-status.ts +5 -0
- package/src/commands/router-refine.ts +30 -0
- package/src/commands/upgrade-helper.ts +15 -1
- package/src/config/mesh-config.ts +7 -0
- package/src/git/git-commands.ts +11 -1
- package/src/mesh/mesh-completion-synthesis.ts +76 -50
- package/src/mesh/mesh-node-identity.ts +37 -3
- package/src/mesh/mesh-queue-assignment.ts +34 -0
- package/src/mesh/mesh-reconcile-loop.ts +62 -71
- package/src/mesh/mesh-refine-gates.ts +58 -0
- package/src/mesh/mesh-runtime-store.ts +9 -1
- package/src/mesh/mesh-work-queue.ts +20 -1
- package/src/mesh/node-facts.ts +54 -0
- package/src/providers/cli-provider-instance.ts +72 -152
- package/src/providers/transcript-evidence.ts +105 -0
- 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
|
-
|
|
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: {
|
|
478
|
-
|
|
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
|
|
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
|
|
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
|
|
509
|
-
|
|
510
|
-
}
|
|
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
|
|
514
|
-
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
return
|
|
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?:
|
|
838
|
-
|
|
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
|
-
|
|
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: {
|
|
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.
|
|
925
|
-
//
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
//
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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
|
|
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.
|
|
1218
|
-
//
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
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
|
-
},
|
|
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?: {
|
|
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
|