@poncho-ai/harness 0.55.0 → 0.57.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/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +38 -0
- package/dist/index.d.ts +210 -2
- package/dist/index.js +569 -19
- package/package.json +1 -1
- package/src/index.ts +18 -0
- package/src/orchestrator/entries-dual-write.ts +265 -0
- package/src/orchestrator/index.ts +7 -0
- package/src/orchestrator/orchestrator.ts +179 -13
- package/src/orchestrator/run-conversation-turn.ts +108 -0
- package/src/state.ts +56 -0
- package/src/storage/engine.ts +18 -0
- package/src/storage/entries.ts +13 -0
- package/src/storage/memory-engine.ts +40 -0
- package/src/storage/schema.ts +30 -0
- package/src/storage/sql-dialect.ts +112 -0
- package/src/storage/store-adapters.ts +8 -0
- package/test/entries-dual-write.test.ts +172 -0
- package/test/entries-store.test.ts +165 -0
package/package.json
CHANGED
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|