Skip to content

Commit 37b462e

Browse files
committed
fix: preserve exact provider continuation identity
1 parent 57a12fd commit 37b462e

19 files changed

Lines changed: 528 additions & 101 deletions

AGENTS.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -335,9 +335,12 @@ POSIX providers run in a dedicated process group; Windows providers use the exac
335335
Provider continuation is agent- and generation-owned and becomes durable only after logical output
336336
validation and the `onComplete` hook succeed. A requested resume is successful only when the
337337
watcher captures that exact same nonempty provider session ID; absent or forked identity fails the
338-
attempt before hooks and forces the retry to rebuild full context. Persist the exact SQLite rowid
339-
high-water sequence, applied guidance sequence, and bounded SHA-256 selected-prompt identity with
340-
the observed provider session; never persist the selected prompt text. Restored
338+
attempt before hooks and forces the retry to rebuild full context. Watchers track every unique
339+
session ID observed in a task; once two IDs differ, the persisted capture is permanently ambiguous
340+
even if a later event repeats the requested ID. Persist SQLite rowid high-water and applied-guidance
341+
cursors as canonical decimal strings, bind them to SQLite as `BigInt`, and never coerce them through
342+
JavaScript `Number`. Persist those cursors and a bounded SHA-256 selected-prompt identity with the
343+
observed provider session; never persist the selected prompt text. Restored
341344
continuations fail closed unless the final durable `TASK_COMPLETED` boundary and all provenance
342345
match. Full and continuation source/guidance reads are bounded through the captured high-water;
343346
continuations query strictly after their prior sequence and de-duplicate the exact triggering

