@adhdev/daemon-core 0.9.82-rc.432 → 0.9.82-rc.433
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 +329 -289
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +329 -289
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-host-ownership.d.ts +21 -1
- package/package.json +2 -2
- package/src/commands/high-family/mesh-status.ts +5 -1
- package/src/mesh/mesh-host-ownership.ts +41 -3
- package/src/mesh/mesh-reconcile-loop.ts +99 -9
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
import type { RepoMeshDaemonRole, RepoMeshHostMetadata, RepoMeshHostStatus } from '../repo-mesh-types.js';
|
|
2
2
|
export declare function normalizeMeshDaemonRole(value: unknown): RepoMeshDaemonRole | undefined;
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Options for resolveMeshHostStatus's read-side host-pin default.
|
|
5
|
+
*
|
|
6
|
+
* `localDaemonId` is the id of the daemon evaluating the mesh (typically
|
|
7
|
+
* `deps.statusInstanceId`). When the persisted `meshHost` declares `role:'host'`
|
|
8
|
+
* but carries NO `hostDaemonId` (the first-setup miss — a host mesh whose pin was
|
|
9
|
+
* never written to config), the host daemon IS this local daemon by definition, so
|
|
10
|
+
* we synthesize `hostDaemonId = localDaemonId` (and, when the mesh has a node
|
|
11
|
+
* representing this daemon, `hostNodeId`). This is the read-side default — the SSOT
|
|
12
|
+
* is computed from the role + the evaluating daemon's identity rather than requiring
|
|
13
|
+
* a config migration to backfill every already-created mesh.
|
|
14
|
+
*
|
|
15
|
+
* HARD guard: the synthesis fires ONLY for `role:'host'`. A `role:'member'` daemon
|
|
16
|
+
* must NEVER fill itself in as host — that would make a member falsely claim
|
|
17
|
+
* coordinator/queue ownership.
|
|
18
|
+
*/
|
|
19
|
+
export interface ResolveMeshHostOptions {
|
|
20
|
+
/** Id of the daemon evaluating this mesh (e.g. deps.statusInstanceId). */
|
|
21
|
+
localDaemonId?: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function resolveMeshHostStatus(mesh: unknown, opts?: ResolveMeshHostOptions): RepoMeshHostStatus;
|
|
4
24
|
export declare function isMeshHostOwner(mesh: unknown): boolean;
|
|
5
25
|
export declare function buildMeshHostRequiredFailure(mesh: unknown, operation: string): Record<string, unknown>;
|
|
6
26
|
export declare function requireMeshHostQueueOwner(opts?: {
|
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.433",
|
|
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.433",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
|
@@ -65,7 +65,11 @@ export const meshStatusHandlers: Record<string, HighFamilyHandler> = {
|
|
|
65
65
|
const meshRecord = await ctx.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
|
|
66
66
|
const mesh = meshRecord?.mesh;
|
|
67
67
|
if (!mesh) return { success: false, error: 'Mesh not found' };
|
|
68
|
-
|
|
68
|
+
// Pass the evaluating daemon's id so a host mesh whose
|
|
69
|
+
// hostDaemonId was never persisted (HOST-MISSEED-FIRSTSETUP) gets
|
|
70
|
+
// pinned to THIS daemon — the dashboard then renders M4 as host
|
|
71
|
+
// instead of falling back to 'no host yet'.
|
|
72
|
+
const meshHost = resolveMeshHostStatus(mesh, { localDaemonId: ctx.deps.statusInstanceId });
|
|
69
73
|
|
|
70
74
|
const refreshRequested = args?.refresh === true || args?.forceRefresh === true;
|
|
71
75
|
// Compact (default) elides each mission's full goal text from the
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { daemonIdsEquivalent } from '@adhdev/mesh-shared';
|
|
1
2
|
import type { RepoMeshDaemonRole, RepoMeshHostMetadata, RepoMeshHostStatus } from '../repo-mesh-types.js';
|
|
2
3
|
|
|
3
4
|
function readObject(value: unknown): Record<string, unknown> | null {
|
|
@@ -12,7 +13,28 @@ export function normalizeMeshDaemonRole(value: unknown): RepoMeshDaemonRole | un
|
|
|
12
13
|
return value === 'host' || value === 'member' ? value : undefined;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Options for resolveMeshHostStatus's read-side host-pin default.
|
|
18
|
+
*
|
|
19
|
+
* `localDaemonId` is the id of the daemon evaluating the mesh (typically
|
|
20
|
+
* `deps.statusInstanceId`). When the persisted `meshHost` declares `role:'host'`
|
|
21
|
+
* but carries NO `hostDaemonId` (the first-setup miss — a host mesh whose pin was
|
|
22
|
+
* never written to config), the host daemon IS this local daemon by definition, so
|
|
23
|
+
* we synthesize `hostDaemonId = localDaemonId` (and, when the mesh has a node
|
|
24
|
+
* representing this daemon, `hostNodeId`). This is the read-side default — the SSOT
|
|
25
|
+
* is computed from the role + the evaluating daemon's identity rather than requiring
|
|
26
|
+
* a config migration to backfill every already-created mesh.
|
|
27
|
+
*
|
|
28
|
+
* HARD guard: the synthesis fires ONLY for `role:'host'`. A `role:'member'` daemon
|
|
29
|
+
* must NEVER fill itself in as host — that would make a member falsely claim
|
|
30
|
+
* coordinator/queue ownership.
|
|
31
|
+
*/
|
|
32
|
+
export interface ResolveMeshHostOptions {
|
|
33
|
+
/** Id of the daemon evaluating this mesh (e.g. deps.statusInstanceId). */
|
|
34
|
+
localDaemonId?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resolveMeshHostStatus(mesh: unknown, opts?: ResolveMeshHostOptions): RepoMeshHostStatus {
|
|
16
38
|
const meshRecord = readObject(mesh);
|
|
17
39
|
const raw = readObject(meshRecord?.meshHost);
|
|
18
40
|
const role = normalizeMeshDaemonRole(raw?.role) ?? 'host';
|
|
@@ -23,9 +45,25 @@ export function resolveMeshHostStatus(mesh: unknown): RepoMeshHostStatus {
|
|
|
23
45
|
canOwnQueue: role === 'host',
|
|
24
46
|
defaulted: !raw,
|
|
25
47
|
};
|
|
26
|
-
|
|
27
|
-
|
|
48
|
+
let hostDaemonId = readString(raw?.hostDaemonId);
|
|
49
|
+
let hostNodeId = readString(raw?.hostNodeId);
|
|
28
50
|
const hostAddress = readString(raw?.hostAddress);
|
|
51
|
+
// HOST-MISSEED-FIRSTSETUP read-side default: a host mesh with no persisted
|
|
52
|
+
// hostDaemonId is hosted by THIS daemon (role:'host' is local-relative), so
|
|
53
|
+
// fill the pin from the evaluating daemon. Member daemons are never synthesized.
|
|
54
|
+
const localDaemonId = readString(opts?.localDaemonId);
|
|
55
|
+
if (role === 'host' && !hostDaemonId && localDaemonId) {
|
|
56
|
+
hostDaemonId = localDaemonId;
|
|
57
|
+
// Anchor hostNodeId to the node representing this local daemon, when present.
|
|
58
|
+
if (!hostNodeId && Array.isArray(meshRecord?.nodes)) {
|
|
59
|
+
const selfNode = (meshRecord!.nodes as unknown[]).find(n => {
|
|
60
|
+
const nodeDaemonId = readString(readObject(n)?.daemonId);
|
|
61
|
+
return nodeDaemonId ? daemonIdsEquivalent(nodeDaemonId, localDaemonId) : false;
|
|
62
|
+
});
|
|
63
|
+
const selfNodeId = readString(readObject(selfNode)?.id);
|
|
64
|
+
if (selfNodeId) hostNodeId = selfNodeId;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
29
67
|
if (hostDaemonId) normalized.hostDaemonId = hostDaemonId;
|
|
30
68
|
if (hostNodeId) normalized.hostNodeId = hostNodeId;
|
|
31
69
|
if (hostAddress) normalized.hostAddress = hostAddress;
|
|
@@ -183,12 +183,58 @@ function resolveAckedDeathDeadlineMs(): number {
|
|
|
183
183
|
return resolveTunedReconcileMs('MESH_INFLIGHT_ACKED_DEATH_DEADLINE_MS', 8 * 60_000, 0, 60 * 60_000);
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
// ACKED-HOLD-IDLE-OVERTRUST (transcript-completion fast-track). The indefinite acked-hold above is
|
|
187
|
+
// safe but SLOW: when the worker's real generating_completed emit is dropped/lost, the only thing
|
|
188
|
+
// that promotes the missing completion is the 8-min death backstop — even though the answer has been
|
|
189
|
+
// FULLY rendered in the transcript for minutes (read_chat reports idle WITH a final visible assistant
|
|
190
|
+
// message every ~4s). Observed live: completions surfaced 144s / 492s late, both incompatible with the
|
|
191
|
+
// provider's own emit ceiling (COMPLETED_FINALIZATION_MAX_WAIT_MS 30s + NATIVE_HISTORY_MESH_IDLE_SETTLE
|
|
192
|
+
// 4s ≈ 34s). That gap = a worker that finished, whose PTY generating→idle edge / real emit was lost,
|
|
193
|
+
// held hostage to the 8-min net.
|
|
194
|
+
//
|
|
195
|
+
// Fast-track: when an acked task reads idle AND a final visible assistant message is present (the same
|
|
196
|
+
// transcript-completion evidence PHASE 4 already requires to synth), and that idle-with-final-assistant
|
|
197
|
+
// state has PERSISTED for a short continuous grace, promote the synth EARLY — ahead of the 8-min
|
|
198
|
+
// backstop. The grace is the correctness gate: a SINGLE idle read could be a mid-turn blip (PTY
|
|
199
|
+
// inter-tool-call settle, or final text rendered while the next tool call is about to start), so we
|
|
200
|
+
// require the idle-with-final-assistant signal to hold continuously for the grace window before
|
|
201
|
+
// trusting it as a genuine turn-end. Any non-idle read (generating / waiting_approval), a read
|
|
202
|
+
// failure, or the disappearance of the final assistant message RESETS the streak — so an actively
|
|
203
|
+
// streaming worker that momentarily reads idle never crosses the grace.
|
|
204
|
+
//
|
|
205
|
+
// Safety: this only changes WHEN an acked synth fires (earlier), never WHETHER it is correct —
|
|
206
|
+
// reconcileDirectDispatchCompletionFromTranscript's hasTerminalLedgerAfterDispatch makes a real
|
|
207
|
+
// emit that lands later an idempotent no-op, exactly as the death-backstop synth relies on. The
|
|
208
|
+
// death backstop (8 min) is PRESERVED unchanged as the final net; the fast-track is a faster path in
|
|
209
|
+
// front of it. The grace is set ABOVE the provider's own emit ceiling (~34s) so a worker still inside
|
|
210
|
+
// its normal finalization window is never pre-empted — we only fast-track once enough continuous idle
|
|
211
|
+
// has elapsed that a live emit would already have arrived.
|
|
212
|
+
function resolveAckedTranscriptFastTrackGraceMs(): number {
|
|
213
|
+
// Default 40s — above the provider emit ceiling (30s COMPLETED_FINALIZATION_MAX_WAIT_MS + 4s
|
|
214
|
+
// NATIVE_HISTORY_MESH_IDLE_SETTLE ≈ 34s): a genuinely-live worker would have emitted its real
|
|
215
|
+
// terminal within that window, so 40s of CONTINUOUS idle-with-final-assistant means the emit was
|
|
216
|
+
// lost, not late. Far below the 8-min death backstop, so the fast-track is the dominant path for a
|
|
217
|
+
// lost emit while the backstop remains the last-resort net. Floor 0 lets tests force an immediate
|
|
218
|
+
// fast-track; ceiling 5min keeps a mis-set env from collapsing it into the death backstop.
|
|
219
|
+
return resolveTunedReconcileMs('MESH_INFLIGHT_ACKED_TRANSCRIPT_FASTTRACK_GRACE_MS', 40_000, 0, 5 * 60_000);
|
|
220
|
+
}
|
|
221
|
+
|
|
186
222
|
// Per-task in-flight hold state for an acked dispatch:
|
|
187
223
|
// - liveConfirmedSinceAck: we have seen at least one conclusive read (idle OR generating) since
|
|
188
224
|
// the ack — proves the session is reachable, so a later read FAILURE is a genuine liveness loss
|
|
189
225
|
// rather than a node that was never reachable.
|
|
190
226
|
// - consecutiveReadFailures: streak of inconclusive read_chat results (death backstop (a)).
|
|
191
|
-
|
|
227
|
+
// - transcriptIdleSinceMs: the timestamp of the FIRST tick in the current continuous run of
|
|
228
|
+
// idle-with-final-assistant reads (ACKED-HOLD-IDLE-OVERTRUST fast-track). Cleared to undefined
|
|
229
|
+
// whenever the signal breaks (non-idle read, read failure, or no final assistant message), so a
|
|
230
|
+
// mid-turn idle blip never accumulates grace. When `now - transcriptIdleSinceMs` exceeds the
|
|
231
|
+
// fast-track grace the synth is promoted ahead of the death backstop.
|
|
232
|
+
interface AckedHoldState {
|
|
233
|
+
liveConfirmedSinceAck: boolean;
|
|
234
|
+
consecutiveReadFailures: number;
|
|
235
|
+
transcriptIdleSinceMs?: number;
|
|
236
|
+
}
|
|
237
|
+
const inFlightAckedHoldState = new Map<string, AckedHoldState>();
|
|
192
238
|
|
|
193
239
|
function inFlightSynthKey(meshId: string, taskId: string): string {
|
|
194
240
|
return `${meshId}::${taskId}`;
|
|
@@ -1612,6 +1658,8 @@ async function reconcileUnterminatedDirectDispatches(
|
|
|
1612
1658
|
const prior = inFlightAckedHoldState.get(synthKey);
|
|
1613
1659
|
const failures = (prior?.consecutiveReadFailures ?? 0) + 1;
|
|
1614
1660
|
const liveConfirmedSinceAck = prior?.liveConfirmedSinceAck ?? false;
|
|
1661
|
+
// A read failure breaks the idle-with-final-assistant run → reset the fast-track streak
|
|
1662
|
+
// (transcriptIdleSinceMs cleared by omission) so it must re-accumulate from scratch.
|
|
1615
1663
|
inFlightAckedHoldState.set(synthKey, { liveConfirmedSinceAck, consecutiveReadFailures: failures });
|
|
1616
1664
|
if (liveConfirmedSinceAck && failures >= ACKED_DEATH_CONSECUTIVE_READ_FAILURES) {
|
|
1617
1665
|
LOG.warn('MeshReconcile', `Acked-hold death signal: task ${taskId} on node ${nodeId} (mesh ${mesh.id}) read_chat failed ${failures}x consecutively after a live-confirmed ack — worker session presumed gone mid-turn; releasing the indefinite synth hold to the stranded-reclaim / orphan-prune nets`);
|
|
@@ -1622,8 +1670,15 @@ async function reconcileUnterminatedDirectDispatches(
|
|
|
1622
1670
|
|
|
1623
1671
|
// Read succeeded (a conclusive idle/generating status) → the session is reachable: reset the
|
|
1624
1672
|
// failure streak and mark it live-confirmed-since-ack, so a LATER read failure is recognized
|
|
1625
|
-
// as a genuine liveness loss (backstop a) rather than a node that was never reachable.
|
|
1626
|
-
|
|
1673
|
+
// as a genuine liveness loss (backstop a) rather than a node that was never reachable. The
|
|
1674
|
+
// fast-track idle streak (transcriptIdleSinceMs) is PRESERVED across this reset — it is
|
|
1675
|
+
// managed below where the idle + final-assistant signal is actually evaluated.
|
|
1676
|
+
const priorHoldState = inFlightAckedHoldState.get(synthKey);
|
|
1677
|
+
inFlightAckedHoldState.set(synthKey, {
|
|
1678
|
+
liveConfirmedSinceAck: true,
|
|
1679
|
+
consecutiveReadFailures: 0,
|
|
1680
|
+
...(priorHoldState?.transcriptIdleSinceMs !== undefined ? { transcriptIdleSinceMs: priorHoldState.transcriptIdleSinceMs } : {}),
|
|
1681
|
+
});
|
|
1627
1682
|
|
|
1628
1683
|
// Only act on a session that has actually settled to idle. A generating /
|
|
1629
1684
|
// waiting_approval session is mid-turn — synthesizing a completion now would
|
|
@@ -1631,7 +1686,9 @@ async function reconcileUnterminatedDirectDispatches(
|
|
|
1631
1686
|
const nowMs = Date.now();
|
|
1632
1687
|
if (readChatPayloadStatus(payload) !== 'idle') {
|
|
1633
1688
|
// Not idle → the worker is genuinely mid-turn (a clear live signal). Keep the
|
|
1634
|
-
// live-confirmed flag set (above) but
|
|
1689
|
+
// live-confirmed flag set (above) but RESET the fast-track idle streak: a turn that
|
|
1690
|
+
// resumed generating proves the prior idle was a mid-turn blip, not a settled turn-end.
|
|
1691
|
+
inFlightAckedHoldState.set(synthKey, { liveConfirmedSinceAck: true, consecutiveReadFailures: 0 });
|
|
1635
1692
|
continue;
|
|
1636
1693
|
}
|
|
1637
1694
|
|
|
@@ -1652,15 +1709,50 @@ async function reconcileUnterminatedDirectDispatches(
|
|
|
1652
1709
|
// A never-acked dispatch (worker never started) is exempt — no in-flight generation to
|
|
1653
1710
|
// pre-empt; it keeps the first-idle-tick synth, with the downstream grace + stale-summary
|
|
1654
1711
|
// guards as its backstops.
|
|
1712
|
+
//
|
|
1713
|
+
// ACKED-HOLD-IDLE-OVERTRUST: the read is idle. Extract the final-assistant evidence NOW (the
|
|
1714
|
+
// same signal the synth below requires) so the fast-track can gate on idle-WITH-final-assistant
|
|
1715
|
+
// rather than bare idle. Only when a final visible assistant message is present do we treat
|
|
1716
|
+
// this tick as a candidate turn-end and accumulate the fast-track grace streak; a bare idle
|
|
1717
|
+
// with no assistant result is the worker still warming up and resets the streak.
|
|
1718
|
+
const messages = Array.isArray(payload.messages) ? payload.messages as ChatMessage[] : [];
|
|
1719
|
+
const evidence = extractFinalAssistantSummaryEvidence(messages);
|
|
1720
|
+
|
|
1655
1721
|
if (isAcked) {
|
|
1656
1722
|
const ackedAtMs = Date.parse(readNonEmptyString(dispatch.updatedAt));
|
|
1657
1723
|
const sinceAckMs = Number.isFinite(ackedAtMs) ? nowMs - ackedAtMs : Number.POSITIVE_INFINITY;
|
|
1658
1724
|
const deathDeadlineMs = resolveAckedDeathDeadlineMs();
|
|
1659
|
-
|
|
1660
|
-
|
|
1725
|
+
|
|
1726
|
+
// ACKED-HOLD-IDLE-OVERTRUST fast-track. Maintain the continuous idle-with-final-assistant
|
|
1727
|
+
// streak. The streak starts (or continues) only while a final visible assistant message is
|
|
1728
|
+
// present; a tick with idle-but-no-assistant breaks it (the answer is not yet rendered).
|
|
1729
|
+
const holdState = inFlightAckedHoldState.get(synthKey);
|
|
1730
|
+
let fastTrackReady = false;
|
|
1731
|
+
if (evidence.finalSummary) {
|
|
1732
|
+
const idleSinceMs = holdState?.transcriptIdleSinceMs ?? nowMs;
|
|
1733
|
+
if (holdState && holdState.transcriptIdleSinceMs === undefined) {
|
|
1734
|
+
inFlightAckedHoldState.set(synthKey, { ...holdState, transcriptIdleSinceMs: idleSinceMs });
|
|
1735
|
+
}
|
|
1736
|
+
const fastTrackGraceMs = resolveAckedTranscriptFastTrackGraceMs();
|
|
1737
|
+
const idleHeldMs = nowMs - idleSinceMs;
|
|
1738
|
+
if (idleHeldMs >= fastTrackGraceMs) {
|
|
1739
|
+
fastTrackReady = true;
|
|
1740
|
+
LOG.info('MeshReconcile', `Acked-hold transcript fast-track: task ${taskId} on node ${nodeId} (mesh ${mesh.id}) read idle WITH a final assistant message for ${Math.round(idleHeldMs / 1000)}s continuous (grace ${Math.round(fastTrackGraceMs / 1000)}s) — promoting the synth ahead of the ${Math.round(deathDeadlineMs / 1000)}s death backstop; the worker's real emit was lost/late and a later one no-ops idempotently.`);
|
|
1741
|
+
}
|
|
1742
|
+
} else if (holdState?.transcriptIdleSinceMs !== undefined) {
|
|
1743
|
+
// Idle but no final assistant yet → not a turn-end; reset the streak.
|
|
1744
|
+
inFlightAckedHoldState.set(synthKey, { ...holdState, transcriptIdleSinceMs: undefined });
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// Hold indefinitely UNLESS the fast-track grace was met OR the absolute death deadline is
|
|
1748
|
+
// reached. The fast-track is the new fast path in front of the (preserved) 8-min backstop.
|
|
1749
|
+
if (!fastTrackReady && sinceAckMs < deathDeadlineMs) {
|
|
1750
|
+
LOG.info('MeshReconcile', `Acked-hold: task ${taskId} on node ${nodeId} (mesh ${mesh.id}) read idle ${Number.isFinite(sinceAckMs) ? Math.round(sinceAckMs / 1000) + 's' : '∞'} since the generating_started ack — HOLDING synth (worker presumed alive; a later real emit is idempotent). Transcript fast-track promotes at ${Math.round(resolveAckedTranscriptFastTrackGraceMs() / 1000)}s continuous idle-with-final-assistant; death backstop at ${Math.round(deathDeadlineMs / 1000)}s or on consecutive read failures.`);
|
|
1661
1751
|
continue;
|
|
1662
1752
|
}
|
|
1663
|
-
|
|
1753
|
+
if (!fastTrackReady) {
|
|
1754
|
+
LOG.warn('MeshReconcile', `Acked-hold death deadline reached: task ${taskId} on node ${nodeId} (mesh ${mesh.id}) still idle ${Math.round(sinceAckMs / 1000)}s after the ack (deadline ${Math.round(deathDeadlineMs / 1000)}s) — synthesizing the missing completion as a notification-loss net (a real emit, if it ever lands, no-ops idempotently).`);
|
|
1755
|
+
}
|
|
1664
1756
|
}
|
|
1665
1757
|
|
|
1666
1758
|
// R4f (auxiliary, was R4e fix 3) — worker-emit priority. Secondary check: if the worker's
|
|
@@ -1677,8 +1769,6 @@ async function reconcileUnterminatedDirectDispatches(
|
|
|
1677
1769
|
continue;
|
|
1678
1770
|
}
|
|
1679
1771
|
|
|
1680
|
-
const messages = Array.isArray(payload.messages) ? payload.messages as ChatMessage[] : [];
|
|
1681
|
-
const evidence = extractFinalAssistantSummaryEvidence(messages);
|
|
1682
1772
|
if (!evidence.finalSummary) continue; // no assistant result yet — nothing to attribute
|
|
1683
1773
|
|
|
1684
1774
|
// STALE-SUMMARY guard (modal-parked / reused-session misattribution): a direct
|