@adhdev/daemon-core 0.9.82-rc.325 → 0.9.82-rc.327
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/cli-adapters/provider-cli-shared.d.ts +15 -0
- package/dist/config/mesh-config.d.ts +5 -0
- package/dist/git/git-commands.d.ts +2 -0
- package/dist/index.js +835 -599
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +835 -599
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-work-queue.d.ts +2 -0
- package/dist/providers/chat-message-normalization.d.ts +12 -0
- package/dist/repo-mesh-types.d.ts +12 -0
- package/package.json +2 -2
- package/src/cli-adapters/provider-cli-adapter.ts +2 -0
- package/src/cli-adapters/provider-cli-shared.d.ts +2 -0
- package/src/cli-adapters/provider-cli-shared.ts +15 -0
- package/src/commands/router.ts +90 -9
- package/src/config/mesh-config.ts +7 -0
- package/src/git/git-commands.ts +13 -2
- package/src/mesh/mesh-reconcile-loop.ts +147 -0
- package/src/mesh/mesh-work-queue.ts +28 -8
- package/src/providers/chat-message-normalization.ts +47 -0
- package/src/providers/cli-provider-instance.ts +9 -1
- package/src/repo-mesh-types.ts +12 -0
|
@@ -84,6 +84,8 @@ export declare function buildMeshNodeCapabilityTags(node: {
|
|
|
84
84
|
isLocalWorktree?: unknown;
|
|
85
85
|
worktreeBranch?: unknown;
|
|
86
86
|
userOverrides?: unknown;
|
|
87
|
+
reportedPlatform?: unknown;
|
|
88
|
+
reportedArch?: unknown;
|
|
87
89
|
} | undefined, providerType?: string): string[];
|
|
88
90
|
export declare function nodeSatisfiesRequiredTags(requiredTags: unknown, capabilityTags: unknown): boolean;
|
|
89
91
|
/**
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import type { ChatMessage } from '../types.js';
|
|
2
2
|
export declare const DEFAULT_FINAL_SUMMARY_MAX_CHARS = 4000;
|
|
3
3
|
export declare function extractFinalSummaryFromMessages(messages: ChatMessage[] | null | undefined, maxChars?: number): string;
|
|
4
|
+
/**
|
|
5
|
+
* Like extractFinalSummaryFromMessages but also returns the ISO timestamp of the
|
|
6
|
+
* selected final assistant/model message. Completion reconciliation needs the
|
|
7
|
+
* timestamp to prove the transcript was produced AFTER the dispatch — without it
|
|
8
|
+
* reconcileDirectDispatchCompletionFromTranscript rejects non-JSON summaries as
|
|
9
|
+
* "transcript_not_proven_after_dispatch". The summary selection is identical so the
|
|
10
|
+
* timestamp always belongs to the message whose text became the summary.
|
|
11
|
+
*/
|
|
12
|
+
export declare function extractFinalAssistantSummaryEvidence(messages: ChatMessage[] | null | undefined, maxChars?: number): {
|
|
13
|
+
finalSummary: string;
|
|
14
|
+
transcriptMessageAt?: string;
|
|
15
|
+
};
|
|
4
16
|
export declare const BUILTIN_CHAT_MESSAGE_KINDS: readonly ["standard", "thought", "tool", "terminal", "system"];
|
|
5
17
|
export type BuiltinChatMessageKind = typeof BUILTIN_CHAT_MESSAGE_KINDS[number];
|
|
6
18
|
export type ChatMessageKind = BuiltinChatMessageKind | (string & {});
|
|
@@ -418,6 +418,18 @@ export interface LocalMeshNodeEntry {
|
|
|
418
418
|
/** Operator-defined capability tags used by mesh queue matching. */
|
|
419
419
|
capabilities?: string[];
|
|
420
420
|
userOverrides: Partial<RepoMeshNodeCapabilities>;
|
|
421
|
+
/**
|
|
422
|
+
* Live, self-healed platform/arch reported by the daemon that owns this
|
|
423
|
+
* node's workspace (its own process.platform/process.arch), carried on the
|
|
424
|
+
* git_status envelope and persisted by the coordinator on each direct git
|
|
425
|
+
* probe. This is auto-detected truth, kept DISTINCT from `userOverrides`
|
|
426
|
+
* (operator intent) so capability-tag derivation can prefer an explicit
|
|
427
|
+
* operator override while still self-correcting auto-detected nodes — and so
|
|
428
|
+
* a stale value is overwritten by the next report rather than sticking.
|
|
429
|
+
* Absent until the first direct probe succeeds.
|
|
430
|
+
*/
|
|
431
|
+
reportedPlatform?: string;
|
|
432
|
+
reportedArch?: string;
|
|
421
433
|
policy: RepoMeshNodePolicy;
|
|
422
434
|
/**
|
|
423
435
|
* Per-node instruction surfaced in the coordinator prompt so the LLM
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "0.9.82-rc.
|
|
3
|
+
"version": "0.9.82-rc.327",
|
|
4
4
|
"description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"author": "vilmire",
|
|
47
47
|
"license": "AGPL-3.0-or-later",
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
49
|
+
"@adhdev/mesh-shared": "0.9.82-rc.327",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
|
@@ -952,6 +952,8 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
952
952
|
errorMessage: this.parseErrorMessage || this.engine.providerErrorMessage || undefined,
|
|
953
953
|
errorReason: this.parseErrorMessage ? 'parse_error' : (this.engine.providerErrorReason || undefined),
|
|
954
954
|
providerSessionId: this.providerSessionId || undefined,
|
|
955
|
+
lastOutputAt: this.lastOutputAt,
|
|
956
|
+
lastScreenChangeAt: this.lastScreenChangeAt,
|
|
955
957
|
...(bufferState ? { bufferState } : {}),
|
|
956
958
|
};
|
|
957
959
|
}
|
|
@@ -18,6 +18,8 @@ export interface CliSessionStatus {
|
|
|
18
18
|
activeModal: { message: string; buttons: string[] } | null;
|
|
19
19
|
errorMessage?: string;
|
|
20
20
|
errorReason?: string;
|
|
21
|
+
lastOutputAt?: number;
|
|
22
|
+
lastScreenChangeAt?: number;
|
|
21
23
|
}
|
|
22
24
|
export interface CliScripts {
|
|
23
25
|
detectStatus?: (input: CliStatusInput) => string | null;
|
|
@@ -50,6 +50,21 @@ export interface CliSessionStatus {
|
|
|
50
50
|
errorMessage?: string;
|
|
51
51
|
errorReason?: string;
|
|
52
52
|
providerSessionId?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Timestamp (ms) of the most recent raw PTY output chunk. Advances on every
|
|
55
|
+
* byte the process emits, including tool/build output that produces no
|
|
56
|
+
* parsed assistant text. Liveness watchdogs use this to distinguish a real
|
|
57
|
+
* stall (no output at all) from an active turn whose assistant buffer is
|
|
58
|
+
* momentarily static while a tool runs.
|
|
59
|
+
*/
|
|
60
|
+
lastOutputAt?: number;
|
|
61
|
+
/**
|
|
62
|
+
* Timestamp (ms) of the most recent *visible* terminal screen change.
|
|
63
|
+
* Stricter than lastOutputAt — only advances when the rendered screen
|
|
64
|
+
* content actually differs, so repeated keepalive bytes do not register as
|
|
65
|
+
* progress. Preferred liveness signal for the long-generating watchdog.
|
|
66
|
+
*/
|
|
67
|
+
lastScreenChangeAt?: number;
|
|
53
68
|
bufferState?: {
|
|
54
69
|
responseBuffer?: { truncated: boolean; droppedChars: number; maxChars: number };
|
|
55
70
|
recentOutputBuffer?: { truncated: boolean; droppedChars: number; maxChars: number };
|
package/src/commands/router.ts
CHANGED
|
@@ -407,8 +407,10 @@ function recordInlineMeshDirectGitTruth(
|
|
|
407
407
|
node: any,
|
|
408
408
|
git: Record<string, unknown>,
|
|
409
409
|
source: 'selected_coordinator_local_git' | 'selected_coordinator_mesh_p2p_git',
|
|
410
|
-
):
|
|
411
|
-
if (!node || typeof node !== 'object' || Array.isArray(node))
|
|
410
|
+
): { reporterPlatform: string | null; reporterArch: string | null } {
|
|
411
|
+
if (!node || typeof node !== 'object' || Array.isArray(node)) {
|
|
412
|
+
return { reporterPlatform: null, reporterArch: null };
|
|
413
|
+
}
|
|
412
414
|
const checkedAt = readNumberValue(git.lastCheckedAt) ?? Date.now();
|
|
413
415
|
const updatedAt = new Date(checkedAt).toISOString();
|
|
414
416
|
const nextGit: Record<string, unknown> = {
|
|
@@ -426,6 +428,73 @@ function recordInlineMeshDirectGitTruth(
|
|
|
426
428
|
node.lastSeenAt = updatedAt;
|
|
427
429
|
const repoRoot = readStringValue(nextGit.repoRoot);
|
|
428
430
|
if (repoRoot && !readStringValue(node.repoRoot)) node.repoRoot = repoRoot;
|
|
431
|
+
// Self-heal per-node platform/arch from the live probe. For a remote member
|
|
432
|
+
// this is the platform the member daemon reported in its git_status envelope
|
|
433
|
+
// (threaded through as reporter*); for the local coordinator's own / worktree
|
|
434
|
+
// nodes the git was computed locally (source 'selected_coordinator_local_git'
|
|
435
|
+
// ⇒ the workspace lives on THIS machine), so process.platform/process.arch is
|
|
436
|
+
// the correct value. Stamp into userOverrides — the exact fields
|
|
437
|
+
// buildMeshNodeCapabilityTags reads — only when absent, so an operator's
|
|
438
|
+
// explicit override is preserved and the value is corrected once per reconnect
|
|
439
|
+
// without any migration.
|
|
440
|
+
const isLocalSource = source === 'selected_coordinator_local_git';
|
|
441
|
+
const reporterPlatform = readStringValue(git.reporterPlatform) ?? (isLocalSource ? process.platform : null);
|
|
442
|
+
const reporterArch = readStringValue(git.reporterArch) ?? (isLocalSource ? process.arch : null);
|
|
443
|
+
stampNodeReporterPlatform(node, reporterPlatform, reporterArch);
|
|
444
|
+
// Mirror onto the in-memory node's dedicated reporter fields too (distinct
|
|
445
|
+
// from userOverrides). For a local_config mesh the caller also persists these
|
|
446
|
+
// to meshes.json via updateNode so the value survives a coordinator restart;
|
|
447
|
+
// for an inline/cache mesh this keeps the runtime object self-consistent.
|
|
448
|
+
if (reporterPlatform) node.reportedPlatform = reporterPlatform;
|
|
449
|
+
if (reporterArch) node.reportedArch = reporterArch;
|
|
450
|
+
return { reporterPlatform, reporterArch };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Fill node.userOverrides.platform/arch from a live report, but never overwrite a
|
|
455
|
+
* value that is already present (an operator override or an earlier report). Used
|
|
456
|
+
* by both the remote-member probe path and the local self-stamp path so the
|
|
457
|
+
* coordinator advertises each node's real OS instead of falling back to the
|
|
458
|
+
* coordinator's own process.platform.
|
|
459
|
+
*/
|
|
460
|
+
function stampNodeReporterPlatform(node: any, platform: string | null, arch: string | null): void {
|
|
461
|
+
if (!node || typeof node !== 'object' || Array.isArray(node)) return;
|
|
462
|
+
if (!platform && !arch) return;
|
|
463
|
+
const overrides = (node.userOverrides && typeof node.userOverrides === 'object' && !Array.isArray(node.userOverrides))
|
|
464
|
+
? node.userOverrides as Record<string, unknown>
|
|
465
|
+
: {};
|
|
466
|
+
let changed = false;
|
|
467
|
+
if (platform && !readStringValue(overrides.platform)) { overrides.platform = platform; changed = true; }
|
|
468
|
+
if (arch && !readStringValue(overrides.arch)) { overrides.arch = arch; changed = true; }
|
|
469
|
+
if (changed) node.userOverrides = overrides;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Persist the live self-reported platform/arch onto the local meshes.json node
|
|
474
|
+
* record so capability-tag os=/arch= self-heals across coordinator restarts.
|
|
475
|
+
*
|
|
476
|
+
* The in-memory stamp done by recordInlineMeshDirectGitTruth lives on the
|
|
477
|
+
* mesh_status assembly object and is discarded after the response; only a
|
|
478
|
+
* `local_config` mesh has a backing meshes.json node to write through to. Inline
|
|
479
|
+
* cache/bootstrap meshes have no local node to update, so we no-op for them.
|
|
480
|
+
* Fire-and-forget (same pattern as the worktreeBootstrap writer) — a persistence
|
|
481
|
+
* failure must never block the status response.
|
|
482
|
+
*/
|
|
483
|
+
function persistNodeReporterPlatform(
|
|
484
|
+
meshSource: 'inline_cache' | 'inline_bootstrap' | 'local_config',
|
|
485
|
+
mesh: any,
|
|
486
|
+
nodeId: string | undefined,
|
|
487
|
+
reporter: { reporterPlatform: string | null; reporterArch: string | null },
|
|
488
|
+
): void {
|
|
489
|
+
if (meshSource !== 'local_config') return;
|
|
490
|
+
const meshId = readStringValue(mesh?.id);
|
|
491
|
+
if (!meshId || !nodeId) return;
|
|
492
|
+
const reportedPlatform = reporter.reporterPlatform ?? undefined;
|
|
493
|
+
const reportedArch = reporter.reporterArch ?? undefined;
|
|
494
|
+
if (!reportedPlatform && !reportedArch) return;
|
|
495
|
+
void import('../config/mesh-config.js')
|
|
496
|
+
.then(({ updateNode }) => updateNode(meshId, nodeId, { reportedPlatform, reportedArch }))
|
|
497
|
+
.catch(() => { /* best-effort self-heal; never block status assembly */ });
|
|
429
498
|
}
|
|
430
499
|
|
|
431
500
|
function buildCachedInlineMeshGitStatus(node: any): Record<string, unknown> | undefined {
|
|
@@ -1101,9 +1170,17 @@ async function probeRemoteMeshGitStatus(args: {
|
|
|
1101
1170
|
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timeout')), args.timeoutMs)),
|
|
1102
1171
|
]) as any;
|
|
1103
1172
|
const remoteGit = remoteResult?.status ?? remoteResult?.git ?? remoteResult;
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1173
|
+
if (!remoteGit || typeof remoteGit !== 'object' || typeof remoteGit.isGitRepo !== 'boolean') return null;
|
|
1174
|
+
// The member daemon stamps its own platform/arch onto the git_status result
|
|
1175
|
+
// envelope (see git-commands.ts). Reflect them onto the returned git object
|
|
1176
|
+
// under non-colliding reporter* keys so recordInlineMeshDirectGitTruth can
|
|
1177
|
+
// persist them to node.userOverrides without touching the git status shape.
|
|
1178
|
+
const reporterPlatform = readStringValue(remoteResult?.reporterPlatform);
|
|
1179
|
+
const reporterArch = readStringValue(remoteResult?.reporterArch);
|
|
1180
|
+
const git = remoteGit as Record<string, unknown>;
|
|
1181
|
+
if (reporterPlatform) git.reporterPlatform = reporterPlatform;
|
|
1182
|
+
if (reporterArch) git.reporterArch = reporterArch;
|
|
1183
|
+
return git;
|
|
1107
1184
|
}
|
|
1108
1185
|
|
|
1109
1186
|
/** Number of bounded retries after the initial direct-peer git probe attempt. */
|
|
@@ -1283,7 +1360,8 @@ async function hydrateInlineMeshDirectTruth(args: {
|
|
|
1283
1360
|
try {
|
|
1284
1361
|
const localGit = await getGitRepoStatus(workspace, { timeoutMs: 10_000, refreshUpstream: true });
|
|
1285
1362
|
if (localGit?.isGitRepo) {
|
|
1286
|
-
recordInlineMeshDirectGitTruth(node, localGit as unknown as Record<string, unknown>, 'selected_coordinator_local_git');
|
|
1363
|
+
const reporter = recordInlineMeshDirectGitTruth(node, localGit as unknown as Record<string, unknown>, 'selected_coordinator_local_git');
|
|
1364
|
+
persistNodeReporterPlatform(args.meshSource, args.mesh, nodeId, reporter);
|
|
1287
1365
|
localConfirmedCount += 1;
|
|
1288
1366
|
continue;
|
|
1289
1367
|
}
|
|
@@ -1335,7 +1413,8 @@ async function hydrateInlineMeshDirectTruth(args: {
|
|
|
1335
1413
|
? await args.probeCache.probe(daemonId, workspace, runProbe)
|
|
1336
1414
|
: await runProbe();
|
|
1337
1415
|
if (remoteGit) {
|
|
1338
|
-
recordInlineMeshDirectGitTruth(node, remoteGit, 'selected_coordinator_mesh_p2p_git');
|
|
1416
|
+
const reporter = recordInlineMeshDirectGitTruth(node, remoteGit, 'selected_coordinator_mesh_p2p_git');
|
|
1417
|
+
persistNodeReporterPlatform(args.meshSource, args.mesh, nodeId, reporter);
|
|
1339
1418
|
peerConfirmedCount += 1;
|
|
1340
1419
|
continue;
|
|
1341
1420
|
}
|
|
@@ -9259,7 +9338,8 @@ export class DaemonCommandRouter {
|
|
|
9259
9338
|
if (!connectionReported || connectionState === 'unknown') {
|
|
9260
9339
|
status.connection = buildLivePeerGitConnection(connection, refreshedAt);
|
|
9261
9340
|
}
|
|
9262
|
-
recordInlineMeshDirectGitTruth(node, remoteGit, 'selected_coordinator_mesh_p2p_git');
|
|
9341
|
+
const reporter = recordInlineMeshDirectGitTruth(node, remoteGit, 'selected_coordinator_mesh_p2p_git');
|
|
9342
|
+
persistNodeReporterPlatform(meshRecord.source, mesh, nodeId, reporter);
|
|
9263
9343
|
remoteProbeApplied = true;
|
|
9264
9344
|
}
|
|
9265
9345
|
}
|
|
@@ -9300,7 +9380,8 @@ export class DaemonCommandRouter {
|
|
|
9300
9380
|
try {
|
|
9301
9381
|
const gitStatus = await getGitRepoStatus(workspace, { timeoutMs: 10_000, refreshUpstream: true });
|
|
9302
9382
|
status.git = gitStatus;
|
|
9303
|
-
recordInlineMeshDirectGitTruth(node, gitStatus as unknown as Record<string, unknown>, 'selected_coordinator_local_git');
|
|
9383
|
+
const reporter = recordInlineMeshDirectGitTruth(node, gitStatus as unknown as Record<string, unknown>, 'selected_coordinator_local_git');
|
|
9384
|
+
persistNodeReporterPlatform(meshRecord.source, mesh, nodeId, reporter);
|
|
9304
9385
|
if (gitStatus.isGitRepo) {
|
|
9305
9386
|
status.health = deriveMeshNodeHealthFromGit(gitStatus as unknown as Record<string, unknown>);
|
|
9306
9387
|
} else {
|
|
@@ -604,6 +604,11 @@ export function updateNode(
|
|
|
604
604
|
/** Per-node instruction surfaced in the coordinator prompt. Pass an
|
|
605
605
|
* empty string or undefined to clear it. */
|
|
606
606
|
systemPrompt?: string;
|
|
607
|
+
/** Live self-reported platform/arch from the owning daemon's git_status
|
|
608
|
+
* envelope. Persisted distinctly from userOverrides (auto-detected, not
|
|
609
|
+
* operator intent) so capability-tag os=/arch= self-heals across loads. */
|
|
610
|
+
reportedPlatform?: string;
|
|
611
|
+
reportedArch?: string;
|
|
607
612
|
},
|
|
608
613
|
): LocalMeshNodeEntry | undefined {
|
|
609
614
|
const config = loadMeshConfig();
|
|
@@ -614,6 +619,8 @@ export function updateNode(
|
|
|
614
619
|
if (!node) return undefined;
|
|
615
620
|
|
|
616
621
|
if (opts.userOverrides) node.userOverrides = { ...node.userOverrides, ...opts.userOverrides };
|
|
622
|
+
if (opts.reportedPlatform && opts.reportedPlatform.trim()) node.reportedPlatform = opts.reportedPlatform.trim();
|
|
623
|
+
if (opts.reportedArch && opts.reportedArch.trim()) node.reportedArch = opts.reportedArch.trim();
|
|
617
624
|
if (opts.policy) node.policy = { ...node.policy, ...opts.policy };
|
|
618
625
|
if (opts.worktreeBootstrap) node.worktreeBootstrap = opts.worktreeBootstrap;
|
|
619
626
|
if (Object.prototype.hasOwnProperty.call(opts, 'systemPrompt')) {
|
package/src/git/git-commands.ts
CHANGED
|
@@ -110,7 +110,10 @@ type GitCommandFailure = {
|
|
|
110
110
|
};
|
|
111
111
|
|
|
112
112
|
type GitCommandSuccess =
|
|
113
|
-
|
|
113
|
+
// reporterPlatform/reporterArch carry the responding daemon's process.platform/
|
|
114
|
+
// process.arch so a mesh coordinator probing this node over P2P can self-heal the
|
|
115
|
+
// node's userOverrides.platform/arch (the fields capability-tag routing reads).
|
|
116
|
+
| { success: true; status: GitRepoStatus; reporterPlatform?: string; reporterArch?: string }
|
|
114
117
|
| { success: true; diffSummary: GitDiffSummary }
|
|
115
118
|
| { success: true; diff: GitFileDiff }
|
|
116
119
|
| { success: true; snapshot: GitSnapshot }
|
|
@@ -304,7 +307,15 @@ export async function handleGitCommand(
|
|
|
304
307
|
if (includeSubmodules !== undefined) statusParams.includeSubmodules = includeSubmodules;
|
|
305
308
|
if (submoduleIgnorePaths && submoduleIgnorePaths.length > 0) statusParams.submoduleIgnorePaths = submoduleIgnorePaths;
|
|
306
309
|
const status = await runService(() => services.getStatus!(statusParams));
|
|
307
|
-
|
|
310
|
+
if ('success' in status) return status;
|
|
311
|
+
// Carry the responding daemon's real platform/arch alongside the git
|
|
312
|
+
// status. A mesh coordinator dispatches `git_status` to each member over
|
|
313
|
+
// P2P on every explicit graph refresh, so this is the recurring live
|
|
314
|
+
// channel that lets a member self-report its OS to the coordinator — which
|
|
315
|
+
// stamps it onto the node's userOverrides.platform/arch (the fields
|
|
316
|
+
// buildMeshNodeCapabilityTags reads). These are siblings of `status`, not
|
|
317
|
+
// part of GitRepoStatus, so the git payload shape is untouched.
|
|
318
|
+
return { success: true, status, reporterPlatform: process.platform, reporterArch: process.arch };
|
|
308
319
|
}
|
|
309
320
|
|
|
310
321
|
case 'git_diff_summary': {
|
|
@@ -55,6 +55,10 @@ import {
|
|
|
55
55
|
expireStaleUnresolvedDelegateForwards,
|
|
56
56
|
} from './mesh-unresolved-forward-outbox.js';
|
|
57
57
|
import { readNonEmptyString } from './mesh-events-utils.js';
|
|
58
|
+
import { getActiveDirectDispatches } from './mesh-work-queue.js';
|
|
59
|
+
import { reconcileDirectDispatchCompletionFromTranscript } from './mesh-events-stale.js';
|
|
60
|
+
import { extractFinalAssistantSummaryEvidence } from '../providers/chat-message-normalization.js';
|
|
61
|
+
import type { ChatMessage } from '../types.js';
|
|
58
62
|
|
|
59
63
|
// Default reconcile cadence. approval/completion notifications to a live CLI
|
|
60
64
|
// coordinator land within at most one interval. Overridable via env for tuning.
|
|
@@ -281,6 +285,31 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
|
|
|
281
285
|
}
|
|
282
286
|
}
|
|
283
287
|
|
|
288
|
+
// ── PHASE 4: synthesize lost completions for unterminated direct dispatches ─
|
|
289
|
+
// Symmetric to PHASE 3 (which recovers a *lost claim* for a newly-idle session)
|
|
290
|
+
// but for the opposite gap: a worker that ALREADY completed, went idle, and
|
|
291
|
+
// whose terminal completion event was never persisted (dropped before reaching
|
|
292
|
+
// the queue/outbox, or its forward was lost). PHASE 1/2/3 can only deliver an
|
|
293
|
+
// event that exists in a queue — they cannot recover a completion that was
|
|
294
|
+
// never recorded, so the coordinator keeps believing the worker is generating.
|
|
295
|
+
//
|
|
296
|
+
// reconcileDirectDispatchCompletionFromTranscript already synthesizes the
|
|
297
|
+
// missing terminal event from the worker's transcript, but until now it ran
|
|
298
|
+
// ONLY when an LLM coordinator polled mesh_status (mcp_mesh_status_transcript_
|
|
299
|
+
// reconciliation). This phase pulls that same correction onto the daemon timer
|
|
300
|
+
// so it no longer depends on the LLM polling. The reconcile is idempotent
|
|
301
|
+
// (hasTerminalLedgerAfterDispatch guards against re-synthesis), so attempting it
|
|
302
|
+
// every tick for the same dispatch is safe — once a terminal exists it no-ops.
|
|
303
|
+
for (const mesh of listMeshes()) {
|
|
304
|
+
const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
|
|
305
|
+
if (!daemonHostsMesh(mesh, selfIds)) continue;
|
|
306
|
+
try {
|
|
307
|
+
await reconcileUnterminatedDirectDispatches(components, mesh, selfIds, localDaemonId);
|
|
308
|
+
} catch (e: any) {
|
|
309
|
+
LOG.warn('MeshReconcile', `Completion reconcile failed for mesh ${mesh.id}: ${e?.message || e}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
284
313
|
// ── PHASE 2: inject into live CLI coordinators on this daemon ──────────────
|
|
285
314
|
const coordinators = findLiveCoordinators(components);
|
|
286
315
|
if (coordinators.length === 0) {
|
|
@@ -445,6 +474,124 @@ async function pullRemoteNodeQueues(
|
|
|
445
474
|
}
|
|
446
475
|
}
|
|
447
476
|
|
|
477
|
+
// Pull the read_chat payload out of whatever envelope the transport returned.
|
|
478
|
+
// A local commandHandler.handle() returns the CommandResult directly; a remote
|
|
479
|
+
// dispatchMeshCommand returns it possibly wrapped in { payload } / { result }.
|
|
480
|
+
function unwrapReadChatPayload(raw: unknown): Record<string, unknown> | null {
|
|
481
|
+
let cursor: unknown = raw;
|
|
482
|
+
for (let depth = 0; depth < 4 && cursor && typeof cursor === 'object'; depth++) {
|
|
483
|
+
const record = cursor as Record<string, unknown>;
|
|
484
|
+
if (Array.isArray(record.messages)) return record;
|
|
485
|
+
if (record.payload && typeof record.payload === 'object') { cursor = record.payload; continue; }
|
|
486
|
+
if (record.result && typeof record.result === 'object') { cursor = record.result; continue; }
|
|
487
|
+
if (record.data && typeof record.data === 'object') { cursor = record.data; continue; }
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
return cursor && typeof cursor === 'object' ? cursor as Record<string, unknown> : null;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function readChatPayloadStatus(payload: Record<string, unknown> | null): string {
|
|
494
|
+
return readNonEmptyString(payload?.status).toLowerCase();
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// PHASE 4 helper. For every active (non-terminal) direct dispatch this daemon
|
|
498
|
+
// hosts, confirm the worker session is idle via a read_chat and — if a final
|
|
499
|
+
// assistant summary is present but no terminal ledger exists for that dispatch —
|
|
500
|
+
// synthesize the missing completion through reconcileDirectDispatchCompletionFromTranscript.
|
|
501
|
+
//
|
|
502
|
+
// read_chat is resolved against the target node: a node on THIS daemon is read
|
|
503
|
+
// through the local commandHandler; a remote node is read over P2P via
|
|
504
|
+
// dispatchMeshCommand. Both yield the same { messages, status, providerSessionId }
|
|
505
|
+
// shape. We only synthesize when the session reports idle AND a final assistant
|
|
506
|
+
// message exists — the same evidence bar the MCP poll path uses — so an actively
|
|
507
|
+
// generating worker is never falsely completed. The reconcile itself is idempotent.
|
|
508
|
+
async function reconcileUnterminatedDirectDispatches(
|
|
509
|
+
components: DaemonComponents,
|
|
510
|
+
mesh: LocalMeshEntry,
|
|
511
|
+
selfIds: string[],
|
|
512
|
+
localDaemonId: string | undefined,
|
|
513
|
+
): Promise<void> {
|
|
514
|
+
const dispatches = getActiveDirectDispatches(mesh.id);
|
|
515
|
+
if (dispatches.length === 0) return; // cheap exit — nothing dispatched, nothing to reconcile
|
|
516
|
+
|
|
517
|
+
const dispatchMeshCommand = components.dispatchMeshCommand;
|
|
518
|
+
const nodeById = new Map(mesh.nodes.map(n => [n.id, n] as const));
|
|
519
|
+
|
|
520
|
+
for (const dispatch of dispatches) {
|
|
521
|
+
const sessionId = readNonEmptyString(dispatch.sessionId);
|
|
522
|
+
const nodeId = readNonEmptyString(dispatch.nodeId);
|
|
523
|
+
const taskId = readNonEmptyString(dispatch.taskId);
|
|
524
|
+
if (!sessionId || !nodeId || !taskId) continue;
|
|
525
|
+
|
|
526
|
+
const node = nodeById.get(nodeId);
|
|
527
|
+
const nodeDaemonId = readNonEmptyString(node?.daemonId);
|
|
528
|
+
// A node is local when it has no daemonId, names this daemon, or actually
|
|
529
|
+
// has a live instance here. Anything else is reached over P2P.
|
|
530
|
+
const isLocalNode = !nodeDaemonId
|
|
531
|
+
|| selfIds.includes(nodeDaemonId)
|
|
532
|
+
|| (localDaemonId !== undefined && nodeDaemonId === localDaemonId)
|
|
533
|
+
|| !!components.instanceManager.getInstance(sessionId);
|
|
534
|
+
|
|
535
|
+
const providerType = readNonEmptyString(dispatch.providerType);
|
|
536
|
+
const readArgs: Record<string, unknown> = {
|
|
537
|
+
sessionId,
|
|
538
|
+
targetSessionId: sessionId,
|
|
539
|
+
tailLimit: 10,
|
|
540
|
+
...(node?.workspace ? { workspace: node.workspace } : {}),
|
|
541
|
+
...(providerType ? { agentType: providerType, providerType } : {}),
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
let payload: Record<string, unknown> | null = null;
|
|
545
|
+
try {
|
|
546
|
+
if (isLocalNode) {
|
|
547
|
+
const result = await components.commandHandler.handle('read_chat', readArgs);
|
|
548
|
+
if (result && (result as { success?: boolean }).success === false) continue;
|
|
549
|
+
payload = unwrapReadChatPayload(result);
|
|
550
|
+
} else if (dispatchMeshCommand) {
|
|
551
|
+
const result = await dispatchMeshCommand(nodeDaemonId, 'read_chat', readArgs);
|
|
552
|
+
payload = unwrapReadChatPayload(result);
|
|
553
|
+
if (payload && (payload as { success?: boolean }).success === false) continue;
|
|
554
|
+
} else {
|
|
555
|
+
continue; // remote node but no P2P transport — can't read; retry next tick
|
|
556
|
+
}
|
|
557
|
+
} catch {
|
|
558
|
+
continue; // best-effort; session may be gone or node offline — retry next tick
|
|
559
|
+
}
|
|
560
|
+
if (!payload) continue;
|
|
561
|
+
|
|
562
|
+
// Only act on a session that has actually settled to idle. A generating /
|
|
563
|
+
// waiting_approval session is mid-turn — synthesizing a completion now would
|
|
564
|
+
// be wrong. (idle is the only status the MCP poll path reconciles too.)
|
|
565
|
+
if (readChatPayloadStatus(payload) !== 'idle') continue;
|
|
566
|
+
|
|
567
|
+
const messages = Array.isArray(payload.messages) ? payload.messages as ChatMessage[] : [];
|
|
568
|
+
const evidence = extractFinalAssistantSummaryEvidence(messages);
|
|
569
|
+
if (!evidence.finalSummary) continue; // no assistant result yet — nothing to attribute
|
|
570
|
+
|
|
571
|
+
const providerSessionId = readNonEmptyString(payload.providerSessionId);
|
|
572
|
+
const coordinatorDaemonId = selfIds.find(id => !!id);
|
|
573
|
+
try {
|
|
574
|
+
const result = reconcileDirectDispatchCompletionFromTranscript({
|
|
575
|
+
meshId: mesh.id,
|
|
576
|
+
nodeId,
|
|
577
|
+
sessionId,
|
|
578
|
+
providerType: providerType || undefined,
|
|
579
|
+
providerSessionId: providerSessionId || undefined,
|
|
580
|
+
taskId,
|
|
581
|
+
finalSummary: evidence.finalSummary,
|
|
582
|
+
...(evidence.transcriptMessageAt ? { transcriptMessageAt: evidence.transcriptMessageAt } : {}),
|
|
583
|
+
...(coordinatorDaemonId ? { targetCoordinatorDaemonId: coordinatorDaemonId } : {}),
|
|
584
|
+
source: 'daemon_reconcile_transcript_completion',
|
|
585
|
+
});
|
|
586
|
+
if (result.reconciled) {
|
|
587
|
+
LOG.info('MeshReconcile', `Synthesized missing completion (${result.kind}) for task ${taskId} on node ${nodeId} (mesh ${mesh.id})`);
|
|
588
|
+
}
|
|
589
|
+
} catch (e: any) {
|
|
590
|
+
LOG.warn('MeshReconcile', `Transcript completion reconcile threw for task ${taskId}: ${e?.message || e}`);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
448
595
|
function extractPendingEvents(raw: unknown): any[] {
|
|
449
596
|
if (Array.isArray(raw)) return raw;
|
|
450
597
|
if (raw && typeof raw === 'object') {
|
|
@@ -385,8 +385,20 @@ function readNodeOverride(node: { userOverrides?: unknown } | undefined, key: 'p
|
|
|
385
385
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
+
/**
|
|
389
|
+
* Live, self-reported platform/arch the owning daemon stamped onto the node from
|
|
390
|
+
* its own process.platform/process.arch via the git_status envelope. Kept on a
|
|
391
|
+
* field DISTINCT from userOverrides so capability-tag derivation can prefer an
|
|
392
|
+
* explicit operator override while still self-healing auto-detected nodes — and
|
|
393
|
+
* so the value reflects the node's real OS rather than the coordinator's.
|
|
394
|
+
*/
|
|
395
|
+
function readNodeReporter(node: { reportedPlatform?: unknown; reportedArch?: unknown } | undefined, key: 'platform' | 'arch'): string | null {
|
|
396
|
+
const value = key === 'platform' ? node?.reportedPlatform : node?.reportedArch;
|
|
397
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
398
|
+
}
|
|
399
|
+
|
|
388
400
|
export function buildMeshNodeCapabilityTags(
|
|
389
|
-
node: { capabilities?: unknown; policy?: unknown; isLocalWorktree?: unknown; worktreeBranch?: unknown; userOverrides?: unknown } | undefined,
|
|
401
|
+
node: { capabilities?: unknown; policy?: unknown; isLocalWorktree?: unknown; worktreeBranch?: unknown; userOverrides?: unknown; reportedPlatform?: unknown; reportedArch?: unknown } | undefined,
|
|
390
402
|
providerType?: string,
|
|
391
403
|
): string[] {
|
|
392
404
|
const provider = typeof providerType === 'string' && providerType.trim()
|
|
@@ -395,17 +407,25 @@ export function buildMeshNodeCapabilityTags(
|
|
|
395
407
|
const worktreeBranch = typeof node?.worktreeBranch === 'string' && node.worktreeBranch.trim()
|
|
396
408
|
? node.worktreeBranch.trim()
|
|
397
409
|
: null;
|
|
398
|
-
// Per-node platform/arch
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
410
|
+
// Per-node platform/arch precedence (highest → lowest):
|
|
411
|
+
// 1. userOverrides.platform/arch — an EXPLICIT operator override always wins.
|
|
412
|
+
// 2. reportedPlatform/reportedArch — the live OS the owning daemon
|
|
413
|
+
// self-reported (its own process.platform/process.arch via the git_status
|
|
414
|
+
// envelope), persisted to the node record on each direct probe. This is
|
|
415
|
+
// why a Windows member advertises os=win32 even though the COORDINATOR
|
|
416
|
+
// computing these tags runs on darwin — without it the consumer reads the
|
|
417
|
+
// persistent node (operator userOverrides empty) and would fall straight
|
|
418
|
+
// through to the coordinator's own process.platform, mislabeling every
|
|
419
|
+
// node os=darwin. We prefer this LIVE value over any stale auto-stamp.
|
|
420
|
+
// 3. process.platform/process.arch — last-resort fallback, correct only for
|
|
421
|
+
// the local coordinator node / local worktree nodes that have not yet
|
|
422
|
+
// been probed (their workspace lives on THIS machine anyway).
|
|
403
423
|
// Vocabulary is raw process.platform/process.arch ("darwin"/"win32"/"linux",
|
|
404
424
|
// "arm64"/"x64") on both the advertiser and the required_tags matcher, which
|
|
405
425
|
// compares with plain string equality (nodeSatisfiesRequiredTags) — so this
|
|
406
426
|
// keeps the win32/darwin/linux vocabulary the matcher already expects.
|
|
407
|
-
const os = readNodeOverride(node, 'platform') ?? process.platform;
|
|
408
|
-
const arch = readNodeOverride(node, 'arch') ?? process.arch;
|
|
427
|
+
const os = readNodeOverride(node, 'platform') ?? readNodeReporter(node, 'platform') ?? process.platform;
|
|
428
|
+
const arch = readNodeOverride(node, 'arch') ?? readNodeReporter(node, 'arch') ?? process.arch;
|
|
409
429
|
return normalizeMeshCapabilityTags([
|
|
410
430
|
...(Array.isArray(node?.capabilities) ? node.capabilities : []),
|
|
411
431
|
`os=${os}`,
|
|
@@ -27,6 +27,53 @@ export function extractFinalSummaryFromMessages(
|
|
|
27
27
|
return '';
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
function readChatMessageTimestampIso(message: ChatMessage | null | undefined): string | undefined {
|
|
31
|
+
if (!message) return undefined;
|
|
32
|
+
const record = message as ChatMessage & Record<string, unknown>;
|
|
33
|
+
for (const value of [record.timestamp, record.createdAt, record.created_at, record.updatedAt, record.time]) {
|
|
34
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
35
|
+
// Heuristic seconds-vs-ms detection mirrors the mesh transcript reader.
|
|
36
|
+
const ms = value > 10_000_000_000 ? value : value * 1000;
|
|
37
|
+
return new Date(ms).toISOString();
|
|
38
|
+
}
|
|
39
|
+
if (typeof value === 'string' && value.trim()) {
|
|
40
|
+
const ms = new Date(value.trim()).getTime();
|
|
41
|
+
if (Number.isFinite(ms)) return new Date(ms).toISOString();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Like extractFinalSummaryFromMessages but also returns the ISO timestamp of the
|
|
49
|
+
* selected final assistant/model message. Completion reconciliation needs the
|
|
50
|
+
* timestamp to prove the transcript was produced AFTER the dispatch — without it
|
|
51
|
+
* reconcileDirectDispatchCompletionFromTranscript rejects non-JSON summaries as
|
|
52
|
+
* "transcript_not_proven_after_dispatch". The summary selection is identical so the
|
|
53
|
+
* timestamp always belongs to the message whose text became the summary.
|
|
54
|
+
*/
|
|
55
|
+
export function extractFinalAssistantSummaryEvidence(
|
|
56
|
+
messages: ChatMessage[] | null | undefined,
|
|
57
|
+
maxChars: number = DEFAULT_FINAL_SUMMARY_MAX_CHARS,
|
|
58
|
+
): { finalSummary: string; transcriptMessageAt?: string } {
|
|
59
|
+
if (!Array.isArray(messages) || messages.length === 0) return { finalSummary: '' };
|
|
60
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
61
|
+
const msg = messages[i];
|
|
62
|
+
if (!msg) continue;
|
|
63
|
+
const classification = classifyChatMessageVisibility(msg);
|
|
64
|
+
if (classification.isUserFacing && (msg.role === 'assistant' || msg.role === 'model')) {
|
|
65
|
+
const text = flattenContent(msg.content).trim();
|
|
66
|
+
if (text) {
|
|
67
|
+
return {
|
|
68
|
+
finalSummary: text.slice(0, maxChars),
|
|
69
|
+
transcriptMessageAt: readChatMessageTimestampIso(msg),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { finalSummary: '' };
|
|
75
|
+
}
|
|
76
|
+
|
|
30
77
|
export const BUILTIN_CHAT_MESSAGE_KINDS = ['standard', 'thought', 'tool', 'terminal', 'system'] as const;
|
|
31
78
|
|
|
32
79
|
export type BuiltinChatMessageKind = typeof BUILTIN_CHAT_MESSAGE_KINDS[number];
|
|
@@ -1545,8 +1545,16 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1545
1545
|
const dirName = workingDirBasename(this.workingDir);
|
|
1546
1546
|
const chatTitle = `${this.provider.name} · ${dirName}`;
|
|
1547
1547
|
const partial = this.adapter.getPartialResponse();
|
|
1548
|
+
// Liveness fingerprint for the long-generating watchdog. The parsed
|
|
1549
|
+
// assistant buffer (`partial`) alone goes static while a tool/build runs
|
|
1550
|
+
// — the assistant emits no tokens even though the PTY is actively
|
|
1551
|
+
// printing tool output — which made the watchdog false-fire a "stuck"
|
|
1552
|
+
// alert mid-turn. Fold in the adapter's raw-activity timestamps so any
|
|
1553
|
+
// visible terminal progress (lastScreenChangeAt) or raw PTY byte
|
|
1554
|
+
// (lastOutputAt) keeps the fingerprint moving. The watchdog then only
|
|
1555
|
+
// survives a genuine stall where nothing at all is happening.
|
|
1548
1556
|
const progressFingerprint = newStatus === 'generating'
|
|
1549
|
-
? `${partial || ''}`.slice(-2000)
|
|
1557
|
+
? `${`${partial || ''}`.slice(-2000)}::scr=${adapterStatus.lastScreenChangeAt ?? 0}::out=${adapterStatus.lastOutputAt ?? 0}`
|
|
1550
1558
|
: undefined;
|
|
1551
1559
|
|
|
1552
1560
|
const previousStatus = this.lastStatus;
|
package/src/repo-mesh-types.ts
CHANGED
|
@@ -533,6 +533,18 @@ export interface LocalMeshNodeEntry {
|
|
|
533
533
|
/** Operator-defined capability tags used by mesh queue matching. */
|
|
534
534
|
capabilities?: string[];
|
|
535
535
|
userOverrides: Partial<RepoMeshNodeCapabilities>;
|
|
536
|
+
/**
|
|
537
|
+
* Live, self-healed platform/arch reported by the daemon that owns this
|
|
538
|
+
* node's workspace (its own process.platform/process.arch), carried on the
|
|
539
|
+
* git_status envelope and persisted by the coordinator on each direct git
|
|
540
|
+
* probe. This is auto-detected truth, kept DISTINCT from `userOverrides`
|
|
541
|
+
* (operator intent) so capability-tag derivation can prefer an explicit
|
|
542
|
+
* operator override while still self-correcting auto-detected nodes — and so
|
|
543
|
+
* a stale value is overwritten by the next report rather than sticking.
|
|
544
|
+
* Absent until the first direct probe succeeds.
|
|
545
|
+
*/
|
|
546
|
+
reportedPlatform?: string;
|
|
547
|
+
reportedArch?: string;
|
|
536
548
|
policy: RepoMeshNodePolicy;
|
|
537
549
|
/**
|
|
538
550
|
* Per-node instruction surfaced in the coordinator prompt so the LLM
|