@poncho-ai/harness 0.55.0 → 0.58.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/harness",
3
- "version": "0.55.0",
3
+ "version": "0.58.0",
4
4
  "description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
5
5
  "repository": {
6
6
  "type": "git",
package/src/index.ts CHANGED
@@ -21,6 +21,24 @@ export * from "./telemetry.js";
21
21
  export * from "./secrets-store.js";
22
22
  export * from "./storage/index.js";
23
23
  export * from "./storage/store-adapters.js";
24
+ // Append-only conversation entries (Phase 3 substrate): types + rebuild fns.
25
+ // appendEntries/readEntries are reachable on the ConversationStore /
26
+ // StorageEngine.conversations surfaces already exported above.
27
+ export {
28
+ buildLlmContext,
29
+ buildDisplaySnapshot,
30
+ getPendingSubagentResults,
31
+ type ConversationEntry,
32
+ type NewConversationEntry,
33
+ type UserMessageEntry,
34
+ type AssistantMessageEntry,
35
+ type AssistantAmendmentEntry,
36
+ type HarnessMessageEntry,
37
+ type CompactionEntry,
38
+ type SubagentResultEntry,
39
+ type CallbackStartedEntry,
40
+ type DisplaySnapshot,
41
+ } from "./storage/entries.js";
24
42
  export {
25
43
  PonchoFsAdapter,
26
44
  type VirtualMount,
@@ -0,0 +1,265 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Phase 3b — dual-write + parity checker (instrumentation only)
3
+ //
4
+ // At each conversation WRITE site we ALSO append the corresponding
5
+ // append-only `ConversationEntry`s alongside the existing mutable-blob write.
6
+ // READ paths are untouched: nothing consumes these entries yet, so a bug here
7
+ // can only mislog — it cannot corrupt behavior. The blob remains the source of
8
+ // truth until the read-cutover PR (3c).
9
+ //
10
+ // Two public surfaces:
11
+ // - `appendEntriesSafe(...)` — fire-and-forget wrapper that swallows every
12
+ // error (so a dual-write failure never breaks a live turn) and stamps a
13
+ // uuid `id` on each entry (the engine inserts `entry.id` as a column).
14
+ // - `verifyEntriesParity(...)` — gated on `PONCHO_VERIFY_ENTRIES === "1"`,
15
+ // rebuilds LLM context + display snapshot from the entry log and diffs
16
+ // them against the blob's `_harnessMessages` / `messages`. Logs mismatches
17
+ // under `[entries-parity]`. Never throws.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ import { randomUUID } from "node:crypto";
21
+ import { getTextContent, type Message } from "@poncho-ai/sdk";
22
+ import type { Logger } from "@poncho-ai/sdk";
23
+ import type { Conversation, ConversationStore, PendingSubagentResult } from "../state.js";
24
+ import {
25
+ buildDisplaySnapshot,
26
+ buildLlmContext,
27
+ type ConversationEntry,
28
+ type NewConversationEntry,
29
+ } from "../storage/entries.js";
30
+
31
+ /** True when dual-write parity verification is opted in via env. */
32
+ export const entriesParityEnabled = (): boolean =>
33
+ process.env.PONCHO_VERIFY_ENTRIES === "1";
34
+
35
+ // DISTRIBUTIVE omit (same reasoning as NewConversationEntry in entries.ts): a
36
+ // plain Omit<NewConversationEntry, "id"> over a union collapses to the keys
37
+ // common to every member, dropping `message`/`result`/etc. Distribute over the
38
+ // union so each member keeps its own discriminant fields.
39
+ type NewEntryNoId = NewConversationEntry extends infer T
40
+ ? T extends NewConversationEntry
41
+ ? Omit<T, "id">
42
+ : never
43
+ : never;
44
+
45
+ /**
46
+ * Append entries to the conversation's append-only log, mirroring an existing
47
+ * blob write. Best-effort and non-blocking by contract:
48
+ * - stamps a fresh uuid `id` on each entry (required input column),
49
+ * - never throws (logs and returns [] on failure),
50
+ * - is safe to `void` (callers needn't await).
51
+ *
52
+ * Returns the stored entries (with seq/createdAt) for callers that want them
53
+ * (e.g. to learn the assistant entry's id for a later amendment), or [] on
54
+ * empty input / failure.
55
+ */
56
+ export const appendEntriesSafe = async (
57
+ store: ConversationStore,
58
+ conversation: Pick<Conversation, "conversationId" | "ownerId" | "tenantId">,
59
+ entries: NewEntryNoId[],
60
+ log: Logger,
61
+ ): Promise<ConversationEntry[]> => {
62
+ if (entries.length === 0) return [];
63
+ try {
64
+ const withIds = entries.map(
65
+ (e) => ({ id: randomUUID(), ...e }) as NewConversationEntry,
66
+ );
67
+ return await store.appendEntries(
68
+ conversation.conversationId,
69
+ conversation.ownerId,
70
+ conversation.tenantId ?? null,
71
+ withIds,
72
+ );
73
+ } catch (err) {
74
+ log.error(
75
+ `[entries-dual-write] append failed for ${conversation.conversationId}: ${
76
+ err instanceof Error ? err.message : String(err)
77
+ }`,
78
+ );
79
+ return [];
80
+ }
81
+ };
82
+
83
+ // --- entry builders (pure; centralize the best-effort derivation) ----------
84
+
85
+ export const userMessageEntry = (
86
+ message: Message,
87
+ turnId: string,
88
+ opts?: { hidden?: boolean },
89
+ ): NewEntryNoId => ({
90
+ type: "user_message",
91
+ message,
92
+ turnId,
93
+ ...(opts?.hidden ? { hidden: true } : {}),
94
+ });
95
+
96
+ export const assistantMessageEntry = (
97
+ message: Message,
98
+ turnId: string,
99
+ runId: string,
100
+ ): NewEntryNoId => ({
101
+ type: "assistant_message",
102
+ message,
103
+ turnId,
104
+ runId,
105
+ });
106
+
107
+ export const harnessMessageEntries = (
108
+ messages: Message[],
109
+ turnId: string,
110
+ ): NewEntryNoId[] =>
111
+ messages.map((message) => ({ type: "harness_message", message, turnId }));
112
+
113
+ export const compactionEntry = (
114
+ summaryMessage: Message,
115
+ firstKeptSeq: number,
116
+ opts?: { tokensBefore?: number; tokensAfter?: number },
117
+ ): NewEntryNoId => ({
118
+ type: "compaction",
119
+ summaryMessage,
120
+ firstKeptSeq,
121
+ ...(opts?.tokensBefore !== undefined ? { tokensBefore: opts.tokensBefore } : {}),
122
+ ...(opts?.tokensAfter !== undefined ? { tokensAfter: opts.tokensAfter } : {}),
123
+ });
124
+
125
+ export const subagentResultEntry = (
126
+ result: PendingSubagentResult,
127
+ ): NewEntryNoId => ({ type: "subagent_result", result });
128
+
129
+ export const callbackStartedEntry = (consumedSeqs: number[]): NewEntryNoId => ({
130
+ type: "callback_started",
131
+ consumedSeqs,
132
+ });
133
+
134
+ export const assistantAmendmentEntry = (
135
+ targetEntryId: string,
136
+ appendText: string,
137
+ ): NewEntryNoId => ({
138
+ type: "assistant_amendment",
139
+ targetEntryId,
140
+ ...(appendText ? { appendText } : {}),
141
+ });
142
+
143
+ // --- "new harness messages this turn" diff ---------------------------------
144
+
145
+ /**
146
+ * The harness messages added during the just-finished turn — i.e. the suffix
147
+ * of the new `_harnessMessages` array beyond what was there before the turn.
148
+ *
149
+ * BEST-EFFORT: the blob replaces `_harnessMessages` wholesale (it's not an
150
+ * append log), so we recover "what's new" by length-diffing prev vs next.
151
+ * When a compaction collapsed history this turn, `next` can be SHORTER than
152
+ * `prev`; in that case there's no clean suffix and we return the whole `next`
153
+ * so the entry log still ends up with the model-visible context (parity will
154
+ * flag the over-count for review). The compaction entry (appended separately)
155
+ * is what makes rebuild correct in that case.
156
+ */
157
+ export const newHarnessMessagesThisTurn = (
158
+ prev: Message[] | undefined,
159
+ next: Message[] | undefined,
160
+ ): { messages: Message[]; approximate: boolean } => {
161
+ const prevArr = prev ?? [];
162
+ const nextArr = next ?? [];
163
+ if (nextArr.length === 0) return { messages: [], approximate: false };
164
+ if (prevArr.length === 0) return { messages: nextArr, approximate: false };
165
+ if (nextArr.length >= prevArr.length) {
166
+ // Assume the new array is prev + appended suffix (the common case).
167
+ return { messages: nextArr.slice(prevArr.length), approximate: false };
168
+ }
169
+ // next shorter than prev — compaction or a rebuild reshaped the array.
170
+ return { messages: nextArr, approximate: true };
171
+ };
172
+
173
+ // --- parity checker ---------------------------------------------------------
174
+
175
+ /** Normalized text projection for length-insensitive content comparison. */
176
+ const projectText = (m: Message): string => {
177
+ const role = m.role;
178
+ const text = getTextContent(m).replace(/\s+/g, " ").trim();
179
+ return `${role}:${text}`;
180
+ };
181
+
182
+ const projectAll = (msgs: Message[]): string[] => msgs.map(projectText);
183
+
184
+ const countMismatch = (label: string, a: number, b: number): string | null =>
185
+ a === b ? null : `${label} length ${a} (entries) vs ${b} (blob)`;
186
+
187
+ /**
188
+ * Rebuild LLM context + display snapshot from the entry log and diff against
189
+ * the blob. Logs under `[entries-parity]` with the conversationId. Never
190
+ * throws. No-op unless PONCHO_VERIFY_ENTRIES === "1".
191
+ */
192
+ export const verifyEntriesParity = async (
193
+ store: ConversationStore,
194
+ conversationId: string,
195
+ blob: { harnessMessages?: Message[]; displayMessages?: Message[] },
196
+ log: Logger,
197
+ ): Promise<void> => {
198
+ if (!entriesParityEnabled()) return;
199
+ try {
200
+ const entries = await store.readEntries(conversationId);
201
+ const mismatches: string[] = [];
202
+
203
+ if (blob.harnessMessages) {
204
+ const llm = buildLlmContext(entries);
205
+ const lenMismatch = countMismatch(
206
+ "llmContext",
207
+ llm.length,
208
+ blob.harnessMessages.length,
209
+ );
210
+ if (lenMismatch) mismatches.push(lenMismatch);
211
+ // Compare a trailing normalized text projection. We don't require
212
+ // byte-equality — metadata, tool-call framing, and exact whitespace
213
+ // differ by construction between the two representations.
214
+ const entriesProj = projectAll(llm);
215
+ const blobProj = projectAll(blob.harnessMessages);
216
+ const tail = Math.min(entriesProj.length, blobProj.length, 5);
217
+ for (let i = 1; i <= tail; i++) {
218
+ const ep = entriesProj[entriesProj.length - i];
219
+ const bp = blobProj[blobProj.length - i];
220
+ if (ep !== bp) {
221
+ mismatches.push(
222
+ `llmContext tail[-${i}] differs: entries=${JSON.stringify(ep).slice(0, 120)} blob=${JSON.stringify(bp).slice(0, 120)}`,
223
+ );
224
+ }
225
+ }
226
+ }
227
+
228
+ if (blob.displayMessages) {
229
+ // tailN large enough to cover the whole transcript for the diff.
230
+ const snap = buildDisplaySnapshot(entries, Number.MAX_SAFE_INTEGER);
231
+ const lenMismatch = countMismatch(
232
+ "display",
233
+ snap.totalMessages,
234
+ blob.displayMessages.length,
235
+ );
236
+ if (lenMismatch) mismatches.push(lenMismatch);
237
+ const entriesProj = projectAll(snap.messages);
238
+ const blobProj = projectAll(blob.displayMessages);
239
+ const tail = Math.min(entriesProj.length, blobProj.length, 5);
240
+ for (let i = 1; i <= tail; i++) {
241
+ const ep = entriesProj[entriesProj.length - i];
242
+ const bp = blobProj[blobProj.length - i];
243
+ if (ep !== bp) {
244
+ mismatches.push(
245
+ `display tail[-${i}] differs: entries=${JSON.stringify(ep).slice(0, 120)} blob=${JSON.stringify(bp).slice(0, 120)}`,
246
+ );
247
+ }
248
+ }
249
+ }
250
+
251
+ if (mismatches.length > 0) {
252
+ log.warn(
253
+ `[entries-parity] ${conversationId} MISMATCH (${mismatches.length}): ${mismatches.join(" | ")}`,
254
+ );
255
+ } else {
256
+ log.info(`[entries-parity] ${conversationId} OK`);
257
+ }
258
+ } catch (err) {
259
+ log.error(
260
+ `[entries-parity] ${conversationId} checker threw (ignored): ${
261
+ err instanceof Error ? err.message : String(err)
262
+ }`,
263
+ );
264
+ }
265
+ };
@@ -61,3 +61,10 @@ export {
61
61
  type RunConversationTurnOpts,
62
62
  type RunConversationTurnResult,
63
63
  } from "./run-conversation-turn.js";
64
+
65
+ export {
66
+ appendEntriesSafe,
67
+ verifyEntriesParity,
68
+ entriesParityEnabled,
69
+ newHarnessMessagesThisTurn,
70
+ } from "./entries-dual-write.js";
@@ -1,4 +1,4 @@
1
- import { getTextContent, type AgentEvent, type Message } from "@poncho-ai/sdk";
1
+ import { createLogger, getTextContent, type AgentEvent, type Message } from "@poncho-ai/sdk";
2
2
  import type { Conversation, ConversationStore, PendingSubagentResult } from "../state.js";
3
3
  import type { AgentHarness } from "../harness.js";
4
4
  import type { TelemetryEmitter } from "../telemetry.js";
@@ -27,6 +27,17 @@ import {
27
27
  CALLBACK_LOCK_STALE_MS,
28
28
  STALE_SUBAGENT_THRESHOLD_MS,
29
29
  } from "./subagents.js";
30
+ import {
31
+ appendEntriesSafe,
32
+ assistantAmendmentEntry,
33
+ assistantMessageEntry,
34
+ callbackStartedEntry,
35
+ subagentResultEntry,
36
+ userMessageEntry,
37
+ verifyEntriesParity,
38
+ } from "./entries-dual-write.js";
39
+
40
+ const dualWriteLog = createLogger("orchestrator:entries");
30
41
 
31
42
  // ── Subagent result extraction ──
32
43
 
@@ -491,6 +502,11 @@ export class AgentOrchestrator {
491
502
  if (!checkpointedRun) {
492
503
  const conv = await this.conversationStore.get(conversationId);
493
504
  if (conv) {
505
+ // Track which dual-write branch the blob took: an in-place merge onto
506
+ // the previous assistant bubble (→ assistant_amendment) or a fresh
507
+ // bubble (→ assistant_message).
508
+ let amendmentText: string | undefined;
509
+ let pushedAssistant: Message | undefined;
494
510
  const hasAssistantContent =
495
511
  draft.assistantResponse.length > 0 || draft.toolTimeline.length > 0 || draft.sections.length > 0;
496
512
  if (hasAssistantContent) {
@@ -519,15 +535,14 @@ export class AgentOrchestrator {
519
535
  } as Message["metadata"],
520
536
  },
521
537
  ];
538
+ amendmentText = draft.assistantResponse;
522
539
  } else {
523
- conv.messages = [
524
- ...prevMessages,
525
- {
526
- role: "assistant" as const,
527
- content: draft.assistantResponse,
528
- metadata: buildAssistantMetadata(draft),
529
- },
530
- ];
540
+ pushedAssistant = {
541
+ role: "assistant" as const,
542
+ content: draft.assistantResponse,
543
+ metadata: buildAssistantMetadata(draft),
544
+ };
545
+ conv.messages = [...prevMessages, pushedAssistant];
531
546
  }
532
547
  }
533
548
  applyTurnMetadata(conv, {
@@ -537,6 +552,62 @@ export class AgentOrchestrator {
537
552
  harnessMessages: execution?.runHarnessMessages,
538
553
  }, { shouldRebuildCanonical: true });
539
554
  await this.conversationStore.update(conv);
555
+
556
+ // DUAL-WRITE (mirrors the resume merge/push above). In-place merge →
557
+ // assistant_amendment targeting the latest assistant_message entry
558
+ // (BEST-EFFORT: the blob carries no entry id, so we resolve the target
559
+ // by reading the log's last assistant_message). Fresh bubble →
560
+ // assistant_message. Then parity-check. Fire-and-forget.
561
+ if (amendmentText !== undefined || pushedAssistant) {
562
+ const finalConv = conv;
563
+ const amendText = amendmentText;
564
+ const pushed = pushedAssistant;
565
+ void (async () => {
566
+ try {
567
+ if (pushed) {
568
+ await appendEntriesSafe(
569
+ this.conversationStore,
570
+ finalConv,
571
+ [assistantMessageEntry(pushed, `resume-${conversationId}`, latestRunId)],
572
+ dualWriteLog,
573
+ );
574
+ } else if (amendText !== undefined) {
575
+ const existing = await this.conversationStore.readEntries(
576
+ conversationId,
577
+ { types: ["assistant_message"] },
578
+ );
579
+ const target = existing[existing.length - 1];
580
+ if (target) {
581
+ await appendEntriesSafe(
582
+ this.conversationStore,
583
+ finalConv,
584
+ [assistantAmendmentEntry(target.id, amendText)],
585
+ dualWriteLog,
586
+ );
587
+ } else {
588
+ dualWriteLog.warn(
589
+ `[entries-dual-write] resume amendment for ${conversationId}: no assistant_message entry to target; skipped`,
590
+ );
591
+ }
592
+ }
593
+ await verifyEntriesParity(
594
+ this.conversationStore,
595
+ conversationId,
596
+ {
597
+ harnessMessages: finalConv._harnessMessages,
598
+ displayMessages: finalConv.messages,
599
+ },
600
+ dualWriteLog,
601
+ );
602
+ } catch (err) {
603
+ dualWriteLog.error(
604
+ `[entries-dual-write] resume finalize append failed for ${conversationId}: ${
605
+ err instanceof Error ? err.message : String(err)
606
+ }`,
607
+ );
608
+ }
609
+ })();
610
+ }
540
611
  }
541
612
  } else {
542
613
  const conv = await this.conversationStore.get(conversationId);
@@ -1158,6 +1229,9 @@ export class AgentOrchestrator {
1158
1229
  const callbackCount = (conversation.subagentCallbackCount ?? 0) + 1;
1159
1230
  conversation.subagentCallbackCount = callbackCount;
1160
1231
 
1232
+ // Collect the injected callback messages so the dual-write can append them
1233
+ // as hidden user_message entries (mirroring the blob pushes below).
1234
+ const injectedCallbackMessages: Message[] = [];
1161
1235
  for (const pr of pendingResults) {
1162
1236
  // An empty response is recoverable, not a dead end: the subagent's work
1163
1237
  // lives in its transcript even when it produced no closing summary (e.g.
@@ -1172,11 +1246,13 @@ export class AgentOrchestrator {
1172
1246
  : pr.error
1173
1247
  ? `Error: ${pr.error.message}`
1174
1248
  : "(no result)";
1175
- conversation.messages.push({
1249
+ const injected: Message = {
1176
1250
  role: "user",
1177
1251
  content: `[Subagent Result] Subagent "${pr.task}" (${pr.subagentId}) ${pr.status}:\n\n${resultBody}`,
1178
1252
  metadata: { _subagentCallback: true, subagentId: pr.subagentId, task: pr.task, timestamp: pr.timestamp } as Message["metadata"],
1179
- });
1253
+ };
1254
+ injectedCallbackMessages.push(injected);
1255
+ conversation.messages.push(injected);
1180
1256
  }
1181
1257
  const processedIds = new Set(pendingResults.map(pr => pr.subagentId));
1182
1258
  const freshForPending = await this.conversationStore.get(conversationId);
@@ -1187,6 +1263,48 @@ export class AgentOrchestrator {
1187
1263
  conversation.updatedAt = Date.now();
1188
1264
  await this.conversationStore.update(conversation);
1189
1265
 
1266
+ // DUAL-WRITE (mirrors the consume-pending + message-push blob writes
1267
+ // above): append a callback_started entry listing the consumed
1268
+ // subagent_result seqs (resolved by matching subagentId against the entry
1269
+ // log — BEST-EFFORT, since the blob's pending array carries no seq), plus
1270
+ // a hidden user_message entry per injected callback message.
1271
+ if (pendingResults.length > 0) {
1272
+ const turnId = `callback-${callbackCount}-${conversation.conversationId}`;
1273
+ void (async () => {
1274
+ try {
1275
+ const resultEntries = await this.conversationStore.readEntries(
1276
+ conversation.conversationId,
1277
+ { types: ["subagent_result"] },
1278
+ );
1279
+ const consumedIds = new Set(pendingResults.map((pr) => pr.subagentId));
1280
+ const consumedSeqs = resultEntries
1281
+ .filter(
1282
+ (e) =>
1283
+ e.type === "subagent_result" &&
1284
+ consumedIds.has(e.result.subagentId),
1285
+ )
1286
+ .map((e) => e.seq);
1287
+ await appendEntriesSafe(
1288
+ this.conversationStore,
1289
+ conversation,
1290
+ [
1291
+ callbackStartedEntry(consumedSeqs),
1292
+ ...injectedCallbackMessages.map((m) =>
1293
+ userMessageEntry(m, turnId, { hidden: true }),
1294
+ ),
1295
+ ],
1296
+ dualWriteLog,
1297
+ );
1298
+ } catch (err) {
1299
+ dualWriteLog.error(
1300
+ `[entries-dual-write] callback_started append failed for ${conversation.conversationId}: ${
1301
+ err instanceof Error ? err.message : String(err)
1302
+ }`,
1303
+ );
1304
+ }
1305
+ })();
1306
+ }
1307
+
1190
1308
  if (callbackCount > MAX_SUBAGENT_CALLBACK_COUNT) {
1191
1309
  console.warn(`[poncho][subagent-callback] Circuit breaker: ${callbackCount} callbacks for ${conversationId}, skipping re-run`);
1192
1310
  conversation.runningCallbackSince = undefined;
@@ -1256,12 +1374,14 @@ export class AgentOrchestrator {
1256
1374
  if (callbackNeedsContinuation || execution.draft.assistantResponse.length > 0 || execution.draft.toolTimeline.length > 0) {
1257
1375
  const freshConv = await this.conversationStore.get(conversationId);
1258
1376
  if (freshConv) {
1377
+ let callbackAssistantMsg: Message | undefined;
1259
1378
  if (!callbackNeedsContinuation) {
1260
- freshConv.messages.push({
1379
+ callbackAssistantMsg = {
1261
1380
  role: "assistant",
1262
1381
  content: execution.draft.assistantResponse,
1263
1382
  metadata: buildAssistantMetadata(execution.draft),
1264
- });
1383
+ };
1384
+ freshConv.messages.push(callbackAssistantMsg);
1265
1385
  }
1266
1386
  applyTurnMetadata(freshConv, {
1267
1387
  latestRunId: execution.latestRunId,
@@ -1275,6 +1395,30 @@ export class AgentOrchestrator {
1275
1395
  freshConv.runningCallbackSince = undefined;
1276
1396
  await this.conversationStore.update(freshConv);
1277
1397
 
1398
+ // DUAL-WRITE (mirrors the assistant push above): the callback re-run's
1399
+ // final assistant bubble. Only when not continuing (a continuation has
1400
+ // no final bubble yet). Then run the parity check on the rebuilt
1401
+ // transcript. Fire-and-forget; never blocks.
1402
+ if (callbackAssistantMsg) {
1403
+ const finalMsg = callbackAssistantMsg;
1404
+ void appendEntriesSafe(
1405
+ this.conversationStore,
1406
+ freshConv,
1407
+ [assistantMessageEntry(finalMsg, `callback-${conversationId}`, execution.latestRunId)],
1408
+ dualWriteLog,
1409
+ ).then(() =>
1410
+ verifyEntriesParity(
1411
+ this.conversationStore,
1412
+ conversationId,
1413
+ {
1414
+ harnessMessages: freshConv._harnessMessages,
1415
+ displayMessages: freshConv.messages,
1416
+ },
1417
+ dualWriteLog,
1418
+ ),
1419
+ );
1420
+ }
1421
+
1278
1422
  // Proactive messaging notification
1279
1423
  if (freshConv.channelMeta && execution.draft.assistantResponse.length > 0) {
1280
1424
  this.hooks?.onMessagingNotify?.(conversationId, execution.draft.assistantResponse);
@@ -1732,6 +1876,28 @@ export class AgentOrchestrator {
1732
1876
  parentConversationId: string,
1733
1877
  result: PendingSubagentResult,
1734
1878
  ): Promise<boolean> {
1879
+ // DUAL-WRITE (mirrors the appendSubagentResult blob write below): append a
1880
+ // subagent_result entry. Fire-and-forget; needs the parent's owner/tenant,
1881
+ // fetched cheaply. Never blocks or fails the reliable append.
1882
+ void (async () => {
1883
+ try {
1884
+ const parent = await this.conversationStore.get(parentConversationId);
1885
+ if (!parent) return;
1886
+ await appendEntriesSafe(
1887
+ this.conversationStore,
1888
+ parent,
1889
+ [subagentResultEntry(result)],
1890
+ dualWriteLog,
1891
+ );
1892
+ } catch (err) {
1893
+ dualWriteLog.error(
1894
+ `[entries-dual-write] subagent_result append failed for ${parentConversationId}: ${
1895
+ err instanceof Error ? err.message : String(err)
1896
+ }`,
1897
+ );
1898
+ }
1899
+ })();
1900
+
1735
1901
  try {
1736
1902
  await this.conversationStore.appendSubagentResult(parentConversationId, result);
1737
1903
  return true;