@adhdev/daemon-core 0.9.82-rc.476 → 0.9.82-rc.478
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/index.js +162 -27
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +162 -27
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-event-forwarding.d.ts +8 -0
- package/dist/providers/cli-provider-instance.d.ts +19 -0
- package/dist/providers/native-history/antigravity-claim-registry.d.ts +17 -6
- package/dist/sessions/registry.d.ts +20 -0
- package/package.json +3 -3
- package/src/commands/chat-commands-read.ts +42 -10
- package/src/mesh/mesh-event-forwarding.ts +38 -0
- package/src/providers/cli-provider-instance.ts +148 -5
- package/src/providers/native-history/antigravity-claim-registry.ts +19 -12
- package/src/providers/native-history/dispatcher.ts +39 -12
- package/src/sessions/registry.ts +38 -0
|
@@ -11,6 +11,14 @@ export declare function handleMeshForwardEvent(components: DaemonComponents, pay
|
|
|
11
11
|
} | {
|
|
12
12
|
success: boolean;
|
|
13
13
|
forwarded: number;
|
|
14
|
+
suppressed: boolean;
|
|
15
|
+
autoApprovingWorkerApproval: boolean;
|
|
16
|
+
error?: undefined;
|
|
17
|
+
} | {
|
|
18
|
+
success: boolean;
|
|
19
|
+
forwarded: number;
|
|
20
|
+
suppressed?: undefined;
|
|
21
|
+
autoApprovingWorkerApproval?: undefined;
|
|
14
22
|
error?: undefined;
|
|
15
23
|
} | {
|
|
16
24
|
success: boolean;
|
|
@@ -308,6 +308,18 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
308
308
|
private completedDebounceTimer;
|
|
309
309
|
private completedDebouncePending;
|
|
310
310
|
private lastExternalCompletionProbe;
|
|
311
|
+
/**
|
|
312
|
+
* The final assistant summary of the last completed turn, cached at
|
|
313
|
+
* completion-emit time. For a native-source provider (antigravity) whose
|
|
314
|
+
* assistant answer lives only in native-history — never in the PTY parse that
|
|
315
|
+
* feeds activeChat.messages — the dashboard's preview / lastMessageRole /
|
|
316
|
+
* completionMarker would otherwise never see the answer and show the session
|
|
317
|
+
* stuck on the user prompt. getState() appends this cached assistant bubble to
|
|
318
|
+
* the status messages when the PTY tail has none, so those fields reflect the
|
|
319
|
+
* real last answer with ZERO per-tick native reads (the native read already ran
|
|
320
|
+
* once at completion). Reset on the next turn's start.
|
|
321
|
+
*/
|
|
322
|
+
private lastCompletionSummary;
|
|
311
323
|
private enforceFreshSessionLaunchIfNeeded;
|
|
312
324
|
private completionHasFinalAssistantMessage;
|
|
313
325
|
private recordPendingTranscriptProbe;
|
|
@@ -321,6 +333,13 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
321
333
|
*/
|
|
322
334
|
private spawnedEnvOverrides;
|
|
323
335
|
private readExternalCompletionMessages;
|
|
336
|
+
/**
|
|
337
|
+
* The content of the LAST visible assistant bubble in a message list, or ''
|
|
338
|
+
* when the tail is not an assistant reply. Skips trailing system/tool/activity
|
|
339
|
+
* bubbles; stops (returns '') at the first user/human message. Used only for
|
|
340
|
+
* the dashboard tail-repair cache — a display value, not a completion decision.
|
|
341
|
+
*/
|
|
342
|
+
private lastVisibleAssistantSummary;
|
|
324
343
|
private completionFinalAssistantEvidence;
|
|
325
344
|
private completionFinalSummary;
|
|
326
345
|
private buildCompletedFinalizationDiagnostic;
|
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
/** A claim older than this with no refresh is reclaimable (owner presumed dead). */
|
|
2
2
|
export declare const CLAIM_STALE_MS: number;
|
|
3
3
|
/**
|
|
4
|
-
* Derive the per-session owner token
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Derive the per-session owner token, keyed ONLY on the stable instanceId
|
|
5
|
+
* (== the session registry sessionId == the read path's targetSessionId). Both
|
|
6
|
+
* the dispatcher (read side) and the provider instance (claim/release side) pass
|
|
7
|
+
* this same id, so their tokens always agree and the claim isolation holds.
|
|
8
|
+
*
|
|
9
|
+
* Returns '' when no instanceId is available — the caller then skips claiming
|
|
10
|
+
* (the exclusion checks still run against existing claims). This is the SSOT
|
|
11
|
+
* rule: there is exactly ONE token form. The removed legacy fallback derived a
|
|
12
|
+
* `spawn:<workspace>:<sessionStartedAtMs>` token from the spawn timestamp when
|
|
13
|
+
* the instanceId was missing; because one session's spawn time is sampled
|
|
14
|
+
* independently at three sites (instance startedAt, adapter spawnedAtMs, registry
|
|
15
|
+
* spawnedAtMs) those never matched, so the SAME session's instance-side and
|
|
16
|
+
* read-side tokens silently diverged and the claim mutual-exclusion collapsed
|
|
17
|
+
* (the antigravity conversation crosswire). An empty token (skip-claim) is
|
|
18
|
+
* strictly safer than a token that disagrees with the same session's other
|
|
19
|
+
* token. workspace/sessionStartedAtMs are kept in the signature for call-site
|
|
20
|
+
* compatibility but no longer affect the token.
|
|
10
21
|
*/
|
|
11
22
|
export declare function antigravityOwnerToken(workspace: string, sessionStartedAtMs: number, instanceId?: string): string;
|
|
12
23
|
/**
|
|
@@ -13,6 +13,19 @@ export interface SessionRuntimeTarget {
|
|
|
13
13
|
/** Wall clock at register time. native-history readers use it as a
|
|
14
14
|
* cutoff so a fresh session can't show records from a prior one. */
|
|
15
15
|
spawnedAtMs?: number;
|
|
16
|
+
/**
|
|
17
|
+
* Authoritative provider-native conversation id for this session (SSOT).
|
|
18
|
+
* For providers that expose a session id on the CLI (codex/claude/hermes)
|
|
19
|
+
* this equals that id. For antigravity — which takes no --session-id — this
|
|
20
|
+
* is the on-disk conversations/<uuid>.db basename, discovered by the
|
|
21
|
+
* native-history dispatcher and written back here via setProviderSessionId
|
|
22
|
+
* the first time it resolves. Every downstream reader (read_chat, the
|
|
23
|
+
* completion probe, the dashboard) should prefer this over re-deriving the
|
|
24
|
+
* conversation by spawn-floor/mtime heuristics — that re-derivation is the
|
|
25
|
+
* source of the antigravity conversation crosswire/theft class. Empty until
|
|
26
|
+
* the first successful native read binds it.
|
|
27
|
+
*/
|
|
28
|
+
providerSessionId?: string;
|
|
16
29
|
}
|
|
17
30
|
export declare class SessionRegistry {
|
|
18
31
|
private readonly bySessionId;
|
|
@@ -21,6 +34,13 @@ export declare class SessionRegistry {
|
|
|
21
34
|
private readonly byParentSessionId;
|
|
22
35
|
register(target: SessionRuntimeTarget): void;
|
|
23
36
|
get(sessionId: string | undefined | null): SessionRuntimeTarget | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* Record the authoritative provider-native conversation id for a session
|
|
39
|
+
* (SSOT). Idempotent; a no-op when the session is unknown or the value is
|
|
40
|
+
* empty or unchanged. Never overwrites a known binding with an empty one.
|
|
41
|
+
* Returns whether the stored value changed.
|
|
42
|
+
*/
|
|
43
|
+
setProviderSessionId(sessionId: string | undefined | null, providerSessionId: string | undefined | null): boolean;
|
|
24
44
|
unregister(sessionId: string | undefined | null): void;
|
|
25
45
|
unregisterByManagerKey(managerKey: string): void;
|
|
26
46
|
unregisterByInstanceKey(instanceKey: string): void;
|
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.478",
|
|
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",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"author": "vilmire",
|
|
48
48
|
"license": "AGPL-3.0-or-later",
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
51
|
-
"@adhdev/session-host-core": "0.9.82-rc.
|
|
50
|
+
"@adhdev/mesh-shared": "0.9.82-rc.478",
|
|
51
|
+
"@adhdev/session-host-core": "0.9.82-rc.478",
|
|
52
52
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
53
53
|
"ajv": "^8.20.0",
|
|
54
54
|
"ajv-formats": "^3.0.1",
|
|
@@ -91,10 +91,18 @@ function hydratePersistedProviderSessionPinsOnce(): void {
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
function recordBoundProviderSessionId(meshSessionId: string | undefined, providerSessionId: string | undefined): void {
|
|
94
|
+
function recordBoundProviderSessionId(h: CommandHelpers, meshSessionId: string | undefined, providerSessionId: string | undefined): void {
|
|
95
95
|
const key = typeof meshSessionId === 'string' ? meshSessionId.trim() : '';
|
|
96
96
|
const value = typeof providerSessionId === 'string' ? providerSessionId.trim() : '';
|
|
97
97
|
if (!key || !value) return;
|
|
98
|
+
// SSOT: the session registry entry (keyed by sessionId == instanceId) is the
|
|
99
|
+
// authoritative sessionId → conversation-uuid record. Writing it here — the
|
|
100
|
+
// moment a native read resolves the real conversation id — makes
|
|
101
|
+
// getHistorySessionId return it directly on every subsequent read, so the
|
|
102
|
+
// conversation is exact-bound instead of re-resolved by the spawn-floor/mtime
|
|
103
|
+
// heuristic (the crosswire/theft source). The pin below stays as the durable
|
|
104
|
+
// cross-restart mirror (the registry is in-memory and cleared on restart).
|
|
105
|
+
try { h.ctx?.sessionRegistry?.setProviderSessionId?.(key, value); } catch { /* best-effort SSOT write-back */ }
|
|
98
106
|
lastBoundProviderSessionIdByMeshSession.set(key, value);
|
|
99
107
|
// Always attempt the disk mirror — recordPersistedProviderSessionPin is itself a
|
|
100
108
|
// no-op when the ON-DISK value already matches, so it does not rewrite state.json
|
|
@@ -1084,6 +1092,28 @@ function hasSafeNativeHistoryMapping(args: {
|
|
|
1084
1092
|
// other. historySessionId (the provider-native session key) is required to
|
|
1085
1093
|
// establish ownership. hasSafeNativeHistoryMapping() enforces the same
|
|
1086
1094
|
// invariant after the read; both guards must hold for native history to be used.
|
|
1095
|
+
/**
|
|
1096
|
+
* The session id a native-history read should be scoped to: the explicit
|
|
1097
|
+
* targetSessionId when the caller named one (reading a specific/worker
|
|
1098
|
+
* session), otherwise the current live session (a self / dashboard read where
|
|
1099
|
+
* the current session IS the one being read). getTargetedCliAdapter already
|
|
1100
|
+
* uses this same fallback to resolve the adapter; the native-history floor and
|
|
1101
|
+
* claim-owner token must use it too. Without the fallback, a self-read arrives
|
|
1102
|
+
* with no targetSessionId → the floor collapses to undefined (→0) and the
|
|
1103
|
+
* antigravity claim-owner token collapses to '' → pickUnboundConversationDb
|
|
1104
|
+
* drops out of its spawn-floor branch into newest-by-mtime and binds whichever
|
|
1105
|
+
* conversation .db was written most recently. For an antigravity MAGI
|
|
1106
|
+
* coordinator that is exactly a co-located replica's .db (the replica finished
|
|
1107
|
+
* its turn last), so the coordinator's read cross-wires onto the replica's
|
|
1108
|
+
* conversation instead of its own (ANTIGRAVITY coordinator↔replica crosswire).
|
|
1109
|
+
*/
|
|
1110
|
+
function effectiveReadSessionId(h: CommandHelpers, targetSessionId: string | undefined): string {
|
|
1111
|
+
const explicit = typeof targetSessionId === 'string' ? targetSessionId.trim() : '';
|
|
1112
|
+
if (explicit) return explicit;
|
|
1113
|
+
const current = (h.currentSession as any)?.sessionId;
|
|
1114
|
+
return typeof current === 'string' ? current.trim() : '';
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1087
1117
|
/**
|
|
1088
1118
|
* Pull the session's spawnedAtMs out of the registry. Native-history
|
|
1089
1119
|
* file pickers use it as a "files older than this can't be from this
|
|
@@ -1091,10 +1121,12 @@ function hasSafeNativeHistoryMapping(args: {
|
|
|
1091
1121
|
* previous session's transcript whenever its file happened to be the
|
|
1092
1122
|
* newest match. Returns undefined when the session isn't registered
|
|
1093
1123
|
* (e.g. read_chat before the live session was wired up) — the executor
|
|
1094
|
-
* treats undefined as "no floor".
|
|
1124
|
+
* treats undefined as "no floor". Resolves the effective session id
|
|
1125
|
+
* (targetSessionId or the current live session) so a self-read still gets its
|
|
1126
|
+
* real spawn floor rather than 0.
|
|
1095
1127
|
*/
|
|
1096
1128
|
function sessionStartedAtMsFromRegistry(h: CommandHelpers, targetSessionId: string | undefined): number | undefined {
|
|
1097
|
-
const sid =
|
|
1129
|
+
const sid = effectiveReadSessionId(h, targetSessionId);
|
|
1098
1130
|
if (!sid) return undefined;
|
|
1099
1131
|
const target = h.ctx?.sessionRegistry?.get?.(sid);
|
|
1100
1132
|
return typeof target?.spawnedAtMs === 'number' ? target.spawnedAtMs : undefined;
|
|
@@ -1597,7 +1629,7 @@ export async function handleChatHistory(h: CommandHelpers, args: any): Promise<C
|
|
|
1597
1629
|
scripts: provider?.scripts as any,
|
|
1598
1630
|
sessionStartedAtMs: sessionStartedAtMsFromRegistry(h, args?.targetSessionId),
|
|
1599
1631
|
envOverrides: sessionSpawnEnvFromAdapter(h, args?.targetSessionId),
|
|
1600
|
-
instanceId:
|
|
1632
|
+
instanceId: effectiveReadSessionId(h, args?.targetSessionId) || undefined,
|
|
1601
1633
|
pinnedProviderSessionId: getBoundProviderSessionIdPin(args?.targetSessionId),
|
|
1602
1634
|
})
|
|
1603
1635
|
: readProviderChatHistory(agentStr, {
|
|
@@ -1619,7 +1651,7 @@ export async function handleChatHistory(h: CommandHelpers, args: any): Promise<C
|
|
|
1619
1651
|
? (result as any).providerSessionId
|
|
1620
1652
|
: readHistorySessionIdFromMessages(messages) || historySessionId;
|
|
1621
1653
|
if (typeof (result as any)?.providerSessionId === 'string' && (result as any).providerSessionId.trim()) {
|
|
1622
|
-
recordBoundProviderSessionId(args?.targetSessionId, (result as any).providerSessionId.trim());
|
|
1654
|
+
recordBoundProviderSessionId(h, effectiveReadSessionId(h, args?.targetSessionId), (result as any).providerSessionId.trim());
|
|
1623
1655
|
}
|
|
1624
1656
|
const safeMapping = hasSafeNativeHistoryMapping({
|
|
1625
1657
|
historySessionId: lookup === 'workspace' ? undefined : historySessionId,
|
|
@@ -1852,7 +1884,7 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
|
|
|
1852
1884
|
envOverrides: sessionSpawnEnvFromAdapter(h, args?.targetSessionId),
|
|
1853
1885
|
// Stable per-session identity for antigravity's conversation-claim
|
|
1854
1886
|
// owner token (== session registry sessionId == instance instanceId).
|
|
1855
|
-
instanceId:
|
|
1887
|
+
instanceId: effectiveReadSessionId(h, args?.targetSessionId) || undefined,
|
|
1856
1888
|
pinnedProviderSessionId: pinnedProviderSessionIdForRead,
|
|
1857
1889
|
// Last-resort only when no pin was ever recorded for this
|
|
1858
1890
|
// session; the downstream workspace-overlap safety gate
|
|
@@ -1868,7 +1900,7 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
|
|
|
1868
1900
|
? nativeHistory.providerSessionId.trim()
|
|
1869
1901
|
: '';
|
|
1870
1902
|
if (resolvedProviderSessionId) {
|
|
1871
|
-
recordBoundProviderSessionId(targetSessionId, resolvedProviderSessionId);
|
|
1903
|
+
recordBoundProviderSessionId(h, effectiveReadSessionId(h, targetSessionId), resolvedProviderSessionId);
|
|
1872
1904
|
}
|
|
1873
1905
|
} catch (error: any) {
|
|
1874
1906
|
nativeHistoryError = error;
|
|
@@ -1928,7 +1960,7 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
|
|
|
1928
1960
|
excludeInProgressTurn: returnedStatus === 'waiting_approval',
|
|
1929
1961
|
sessionStartedAtMs,
|
|
1930
1962
|
envOverrides: sessionSpawnEnvFromAdapter(h, args?.targetSessionId),
|
|
1931
|
-
instanceId:
|
|
1963
|
+
instanceId: effectiveReadSessionId(h, args?.targetSessionId) || undefined,
|
|
1932
1964
|
});
|
|
1933
1965
|
nativeHistoryError = undefined;
|
|
1934
1966
|
nativeMessages = nativeHistory && Array.isArray(nativeHistory.messages)
|
|
@@ -2227,7 +2259,7 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
|
|
|
2227
2259
|
scripts: provider?.scripts as any,
|
|
2228
2260
|
sessionStartedAtMs: sessionStartedAtMsFromRegistry(h, args?.targetSessionId),
|
|
2229
2261
|
envOverrides: sessionSpawnEnvFromAdapter(h, args?.targetSessionId),
|
|
2230
|
-
instanceId:
|
|
2262
|
+
instanceId: effectiveReadSessionId(h, args?.targetSessionId) || undefined,
|
|
2231
2263
|
pinnedProviderSessionId: pinnedProviderSessionIdForHistory,
|
|
2232
2264
|
// Last-resort only when no pin was ever recorded AND the
|
|
2233
2265
|
// runtime fallback did not resolve a real provider session.
|
|
@@ -2252,7 +2284,7 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
|
|
|
2252
2284
|
: readHistorySessionIdFromMessages(historyMessages) || effectiveHistorySessionIdForRead;
|
|
2253
2285
|
// Refresh the pin whenever this path resolves a real provider id.
|
|
2254
2286
|
if (typeof (history as any)?.providerSessionId === 'string' && (history as any).providerSessionId.trim()) {
|
|
2255
|
-
recordBoundProviderSessionId(targetSid, (history as any).providerSessionId.trim());
|
|
2287
|
+
recordBoundProviderSessionId(h, effectiveReadSessionId(h, targetSid), (history as any).providerSessionId.trim());
|
|
2256
2288
|
}
|
|
2257
2289
|
// Use the id we actually read with (pin / real provider id), NOT the
|
|
2258
2290
|
// raw runtime-fallback historySessionId — otherwise the mapping guard
|
|
@@ -688,6 +688,31 @@ function evaluateMeshEventSuppression(
|
|
|
688
688
|
return null;
|
|
689
689
|
}
|
|
690
690
|
|
|
691
|
+
/**
|
|
692
|
+
* True when the worker session that emitted this event has auto-approve enabled
|
|
693
|
+
* (a MAGI/delegated worker is launched with autoApprove:true). Such a worker
|
|
694
|
+
* resolves its own approval modals locally, so an agent:waiting_approval event
|
|
695
|
+
* from it is transient noise for the coordinator: forwarding it injects a
|
|
696
|
+
* "[System] … is waiting for approval, use mesh_approve" turn that the
|
|
697
|
+
* coordinator cannot usefully act on (the modal is already auto-resolving) and,
|
|
698
|
+
* mid-MAGI-collect, HIJACKS the coordinator's synthesis turn — the observed
|
|
699
|
+
* failure where a replica's repeated auto-approvals drowned out the completion
|
|
700
|
+
* events and the coordinator answered about approvals instead of the RCA. Only
|
|
701
|
+
* suppress when we can positively confirm the source worker auto-approves; a
|
|
702
|
+
* worker that genuinely needs a human/coordinator approval (autoApprove off)
|
|
703
|
+
* still forwards so the coordinator is told.
|
|
704
|
+
*/
|
|
705
|
+
function sourceWorkerAutoApproves(components: DaemonComponents, sessionId: string): boolean {
|
|
706
|
+
if (!sessionId) return false;
|
|
707
|
+
try {
|
|
708
|
+
const state = components.instanceManager?.getInstance?.(sessionId)?.getState?.();
|
|
709
|
+
const settings = (state?.settings as Record<string, unknown>) || {};
|
|
710
|
+
return settings.autoApprove === true;
|
|
711
|
+
} catch {
|
|
712
|
+
return false;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
691
716
|
function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
692
717
|
meshId: string;
|
|
693
718
|
sourceInstanceId?: string;
|
|
@@ -805,6 +830,19 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
805
830
|
}
|
|
806
831
|
|
|
807
832
|
const eventTimestamp = readEventTimestamp(args.metadataEvent.timestamp);
|
|
833
|
+
|
|
834
|
+
// Auto-approving worker: never forward its approval prompts to the coordinator.
|
|
835
|
+
// The daemon resolves the modal locally, so the "[System] … waiting for
|
|
836
|
+
// approval" injection is pure noise that hijacks the coordinator's turn — the
|
|
837
|
+
// observed MAGI failure where a replica's repeated auto-approvals flooded the
|
|
838
|
+
// coordinator and derailed its final synthesis. A worker without auto-approve
|
|
839
|
+
// (genuinely blocked on a human/coordinator decision) still forwards.
|
|
840
|
+
if (args.event === 'agent:waiting_approval' && sourceWorkerAutoApproves(components, eventSessionId)) {
|
|
841
|
+
LOG.info('MeshEvents', `Suppressed agent:waiting_approval for auto-approving worker session ${eventSessionId || '(unknown)'} (mesh ${args.meshId}) — modal is resolved locally, coordinator not notified`);
|
|
842
|
+
traceMeshEventDrop('waiting_approval_auto_approving_worker', traceCtx);
|
|
843
|
+
return { success: true, forwarded: 0, suppressed: true, autoApprovingWorkerApproval: true };
|
|
844
|
+
}
|
|
845
|
+
|
|
808
846
|
// Coordinator-side dedup/suppression gate (extracted, behavior-preserving). A non-null
|
|
809
847
|
// outcome either short-circuits with a forwarded result or signals a no-progress→completion
|
|
810
848
|
// reconciliation that we re-inject; null lets the event fall through to the ledger machinery.
|
|
@@ -20,6 +20,7 @@ import { createCliAdapter } from './spec/route.js';
|
|
|
20
20
|
import type { PtyRuntimeMetadata, PtyTransportFactory } from '../cli-adapters/pty-transport.js';
|
|
21
21
|
import { StatusMonitor } from './status-monitor.js';
|
|
22
22
|
import { ChatHistoryWriter, isNativeSourceCanonicalHistory, materializeProviderNativeHistory, readChatHistory, readProviderChatHistory } from '../config/chat-history.js';
|
|
23
|
+
import { loadPersistedProviderSessionPins } from '../config/state-store.js';
|
|
23
24
|
import { LOG } from '../logging/logger.js';
|
|
24
25
|
import { recordDebugTrace } from '../logging/debug-trace.js';
|
|
25
26
|
import { shouldCollectTraceCategory } from '../logging/debug-config.js';
|
|
@@ -572,7 +573,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
572
573
|
const canonicalBackedHistory = this.shouldHydrateExistingProviderHistory()
|
|
573
574
|
? this.syncCanonicalSavedHistoryIfNeeded()
|
|
574
575
|
: false;
|
|
575
|
-
const statusMessages = canonicalBackedHistory && this.lastPersistedHistoryMessages.length > 0
|
|
576
|
+
const statusMessages: any[] = canonicalBackedHistory && this.lastPersistedHistoryMessages.length > 0
|
|
576
577
|
? this.lastPersistedHistoryMessages.map((message) => ({
|
|
577
578
|
role: message.role,
|
|
578
579
|
content: message.content,
|
|
@@ -582,6 +583,42 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
582
583
|
}))
|
|
583
584
|
: mergedMessages;
|
|
584
585
|
|
|
586
|
+
// Dashboard-tail repair (native-source providers, e.g. antigravity): the
|
|
587
|
+
// assistant answer lives only in native-history, so the PTY-parsed
|
|
588
|
+
// statusMessages end on the user prompt / auto-approve system lines and the
|
|
589
|
+
// snapshot's preview / lastMessageRole / completionMarker never see the
|
|
590
|
+
// answer — the session looks stuck on the user turn. We already cached the
|
|
591
|
+
// real final assistant summary at completion time (lastCompletionSummary),
|
|
592
|
+
// so append it as the trailing assistant bubble when the current tail has no
|
|
593
|
+
// assistant message at/after it. Purely additive to the status view; no
|
|
594
|
+
// per-tick native read, no effect on providers whose PTY carries the
|
|
595
|
+
// assistant (they surface it themselves and the guard below is a no-op).
|
|
596
|
+
const adapterOwnsMessagesElsewhereForTail = (this.adapter as any)?.chatMessagesOwnedExternally === true;
|
|
597
|
+
if (adapterOwnsMessagesElsewhereForTail && this.lastCompletionSummary) {
|
|
598
|
+
const summary = this.lastCompletionSummary;
|
|
599
|
+
let hasTrailingAssistant = false;
|
|
600
|
+
for (let i = statusMessages.length - 1; i >= 0; i -= 1) {
|
|
601
|
+
const m = statusMessages[i] as { role?: string; kind?: string; receivedAt?: number };
|
|
602
|
+
const role = typeof m?.role === 'string' ? m.role : '';
|
|
603
|
+
if (role === 'system') continue;
|
|
604
|
+
if (typeof m?.kind === 'string' && m.kind === 'tool') continue;
|
|
605
|
+
// First non-system/non-tool message from the tail: if it's already an
|
|
606
|
+
// assistant reply not older than our cached summary, the tail is fine.
|
|
607
|
+
hasTrailingAssistant = role === 'assistant'
|
|
608
|
+
&& typeof m?.receivedAt === 'number'
|
|
609
|
+
&& m.receivedAt >= summary.receivedAt - 1000;
|
|
610
|
+
break;
|
|
611
|
+
}
|
|
612
|
+
if (!hasTrailingAssistant) {
|
|
613
|
+
statusMessages.push({
|
|
614
|
+
role: 'assistant',
|
|
615
|
+
content: summary.content,
|
|
616
|
+
kind: 'standard',
|
|
617
|
+
receivedAt: summary.receivedAt,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
585
622
|
const dirName = workingDirBasename(this.workingDir);
|
|
586
623
|
const parsedChatStatus = typeof parsedStatus?.status === 'string' && parsedStatus.status.trim()
|
|
587
624
|
? parsedStatus.status.trim()
|
|
@@ -1132,6 +1169,18 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1132
1169
|
private completedDebounceTimer: NodeJS.Timeout | null = null;
|
|
1133
1170
|
private completedDebouncePending: CompletedDebouncePending | null = null;
|
|
1134
1171
|
private lastExternalCompletionProbe: ExternalTranscriptProbe | null = null;
|
|
1172
|
+
/**
|
|
1173
|
+
* The final assistant summary of the last completed turn, cached at
|
|
1174
|
+
* completion-emit time. For a native-source provider (antigravity) whose
|
|
1175
|
+
* assistant answer lives only in native-history — never in the PTY parse that
|
|
1176
|
+
* feeds activeChat.messages — the dashboard's preview / lastMessageRole /
|
|
1177
|
+
* completionMarker would otherwise never see the answer and show the session
|
|
1178
|
+
* stuck on the user prompt. getState() appends this cached assistant bubble to
|
|
1179
|
+
* the status messages when the PTY tail has none, so those fields reflect the
|
|
1180
|
+
* real last answer with ZERO per-tick native reads (the native read already ran
|
|
1181
|
+
* once at completion). Reset on the next turn's start.
|
|
1182
|
+
*/
|
|
1183
|
+
private lastCompletionSummary: { content: string; receivedAt: number } | null = null;
|
|
1135
1184
|
|
|
1136
1185
|
private async enforceFreshSessionLaunchIfNeeded(): Promise<void> {
|
|
1137
1186
|
const scriptName = getForcedNewSessionScriptName(this.provider, this.launchMode);
|
|
@@ -1219,21 +1268,59 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1219
1268
|
private readExternalCompletionMessages(): unknown[] | null {
|
|
1220
1269
|
const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
|
|
1221
1270
|
if (!adapterOwnsMessagesElsewhere) return null;
|
|
1222
|
-
if (!this.providerSessionId) return null;
|
|
1223
1271
|
if (!isNativeSourceCanonicalHistory(this.provider.nativeHistory)) return null;
|
|
1224
1272
|
|
|
1273
|
+
// Resolve a CONCRETE native-history handle for this session's OWN
|
|
1274
|
+
// conversation. A provider that exposes its session id on the CLI
|
|
1275
|
+
// (codex/claude/hermes) sets this.providerSessionId. antigravity takes no
|
|
1276
|
+
// --session-id, so this.providerSessionId stays empty — recover the real
|
|
1277
|
+
// on-disk conversation id from the read pin persisted across restart
|
|
1278
|
+
// (state.json sessionProviderSessionPins, keyed by this session's
|
|
1279
|
+
// instanceId — the same map a binding read_chat / mesh_read_chat records
|
|
1280
|
+
// the resolved uuid into). The OLD `if (!this.providerSessionId) return
|
|
1281
|
+
// null` guard blocked antigravity's completion transcript entirely, so its
|
|
1282
|
+
// final-assistant evidence was permanently 'unavailable' and the turn
|
|
1283
|
+
// completion never emitted → the mesh reconcile loop reclaimed the
|
|
1284
|
+
// "delivered but no completion" task and re-dispatched the same MAGI prompt
|
|
1285
|
+
// (ANTIGRAVITY-FINAL-MESSAGE-TAIL-GAP, completion side).
|
|
1286
|
+
//
|
|
1287
|
+
// Prefer a concrete handle (providerSessionId, else the persisted pin). When
|
|
1288
|
+
// neither exists yet — a fresh antigravity turn whose conversation has not
|
|
1289
|
+
// been bound by any read_chat — fall through with an EMPTY handle and let the
|
|
1290
|
+
// dispatcher resolve this session's own conversations/<uuid>.db by the
|
|
1291
|
+
// spawn-floor + workspace + instanceId claim. This is safe now that the
|
|
1292
|
+
// claim owner token is single-form iid:<instanceId> (never collapses to '')
|
|
1293
|
+
// and pickUnboundConversationDb picks the oldest store born at/after THIS
|
|
1294
|
+
// session's floor: the probe resolves the session's OWN db under its own
|
|
1295
|
+
// owner token, so it can never steal a sibling's conversation (the earlier
|
|
1296
|
+
// theft required a floor=0 / owner='' collapse that no longer happens). It
|
|
1297
|
+
// means the completion summary is available on the FIRST completion — before
|
|
1298
|
+
// any read_chat has recorded a pin — which the dashboard tail-repair needs.
|
|
1299
|
+
let resolvedHandle = this.providerSessionId || '';
|
|
1300
|
+
if (!resolvedHandle) {
|
|
1301
|
+
try {
|
|
1302
|
+
const pinned = loadPersistedProviderSessionPins()[this.instanceId];
|
|
1303
|
+
if (typeof pinned === 'string' && pinned.trim()) resolvedHandle = pinned.trim();
|
|
1304
|
+
} catch { /* best-effort pin hydration */ }
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1225
1307
|
if (this.lastExternalCompletionProbe?.sourcePath) {
|
|
1226
1308
|
try { fs.statSync(this.lastExternalCompletionProbe.sourcePath); } catch { /* best-effort metadata refresh */ }
|
|
1227
1309
|
}
|
|
1228
1310
|
const restoredHistory = readProviderChatHistory(this.type, {
|
|
1229
1311
|
canonicalHistory: this.provider.nativeHistory,
|
|
1230
|
-
historySessionId:
|
|
1312
|
+
historySessionId: resolvedHandle || undefined,
|
|
1231
1313
|
workspace: this.workingDir,
|
|
1232
1314
|
offset: 0,
|
|
1233
1315
|
limit: Number.MAX_SAFE_INTEGER,
|
|
1234
1316
|
historyBehavior: this.provider.historyBehavior,
|
|
1235
1317
|
scripts: this.provider.scripts as any,
|
|
1236
1318
|
sessionStartedAtMs: this.startedAt,
|
|
1319
|
+
// The claim owner token must match read_chat's so the exact-bind on our
|
|
1320
|
+
// own conversation stays idempotent rather than looking foreign, and so
|
|
1321
|
+
// the floor-based resolution above claims THIS session's db under its
|
|
1322
|
+
// own owner (never a sibling's).
|
|
1323
|
+
instanceId: this.instanceId,
|
|
1237
1324
|
envOverrides: this.spawnedEnvOverrides(),
|
|
1238
1325
|
forceRefresh: true,
|
|
1239
1326
|
});
|
|
@@ -1249,6 +1336,27 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1249
1336
|
return restoredHistory.messages;
|
|
1250
1337
|
}
|
|
1251
1338
|
|
|
1339
|
+
/**
|
|
1340
|
+
* The content of the LAST visible assistant bubble in a message list, or ''
|
|
1341
|
+
* when the tail is not an assistant reply. Skips trailing system/tool/activity
|
|
1342
|
+
* bubbles; stops (returns '') at the first user/human message. Used only for
|
|
1343
|
+
* the dashboard tail-repair cache — a display value, not a completion decision.
|
|
1344
|
+
*/
|
|
1345
|
+
private lastVisibleAssistantSummary(messages: unknown): string {
|
|
1346
|
+
if (!Array.isArray(messages)) return '';
|
|
1347
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
1348
|
+
const m = messages[i] as { role?: string; kind?: string; content?: unknown };
|
|
1349
|
+
const role = typeof m?.role === 'string' ? m.role : '';
|
|
1350
|
+
const kind = typeof m?.kind === 'string' ? m.kind : '';
|
|
1351
|
+
if (role === 'system') continue;
|
|
1352
|
+
if (kind === 'tool' || kind === 'activity') continue;
|
|
1353
|
+
if (role === 'user' || role === 'human') return '';
|
|
1354
|
+
if (role === 'assistant') return flattenContent(m.content as any).trim();
|
|
1355
|
+
return '';
|
|
1356
|
+
}
|
|
1357
|
+
return '';
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1252
1360
|
private completionFinalAssistantEvidence(parsedMessages: unknown, turnStartedAt?: number): CompletionFinalAssistantEvidence {
|
|
1253
1361
|
// (FALSEIDLE FixB) UPPER-BOUND turn-end evidence. completionHasFinalAssistantMessage is a
|
|
1254
1362
|
// pure message-content check ("does the last visible bubble read as a finalized assistant
|
|
@@ -1274,8 +1382,22 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1274
1382
|
|
|
1275
1383
|
const externalMessages = this.readExternalCompletionMessages();
|
|
1276
1384
|
if (externalMessages) {
|
|
1385
|
+
const present = turnClosed && this.completionHasFinalAssistantMessage(externalMessages, turnStartedAt);
|
|
1386
|
+
// Dashboard tail-repair cache: this runs on EVERY completion check
|
|
1387
|
+
// (mesh AND non-mesh — the non-mesh path suppresses the
|
|
1388
|
+
// generating_completed emit, so completionFinalSummary never runs there
|
|
1389
|
+
// and cannot cache). Cache whenever the external transcript's LAST
|
|
1390
|
+
// visible bubble is an assistant reply — this is a display value only, so
|
|
1391
|
+
// it is intentionally looser than the strict `present` completion gate
|
|
1392
|
+
// (which also requires turnClosed and turn-scoping): the dashboard should
|
|
1393
|
+
// show the answer as soon as native-history has it, even if the FSM has
|
|
1394
|
+
// not yet ratified the turn end. getState() replaces it on the next turn.
|
|
1395
|
+
const lastVisibleAssistant = this.lastVisibleAssistantSummary(externalMessages);
|
|
1396
|
+
if (lastVisibleAssistant) {
|
|
1397
|
+
this.lastCompletionSummary = { content: lastVisibleAssistant, receivedAt: Date.now() };
|
|
1398
|
+
}
|
|
1277
1399
|
return {
|
|
1278
|
-
present
|
|
1400
|
+
present,
|
|
1279
1401
|
messages: externalMessages,
|
|
1280
1402
|
source: 'external-native',
|
|
1281
1403
|
};
|
|
@@ -1328,7 +1450,15 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1328
1450
|
// The transcript is authoritative for native-source providers. Use it unless it is
|
|
1329
1451
|
// empty (not yet written, or no in-turn bubble) — only then fall back to the screen
|
|
1330
1452
|
// parse, which reflects the LIVE screen (this turn's output), not the stale tail.
|
|
1331
|
-
if (externalSummary)
|
|
1453
|
+
if (externalSummary) {
|
|
1454
|
+
// Cache the resolved final assistant for the dashboard tail-repair in
|
|
1455
|
+
// getState(). This runs on EVERY completion attempt (including non-mesh
|
|
1456
|
+
// sessions whose generating_completed is suppressed, and short-gen
|
|
1457
|
+
// settle paths), so the dashboard sees the answer even when no
|
|
1458
|
+
// agent:generating_completed is ever emitted. Native read already ran.
|
|
1459
|
+
this.lastCompletionSummary = { content: externalSummary, receivedAt: Date.now() };
|
|
1460
|
+
return externalSummary;
|
|
1461
|
+
}
|
|
1332
1462
|
return parsedSummary || undefined;
|
|
1333
1463
|
}
|
|
1334
1464
|
return parsedSummary || undefined;
|
|
@@ -1941,6 +2071,14 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1941
2071
|
evidenceLevel?: string;
|
|
1942
2072
|
completionDiagnostic?: Record<string, unknown>;
|
|
1943
2073
|
}): void {
|
|
2074
|
+
// Cache the final assistant summary so the dashboard snapshot can surface it
|
|
2075
|
+
// for native-source providers whose assistant answer is absent from the PTY
|
|
2076
|
+
// parse (antigravity). completionFinalSummary already read native-history to
|
|
2077
|
+
// produce this, so nothing extra is read here.
|
|
2078
|
+
const summary = typeof opts.finalSummary === 'string' ? opts.finalSummary.trim() : '';
|
|
2079
|
+
if (summary) {
|
|
2080
|
+
this.lastCompletionSummary = { content: summary, receivedAt: opts.timestamp };
|
|
2081
|
+
}
|
|
1944
2082
|
this.pushEvent({
|
|
1945
2083
|
event: 'agent:generating_completed',
|
|
1946
2084
|
chatTitle: opts.chatTitle,
|
|
@@ -2465,6 +2603,11 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2465
2603
|
}
|
|
2466
2604
|
|
|
2467
2605
|
if (!this.generatingStartedAt) this.generatingStartedAt = now;
|
|
2606
|
+
// A genuinely new turn is underway — drop the previous turn's cached
|
|
2607
|
+
// final-summary so the dashboard does not keep showing the old answer
|
|
2608
|
+
// as "done" while the new turn generates. Re-populated when this turn
|
|
2609
|
+
// completes.
|
|
2610
|
+
this.lastCompletionSummary = null;
|
|
2468
2611
|
// FALSE-IDLE continuity: entering a busy phase invalidates any
|
|
2469
2612
|
// completedDebouncePending armed earlier in this settle window.
|
|
2470
2613
|
this.busyEpoch++;
|
|
@@ -51,25 +51,32 @@ function normalizeUuid(uuid: string): string {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
|
-
* Derive the per-session owner token
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
54
|
+
* Derive the per-session owner token, keyed ONLY on the stable instanceId
|
|
55
|
+
* (== the session registry sessionId == the read path's targetSessionId). Both
|
|
56
|
+
* the dispatcher (read side) and the provider instance (claim/release side) pass
|
|
57
|
+
* this same id, so their tokens always agree and the claim isolation holds.
|
|
58
|
+
*
|
|
59
|
+
* Returns '' when no instanceId is available — the caller then skips claiming
|
|
60
|
+
* (the exclusion checks still run against existing claims). This is the SSOT
|
|
61
|
+
* rule: there is exactly ONE token form. The removed legacy fallback derived a
|
|
62
|
+
* `spawn:<workspace>:<sessionStartedAtMs>` token from the spawn timestamp when
|
|
63
|
+
* the instanceId was missing; because one session's spawn time is sampled
|
|
64
|
+
* independently at three sites (instance startedAt, adapter spawnedAtMs, registry
|
|
65
|
+
* spawnedAtMs) those never matched, so the SAME session's instance-side and
|
|
66
|
+
* read-side tokens silently diverged and the claim mutual-exclusion collapsed
|
|
67
|
+
* (the antigravity conversation crosswire). An empty token (skip-claim) is
|
|
68
|
+
* strictly safer than a token that disagrees with the same session's other
|
|
69
|
+
* token. workspace/sessionStartedAtMs are kept in the signature for call-site
|
|
70
|
+
* compatibility but no longer affect the token.
|
|
60
71
|
*/
|
|
61
72
|
export function antigravityOwnerToken(
|
|
62
73
|
workspace: string,
|
|
63
74
|
sessionStartedAtMs: number,
|
|
64
75
|
instanceId?: string,
|
|
65
76
|
): string {
|
|
77
|
+
void workspace; void sessionStartedAtMs;
|
|
66
78
|
const iid = typeof instanceId === 'string' ? instanceId.trim() : '';
|
|
67
|
-
|
|
68
|
-
if (typeof sessionStartedAtMs === 'number' && sessionStartedAtMs > 0) {
|
|
69
|
-
const ws = String(workspace || '').trim().toLowerCase();
|
|
70
|
-
return `spawn:${ws}:${sessionStartedAtMs}`;
|
|
71
|
-
}
|
|
72
|
-
return '';
|
|
79
|
+
return iid ? `iid:${iid}` : '';
|
|
73
80
|
}
|
|
74
81
|
|
|
75
82
|
/**
|