src/agent-wrapper.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,9 @@ class AgentWrapper {
7171
this.currentTask = null;
7272
/** @type {string | null} */
7373
this.currentTaskId = null; // Track spawned task ID for resume capability
74-
/** @type {{provider: string, sessionId: string, agentId: string, taskId: string, generation: number, cwd: string, worktreePath: string|null, contextSequence: number, guidanceSequence: number|null, promptIdentity: string|null} | null} */
74+
/** @type {{provider: string, sessionId: string, agentId: string, taskId: string, generation: number, cwd: string, worktreePath: string|null, contextSequence: string, guidanceSequence: string|null, promptIdentity: string|null} | null} */
7575
this.providerSession = null; // Provider continuation state, owned by this logical agent only
76-
this.currentContextSequence = 0;
76+
this.currentContextSequence = '0';
7777
this.currentGuidanceSequence = null;
7878
this.currentPromptIdentity = null;
7979
/** @type {number | null} */
@@ -458,7 +458,7 @@ class AgentWrapper {
458458
cluster_id: this.cluster.id,
459459
orderBySequence: true,
460460
});
461-
this.currentContextSequence = latestMessage?.sequence || 0;
461+
this.currentContextSequence = latestMessage?.sequence || '0';
462462
const queuedGuidance = collectQueuedGuidance({
463463
messageBus: this.messageBus,
464464
clusterId: this.cluster.id,

src/agent/guidance-queue.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const GUIDANCE_BLOCK_START = '<<GUIDANCE_QUEUE_START>>';
22
const GUIDANCE_BLOCK_END = '<<GUIDANCE_QUEUE_END>>';
3+
const { compareMessageSequences } = require('../ledger-sequence');
34

45
function formatGuidanceMessage(message) {
56
const timestamp = Number.isFinite(message.timestamp)
@@ -24,7 +25,7 @@ function formatGuidanceMessage(message) {
2425
function orderGuidanceMessages(messages, orderBySequence) {
2526
return messages.slice().sort((a, b) => {
2627
if (orderBySequence) {
27-
return (a.sequence || 0) - (b.sequence || 0);
28+
return compareMessageSequences(a.sequence || '0', b.sequence || '0');
2829
}
2930
return (a.timestamp || 0) - (b.timestamp || 0);
3031
});

src/agent/provider-session.js

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const crypto = require('crypto');
22
const path = require('path');
33

44
const { normalizeProviderName, providerSupportsCapability } = require('../../lib/provider-names');
5+
const { tryCanonicalMessageSequence } = require('../ledger-sequence');
56

67
const DURABLE_SESSION_BOUNDARY_EVENTS = new Set([
78
'TASK_STARTED',
@@ -22,7 +23,7 @@ function normalizeAbsolutePath(value) {
2223
}
2324

2425
function normalizeCursor(value) {
25-
return Number.isInteger(value) && value >= 0 ? value : null;
26+
return tryCanonicalMessageSequence(value);
2627
}
2728

2829
function normalizeNullableCursor(value) {
@@ -148,6 +149,9 @@ function validateCompletedResumeIdentity(taskInfo) {
148149
if (!requestedSessionId) {
149150
return null;
150151
}
152+
if (taskInfo?.sessionIdConflict === true) {
153+
return 'Provider continuation emitted conflicting session identities';
154+
}
151155

152156
const capturedSessionId = normalizeNonEmptyString(taskInfo?.sessionId);
153157
if (capturedSessionId === requestedSessionId) {
@@ -175,6 +179,9 @@ function providerSessionFromCompletedTask({
175179
if (normalizeProviderName(taskInfo.provider) !== provider) {
176180
return null;
177181
}
182+
if (taskInfo.sessionIdConflict === true) {
183+
return null;
184+
}
178185
if (validateCompletedResumeIdentity(taskInfo)) {
179186
return null;
180187
}
@@ -208,7 +215,7 @@ function readLastDurableSessionBoundary(messageBus, clusterId, agentId) {
208215
cluster_id: clusterId,
209216
topic: 'AGENT_LIFECYCLE',
210217
sender: agentId,
211-
afterId: 0,
218+
afterId: '0',
212219
});
213220
return lifecycle
214221
.filter((message) => DURABLE_SESSION_BOUNDARY_EVENTS.has(message.content?.data?.event))
@@ -222,7 +229,7 @@ function restoreAgentProviderSession({ agent, savedState, messageBus, clusterId
222229
}
223230
if (
224231
!Object.hasOwn(savedState, 'lastGuidanceAppliedId') ||
225-
savedState.lastGuidanceAppliedId !== session.guidanceSequence
232+
normalizeNullableCursor(savedState.lastGuidanceAppliedId) !== session.guidanceSequence
226233
) {
227234
return null;
228235
}
@@ -237,8 +244,8 @@ function restoreAgentProviderSession({ agent, savedState, messageBus, clusterId
237244
data.taskId !== session.taskId ||
238245
data.iteration !== session.generation ||
239246
normalizeProviderName(data.provider) !== session.provider ||
240-
data.contextSequence !== session.contextSequence ||
241-
data.guidanceSequence !== session.guidanceSequence ||
247+
normalizeCursor(data.contextSequence) !== session.contextSequence ||
248+
normalizeNullableCursor(data.guidanceSequence) !== session.guidanceSequence ||
242249
data.promptIdentity !== session.promptIdentity
243250
) {
244251
return null;

src/ledger-sequence.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
const MAX_SQLITE_ROWID = 9223372036854775807n;
2+
const CANONICAL_SEQUENCE = /^(0|[1-9][0-9]*)$/;
3+
4+
function canonicalMessageSequence(value, name = 'message sequence') {
5+
let sequence;
6+
if (typeof value === 'string' && CANONICAL_SEQUENCE.test(value)) {
7+
sequence = value;
8+
} else if (Number.isSafeInteger(value) && value >= 0) {
9+
// Accept legacy JSON state while normalizing every new boundary to a
10+
// JSON-safe decimal string.
11+
sequence = String(value);
12+
} else {
13+
throw new TypeError(`${name} must be a canonical non-negative decimal string`);
14+
}
15+
16+
if (BigInt(sequence) > MAX_SQLITE_ROWID) {
17+
throw new RangeError(`${name} exceeds the SQLite rowid range`);
18+
}
19+
return sequence;
20+
}
21+
22+
function messageSequenceFromSql(value, name = 'message sequence') {
23+
if (typeof value === 'bigint') {
24+
if (value < 0n || value > MAX_SQLITE_ROWID) {
25+
throw new RangeError(`${name} is outside the SQLite rowid range`);
26+
}
27+
return value.toString();
28+
}
29+
return canonicalMessageSequence(value, name);
30+
}
31+
32+
function messageSequenceToSql(value, name = 'message sequence') {
33+
return BigInt(canonicalMessageSequence(value, name));
34+
}
35+
36+
function compareMessageSequences(left, right) {
37+
const leftValue = messageSequenceToSql(left, 'left message sequence');
38+
const rightValue = messageSequenceToSql(right, 'right message sequence');
39+
if (leftValue < rightValue) {
40+
return -1;
41+
}
42+
if (leftValue > rightValue) {
43+
return 1;
44+
}
45+
return 0;
46+
}
47+
48+
function tryCanonicalMessageSequence(value) {
49+
try {
50+
return canonicalMessageSequence(value);
51+
} catch {
52+
return null;
53+
}
54+
}
55+
56+
module.exports = {
57+
MAX_SQLITE_ROWID,
58+
canonicalMessageSequence,
59+
compareMessageSequences,
60+
messageSequenceFromSql,
61+
messageSequenceToSql,
62+
tryCanonicalMessageSequence,
63+
};

0 commit comments

Comments
 (0)