fix(pet): bound Codewhale session import per event and keep call identities

P01-02 (Refs #6561). fromCodewhaleSession() (reached from importTrace at
ingest.ts:238, before the generic duplicate-identity check) had three
integrity gaps:
- maxEvents was checked once per journal entry, so one entry with any
  number of content blocks bypassed the 250,000-event bound (memory).
- A repeated tool_use id overwrote the pending map and produced two events
  with the same identity; compilePetTelemetry keeps the later one, so the
  earlier call silently disappeared, contrary to importTrace's "duplicate
  identities never disappear silently".
- tool_result set endTime to its own entry time, so a skewed or untimed
  result entry gave endTime < startTime, and the whole pet compile then
  failed with "Invalid event-v1 pet input".

The session importer now pushes through one local guard that enforces
maxEvents per emitted event and rejects a repeated identity with the same
"Duplicate event identity ... Import cancelled." message as the generic
path. A result timestamped before its call is clamped to zero duration
(as the Runtime importer already does with Math.max) and reported in the
trace warnings. The Runtime importer is unchanged.

Validation (pet/):
- node --test tests/pet-import.test.mjs: ℹ tests 1 / pass 1 / fail 0
- npm test: ℹ tests 73 / pass 73 / fail 0; build-pet.mjs --check ok
- Negative control: restored HEAD codewhale.ts only and rebuilt: the
  test fails "AssertionError: Missing expected exception" (tests 1 /
  pass 0 / fail 1); a probe on that build showed 5 events imported under
  maxEvents=3, 2 events with id call-1, and end -6000 < start 0 followed
  by "Invalid event-v1 pet input" from compilePetTelemetry. Fix restored
  byte-identically (cmp), rebuilt, probe shows the limit error, duplicate
  error, start 0 end 0, compile ok; tests 1 / pass 1 / fail 0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-30 01:07:47 -07:00
1 parent 78f26b28fa
commit 5f95643c4d
2 files changed
+43 -6

No files matched your search

+16 -6
View File
@@ -147,6 +147,15 @@ export function fromCodewhaleSession(document: unknown, filename = 'Codewhale se
const events: WhaleEvent[] = [];
const pending = new Map<string, number>();
// One journal entry may hold any number of blocks, so bound every emitted
// event, not every entry. Identities stay unique like the generic importer:
// a repeated tool call id would otherwise replace the earlier call.
const identities = new Set<string>();
const push = (event: WhaleEvent): void => {
if (events.length >= maxEvents) throw new Error(`Import exceeds the ${maxEvents.toLocaleString()} event limit.`);
if (identities.has(event.id)) throw new Error(`Duplicate event identity (${sessionId}, ${event.id}). Import cancelled.`);
identities.add(event.id); pushEvent(events, event);
};
let seq = 0;
const originWall = orderOnly ? undefined : sourceEntries.map(e => parseTime(e.created_at)).find((n): n is number => n !== undefined);
const agentId = 'parent';
@@ -161,7 +170,6 @@ export function fromCodewhaleSession(document: unknown, filename = 'Codewhale se
};
for (const entry of sourceEntries) {
if (events.length >= maxEvents) throw new Error(`Import exceeds the ${maxEvents.toLocaleString()} event limit.`);
const entryId = str(entry.id) ?? `${sessionId}/entry/${seq}`;
const message = obj(entry.message ?? (entry.kind === 'message' ? entry : {}));
const role = str(message.role) ?? (str(entry.kind) === 'user' ? 'user' : str(entry.kind) === 'assistant' ? 'assistant' : undefined);
@@ -190,21 +198,23 @@ export function fromCodewhaleSession(document: unknown, filename = 'Codewhale se
payload: { arguments: clip(block.input) }, raw,
};
pending.set(callId, events.length);
pushEvent(events, event);
push(event);
} else if (type === 'tool_result') {
const callId = str(block.tool_use_id);
const isError = block.is_error === true;
const target = callId !== undefined ? pending.get(callId) : undefined;
if (target !== undefined) {
const prior = events[target]!;
prior.endTime = t.start;
// Skewed or missing entry times never produce a negative duration.
if (t.start < prior.startTime) warnings.push(`Tool ${callId} result is timestamped before its call; its duration is recorded as zero.`);
prior.endTime = Math.max(prior.startTime, t.start);
prior.openEnded = false;
prior.status = statusOf('completed', isError);
prior.payload = { ...(obj(prior.payload)), result: clip(block.content) };
prior.attributes = { ...prior.attributes, 'codewhale.result_entry_id': entryId };
pending.delete(callId as string);
} else {
pushEvent(events, {
push({
schemaVersion: 1, id: idBase, traceId: sessionId, parentId: callId ?? parentEventId,
startTime: t.start, endTime: t.start, agentId,
name: 'tool_result', category: 'tool', model, provider,
@@ -214,7 +224,7 @@ export function fromCodewhaleSession(document: unknown, filename = 'Codewhale se
});
}
} else if (type === 'thinking') {
pushEvent(events, {
push({
schemaVersion: 1, id: idBase, traceId: sessionId, parentId: parentEventId,
startTime: t.start, endTime: t.start, agentId, name: 'thinking', category: 'reasoning',
model, provider, status: 'success',
@@ -225,7 +235,7 @@ export function fromCodewhaleSession(document: unknown, filename = 'Codewhale se
const text = str(block.text) ?? '';
const operate = text.includes('codewhale:runtime_event');
const user = role === 'user' || role === 'User';
pushEvent(events, {
push({
schemaVersion: 1, id: idBase, traceId: sessionId, parentId: parentEventId,
startTime: t.start, endTime: t.start, agentId,
name: operate ? 'operate_contract' : user ? 'user_message' : 'assistant_message',
+27
View File
@@ -0,0 +1,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { importTrace } from '../dist/core/ingest.js';
import { compilePetTelemetry } from '../dist/core/pet-telemetry.js';
const session = entries => JSON.stringify({ metadata: { id: 'session-1' }, journal: { entries } });
const at = seconds => new Date(Date.UTC(2026, 0, 1, 0, 0, seconds)).toISOString();
test('Codewhale session import bounds emitted events, keeps call identities and never ends a call before it starts', () => {
// One entry, many blocks: the limit applies per event, not per entry.
const blocks = Array.from({ length: 5 }, (_, i) => ({ type: 'text', text: `block ${i}` }));
assert.throws(() => importTrace(session([{ id: 'e1', message: { role: 'assistant', content: blocks } }]), 's', { maxEvents: 3 }),
/3 event limit/);
// A repeated tool call id would otherwise silently replace the first call.
const call = id => ({ id, message: { role: 'assistant', content: [{ type: 'tool_use', id: 'call-1', name: 'read_file', input: {} }] } });
assert.throws(() => importTrace(session([call('e1'), call('e2')]), 's'), /Duplicate event identity \(session-1, call-1\)/);
// A result timestamped before its call is clamped, reported, and still compiles.
const [trace] = importTrace(session([
{ id: 'e1', created_at: at(10), message: { role: 'assistant', content: [{ type: 'tool_use', id: 'call-1', name: 'read_file', input: {} }] } },
{ id: 'e2', created_at: at(4), message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call-1', content: 'ok' }] } },
]), 's');
const tool = trace.events.find(e => e.id === 'call-1');
assert.equal(tool.openEnded, false);
assert.ok(tool.endTime >= tool.startTime);
assert.ok(trace.warnings.some(w => w.includes('timestamped before its call')));
assert.ok(compilePetTelemetry(trace.events, trace.duration).length > 0);
});