@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
|
@@ -36,6 +36,15 @@ import {
|
|
|
36
36
|
executeConversationTurn,
|
|
37
37
|
flushTurnDraft,
|
|
38
38
|
} from "./turn.js";
|
|
39
|
+
import {
|
|
40
|
+
appendEntriesSafe,
|
|
41
|
+
assistantMessageEntry,
|
|
42
|
+
compactionEntry,
|
|
43
|
+
harnessMessageEntries,
|
|
44
|
+
newHarnessMessagesThisTurn,
|
|
45
|
+
userMessageEntry,
|
|
46
|
+
verifyEntriesParity,
|
|
47
|
+
} from "./entries-dual-write.js";
|
|
39
48
|
|
|
40
49
|
const log = createLogger("orchestrator");
|
|
41
50
|
|
|
@@ -185,6 +194,14 @@ export const runConversationTurn = async (
|
|
|
185
194
|
await opts.conversationStore.update(conversation);
|
|
186
195
|
};
|
|
187
196
|
|
|
197
|
+
// Snapshot the harness-message array as it stood BEFORE this turn so the
|
|
198
|
+
// finalize path can diff out the messages this turn appended (dual-write).
|
|
199
|
+
const preTurnHarnessMessages = conversation._harnessMessages
|
|
200
|
+
? [...conversation._harnessMessages]
|
|
201
|
+
: undefined;
|
|
202
|
+
// The stable per-turn id used to group dual-write entries.
|
|
203
|
+
const turnId = assistantId;
|
|
204
|
+
|
|
188
205
|
// Persist the user turn immediately so a crash mid-run still records what
|
|
189
206
|
// the user said. Fire-and-forget — don't block the run.
|
|
190
207
|
conversation.messages = [...historyMessages, userMessage];
|
|
@@ -197,6 +214,15 @@ export const runConversationTurn = async (
|
|
|
197
214
|
);
|
|
198
215
|
});
|
|
199
216
|
|
|
217
|
+
// DUAL-WRITE (additive, mirrors the user-turn blob write above): append a
|
|
218
|
+
// user_message entry. Fire-and-forget — never blocks or breaks the turn.
|
|
219
|
+
void appendEntriesSafe(
|
|
220
|
+
opts.conversationStore,
|
|
221
|
+
conversation,
|
|
222
|
+
[userMessageEntry(userMessage, turnId)],
|
|
223
|
+
log,
|
|
224
|
+
);
|
|
225
|
+
|
|
200
226
|
try {
|
|
201
227
|
const execution = await executeConversationTurn({
|
|
202
228
|
harness: opts.harness,
|
|
@@ -247,6 +273,48 @@ export const runConversationTurn = async (
|
|
|
247
273
|
...existingHistory,
|
|
248
274
|
...preRunMessages.slice(0, removedCount),
|
|
249
275
|
];
|
|
276
|
+
|
|
277
|
+
// DUAL-WRITE (mirrors the compactedHistory blob write above): the
|
|
278
|
+
// compacted array is [summaryMessage, ...keptMessages]. BEST-EFFORT
|
|
279
|
+
// firstKeptSeq: the entry-log seqs of the kept harness messages
|
|
280
|
+
// aren't known here, so we derive a sentinel from the kept-count by
|
|
281
|
+
// reading the current max harness_message seq and pointing at the
|
|
282
|
+
// tail. We read the existing entries to compute it.
|
|
283
|
+
const summaryMessage = event.compactedMessages[0];
|
|
284
|
+
const keptCount = Math.max(0, event.compactedMessages.length - 1);
|
|
285
|
+
if (summaryMessage) {
|
|
286
|
+
void (async () => {
|
|
287
|
+
try {
|
|
288
|
+
const existing = await opts.conversationStore.readEntries(
|
|
289
|
+
opts.conversationId,
|
|
290
|
+
{ types: ["harness_message"] },
|
|
291
|
+
);
|
|
292
|
+
// firstKeptSeq = seq of the (keptCount)-th-from-last existing
|
|
293
|
+
// harness message, so rebuild keeps exactly that many.
|
|
294
|
+
const harnessSeqs = existing.map((e) => e.seq);
|
|
295
|
+
const firstKeptSeq =
|
|
296
|
+
harnessSeqs.length >= keptCount && keptCount > 0
|
|
297
|
+
? harnessSeqs[harnessSeqs.length - keptCount]!
|
|
298
|
+
: (harnessSeqs[harnessSeqs.length - 1] ?? 0) + 1;
|
|
299
|
+
await appendEntriesSafe(
|
|
300
|
+
opts.conversationStore,
|
|
301
|
+
conversation,
|
|
302
|
+
[
|
|
303
|
+
compactionEntry(summaryMessage, firstKeptSeq, {
|
|
304
|
+
tokensBefore: conversation.contextTokens,
|
|
305
|
+
}),
|
|
306
|
+
],
|
|
307
|
+
log,
|
|
308
|
+
);
|
|
309
|
+
} catch (err) {
|
|
310
|
+
log.error(
|
|
311
|
+
`[entries-dual-write] compaction append failed: ${
|
|
312
|
+
err instanceof Error ? err.message : String(err)
|
|
313
|
+
}`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
})();
|
|
317
|
+
}
|
|
250
318
|
}
|
|
251
319
|
}
|
|
252
320
|
if (event.type === "step:completed") {
|
|
@@ -387,6 +455,46 @@ export const runConversationTurn = async (
|
|
|
387
455
|
{ shouldRebuildCanonical },
|
|
388
456
|
);
|
|
389
457
|
await opts.conversationStore.update(conversation);
|
|
458
|
+
|
|
459
|
+
// DUAL-WRITE at finalize (mirrors applyTurnMetadata's _harnessMessages
|
|
460
|
+
// write + the final assistant bubble in conversation.messages):
|
|
461
|
+
// 1. harness_message entries for the messages this turn appended,
|
|
462
|
+
// 2. the final assistant_message entry.
|
|
463
|
+
// Best-effort + fire-and-forget; never blocks the return.
|
|
464
|
+
const finalAssistant =
|
|
465
|
+
conversation.messages[conversation.messages.length - 1];
|
|
466
|
+
const { messages: newHarness, approximate } = newHarnessMessagesThisTurn(
|
|
467
|
+
preTurnHarnessMessages,
|
|
468
|
+
conversation._harnessMessages,
|
|
469
|
+
);
|
|
470
|
+
if (approximate) {
|
|
471
|
+
log.warn(
|
|
472
|
+
`[entries-dual-write] ${opts.conversationId} harness-message diff approximate ` +
|
|
473
|
+
`(blob array shrank this turn — likely compaction); appended full context`,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
const finalizeEntries = [
|
|
477
|
+
...harnessMessageEntries(newHarness, turnId),
|
|
478
|
+
...(finalAssistant && finalAssistant.role === "assistant"
|
|
479
|
+
? [assistantMessageEntry(finalAssistant, turnId, latestRunId)]
|
|
480
|
+
: []),
|
|
481
|
+
];
|
|
482
|
+
void appendEntriesSafe(
|
|
483
|
+
opts.conversationStore,
|
|
484
|
+
conversation,
|
|
485
|
+
finalizeEntries,
|
|
486
|
+
log,
|
|
487
|
+
).then(() =>
|
|
488
|
+
verifyEntriesParity(
|
|
489
|
+
opts.conversationStore,
|
|
490
|
+
opts.conversationId,
|
|
491
|
+
{
|
|
492
|
+
harnessMessages: conversation._harnessMessages,
|
|
493
|
+
displayMessages: conversation.messages,
|
|
494
|
+
},
|
|
495
|
+
log,
|
|
496
|
+
),
|
|
497
|
+
);
|
|
390
498
|
}
|
|
391
499
|
|
|
392
500
|
return {
|
package/src/state.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Message } from "@poncho-ai/sdk";
|
|
2
|
+
import type { ConversationEntry, NewConversationEntry } from "./storage/entries.js";
|
|
2
3
|
|
|
3
4
|
export interface ConversationState {
|
|
4
5
|
runId: string;
|
|
@@ -142,6 +143,23 @@ export interface ConversationStore {
|
|
|
142
143
|
clearCallbackLock(conversationId: string): Promise<Conversation | undefined>;
|
|
143
144
|
/** List thread conversations anchored under `parentConversationId`. */
|
|
144
145
|
listThreads(parentConversationId: string): Promise<ConversationSummary[]>;
|
|
146
|
+
/**
|
|
147
|
+
* Append entries to a conversation's append-only log (Phase 3 substrate).
|
|
148
|
+
* Assigns a per-conversation monotonic `seq` and a `createdAt` to each
|
|
149
|
+
* entry, persists them in order, and returns the stored entries with those
|
|
150
|
+
* fields filled in. Additive — no existing read path consumes these yet.
|
|
151
|
+
*/
|
|
152
|
+
appendEntries(
|
|
153
|
+
conversationId: string,
|
|
154
|
+
agentId: string,
|
|
155
|
+
tenantId: string | null,
|
|
156
|
+
entries: NewConversationEntry[],
|
|
157
|
+
): Promise<ConversationEntry[]>;
|
|
158
|
+
/** Read a conversation's entries ordered by `seq` ascending. */
|
|
159
|
+
readEntries(
|
|
160
|
+
conversationId: string,
|
|
161
|
+
opts?: { types?: string[]; afterSeq?: number; limit?: number },
|
|
162
|
+
): Promise<ConversationEntry[]>;
|
|
145
163
|
}
|
|
146
164
|
|
|
147
165
|
export type StateProviderName =
|
|
@@ -201,6 +219,7 @@ export class InMemoryStateStore implements StateStore {
|
|
|
201
219
|
|
|
202
220
|
export class InMemoryConversationStore implements ConversationStore {
|
|
203
221
|
private readonly conversations = new Map<string, Conversation>();
|
|
222
|
+
private readonly entries = new Map<string, ConversationEntry[]>();
|
|
204
223
|
private readonly ttlMs?: number;
|
|
205
224
|
|
|
206
225
|
constructor(ttlSeconds?: number) {
|
|
@@ -372,6 +391,43 @@ export class InMemoryConversationStore implements ConversationStore {
|
|
|
372
391
|
channelMeta: c.channelMeta,
|
|
373
392
|
}));
|
|
374
393
|
}
|
|
394
|
+
|
|
395
|
+
async appendEntries(
|
|
396
|
+
conversationId: string,
|
|
397
|
+
_agentId: string,
|
|
398
|
+
_tenantId: string | null,
|
|
399
|
+
entries: NewConversationEntry[],
|
|
400
|
+
): Promise<ConversationEntry[]> {
|
|
401
|
+
const list = this.entries.get(conversationId) ?? [];
|
|
402
|
+
// seq is per-conversation: max existing seq + 1, then consecutive.
|
|
403
|
+
let nextSeq = list.reduce((max, e) => (e.seq > max ? e.seq : max), 0) + 1;
|
|
404
|
+
const now = Date.now();
|
|
405
|
+
const stored: ConversationEntry[] = entries.map(
|
|
406
|
+
(e) => ({ ...e, seq: nextSeq++, createdAt: now }) as ConversationEntry,
|
|
407
|
+
);
|
|
408
|
+
this.entries.set(conversationId, [...list, ...stored]);
|
|
409
|
+
return stored;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async readEntries(
|
|
413
|
+
conversationId: string,
|
|
414
|
+
opts?: { types?: string[]; afterSeq?: number; limit?: number },
|
|
415
|
+
): Promise<ConversationEntry[]> {
|
|
416
|
+
let list = (this.entries.get(conversationId) ?? [])
|
|
417
|
+
.slice()
|
|
418
|
+
.sort((a, b) => a.seq - b.seq);
|
|
419
|
+
if (opts?.types && opts.types.length > 0) {
|
|
420
|
+
const allowed = new Set(opts.types);
|
|
421
|
+
list = list.filter((e) => allowed.has(e.type));
|
|
422
|
+
}
|
|
423
|
+
if (typeof opts?.afterSeq === "number") {
|
|
424
|
+
list = list.filter((e) => e.seq > opts.afterSeq!);
|
|
425
|
+
}
|
|
426
|
+
if (typeof opts?.limit === "number") {
|
|
427
|
+
list = list.slice(0, opts.limit);
|
|
428
|
+
}
|
|
429
|
+
return list;
|
|
430
|
+
}
|
|
375
431
|
}
|
|
376
432
|
|
|
377
433
|
export type ConversationSummary = {
|
package/src/storage/engine.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
ConversationSummary,
|
|
6
6
|
PendingSubagentResult,
|
|
7
7
|
} from "../state.js";
|
|
8
|
+
import type { ConversationEntry, NewConversationEntry } from "./entries.js";
|
|
8
9
|
import type { MainMemory } from "../memory.js";
|
|
9
10
|
import type { TodoItem } from "../todo-tools.js";
|
|
10
11
|
import type { Reminder, ReminderCreateInput, ReminderStatus } from "../reminder-store.js";
|
|
@@ -77,6 +78,23 @@ export interface StorageEngine {
|
|
|
77
78
|
clearCallbackLock(conversationId: string): Promise<Conversation | undefined>;
|
|
78
79
|
/** List thread conversations anchored under `parentConversationId`. */
|
|
79
80
|
listThreads(parentConversationId: string): Promise<ConversationSummary[]>;
|
|
81
|
+
/**
|
|
82
|
+
* Append entries to a conversation's append-only log (Phase 3 substrate).
|
|
83
|
+
* Assigns a per-conversation monotonic `seq` and `createdAt` to each entry,
|
|
84
|
+
* persists them in order, and returns the stored entries. Additive — no
|
|
85
|
+
* read path consumes these yet.
|
|
86
|
+
*/
|
|
87
|
+
appendEntries(
|
|
88
|
+
conversationId: string,
|
|
89
|
+
agentId: string,
|
|
90
|
+
tenantId: string | null,
|
|
91
|
+
entries: NewConversationEntry[],
|
|
92
|
+
): Promise<ConversationEntry[]>;
|
|
93
|
+
/** Read a conversation's entries ordered by `seq` ascending. */
|
|
94
|
+
readEntries(
|
|
95
|
+
conversationId: string,
|
|
96
|
+
opts?: { types?: string[]; afterSeq?: number; limit?: number },
|
|
97
|
+
): Promise<ConversationEntry[]>;
|
|
80
98
|
};
|
|
81
99
|
|
|
82
100
|
// --- Memory (replaces MemoryStore) ---
|
package/src/storage/entries.ts
CHANGED
|
@@ -100,6 +100,19 @@ export type ConversationEntry =
|
|
|
100
100
|
| SubagentResultEntry
|
|
101
101
|
| CallbackStartedEntry;
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* An entry to append, before the engine assigns `seq` and `createdAt`. This
|
|
105
|
+
* is a DISTRIBUTIVE omit — `Omit<ConversationEntry, K>` over a union would
|
|
106
|
+
* collapse to only the keys common to every member (dropping `message`,
|
|
107
|
+
* `summaryMessage`, etc.), so we distribute over the union with a
|
|
108
|
+
* conditional type to omit those fields from each member individually.
|
|
109
|
+
*/
|
|
110
|
+
export type NewConversationEntry = ConversationEntry extends infer T
|
|
111
|
+
? T extends ConversationEntry
|
|
112
|
+
? Omit<T, "seq" | "createdAt">
|
|
113
|
+
: never
|
|
114
|
+
: never;
|
|
115
|
+
|
|
103
116
|
/**
|
|
104
117
|
* Rebuild the LLM-visible message context from the entry log.
|
|
105
118
|
*
|
|
@@ -14,6 +14,7 @@ import type { MainMemory } from "../memory.js";
|
|
|
14
14
|
import type { TodoItem } from "../todo-tools.js";
|
|
15
15
|
import type { Reminder, ReminderCreateInput, ReminderStatus } from "../reminder-store.js";
|
|
16
16
|
import type { StorageEngine, VfsDirEntry, VfsStat } from "./engine.js";
|
|
17
|
+
import type { ConversationEntry, NewConversationEntry } from "./entries.js";
|
|
17
18
|
|
|
18
19
|
// ---------------------------------------------------------------------------
|
|
19
20
|
// Internal VFS entry type
|
|
@@ -59,6 +60,8 @@ export class InMemoryEngine implements StorageEngine {
|
|
|
59
60
|
|
|
60
61
|
// Conversation data
|
|
61
62
|
private convs = new Map<string, Conversation>();
|
|
63
|
+
// Append-only conversation entries (Phase 3 substrate)
|
|
64
|
+
private entries = new Map<string, ConversationEntry[]>();
|
|
62
65
|
// Memory data
|
|
63
66
|
private mem = new Map<string, MainMemory>();
|
|
64
67
|
// Todos data
|
|
@@ -239,6 +242,43 @@ export class InMemoryEngine implements StorageEngine {
|
|
|
239
242
|
results.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
240
243
|
return results;
|
|
241
244
|
},
|
|
245
|
+
|
|
246
|
+
appendEntries: async (
|
|
247
|
+
conversationId: string,
|
|
248
|
+
_agentId: string,
|
|
249
|
+
_tenantId: string | null,
|
|
250
|
+
entries: NewConversationEntry[],
|
|
251
|
+
): Promise<ConversationEntry[]> => {
|
|
252
|
+
const list = this.entries.get(conversationId) ?? [];
|
|
253
|
+
// seq is per-conversation: max existing seq + 1, then consecutive.
|
|
254
|
+
let nextSeq = list.reduce((max, e) => (e.seq > max ? e.seq : max), 0) + 1;
|
|
255
|
+
const now = Date.now();
|
|
256
|
+
const stored: ConversationEntry[] = entries.map(
|
|
257
|
+
(e) => ({ ...e, seq: nextSeq++, createdAt: now }) as ConversationEntry,
|
|
258
|
+
);
|
|
259
|
+
this.entries.set(conversationId, [...list, ...stored]);
|
|
260
|
+
return stored;
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
readEntries: async (
|
|
264
|
+
conversationId: string,
|
|
265
|
+
opts?: { types?: string[]; afterSeq?: number; limit?: number },
|
|
266
|
+
): Promise<ConversationEntry[]> => {
|
|
267
|
+
let list = (this.entries.get(conversationId) ?? [])
|
|
268
|
+
.slice()
|
|
269
|
+
.sort((a, b) => a.seq - b.seq);
|
|
270
|
+
if (opts?.types && opts.types.length > 0) {
|
|
271
|
+
const allowed = new Set(opts.types);
|
|
272
|
+
list = list.filter((e) => allowed.has(e.type));
|
|
273
|
+
}
|
|
274
|
+
if (typeof opts?.afterSeq === "number") {
|
|
275
|
+
list = list.filter((e) => e.seq > opts.afterSeq!);
|
|
276
|
+
}
|
|
277
|
+
if (typeof opts?.limit === "number") {
|
|
278
|
+
list = list.slice(0, opts.limit);
|
|
279
|
+
}
|
|
280
|
+
return list;
|
|
281
|
+
},
|
|
242
282
|
};
|
|
243
283
|
|
|
244
284
|
// -----------------------------------------------------------------------
|
package/src/storage/schema.ts
CHANGED
|
@@ -214,4 +214,34 @@ export const migrations: Migration[] = [
|
|
|
214
214
|
];
|
|
215
215
|
},
|
|
216
216
|
},
|
|
217
|
+
{
|
|
218
|
+
version: 8,
|
|
219
|
+
name: "conversation_entries",
|
|
220
|
+
// Append-only conversation log (Phase 3 substrate). Additive: no
|
|
221
|
+
// existing table or behavior changes. `seq` is a per-conversation
|
|
222
|
+
// monotonic order assigned by the application (NOT an autoincrement
|
|
223
|
+
// serial), so the same seq space restarts at 1 for every conversation.
|
|
224
|
+
// The UNIQUE (conversation_id, seq) constraint backstops the
|
|
225
|
+
// app-assigned ordering against concurrent writers.
|
|
226
|
+
up: (d) => {
|
|
227
|
+
const jsonType = d === "sqlite" ? "TEXT" : "JSONB";
|
|
228
|
+
const tsDefault = d === "sqlite" ? "datetime('now')" : "NOW()";
|
|
229
|
+
const autoTs = `DEFAULT (${tsDefault})`;
|
|
230
|
+
return [
|
|
231
|
+
`CREATE TABLE IF NOT EXISTS conversation_entries (
|
|
232
|
+
seq INTEGER NOT NULL,
|
|
233
|
+
id TEXT NOT NULL UNIQUE,
|
|
234
|
+
agent_id TEXT NOT NULL,
|
|
235
|
+
tenant_id TEXT NOT NULL DEFAULT '__default__',
|
|
236
|
+
conversation_id TEXT NOT NULL,
|
|
237
|
+
type TEXT NOT NULL,
|
|
238
|
+
payload ${jsonType} NOT NULL,
|
|
239
|
+
created_at TIMESTAMP ${autoTs},
|
|
240
|
+
UNIQUE (conversation_id, seq)
|
|
241
|
+
)`,
|
|
242
|
+
`CREATE INDEX IF NOT EXISTS idx_conversation_entries_seq
|
|
243
|
+
ON conversation_entries (conversation_id, seq)`,
|
|
244
|
+
];
|
|
245
|
+
},
|
|
246
|
+
},
|
|
217
247
|
];
|
|
@@ -22,6 +22,7 @@ import type { MainMemory } from "../memory.js";
|
|
|
22
22
|
import type { TodoItem } from "../todo-tools.js";
|
|
23
23
|
import type { Reminder, ReminderCreateInput, ReminderStatus } from "../reminder-store.js";
|
|
24
24
|
import type { StorageEngine, VfsDirEntry, VfsStat } from "./engine.js";
|
|
25
|
+
import type { ConversationEntry, NewConversationEntry } from "./entries.js";
|
|
25
26
|
import { type DialectTag, migrations } from "./schema.js";
|
|
26
27
|
|
|
27
28
|
// ---------------------------------------------------------------------------
|
|
@@ -628,6 +629,117 @@ export abstract class SqlStorageEngine implements StorageEngine {
|
|
|
628
629
|
]);
|
|
629
630
|
return rows.map((r) => this.rowToSummary(r));
|
|
630
631
|
},
|
|
632
|
+
|
|
633
|
+
appendEntries: async (
|
|
634
|
+
conversationId: string,
|
|
635
|
+
agentId: string,
|
|
636
|
+
tenantId: string | null,
|
|
637
|
+
entries: NewConversationEntry[],
|
|
638
|
+
): Promise<ConversationEntry[]> => {
|
|
639
|
+
if (entries.length === 0) return [];
|
|
640
|
+
const tid = normalizeTenant(tenantId);
|
|
641
|
+
|
|
642
|
+
// CONCURRENCY: seq is assigned by the app as COALESCE(MAX(seq),0)+1 per
|
|
643
|
+
// conversation, inside a transaction. Two concurrent writers can read
|
|
644
|
+
// the same MAX and collide on the UNIQUE (conversation_id, seq)
|
|
645
|
+
// constraint; we let the loser's transaction fail and retry the whole
|
|
646
|
+
// compute-and-insert up to 3 times. This MAX+1+retry scheme is correct
|
|
647
|
+
// for the modest concurrency here (a single conversation rarely has
|
|
648
|
+
// simultaneous appenders). A later hardening — a per-conversation
|
|
649
|
+
// advisory lock — can replace it before any read path depends on these
|
|
650
|
+
// entries.
|
|
651
|
+
const maxAttempts = 3;
|
|
652
|
+
let lastErr: unknown;
|
|
653
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
654
|
+
const stored: ConversationEntry[] = [];
|
|
655
|
+
try {
|
|
656
|
+
await this.executor.transaction(async () => {
|
|
657
|
+
stored.length = 0; // reset on retry within the same closure
|
|
658
|
+
const row = await this.executor.get<{ max_seq: number | null }>(
|
|
659
|
+
rewrite(
|
|
660
|
+
"SELECT MAX(seq) AS max_seq FROM conversation_entries WHERE conversation_id = $1",
|
|
661
|
+
this.dialect,
|
|
662
|
+
),
|
|
663
|
+
[conversationId],
|
|
664
|
+
);
|
|
665
|
+
let nextSeq = Number(row?.max_seq ?? 0) + 1;
|
|
666
|
+
const now = Date.now();
|
|
667
|
+
for (const entry of entries) {
|
|
668
|
+
const seq = nextSeq++;
|
|
669
|
+
const createdAt = now;
|
|
670
|
+
// Persist the full entry object (minus seq/createdAt, which are
|
|
671
|
+
// columns) as the JSON payload. seq, id, created_at are columns.
|
|
672
|
+
const payload = JSON.stringify(entry);
|
|
673
|
+
await this.executor.run(
|
|
674
|
+
rewrite(
|
|
675
|
+
`INSERT INTO conversation_entries
|
|
676
|
+
(seq, id, agent_id, tenant_id, conversation_id, type, payload, created_at)
|
|
677
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
678
|
+
this.dialect,
|
|
679
|
+
),
|
|
680
|
+
[
|
|
681
|
+
seq,
|
|
682
|
+
entry.id,
|
|
683
|
+
agentId,
|
|
684
|
+
tid,
|
|
685
|
+
conversationId,
|
|
686
|
+
entry.type,
|
|
687
|
+
payload,
|
|
688
|
+
new Date(createdAt).toISOString(),
|
|
689
|
+
],
|
|
690
|
+
);
|
|
691
|
+
stored.push({ ...entry, seq, createdAt } as ConversationEntry);
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
return stored;
|
|
695
|
+
} catch (err) {
|
|
696
|
+
lastErr = err;
|
|
697
|
+
// Retry on any failure (the likely cause is a UNIQUE collision from
|
|
698
|
+
// a concurrent writer); the next attempt re-reads MAX(seq).
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
throw lastErr;
|
|
702
|
+
},
|
|
703
|
+
|
|
704
|
+
readEntries: async (
|
|
705
|
+
conversationId: string,
|
|
706
|
+
opts?: { types?: string[]; afterSeq?: number; limit?: number },
|
|
707
|
+
): Promise<ConversationEntry[]> => {
|
|
708
|
+
const params: unknown[] = [conversationId];
|
|
709
|
+
let sql = "SELECT seq, id, payload, created_at FROM conversation_entries WHERE conversation_id = $1";
|
|
710
|
+
if (opts?.types && opts.types.length > 0) {
|
|
711
|
+
const placeholders = opts.types.map(
|
|
712
|
+
(_t, i) => `$${params.length + 1 + i}`,
|
|
713
|
+
);
|
|
714
|
+
sql += ` AND type IN (${placeholders.join(", ")})`;
|
|
715
|
+
params.push(...opts.types);
|
|
716
|
+
}
|
|
717
|
+
if (typeof opts?.afterSeq === "number") {
|
|
718
|
+
sql += ` AND seq > $${params.length + 1}`;
|
|
719
|
+
params.push(opts.afterSeq);
|
|
720
|
+
}
|
|
721
|
+
sql += " ORDER BY seq ASC";
|
|
722
|
+
if (typeof opts?.limit === "number") {
|
|
723
|
+
sql += ` LIMIT $${params.length + 1}`;
|
|
724
|
+
params.push(opts.limit);
|
|
725
|
+
}
|
|
726
|
+
const rows = await this.executor.all<{
|
|
727
|
+
seq: number;
|
|
728
|
+
id: string;
|
|
729
|
+
payload: unknown;
|
|
730
|
+
created_at: string;
|
|
731
|
+
}>(rewrite(sql, this.dialect), params);
|
|
732
|
+
return rows.map((r) => {
|
|
733
|
+
const parsed =
|
|
734
|
+
typeof r.payload === "string" ? JSON.parse(r.payload) : r.payload;
|
|
735
|
+
return {
|
|
736
|
+
...parsed,
|
|
737
|
+
id: r.id,
|
|
738
|
+
seq: Number(r.seq),
|
|
739
|
+
createdAt: new Date(r.created_at).getTime(),
|
|
740
|
+
} as ConversationEntry;
|
|
741
|
+
});
|
|
742
|
+
},
|
|
631
743
|
};
|
|
632
744
|
|
|
633
745
|
// -----------------------------------------------------------------------
|
|
@@ -58,6 +58,14 @@ export function createConversationStoreFromEngine(
|
|
|
58
58
|
engine.conversations.clearCallbackLock(conversationId),
|
|
59
59
|
listThreads: (parentConversationId: string) =>
|
|
60
60
|
engine.conversations.listThreads(parentConversationId),
|
|
61
|
+
appendEntries: (
|
|
62
|
+
conversationId: string,
|
|
63
|
+
agentId: string,
|
|
64
|
+
tenantId: string | null,
|
|
65
|
+
entries,
|
|
66
|
+
) => engine.conversations.appendEntries(conversationId, agentId, tenantId, entries),
|
|
67
|
+
readEntries: (conversationId: string, opts) =>
|
|
68
|
+
engine.conversations.readEntries(conversationId, opts),
|
|
61
69
|
};
|
|
62
70
|
}
|
|
63
71
|
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import type { Message } from "@poncho-ai/sdk";
|
|
3
|
+
import { createLogger } from "@poncho-ai/sdk";
|
|
4
|
+
import { InMemoryConversationStore } from "../src/state.js";
|
|
5
|
+
import {
|
|
6
|
+
buildDisplaySnapshot,
|
|
7
|
+
buildLlmContext,
|
|
8
|
+
getPendingSubagentResults,
|
|
9
|
+
} from "../src/storage/entries.js";
|
|
10
|
+
import {
|
|
11
|
+
appendEntriesSafe,
|
|
12
|
+
assistantMessageEntry,
|
|
13
|
+
callbackStartedEntry,
|
|
14
|
+
compactionEntry,
|
|
15
|
+
harnessMessageEntries,
|
|
16
|
+
newHarnessMessagesThisTurn,
|
|
17
|
+
subagentResultEntry,
|
|
18
|
+
userMessageEntry,
|
|
19
|
+
} from "../src/orchestrator/entries-dual-write.js";
|
|
20
|
+
|
|
21
|
+
const log = createLogger("test");
|
|
22
|
+
const msg = (role: Message["role"], content: string): Message => ({
|
|
23
|
+
role,
|
|
24
|
+
content,
|
|
25
|
+
metadata: { id: `${role}-${content}` },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const conv = (id: string) => ({
|
|
29
|
+
conversationId: id,
|
|
30
|
+
ownerId: "owner-1",
|
|
31
|
+
tenantId: null as string | null,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("entries dual-write", () => {
|
|
35
|
+
it("rebuilds llm context + display from a simulated chat turn's appends", async () => {
|
|
36
|
+
const store = new InMemoryConversationStore();
|
|
37
|
+
const c = conv("c1");
|
|
38
|
+
const turnId = "turn-1";
|
|
39
|
+
|
|
40
|
+
// Turn start: user message.
|
|
41
|
+
await appendEntriesSafe(store, c, [userMessageEntry(msg("user", "hi"), turnId)], log);
|
|
42
|
+
|
|
43
|
+
// During the turn the harness produced two model-visible messages and a
|
|
44
|
+
// final assistant bubble.
|
|
45
|
+
const harness1 = msg("user", "hi");
|
|
46
|
+
const harness2 = msg("assistant", "hello there");
|
|
47
|
+
const finalAssistant = msg("assistant", "hello there");
|
|
48
|
+
await appendEntriesSafe(
|
|
49
|
+
store,
|
|
50
|
+
c,
|
|
51
|
+
[
|
|
52
|
+
...harnessMessageEntries([harness1, harness2], turnId),
|
|
53
|
+
assistantMessageEntry(finalAssistant, turnId, "run-1"),
|
|
54
|
+
],
|
|
55
|
+
log,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const entries = await store.readEntries("c1");
|
|
59
|
+
|
|
60
|
+
// LLM context == the harness messages in order.
|
|
61
|
+
const llm = buildLlmContext(entries);
|
|
62
|
+
expect(llm.map((m) => m.content)).toEqual(["hi", "hello there"]);
|
|
63
|
+
|
|
64
|
+
// Display == [user, assistant] (final assistant bubble; harness msgs hidden).
|
|
65
|
+
const snap = buildDisplaySnapshot(entries, 100);
|
|
66
|
+
expect(snap.messages.map((m) => [m.role, m.content])).toEqual([
|
|
67
|
+
["user", "hi"],
|
|
68
|
+
["assistant", "hello there"],
|
|
69
|
+
]);
|
|
70
|
+
expect(snap.totalMessages).toBe(2);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("compaction overlay keeps summary + tail at rebuild", async () => {
|
|
74
|
+
const store = new InMemoryConversationStore();
|
|
75
|
+
const c = conv("c2");
|
|
76
|
+
|
|
77
|
+
await appendEntriesSafe(
|
|
78
|
+
store,
|
|
79
|
+
c,
|
|
80
|
+
harnessMessageEntries(
|
|
81
|
+
[msg("user", "m1"), msg("assistant", "m2"), msg("user", "m3")],
|
|
82
|
+
"t",
|
|
83
|
+
),
|
|
84
|
+
log,
|
|
85
|
+
);
|
|
86
|
+
const before = await store.readEntries("c2");
|
|
87
|
+
// Keep only the last harness message (seq 3) after compaction.
|
|
88
|
+
const firstKeptSeq = before[before.length - 1]!.seq;
|
|
89
|
+
await appendEntriesSafe(
|
|
90
|
+
store,
|
|
91
|
+
c,
|
|
92
|
+
[compactionEntry(msg("assistant", "SUMMARY"), firstKeptSeq)],
|
|
93
|
+
log,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const llm = buildLlmContext(await store.readEntries("c2"));
|
|
97
|
+
expect(llm.map((m) => m.content)).toEqual(["SUMMARY", "m3"]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("subagent_result + callback_started track pending consumption", async () => {
|
|
101
|
+
const store = new InMemoryConversationStore();
|
|
102
|
+
const c = conv("c3");
|
|
103
|
+
|
|
104
|
+
const stored = await appendEntriesSafe(
|
|
105
|
+
store,
|
|
106
|
+
c,
|
|
107
|
+
[
|
|
108
|
+
subagentResultEntry({
|
|
109
|
+
subagentId: "sa-1",
|
|
110
|
+
task: "do thing",
|
|
111
|
+
status: "completed",
|
|
112
|
+
timestamp: 1,
|
|
113
|
+
}),
|
|
114
|
+
],
|
|
115
|
+
log,
|
|
116
|
+
);
|
|
117
|
+
const resultSeq = stored[0]!.seq;
|
|
118
|
+
|
|
119
|
+
// Before consumption: pending.
|
|
120
|
+
expect(getPendingSubagentResults(await store.readEntries("c3"))).toHaveLength(1);
|
|
121
|
+
|
|
122
|
+
// The callback consumes it + injects a hidden user message.
|
|
123
|
+
await appendEntriesSafe(
|
|
124
|
+
store,
|
|
125
|
+
c,
|
|
126
|
+
[
|
|
127
|
+
callbackStartedEntry([resultSeq]),
|
|
128
|
+
userMessageEntry(msg("user", "[Subagent Result] ..."), "cb-1", { hidden: true }),
|
|
129
|
+
],
|
|
130
|
+
log,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const after = await store.readEntries("c3");
|
|
134
|
+
expect(getPendingSubagentResults(after)).toHaveLength(0);
|
|
135
|
+
// Hidden injected message does not appear in the display transcript.
|
|
136
|
+
expect(buildDisplaySnapshot(after, 100).messages).toHaveLength(0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("newHarnessMessagesThisTurn diffs the suffix and flags shrinks", () => {
|
|
140
|
+
const a = msg("user", "a");
|
|
141
|
+
const b = msg("assistant", "b");
|
|
142
|
+
const cc = msg("user", "c");
|
|
143
|
+
|
|
144
|
+
expect(newHarnessMessagesThisTurn(undefined, [a, b])).toEqual({
|
|
145
|
+
messages: [a, b],
|
|
146
|
+
approximate: false,
|
|
147
|
+
});
|
|
148
|
+
expect(newHarnessMessagesThisTurn([a], [a, b, cc])).toEqual({
|
|
149
|
+
messages: [b, cc],
|
|
150
|
+
approximate: false,
|
|
151
|
+
});
|
|
152
|
+
// Shrink (compaction reshaped the array) → approximate, returns full next.
|
|
153
|
+
const shrink = newHarnessMessagesThisTurn([a, b, cc], [a]);
|
|
154
|
+
expect(shrink.approximate).toBe(true);
|
|
155
|
+
expect(shrink.messages).toEqual([a]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("appendEntriesSafe swallows store errors and returns []", async () => {
|
|
159
|
+
const brokenStore = {
|
|
160
|
+
appendEntries: async () => {
|
|
161
|
+
throw new Error("boom");
|
|
162
|
+
},
|
|
163
|
+
} as unknown as InMemoryConversationStore;
|
|
164
|
+
const result = await appendEntriesSafe(
|
|
165
|
+
brokenStore,
|
|
166
|
+
conv("c4"),
|
|
167
|
+
[userMessageEntry(msg("user", "x"), "t")],
|
|
168
|
+
log,
|
|
169
|
+
);
|
|
170
|
+
expect(result).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
});
|