@adhdev/daemon-core 0.9.82-rc.442 → 0.9.82-rc.444
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/commands/high-family/types.d.ts +4 -0
- package/dist/commands/router.d.ts +4 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +551 -63
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +543 -63
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-ledger.d.ts +39 -1
- package/dist/mesh/mesh-node-identity.d.ts +15 -0
- package/dist/mesh/mesh-reconcile-loop.d.ts +15 -1
- package/dist/providers/chat-message-normalization.d.ts +1 -0
- package/dist/providers/cli-provider-instance.d.ts +2 -0
- package/dist/providers/native-history/antigravity-cli-transcript.d.ts +28 -2
- package/package.json +2 -2
- package/src/commands/chat-commands-read.ts +41 -0
- package/src/commands/high-family/mesh-coordinator-launch.ts +4 -2
- package/src/commands/high-family/mesh-events.ts +11 -2
- package/src/commands/high-family/mesh-status.ts +102 -9
- package/src/commands/high-family/types.ts +5 -1
- package/src/commands/router.ts +19 -2
- package/src/index.ts +1 -1
- package/src/mesh/coordinator-prompt.ts +1 -0
- package/src/mesh/mesh-ledger.ts +185 -0
- package/src/mesh/mesh-node-identity.ts +106 -18
- package/src/mesh/mesh-reconcile-loop.ts +22 -1
- package/src/providers/chat-message-normalization.ts +1 -1
- package/src/providers/cli-provider-instance.ts +130 -17
- package/src/providers/native-history/antigravity-cli-transcript.ts +348 -25
- package/src/providers/native-history/dispatcher.ts +33 -14
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { EventEmitter } from 'events';
|
|
16
16
|
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
17
|
-
export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'p2p_dispatch_failed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward' | 'delivery_unroutable' | 'direct_dispatch_pruned' | 'event_held' | 'task_reclaimed' | 'coordinator_operating_note' | 'mission_created' | 'mission_status_changed' | 'mission_goal_updated' | 'magi_dispatched' | 'magi_synthesis';
|
|
17
|
+
export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'p2p_dispatch_failed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward' | 'delivery_unroutable' | 'direct_dispatch_pruned' | 'event_held' | 'task_reclaimed' | 'coordinator_operating_note' | 'coordinator_operating_note_tombstone' | 'mission_created' | 'mission_status_changed' | 'mission_goal_updated' | 'magi_dispatched' | 'magi_synthesis';
|
|
18
18
|
export interface MeshLedgerEntry {
|
|
19
19
|
id: string;
|
|
20
20
|
meshId: string;
|
|
@@ -151,6 +151,10 @@ export interface AppendRemoteLedgerResult {
|
|
|
151
151
|
entries: MeshLedgerEntry[];
|
|
152
152
|
}
|
|
153
153
|
export declare const MAX_LEDGER_SLICE_LIMIT = 500;
|
|
154
|
+
export declare const OPERATING_NOTE_KIND: MeshLedgerKind;
|
|
155
|
+
export declare const OPERATING_NOTE_TOMBSTONE_KIND: MeshLedgerKind;
|
|
156
|
+
export declare const OPERATING_NOTE_DEDUPE_WINDOW = 40;
|
|
157
|
+
export declare const OPERATING_NOTE_KEEP_LATEST = 100;
|
|
154
158
|
export declare function getLedgerDir(): string;
|
|
155
159
|
/**
|
|
156
160
|
* Footer to append to worker task messages so workers output structured results
|
|
@@ -180,6 +184,40 @@ export declare function buildTaskCompletionEvidence(opts: BuildTaskCompletionEvi
|
|
|
180
184
|
*/
|
|
181
185
|
export declare const meshLedgerEvents: EventEmitter<[never]>;
|
|
182
186
|
export declare function appendLedgerEntry(meshId: string, partial: Omit<MeshLedgerEntry, 'id' | 'meshId' | 'timestamp'>): MeshLedgerEntry;
|
|
187
|
+
/** True if the operating note is retracted by any tombstone in `tombstones`. */
|
|
188
|
+
export declare function isOperatingNoteTombstoned(entry: Pick<MeshLedgerEntry, 'id' | 'payload'>, tombstones: {
|
|
189
|
+
ids: Set<string>;
|
|
190
|
+
fingerprints: Set<string>;
|
|
191
|
+
}): boolean;
|
|
192
|
+
/**
|
|
193
|
+
* Fix (2) supersede/remove: append a tombstone that retracts a coordinator
|
|
194
|
+
* operating note. Targets by note id and/or by exact trimmed text (a text target
|
|
195
|
+
* retracts every note with that text). History is preserved — the notes stay in
|
|
196
|
+
* the ledger but readers filter them out. Returns how many currently-live notes
|
|
197
|
+
* the tombstone will hide.
|
|
198
|
+
*/
|
|
199
|
+
export declare function tombstoneOperatingNote(meshId: string, target: {
|
|
200
|
+
noteId?: string;
|
|
201
|
+
text?: string;
|
|
202
|
+
reason?: string;
|
|
203
|
+
}): {
|
|
204
|
+
tombstone: MeshLedgerEntry;
|
|
205
|
+
matched: number;
|
|
206
|
+
};
|
|
207
|
+
/**
|
|
208
|
+
* Read live operating notes (tombstoned notes filtered out), oldest→newest.
|
|
209
|
+
* `tail` bounds the number of live notes returned (the freshest N).
|
|
210
|
+
*/
|
|
211
|
+
export declare function readOperatingNotes(meshId: string, opts?: {
|
|
212
|
+
tail?: number;
|
|
213
|
+
}): MeshLedgerEntry[];
|
|
214
|
+
/**
|
|
215
|
+
* Fix (3) keep-latest-N prune for coordinator_operating_note. Removes, from the
|
|
216
|
+
* store, (a) every note retracted by a tombstone, and (b) the oldest live notes
|
|
217
|
+
* beyond `keepLatest`. Tombstone entries themselves are retained as an audit trail
|
|
218
|
+
* of what was forgotten. Returns the number of note entries removed.
|
|
219
|
+
*/
|
|
220
|
+
export declare function pruneOperatingNotes(meshId: string, keepLatest?: number): number;
|
|
183
221
|
/**
|
|
184
222
|
* Append entries received over local-first/P2P ledger replication to the local ledger.
|
|
185
223
|
* This skips deduplicated entries and rejects malformed/cross-mesh entries.
|
|
@@ -199,6 +199,21 @@ export declare class MeshGitProbeCache {
|
|
|
199
199
|
private recent;
|
|
200
200
|
constructor(reuseMs: number, now?: () => number);
|
|
201
201
|
private key;
|
|
202
|
+
/**
|
|
203
|
+
* Local (same-machine) git_status dedup. The bootstrap direct-truth hydrate
|
|
204
|
+
* and the per-node render loop both call getGitRepoStatus(refreshUpstream:true)
|
|
205
|
+
* for the same local workspace within one mesh_status call. Each such probe
|
|
206
|
+
* fans out ~13-15 git subprocesses, and because the two passes are separated by
|
|
207
|
+
* the render/hydrate work of every OTHER node they routinely straddle the
|
|
208
|
+
* getGitRepoStatus 1.5s TTL, so the second pass re-shells the whole ~14-process
|
|
209
|
+
* collection. Routing both through this cache (namespaced under a reserved
|
|
210
|
+
* daemon id so it never collides with a remote-peer key) collapses them to one
|
|
211
|
+
* collection per workspace per request, and reuses it across the reuse window
|
|
212
|
+
* so the dashboard auto-retry loop can't restart a fresh local probe seconds
|
|
213
|
+
* apart either.
|
|
214
|
+
*/
|
|
215
|
+
private static readonly LOCAL_PROBE_DAEMON_ID;
|
|
216
|
+
probeLocal(workspace: string, probe: () => Promise<Record<string, unknown> | null>): Promise<Record<string, unknown> | null>;
|
|
202
217
|
/**
|
|
203
218
|
* Run `probe` for this peer, but reuse a fresh recent result or an in-flight
|
|
204
219
|
* probe for the same key when one is available. `probe` is only invoked when
|
|
@@ -52,10 +52,24 @@ export declare function resolveCoordinatorDrainDeliverability(components: Pick<D
|
|
|
52
52
|
* remote pull is never blocked by our local coordinator's busy state. A pure
|
|
53
53
|
* stdio MCP coordinator (no live CLI session) never satisfies (1), so its tool
|
|
54
54
|
* result remains the surface and the drain proceeds. No regression to either.
|
|
55
|
+
*
|
|
56
|
+
* SELF-COORDINATOR INBOX LEVEL-DRAIN (Defect 2): the hold above assumes the ONLY
|
|
57
|
+
* surface for a busy local coordinator's events is a future PTY inject on its idle
|
|
58
|
+
* edge, so it defers to the reconcile loop. But when the drain caller IS the local
|
|
59
|
+
* coordinator reading its OWN inbox (the `get_pending_mesh_events` call whose events
|
|
60
|
+
* are returned in the caller's tool RESULT — a data queue the self-coordinating LLM
|
|
61
|
+
* consumes directly), the events ARE surfaced losslessly the moment the tool returns,
|
|
62
|
+
* with NO PTY write. A busy self-coordinating LLM that calls a mesh tool mid-turn would
|
|
63
|
+
* otherwise get an empty inbox (held) and only see the completion on its NEXT busy→idle
|
|
64
|
+
* edge — the measured ~59s strand. `callerIsSelfCoordinatorInboxRead` marks that safe
|
|
65
|
+
* caller: the hold is relaxed for it (return the events), while every OTHER drain (a
|
|
66
|
+
* backfill relay, a broadcast poll, a DIFFERENT coordinator that genuinely needs its PTY)
|
|
67
|
+
* still defers to the reconcile loop. This relaxes delivery INTO the coordinator's own
|
|
68
|
+
* inbox only — it never changes how events are injected into a live PTY prompt.
|
|
55
69
|
*/
|
|
56
70
|
export declare function shouldHoldPendingDrainForBusyLocalCoordinator(components: Pick<DaemonComponents, 'instanceManager'> & {
|
|
57
71
|
statusInstanceId?: string;
|
|
58
|
-
}, meshId: string, requestedCoordinatorDaemonId?: string | null): boolean;
|
|
72
|
+
}, meshId: string, requestedCoordinatorDaemonId?: string | null, callerIsSelfCoordinatorInboxRead?: boolean): boolean;
|
|
59
73
|
export declare function runMeshReconcileTick(components: DaemonComponents): Promise<void>;
|
|
60
74
|
export declare function __resetUnresolvedForwardRejectionCountsForTests(): void;
|
|
61
75
|
interface ReconcileLoopHandle {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ChatMessage } from '../types.js';
|
|
2
2
|
export declare const DEFAULT_FINAL_SUMMARY_MAX_CHARS = 16000;
|
|
3
3
|
export declare function extractFinalSummaryFromMessages(messages: ChatMessage[] | null | undefined, maxChars?: number): string;
|
|
4
|
+
export declare function readChatMessageTimestampMs(message: ChatMessage | null | undefined): number | undefined;
|
|
4
5
|
/**
|
|
5
6
|
* Turn-scoped variant of extractFinalSummaryFromMessages. Selects the last
|
|
6
7
|
* user-facing assistant/model bubble whose own timestamp is at/after the
|
|
@@ -99,6 +99,7 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
99
99
|
private lastStatus;
|
|
100
100
|
private agentReadyEmitted;
|
|
101
101
|
private generatingStartedAt;
|
|
102
|
+
private busyEpoch;
|
|
102
103
|
private fastCollapseSynthesizedTaskId;
|
|
103
104
|
private startupGraceCollapseAt;
|
|
104
105
|
private settings;
|
|
@@ -285,6 +286,7 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
285
286
|
private approvalResolutionFinalizationBlock;
|
|
286
287
|
private scheduleCompletedDebounceFlush;
|
|
287
288
|
private isMeshWorkerSession;
|
|
289
|
+
private isAutonomousMeshSession;
|
|
288
290
|
/**
|
|
289
291
|
* ARCH-REFACTOR R1: the taskId to attribute the CURRENTLY-completing turn to.
|
|
290
292
|
* Prefers the per-turn binding (engine.currentTurnTaskId, set when the turn was
|
|
@@ -12,11 +12,35 @@
|
|
|
12
12
|
* { source: 'USER_EXPLICIT'|'MODEL', type: string, content: string, status: 'DONE'|..., created_at: number }
|
|
13
13
|
*
|
|
14
14
|
* 3. ~/.gemini/antigravity-cli/conversations/<uuid>.pb
|
|
15
|
-
*
|
|
15
|
+
* Legacy protobuf binary — schema not publicly documented. Adapter extracts
|
|
16
16
|
* printable UTF-8 text runs as best-effort content (no proto library needed).
|
|
17
17
|
*
|
|
18
|
+
* 4. ~/.gemini/antigravity-cli/conversations/<uuid>.db ← current format
|
|
19
|
+
* Per-session SQLite database. Recent antigravity migrated conversation
|
|
20
|
+
* storage from .pb (+ brain/*.jsonl) to a per-session SQLite db. The
|
|
21
|
+
* schema is a trajectory of `steps`, NOT a simple messages(role,content)
|
|
22
|
+
* table:
|
|
23
|
+
* steps(idx INTEGER PK, step_type INTEGER, status INTEGER,
|
|
24
|
+
* step_payload BLOB [protobuf], ...)
|
|
25
|
+
* Each `step_payload` is a protobuf message. Empirically (introspected
|
|
26
|
+
* from real stores):
|
|
27
|
+
* - step_type 14 → a USER turn. The prompt text is the largest
|
|
28
|
+
* contiguous UTF-8 run inside the payload (field 19 subtree).
|
|
29
|
+
* - step_type 15 → a MODEL/assistant turn. The assistant's final
|
|
30
|
+
* natural-language answer lives at payload field 20 → field 1
|
|
31
|
+
* (identical to field 8). Field 20 → field 3 is the internal
|
|
32
|
+
* reasoning summary and is intentionally NOT surfaced.
|
|
33
|
+
* - other step types are tool calls / ephemeral system context.
|
|
34
|
+
* We read the blobs with a tiny dependency-free protobuf field walker
|
|
35
|
+
* (no proto schema / codegen needed) and map the two message step types.
|
|
36
|
+
* Because the daemon does NOT read this db, native history previously
|
|
37
|
+
* returned 0 rows for these sessions and read_chat fell back to the
|
|
38
|
+
* pty parser (which only echoes the user's own input) — assistant
|
|
39
|
+
* answers appeared lost even though they were on disk.
|
|
40
|
+
*
|
|
18
41
|
* This adapter provides:
|
|
19
|
-
* - Full coverage
|
|
42
|
+
* - Full coverage from a per-session .db (current format) — preferred.
|
|
43
|
+
* - Full coverage when a brain transcript exists (legacy authoritative source).
|
|
20
44
|
* - Partial coverage (user prompts only) from history.jsonl as fallback.
|
|
21
45
|
* - Best-effort raw-string extraction from .pb files when no other source exists.
|
|
22
46
|
*
|
|
@@ -24,6 +48,7 @@
|
|
|
24
48
|
* ~/.gemini/antigravity-cli/history.jsonl
|
|
25
49
|
* ~/.gemini/antigravity-cli/brain/{uuid}/.system_generated/logs/transcript*.jsonl
|
|
26
50
|
* ~/.gemini/antigravity-cli/conversations/{uuid}.pb
|
|
51
|
+
* ~/.gemini/antigravity-cli/conversations/{uuid}.db
|
|
27
52
|
*
|
|
28
53
|
* OSS code (AGPL-3.0). Must not import from packages/ (proprietary).
|
|
29
54
|
*/
|
|
@@ -79,6 +104,7 @@ export interface NativeHistorySessionMeta {
|
|
|
79
104
|
* `sessionPath` is the absolute path to one of:
|
|
80
105
|
* - A brain transcript JSONL: ~/.gemini/antigravity-cli/brain/<uuid>/.system_generated/logs/transcript*.jsonl
|
|
81
106
|
* - The shared history.jsonl: ~/.gemini/antigravity-cli/history.jsonl
|
|
107
|
+
* - A conversation SQLite db: ~/.gemini/antigravity-cli/conversations/<uuid>.db
|
|
82
108
|
* - A conversation protobuf: ~/.gemini/antigravity-cli/conversations/<uuid>.pb
|
|
83
109
|
*
|
|
84
110
|
* The session UUID is inferred from the directory name (brain), filename (pb), or
|
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.444",
|
|
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.444",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
|
@@ -2210,6 +2210,47 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
|
|
|
2210
2210
|
});
|
|
2211
2211
|
|
|
2212
2212
|
if (supportsNative && !decision.nativeSelected) {
|
|
2213
|
+
// Native-only content preservation (hermes chat_tail gap).
|
|
2214
|
+
// The history-only path has NO PTY transcript (native-only
|
|
2215
|
+
// providers suppress PTY bodies), so args.ptyMessages is empty
|
|
2216
|
+
// and the machine's pty-parser selection returns NOTHING. But a
|
|
2217
|
+
// post-turn / cold read routinely lands here with a REAL,
|
|
2218
|
+
// safely-mapped native slice that the source FSM declined only
|
|
2219
|
+
// because coverage came back 'partial' (missing sessionStartedAtMs
|
|
2220
|
+
// → Booting→Recovering→pty-parser) or a transient shrink looked
|
|
2221
|
+
// like a regression. Dropping those rows deletes the assistant
|
|
2222
|
+
// answer from chat_tail / read_chat entirely. When the native
|
|
2223
|
+
// read actually resolved rows for THIS session identity
|
|
2224
|
+
// (safeMapping proves ownership: matching historySessionId /
|
|
2225
|
+
// providerSessionId + workspace), return them instead of an empty
|
|
2226
|
+
// array. This never loosens identity safety — it is gated on the
|
|
2227
|
+
// same hasSafeNativeHistoryMapping used everywhere else — and it
|
|
2228
|
+
// is scoped to the native-only history path (no PTY to prefer).
|
|
2229
|
+
// Truly-empty native reads (historyMessages.length === 0) and
|
|
2230
|
+
// unsafe/workspace-aliasing reads (safeMapping === false) still
|
|
2231
|
+
// fall through to the soft-pending dead-end below.
|
|
2232
|
+
if (safeMapping && historyMessages.length > 0) {
|
|
2233
|
+
LOG.debug('Command', `[read_chat] native-only content preserved despite pty-parser selection target=${String(args?.targetSessionId || '')} provider=${agentStr} rows=${historyMessages.length} cause=${decision.decision.transition.cause}`);
|
|
2234
|
+
return buildReadChatCommandResult({
|
|
2235
|
+
messages: historyMessages,
|
|
2236
|
+
status: 'idle',
|
|
2237
|
+
messageSource: {
|
|
2238
|
+
...decision.messageSource,
|
|
2239
|
+
nativeOnlyContentPreserved: true,
|
|
2240
|
+
returnedMessageCount: historyMessages.length,
|
|
2241
|
+
},
|
|
2242
|
+
transcriptProvenance: {
|
|
2243
|
+
...decision.messageSource,
|
|
2244
|
+
nativeOnlyContentPreserved: true,
|
|
2245
|
+
},
|
|
2246
|
+
...(typeof (history as any)?.title === 'string' ? { title: (history as any).title } : {}),
|
|
2247
|
+
...(historyProviderSessionId ? { providerSessionId: historyProviderSessionId } : {}),
|
|
2248
|
+
...(((provider?.historyBehavior as any)?.transcriptAuthority === 'provider' || (provider?.historyBehavior as any)?.transcriptAuthority === 'daemon')
|
|
2249
|
+
? { transcriptAuthority: (provider?.historyBehavior as any).transcriptAuthority }
|
|
2250
|
+
: {}),
|
|
2251
|
+
coverage: 'tail',
|
|
2252
|
+
}, args, h);
|
|
2253
|
+
}
|
|
2213
2254
|
// Dead-end: we are in the history-only path (no live PTY/ACP
|
|
2214
2255
|
// adapter was found for this target session) AND provider-native
|
|
2215
2256
|
// history is not safely mappable to the requested session
|
|
@@ -101,8 +101,10 @@ export const meshCoordinatorLaunchHandlers: Record<string, HighFamilyHandler> =
|
|
|
101
101
|
// Best-effort: a read failure just omits the section.
|
|
102
102
|
const buildOperatingNotesBestEffort = async (id: string) => {
|
|
103
103
|
try {
|
|
104
|
-
const {
|
|
105
|
-
|
|
104
|
+
const { readOperatingNotes } = await import('../../mesh/mesh-ledger.js');
|
|
105
|
+
// readOperatingNotes filters out tombstoned (forgotten) notes so a
|
|
106
|
+
// retracted lesson never rides into the prompt. Newest last; tail 20.
|
|
107
|
+
const noteEntries = readOperatingNotes(id, { tail: 20 });
|
|
106
108
|
const notes = noteEntries
|
|
107
109
|
.map((e) => {
|
|
108
110
|
const p = (e.payload || {}) as Record<string, unknown>;
|
|
@@ -29,6 +29,11 @@ export const meshEventsHandlers: Record<string, HighFamilyHandler> = {
|
|
|
29
29
|
const coordinatorDaemonId = typeof args?.coordinatorDaemonId === 'string' && args.coordinatorDaemonId.trim()
|
|
30
30
|
? args.coordinatorDaemonId.trim()
|
|
31
31
|
: undefined;
|
|
32
|
+
// SELF-COORDINATOR INBOX LEVEL-DRAIN (Defect 2): the MCP self-coordinator inbox read
|
|
33
|
+
// (drainCoordinatorPendingEvents) sets this so its own drain is not held while its CLI
|
|
34
|
+
// is busy — the events return in ITS tool result (a lossless data-queue surface), never
|
|
35
|
+
// a PTY inject. Every other drain leaves it unset and keeps the busy-coordinator hold.
|
|
36
|
+
const selfCoordinatorInboxRead = args?.selfCoordinatorInboxRead === true;
|
|
32
37
|
// DRAIN-WITHOUT-INJECT guard: when a LOCAL live CLI coordinator for this mesh is
|
|
33
38
|
// busy (generating / modal-parked), the reconcile loop is HOLDING its terminal
|
|
34
39
|
// events (drained=0) for the coordinator's next idle tick. Draining here would
|
|
@@ -57,11 +62,15 @@ export const meshEventsHandlers: Record<string, HighFamilyHandler> = {
|
|
|
57
62
|
// loop: return nothing, leaving the rows undrained for its idle-tick delivery. A
|
|
58
63
|
// remote pull (foreign coordinatorDaemonId) or a pure stdio MCP coordinator (no live
|
|
59
64
|
// CLI session) is NOT held — see shouldHoldPendingDrainForBusyLocalCoordinator.
|
|
60
|
-
if (meshId && shouldHoldPendingDrainForBusyLocalCoordinator(ctx.deps, meshId, coordinatorDaemonId)) {
|
|
65
|
+
if (meshId && shouldHoldPendingDrainForBusyLocalCoordinator(ctx.deps, meshId, coordinatorDaemonId, selfCoordinatorInboxRead)) {
|
|
61
66
|
return { success: true, events: [], heldForBusyLocalCoordinator: true, hasLiveCliCoordinator };
|
|
62
67
|
}
|
|
63
68
|
const events = drainPendingMeshCoordinatorEvents(meshId || undefined, coordinatorDaemonId);
|
|
64
|
-
|
|
69
|
+
// SELF-COORDINATOR INBOX LEVEL-DRAIN: when the busy local coordinator drained its OWN
|
|
70
|
+
// inbox (selfCoordinatorInboxRead), tell the puller these events were surfaced through
|
|
71
|
+
// the caller's tool result — it must NOT re-forward them into the (busy) PTY (that is the
|
|
72
|
+
// lossy path). Absent the flag, delivery is unchanged (reconcile-owned PTY / remote pull).
|
|
73
|
+
return { success: true, events, hasLiveCliCoordinator, ...(selfCoordinatorInboxRead ? { surfacedForSelfCoordinator: true } : {}) };
|
|
65
74
|
},
|
|
66
75
|
|
|
67
76
|
interactive_prompt_response: async (ctx: HighFamilyContext, args: any) => {
|
|
@@ -61,6 +61,11 @@ export const meshStatusHandlers: Record<string, HighFamilyHandler> = {
|
|
|
61
61
|
mesh_status: async (ctx: HighFamilyContext, args: any) => {
|
|
62
62
|
const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
|
|
63
63
|
if (!meshId) return { success: false, error: 'meshId required' };
|
|
64
|
+
// Latency telemetry: stamp handler entry so every return path can
|
|
65
|
+
// report durationMs. Makes the detail-open cost measurable in the
|
|
66
|
+
// repo-mesh-status debug log (cache hit ≈ single-digit ms; a full
|
|
67
|
+
// live rebuild is the ~seconds path this change is reducing).
|
|
68
|
+
const startedAtMs = Date.now();
|
|
64
69
|
try {
|
|
65
70
|
const meshRecord = await ctx.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
|
|
66
71
|
const mesh = meshRecord?.mesh;
|
|
@@ -94,10 +99,51 @@ export const meshStatusHandlers: Record<string, HighFamilyHandler> = {
|
|
|
94
99
|
meshId,
|
|
95
100
|
command: 'mesh_status',
|
|
96
101
|
refreshRequested,
|
|
102
|
+
durationMs: Date.now() - startedAtMs,
|
|
97
103
|
summary: summarizeRepoMeshStatusDebug(cachedStatus),
|
|
98
104
|
});
|
|
99
105
|
return cachedStatus;
|
|
100
106
|
}
|
|
107
|
+
// SWR stale-serve: the strict serve above missed only because
|
|
108
|
+
// some node still has a pending peer-git probe (the
|
|
109
|
+
// shouldRefreshStalePendingAggregate gate). Rather than block
|
|
110
|
+
// this interactive detail-open on a full synchronous live
|
|
111
|
+
// rebuild, serve the held (slightly stale) snapshot instantly
|
|
112
|
+
// and kick ONE coalesced background freshen whose result
|
|
113
|
+
// repopulates the cache for the next poll/subscription push.
|
|
114
|
+
// allowStalePending relaxes ONLY the pending-git freshness gate;
|
|
115
|
+
// getCachedAggregateMeshStatus still enforces the queueRevision
|
|
116
|
+
// guard, so a genuine queue/identity mutation is never stale-served.
|
|
117
|
+
const staleStatus = ctx.getCachedAggregateMeshStatus(meshId, mesh, {
|
|
118
|
+
requireDirectPeerTruth: args?.requireDirectPeerTruth === true,
|
|
119
|
+
allowStalePending: true,
|
|
120
|
+
});
|
|
121
|
+
if (staleStatus) {
|
|
122
|
+
if (!ctx.swrRefreshInFlight.has(meshId)) {
|
|
123
|
+
ctx.swrRefreshInFlight.add(meshId);
|
|
124
|
+
// Fire-and-forget: a full refresh (peer fan-out) that
|
|
125
|
+
// rewrites the aggregate cache. Errors are swallowed —
|
|
126
|
+
// the stale snapshot was already returned to the caller.
|
|
127
|
+
void Promise.resolve()
|
|
128
|
+
.then(() => ctx.execute('mesh_status', {
|
|
129
|
+
meshId,
|
|
130
|
+
inlineMesh: args?.inlineMesh,
|
|
131
|
+
coordinatorDaemonId: args?.coordinatorDaemonId,
|
|
132
|
+
requireDirectPeerTruth: args?.requireDirectPeerTruth === true,
|
|
133
|
+
refresh: true,
|
|
134
|
+
}, 'mesh_status_swr_freshen'))
|
|
135
|
+
.catch(() => {})
|
|
136
|
+
.finally(() => { ctx.swrRefreshInFlight.delete(meshId); });
|
|
137
|
+
}
|
|
138
|
+
logRepoMeshStatusDebug('return_stale_swr', {
|
|
139
|
+
meshId,
|
|
140
|
+
command: 'mesh_status',
|
|
141
|
+
refreshRequested,
|
|
142
|
+
durationMs: Date.now() - startedAtMs,
|
|
143
|
+
summary: summarizeRepoMeshStatusDebug(staleStatus),
|
|
144
|
+
});
|
|
145
|
+
return staleStatus;
|
|
146
|
+
}
|
|
101
147
|
}
|
|
102
148
|
const refreshReason = refreshRequested
|
|
103
149
|
? 'explicit_refresh'
|
|
@@ -229,8 +275,15 @@ export const meshStatusHandlers: Record<string, HighFamilyHandler> = {
|
|
|
229
275
|
? selectedCoordinatorNodeId
|
|
230
276
|
: undefined;
|
|
231
277
|
const refreshedAt = new Date().toISOString();
|
|
232
|
-
|
|
233
|
-
|
|
278
|
+
// Per-node render is parallelized (Promise.allSettled below):
|
|
279
|
+
// each node's git probe (local getGitRepoStatus or the remote
|
|
280
|
+
// P2P fan-out) is independent, so serializing them stacked one
|
|
281
|
+
// slow node's latency onto every other node. Each node has its
|
|
282
|
+
// own bounded timeout; a rejected/timed-out node degrades to a
|
|
283
|
+
// partial/last-known status for THAT node only and never rejects
|
|
284
|
+
// the whole aggregate. Order is preserved by mapping over the
|
|
285
|
+
// original entries() index and re-assembling in order.
|
|
286
|
+
const renderMeshNode = async (nodeIndex: number, node: any): Promise<Record<string, unknown>> => {
|
|
234
287
|
const nodeId = normalizeMeshNodeId(node) ?? '';
|
|
235
288
|
const daemonId = readStringValue(node.daemonId);
|
|
236
289
|
const nodeMachineId = readMeshNodeMachineId(node as Record<string, unknown>);
|
|
@@ -425,19 +478,23 @@ export const meshStatusHandlers: Record<string, HighFamilyHandler> = {
|
|
|
425
478
|
)) {
|
|
426
479
|
applyInlineMeshBranchConvergence(mesh, node, status);
|
|
427
480
|
finalizeMeshNodeStatus({ status, node, daemonId, isSelfNode, directTruthUnavailable: directTruthUnavailableNodeIds.has(nodeId) });
|
|
428
|
-
|
|
429
|
-
continue;
|
|
481
|
+
return status;
|
|
430
482
|
}
|
|
431
483
|
if (meshRecord?.source === 'inline_cache' && !isSelfNode) {
|
|
432
484
|
applyInlineMeshBranchConvergence(mesh, node, status);
|
|
433
485
|
finalizeMeshNodeStatus({ status, node, daemonId, isSelfNode, directTruthUnavailable: directTruthUnavailableNodeIds.has(nodeId) });
|
|
434
|
-
|
|
435
|
-
continue;
|
|
486
|
+
return status;
|
|
436
487
|
}
|
|
437
488
|
}
|
|
438
489
|
} else {
|
|
439
490
|
try {
|
|
440
|
-
|
|
491
|
+
// Route through the shared per-request cache so this
|
|
492
|
+
// local probe reuses the bootstrap hydrate's probe for
|
|
493
|
+
// the same workspace (and vice versa) instead of
|
|
494
|
+
// re-shelling ~14 git processes across the 1.5s TTL.
|
|
495
|
+
const runLocalProbe = () => getGitRepoStatus(workspace, { timeoutMs: 10_000, refreshUpstream: true }) as unknown as Promise<Record<string, unknown> | null>;
|
|
496
|
+
const gitStatus = (await meshGitProbeCache.probeLocal(workspace, runLocalProbe)) as any;
|
|
497
|
+
if (!gitStatus) throw new Error('local_git_probe_unavailable');
|
|
441
498
|
status.git = gitStatus;
|
|
442
499
|
status[MESH_NODE_LIVE_TRUTH_MARKER] = true;
|
|
443
500
|
const reporter = recordInlineMeshDirectGitTruth(node, gitStatus as unknown as Record<string, unknown>, 'selected_coordinator_local_git');
|
|
@@ -459,8 +516,43 @@ export const meshStatusHandlers: Record<string, HighFamilyHandler> = {
|
|
|
459
516
|
}
|
|
460
517
|
applyInlineMeshBranchConvergence(mesh, node, status);
|
|
461
518
|
finalizeMeshNodeStatus({ status, node, daemonId, isSelfNode, directTruthUnavailable: directTruthUnavailableNodeIds.has(nodeId) });
|
|
462
|
-
|
|
463
|
-
}
|
|
519
|
+
return status;
|
|
520
|
+
};
|
|
521
|
+
const meshNodeEntries = [...(mesh.nodes || []).entries()];
|
|
522
|
+
const settledNodeStatuses = await Promise.allSettled(
|
|
523
|
+
meshNodeEntries.map(([nodeIndex, node]) => renderMeshNode(nodeIndex, node)),
|
|
524
|
+
);
|
|
525
|
+
const nodeStatuses = settledNodeStatuses.map((settled, i) => {
|
|
526
|
+
if (settled.status === 'fulfilled') return settled.value;
|
|
527
|
+
// A per-node render should never reject (every git path is
|
|
528
|
+
// caught internally), but if one does, degrade to a minimal
|
|
529
|
+
// last-known/unknown entry for THAT node so a single failure
|
|
530
|
+
// can't drop the whole aggregate. Best-effort recovery of the
|
|
531
|
+
// node's cached inline truth, else an unknown-health stub.
|
|
532
|
+
const [nodeIndex, node] = meshNodeEntries[i];
|
|
533
|
+
const nodeId = normalizeMeshNodeId(node) ?? '';
|
|
534
|
+
const daemonId = readStringValue(node.daemonId);
|
|
535
|
+
const fallback: Record<string, unknown> = {
|
|
536
|
+
nodeId,
|
|
537
|
+
machineLabel: buildMeshNodeDisplayLabel(node as Record<string, unknown>, nodeId, readProviderPriorityFromPolicy(node.policy)),
|
|
538
|
+
workspace: node.workspace,
|
|
539
|
+
repoRoot: node.repoRoot,
|
|
540
|
+
isLocalWorktree: node.isLocalWorktree,
|
|
541
|
+
worktreeBranch: node.worktreeBranch,
|
|
542
|
+
daemonId,
|
|
543
|
+
machineId: readMeshNodeMachineId(node as Record<string, unknown>) || node.machineId,
|
|
544
|
+
health: 'unknown',
|
|
545
|
+
providers: node.providers || [],
|
|
546
|
+
activeSessions: [],
|
|
547
|
+
activeSessionDetails: [],
|
|
548
|
+
launchReady: false,
|
|
549
|
+
error: settled.reason instanceof Error ? settled.reason.message : 'node render failed',
|
|
550
|
+
};
|
|
551
|
+
applyCachedInlineMeshNodeStatus(fallback, node);
|
|
552
|
+
applyInlineMeshBranchConvergence(mesh, node, fallback);
|
|
553
|
+
finalizeMeshNodeStatus({ status: fallback, node, daemonId, isSelfNode: false, directTruthUnavailable: directTruthUnavailableNodeIds.has(nodeId) });
|
|
554
|
+
return fallback;
|
|
555
|
+
});
|
|
464
556
|
|
|
465
557
|
// (B3) Resolve the coordinator daemon scope for the peek.
|
|
466
558
|
// mesh_status is a read-only status query — it must not consume
|
|
@@ -620,6 +712,7 @@ export const meshStatusHandlers: Record<string, HighFamilyHandler> = {
|
|
|
620
712
|
refreshReason,
|
|
621
713
|
meshSource: meshRecord.source,
|
|
622
714
|
directTruth,
|
|
715
|
+
durationMs: Date.now() - startedAtMs,
|
|
623
716
|
summary: summarizeRepoMeshStatusDebug(returnedStatus),
|
|
624
717
|
});
|
|
625
718
|
return returnedStatus;
|
|
@@ -45,7 +45,7 @@ export interface HighFamilyContext {
|
|
|
45
45
|
getCachedAggregateMeshStatus: (
|
|
46
46
|
meshId: string,
|
|
47
47
|
mesh?: any,
|
|
48
|
-
options?: { requireDirectPeerTruth?: boolean },
|
|
48
|
+
options?: { requireDirectPeerTruth?: boolean; allowStalePending?: boolean },
|
|
49
49
|
) => any | null;
|
|
50
50
|
|
|
51
51
|
/** Bound `DaemonCommandRouter.rememberAggregateMeshStatus`. */
|
|
@@ -61,6 +61,10 @@ export interface HighFamilyContext {
|
|
|
61
61
|
/** Router's aggregate-status memory cache (`.has()` probe in mesh_status). */
|
|
62
62
|
aggregateMeshStatusCache: Map<string, { builtAt: number; snapshot: any; queueRevision: string }>;
|
|
63
63
|
|
|
64
|
+
/** Meshes with a background SWR freshen already in flight — coalesces the
|
|
65
|
+
* async refresh a stale-serve interactive open kicks off. */
|
|
66
|
+
swrRefreshInFlight: Set<string>;
|
|
67
|
+
|
|
64
68
|
/** Router's running-refine-job table (surfaced as activeRefineJobs in mesh_status). */
|
|
65
69
|
runningRefineJobs: Map<string, MeshRefineJobHandle>;
|
|
66
70
|
|
package/src/commands/router.ts
CHANGED
|
@@ -286,6 +286,10 @@ export class DaemonCommandRouter {
|
|
|
286
286
|
* Spans separate mesh_status/get_mesh calls so the dashboard auto-retry
|
|
287
287
|
* loop cannot storm a slow peer with back-to-back refreshUpstream probes. */
|
|
288
288
|
private meshGitProbeCache = new MeshGitProbeCache(MESH_DIRECT_PROBE_REUSE_MS);
|
|
289
|
+
/** Meshes with a background SWR freshen (async mesh_status refresh) already in
|
|
290
|
+
* flight — so a burst of interactive detail-opens serves the cached snapshot
|
|
291
|
+
* and coalesces onto ONE background refresh instead of storming the peers. */
|
|
292
|
+
private swrRefreshInFlight = new Set<string>();
|
|
289
293
|
/** In-memory async Refinery jobs keyed by meshId:nodeId to reject/return duplicate in-flight requests. */
|
|
290
294
|
private runningRefineJobs = new Map<string, MeshRefineJobHandle>();
|
|
291
295
|
/** Terminal async Refinery jobs preserve a clear answer after the worktree node has been removed. */
|
|
@@ -403,13 +407,25 @@ export class DaemonCommandRouter {
|
|
|
403
407
|
};
|
|
404
408
|
}
|
|
405
409
|
|
|
406
|
-
private getCachedAggregateMeshStatus(
|
|
410
|
+
private getCachedAggregateMeshStatus(
|
|
411
|
+
meshId: string,
|
|
412
|
+
mesh?: any,
|
|
413
|
+
options?: { requireDirectPeerTruth?: boolean; allowStalePending?: boolean },
|
|
414
|
+
): any | null {
|
|
407
415
|
const cached = this.aggregateMeshStatusCache.get(meshId);
|
|
408
416
|
if (!cached?.snapshot || cached.snapshot.success !== true || !Array.isArray(cached.snapshot.nodes)) return null;
|
|
417
|
+
// Genuine invalidation still forces truth: a queue mutation bumps the
|
|
418
|
+
// revision, so a stale-revision snapshot is never served (even under the
|
|
419
|
+
// SWR allowStalePending path below).
|
|
409
420
|
if (cached.queueRevision !== getMeshQueueRevision(meshId)) return null;
|
|
410
421
|
let snapshot = this.cloneJsonValue(cached.snapshot);
|
|
411
422
|
snapshot = this.hydrateCachedAggregateMeshStatusFromInline(snapshot, mesh, options);
|
|
412
|
-
|
|
423
|
+
// SWR: allowStalePending lets the interactive detail-open serve a snapshot
|
|
424
|
+
// that still has pending peer-git nodes (would otherwise miss here) so the
|
|
425
|
+
// graph paints instantly; the caller fires a background freshen. The
|
|
426
|
+
// queueRevision guard above is NOT relaxed — only the pending-git freshness
|
|
427
|
+
// gate is, so a genuine queue/identity mutation still forces a live rebuild.
|
|
428
|
+
if (!options?.allowStalePending && shouldRefreshStalePendingAggregate(snapshot, options)) return null;
|
|
413
429
|
const ageMs = Math.max(0, Date.now() - cached.builtAt);
|
|
414
430
|
const sourceOfTruth = snapshot.sourceOfTruth && typeof snapshot.sourceOfTruth === 'object'
|
|
415
431
|
? snapshot.sourceOfTruth
|
|
@@ -677,6 +693,7 @@ export class DaemonCommandRouter {
|
|
|
677
693
|
rememberAggregateMeshStatus: this.rememberAggregateMeshStatus.bind(this),
|
|
678
694
|
execute: this.execute.bind(this),
|
|
679
695
|
aggregateMeshStatusCache: this.aggregateMeshStatusCache,
|
|
696
|
+
swrRefreshInFlight: this.swrRefreshInFlight,
|
|
680
697
|
runningRefineJobs: this.runningRefineJobs,
|
|
681
698
|
inlineMeshCache: this.inlineMeshCache,
|
|
682
699
|
meshGitProbeCache: this.meshGitProbeCache,
|
package/src/index.ts
CHANGED
|
@@ -262,7 +262,7 @@ export { loadRepoSettings } from './config/repo-settings.js';
|
|
|
262
262
|
export type { RepoSettings, LoadRepoSettingsOptions } from './config/repo-settings.js';
|
|
263
263
|
|
|
264
264
|
// ── Mesh Task Ledger ──
|
|
265
|
-
export { appendLedgerEntry, appendRemoteLedgerEntries, buildTaskCompletionEvidence, normalizeMeshWorkerResult, readLedgerEntries, readLedgerSlice, readLedgerSliceFromStore, getLedgerSummary, getLedgerDir, getSessionRecoveryContext, MAX_LEDGER_SLICE_LIMIT } from './mesh/mesh-ledger.js';
|
|
265
|
+
export { appendLedgerEntry, appendRemoteLedgerEntries, buildTaskCompletionEvidence, normalizeMeshWorkerResult, readLedgerEntries, readLedgerSlice, readLedgerSliceFromStore, getLedgerSummary, getLedgerDir, getSessionRecoveryContext, MAX_LEDGER_SLICE_LIMIT, tombstoneOperatingNote, readOperatingNotes, pruneOperatingNotes, isOperatingNoteTombstoned, OPERATING_NOTE_KIND, OPERATING_NOTE_TOMBSTONE_KIND, OPERATING_NOTE_DEDUPE_WINDOW, OPERATING_NOTE_KEEP_LATEST } from './mesh/mesh-ledger.js';
|
|
266
266
|
export type { AppendRemoteLedgerResult, MeshLedgerEntry, MeshLedgerKind, MeshLedgerSlice, MeshLedgerSummary, ReadLedgerOptions, ReadLedgerSliceOptions, SessionRecoveryContext, MeshTaskCompletionEvidence, MeshWorkerResultArtifact, MeshProcessArtifact, MeshValidationResultArtifact } from './mesh/mesh-ledger.js';
|
|
267
267
|
export { fastForwardMeshNode } from './mesh/mesh-fast-forward.js';
|
|
268
268
|
export type { MeshFastForwardNodeArgs, MeshFastForwardPlannedStep, MeshFastForwardResult } from './mesh/mesh-fast-forward.js';
|
|
@@ -490,6 +490,7 @@ const TOOLS_SECTION = `## Available Tools
|
|
|
490
490
|
| \`mesh_read_debug\` | Collect a daemon-side chat/parser debug bundle for a session |
|
|
491
491
|
| \`mesh_task_history\` | Read the task ledger — dispatches, completions, failures. Use to understand what has been done before deciding next steps |
|
|
492
492
|
| \`mesh_record_note\` | Record a durable, provider-neutral operating note (provider quirk / pattern to avoid / recovery lesson). Future coordinators see it under "## Operating Notes" at launch |
|
|
493
|
+
| \`mesh_forget_note\` | Retract a stale/wrong operating note by note_id or exact text so it stops riding into future coordinators' prompts (append-only tombstone; history preserved) |
|
|
493
494
|
| \`mesh_git_status\` | Check git status on a specific node |
|
|
494
495
|
| \`mesh_read_node_logs\` | Fetch a remote node's daemon log tail directly over P2P (grep/since/byte-bounded, secrets redacted) — no session/PowerShell needed to debug a node's daemon |
|
|
495
496
|
| \`mesh_fast_forward_node\` | Safely dry-run or explicitly execute an obvious clean fast-forward without launching an agent session |
|