[ai-harness] Rejected receipt resolves before durable rejection; recovery can rerun the input
Maintainer thường phản hồi trong vòng 1 ngày
Đánh giá
- Độ khó
- 4/5
- Thời gian dự kiến
- 3-5 ngày
- Mức phù hợp với người mới
- 52/100
- Loại issue
- Lỗi
- Độ rõ ràng
- Đặc tả rõ ràng
- Mức độ hoạt động
- Sôi nổi
- Công nghệ
- node.js, typescript
Hướng nghiên cứu
Read session/inputs.js around admit() and reject(), then follow the post-admission rejection paths in session/requests.js and recovery in session/harness-session.js. Use the reproduction described in the issue to confirm that a rejected receipt can precede its durable log record and that recovery can rerun the input. Done means the rejection outcome is durably recorded with admission before the receipt resolves, and recovery does not rerun rejected work.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
[ai-harness] A post-admission rejection's receipt resolves before harness.input.rejected is written, in a second append that is not awaited and can be lost
Versions: @tanstack/[email protected], @tanstack/[email protected], @tanstack/[email protected], [email protected], Node.js 24.22.1
Area: session input admission (session/inputs.js, session/requests.js) and log recovery (session/harness-session.js)
Summary
When a session rejects a work input after admitting it, the log receives two separate appends:
admit()awaits an append ofharness.input(with any turn records).- The admission decision then runs against in-memory state. A rejection calls
reject(), which appendsharness.input.rejectedwithout awaiting it and with its error swallowed (.catch(() => {})), and returns the rejected receipt synchronously.
The reproduction below shows, deterministically, on 0.1.0:
- The receipt resolves before the rejection is written. A log read made right after the receipt shows
harness.inputand no outcome for that id. This happens even with the plainmemoryLogStore()and nothing held (scenario A). - The two records land in separate appends. Holding the second append open shows a reader seeing the input with no outcome for as long as the append is in flight (scenario B).
- A crash between the appends turns a rejected input into one that ran. If the host stops before the rejection lands, the next host's recovery re-runs the logged follow-up as a turn. It fails with
InterruptResumeValidationErrorand settlesfailed. The caller was toldrejected, but the log never records a rejection (scenario C).
LogStore.append is documented as atomic per batch ("the store writes the whole batch … or it changes nothing"). The gap comes from the harness splitting one admission outcome across two batches.
Where (0.1.0 dist)
session/inputs.jsadmit():await this.writer.append([{ type: "harness.input", … }, ...records], { consumes }), thenreturn "new".session/requests.jsstartTurn(prompt / steer / followUp): afteraccept()resolves"new",if (!running && this.waitsForInterrupts()) { this.reject(inputId, "pending_interrupts"); … }. The same method also rejects after admission withconflict,busyandno_human_wait.runToolandresetreject withpending_interruptsthe same way.resolveForRundoes so withconflict,use_answer,mixed_runs,unknown_question,no_pending_interruptsandbusy.session/inputs.jsreject(inputId, reason):this.writer.append([{ type: "harness.input.rejected", inputId, reason }]).catch(() => {}); const receipt = { inputId, status: "rejected", reason }; // … return receipt;session/harness-session.jsrecoverInputs: apendingchat input with a message is re-run (recoverDecision→run→enqueueTurn).
Reproduction
The reproduction is self-contained. It uses only the published packages above, memoryLogStore, and the fake adapter from @tanstack/ai/testing. It needs no network access, model provider or credentials. A LogStore proxy holds the one append that carries harness.input.rejected until the script releases it. No timer orders any step. The only timer is a 20 s watchdog that fails the run if a step never finishes.
Create these three files in an empty directory, then run:
npm install # or `npm ci` with the lockfile this produces
npm run typecheck # tsc --noEmit against the installed type definitions
npm run repro # node repro.ts (Node.js >= 22.18 runs .ts directly)
package.json
{
"name": "tanstack-harness-rejection-durability-repro",
"private": true,
"type": "module",
"engines": { "node": ">=22.18" },
"scripts": {
"typecheck": "tsc --noEmit",
"repro": "node repro.ts"
},
"dependencies": {
"@opentelemetry/api": "1.9.1",
"@tanstack/ai": "1.0.0",
"@tanstack/ai-harness": "0.1.0",
"@tanstack/ai-persistence": "1.0.0",
"zod": "4.4.3"
},
"devDependencies": {
"@types/node": "24.10.1",
"typescript": "5.9.3"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["repro.ts"]
}
repro.ts
/**
* Reproduction: @tanstack/ai-harness 0.1.0 returns a post-admission
* rejection's receipt before `harness.input.rejected` is durable, and logs
* the input and its rejection in two separate appends.
*
* Only published packages, an in-memory log store, and the fake text adapter
* from `@tanstack/ai/testing`. No network, no model provider, no timers that
* order anything: a LogStore wrapper holds the one append that carries
* `harness.input.rejected` until the script releases it.
*
* Run: npm ci && npm run typecheck && npm run repro
*/
import assert from 'node:assert/strict'
import { toolDefinition } from '@tanstack/ai'
import {
createHarnessHost,
defineHarness,
logIdOf,
type HarnessPersistence,
} from '@tanstack/ai-harness'
import {
memoryLogState,
memoryLogStore,
memoryPersistence,
type LogStore,
} from '@tanstack/ai-persistence'
import { fakeText } from '@tanstack/ai/testing'
import { z } from 'zod'
const THREAD = 'thread-1'
const LOG_ID = logIdOf({ logId: THREAD })
const REPLY = 'reply-1'
/** Every step, in the order it happened. */
const timeline: string[] = []
const note = (step: string) => {
timeline.push(step)
console.log(` [${timeline.length}] ${step}`)
}
/** A promise and the function that resolves it. */
function deferred() {
let resolve!: () => void
const promise = new Promise<void>((done) => {
resolve = done
})
return { promise, resolve }
}
/**
* Wraps a LogStore so the first append that carries `harness.input.rejected`
* after `arm()` waits until `release()`. Every other call passes through.
*/
function holdingRejections(inner: LogStore) {
const reached = deferred()
const released = deferred()
const landed = deferred()
let armed = false
const log = new Proxy(inner, {
get(target, prop, receiver) {
if (prop !== 'append') {
const value: unknown = Reflect.get(target, prop, receiver)
return typeof value === 'function' ? value.bind(target) : value
}
const append: LogStore['append'] = async (batch) => {
const isRejection = batch.records.some(
(record) => record.type === 'harness.input.rejected',
)
if (!(armed && isRejection)) return target.append(batch)
armed = false
note('store: append of harness.input.rejected started (held)')
reached.resolve()
await released.promise
await target.append(batch)
note('store: append of harness.input.rejected landed')
landed.resolve()
}
return append
},
})
return {
log,
arm: () => {
armed = true
},
reached: reached.promise,
release: released.resolve,
landed: landed.promise,
}
}
/** The record types the log holds for one input id, in log order. */
async function recordsFor(log: LogStore, inputId: string) {
const entries = await log.read({ logId: LOG_ID })
return entries
.filter(({ record }) => record['inputId'] === inputId)
.map(({ record }) =>
record.type === 'harness.input.settled'
? `${record.type}(${String(record['outcome'])})`
: record.type,
)
}
/** Resolves once `inputId` has an outcome record in the log. */
function outcomeOf(log: LogStore, inputId: string) {
return new Promise<string[]>((resolve) => {
const check = async () => {
const types = await recordsFor(log, inputId)
if (
types.some(
(type) =>
type === 'harness.input.rejected' ||
type.startsWith('harness.input.settled'),
)
) {
stop()
resolve(types)
}
}
const stop = log.subscribe({
logId: LOG_ID,
listener: () => void check(),
})
void check()
})
}
const askPerson = toolDefinition({
name: 'askPerson',
description: 'Asks the person a question and waits for the answer.',
inputSchema: z.object({ question: z.string() }),
outputSchema: z.object({ text: z.string() }),
})
/** A harness whose first turn ends on a client-tool interrupt. */
function asker() {
const adapter = fakeText()
adapter.setResponses([
{
toolCalls: [{ name: 'askPerson', input: { question: 'Which day?' } }],
},
{ text: 'Thanks.' },
])
return defineHarness({
name: 'repro/asker',
adapter,
tools: [askPerson.client()],
})
}
/** Persistence over `log`, with the other stores in memory. */
function persistenceWith(log: LogStore): HarnessPersistence {
const { metadata, sessions } = memoryPersistence().stores
return { stores: { log, metadata, sessions } }
}
/** A thread that waits on a client-tool interrupt, on a host over `log`. */
async function waitingThread(log: LogStore) {
const harness = asker()
const host = createHarnessHost({ persistence: persistenceWith(log) })
const session = await host.open(harness, { threadId: THREAD })
const first = session.send('Start.', { inputId: 'turn-1' })
await first.receipt
await outcomeOf(log, 'turn-1')
const pending = session.snapshot().pendingWaits
assert.equal(
pending.length,
1,
'the first turn ends on one open wait (its interrupt)',
)
return { harness, host, session }
}
async function baseline() {
console.log('\nA. Plain memory store, nothing held')
const log = memoryLogStore()
const { host, session } = await waitingThread(log)
const operation = session.send('Tuesday.', {
mode: 'followUp',
inputId: REPLY,
})
const receipt = await operation.receipt
note(`caller: receipt ${JSON.stringify(receipt)}`)
const atReceipt = await recordsFor(log, REPLY)
note(`caller: log read right after the receipt: ${atReceipt.join(', ')}`)
const settled = await outcomeOf(log, REPLY)
note(`caller: log once the rejection landed: ${settled.join(', ')}`)
assert.deepEqual(receipt, {
inputId: REPLY,
status: 'rejected',
reason: 'pending_interrupts',
})
// Even the in-memory store, with nothing held, has not written the
// rejection when the receipt resolves: a read the caller makes next sees
// the input admitted with no outcome.
assert.deepEqual(atReceipt, ['harness.input'])
// It lands later, as a second append.
assert.deepEqual(settled, ['harness.input', 'harness.input.rejected'])
await host.close()
}
async function heldRejection() {
console.log('\nB. The rejection append is held: receipt before durability')
timeline.length = 0
const held = holdingRejections(memoryLogStore())
const { host, session } = await waitingThread(held.log)
held.arm()
const operation = session.send('Tuesday.', {
mode: 'followUp',
inputId: REPLY,
})
const receipt = await operation.receipt
note(`caller: receipt ${JSON.stringify(receipt)}`)
await held.reached
const inWindow = await recordsFor(held.log, REPLY)
note(`reader: log read while the rejection is held: ${inWindow.join(', ')}`)
held.release()
await held.landed
const after = await recordsFor(held.log, REPLY)
note(`reader: log read after release: ${after.join(', ')}`)
// 1. The caller was told `rejected`...
assert.deepEqual(receipt, {
inputId: REPLY,
status: 'rejected',
reason: 'pending_interrupts',
})
// 2. ...before the rejection was durable: the receipt came first.
assert.ok(
timeline.findIndex((step) => step.startsWith('caller: receipt')) <
timeline.findIndex((step) => step.includes('landed')),
'the receipt resolved before harness.input.rejected landed',
)
// 3. A reader in that window sees the input admitted, with no outcome.
assert.deepEqual(inWindow, ['harness.input'])
// 4. The rejection is a second, separate append.
assert.deepEqual(after, ['harness.input', 'harness.input.rejected'])
await host.close()
}
async function crashBetweenAppends() {
console.log(
'\nC. The host stops between the two appends; a second host opens the thread',
)
timeline.length = 0
let skippedMs = 0
const state = memoryLogState({ now: () => Date.now() + skippedMs })
const held = holdingRejections(memoryLogStore({ state }))
const first = await waitingThread(held.log)
held.arm()
const receipt = await first.session.send('Tuesday.', {
mode: 'followUp',
inputId: REPLY,
}).receipt
note(`caller (host 1): receipt ${JSON.stringify(receipt)}`)
await held.reached
// Host 1 stops here: its rejection append never lands, and its log
// ownership lapses (the store clock moves past the ownership TTL).
skippedMs += 60 * 60 * 1000
note('host 1: stopped before its rejection append landed')
const secondLog = memoryLogStore({ state })
const second = createHarnessHost({ persistence: persistenceWith(secondLog) })
await second.open(asker(), { threadId: THREAD })
note('host 2: opened the thread (its recovery runs)')
const outcome = await outcomeOf(secondLog, REPLY)
note(
`reader: log for ${REPLY} after host 2's recovery: ${outcome.join(', ')}`,
)
assert.equal(receipt.status, 'rejected')
// The caller was told `rejected`, but the log never records that: host 2
// re-ran the logged input as a turn, which settled `failed`.
assert.ok(!outcome.includes('harness.input.rejected'))
assert.ok(outcome.includes('harness.input.applied'))
assert.ok(outcome.includes('harness.input.settled(failed)'))
await second.close()
}
const watchdog = setTimeout(() => {
console.error('FAIL: a step did not finish within 20 s')
process.exit(2)
}, 20_000)
watchdog.unref()
await baseline()
await heldRejection()
await crashBetweenAppends()
console.log('\nAll assertions passed.')
// Host 1 of C still holds its never-released append; end the process.
process.exit(0)
Output on 0.1.0
The run exits 0, so every assertion holds. Scenario C also logs TanStack's own InterruptResumeValidationError ("Thread has pending interrupts; resume is required before accepting new input. Resume entries must resolve or cancel the complete interrupt batch.") while host 2 runs the recovered turn. The step lines were identical across five consecutive runs:
A. Plain memory store, nothing held
[1] caller: receipt {"inputId":"reply-1","status":"rejected","reason":"pending_interrupts"}
[2] caller: log read right after the receipt: harness.input
[3] caller: log once the rejection landed: harness.input, harness.input.rejected
B. The rejection append is held: receipt before durability
[1] caller: receipt {"inputId":"reply-1","status":"rejected","reason":"pending_interrupts"}
[2] store: append of harness.input.rejected started (held)
[3] reader: log read while the rejection is held: harness.input
[4] store: append of harness.input.rejected landed
[5] reader: log read after release: harness.input, harness.input.rejected
C. The host stops between the two appends; a second host opens the thread
[1] caller (host 1): receipt {"inputId":"reply-1","status":"rejected","reason":"pending_interrupts"}
[2] store: append of harness.input.rejected started (held)
[3] host 1: stopped before its rejection append landed
[4] host 2: opened the thread (its recovery runs)
[5] reader: log for reply-1 after host 2's recovery: harness.input, harness.run.node, harness.input.applied, harness.input.settled(failed)
All assertions passed.
What each assertion shows
- A,
receiptequalsrejected / pending_interrupts: the follow-up was admitted and then rejected, because the first turn ended on a client-tool interrupt. - A,
atReceiptequals['harness.input']: with the plain in-memory store and nothing held, a read issued right after the receipt resolves does not yet contain the rejection. The receipt runs ahead of durability with no help from the test. - A,
settledequals['harness.input', 'harness.input.rejected']: the rejection does land, later, as its own record. - B, the receipt step comes before the "landed" step: the caller held
rejectedwhile the rejection append had not completed. The append waits on the store, and the receipt does not wait on the append. - B,
inWindowequals['harness.input']: for as long as that append is in flight, any reader sees the input admitted with no outcome, the same state as an input that will run. - B,
afterequals['harness.input', 'harness.input.rejected']: the outcome is a second append, not part of the admission's batch. - C,
receipt.statusequalsrejected, and the log has noharness.input.rejected: host 1 stopped between the two appends. Its rejection never became durable, even though its caller was toldrejected. - C, the log has
harness.input.appliedandharness.input.settled(failed): host 2's recovery treated the logged input as pending work and ran it. With the interrupt still open, the turn failed. An input its sender was told was rejected is recorded as one that was applied and failed.
Scenario C simulates the stop. Host 1 stays in the process, but its held append is never released. Moving the shared store clock one hour ahead lets its 30 s log ownership lapse, so host 2 can take the log.
Expected semantics
An input id should never be durably visible in a state that contradicts the receipt its caller got:
- A rejected receipt implies a durable rejection. When a caller holds
{ status: 'rejected' }for an admitted input, every subsequent read of the log should includeharness.input.rejectedfor that id. - No observable "admitted but undecided" state for an input that is rejected. A reader should never see
harness.inputwithout an outcome for an input that will be rejected. Equivalently,harness.inputwithout an outcome should mean "admitted, and it will run (or join, or queue)". - Crash safety. A crash at any point must leave the input either absent, so the caller may resend the same id, or rejected. It must never be pending in a way that recovery re-runs.
- A failed rejection write is not silent. If the rejection cannot be written, the input must not be left looking admitted. The write failure should surface the way other log failures do: fail the operation and treat the log as failed.
Proposed fix
Root fix: atomic admission and rejection (crash-safe). Make the admission decision before the input record is written, and write the outcome in the same append:
- accepted:
[harness.input, ...turnRecords], as today; - rejected after the checks:
[harness.input, harness.input.rejected]in one batch, with the sameconsumes. The input stays in the log, soknownAdmissiondedupe and duplicate/conflict replay keep working.
The decision inputs (activeTurn, queue, interrupted, the runId checks, the open waits for resolve) are in-memory state of the single session that owns the log. Evaluating them just before the append is equivalent to today's check just after it, provided the writer chain orders the append before any later turn-end write. Alternatively, keep the decision after the input record but stage that record and flush it together with either the turn records or the rejection. Either way, there is exactly one append per admission outcome. This also gives the crash guarantee (3) for free: the batch lands entirely or not at all.
Mitigation only: await the rejection append (not crash-safe). Make reject() async, await the append, and fail the operation (and the log) when the append fails, instead of .catch(() => {}). This closes the "receipt before durability" window (1) and the silent-failure case (4). It does not close the crash window (3): a process that dies after the harness.input append and before the rejection append still leaves a pending input that recovery re-runs. Other hosts can also still observe the intermediate state (2) while the second append is in flight. It is a smaller change and a useful step, but not a full fix.
Suggested tests
- Held rejection append. Use the
holdingRejectionswrapper from the reproduction. Assert that a post-admission rejection's receipt does not resolve before the log containsharness.input.rejected; today it does (scenario B). With the atomic fix, also assert that no read ever returnsharness.inputfor the id without its outcome. - Crash between appends. Use scenario C as a test. With the atomic fix, host 2 sees the input either absent or rejected, and recovery runs no turn for it.
- Failed rejection append. Make the store reject the append that carries the rejection, twice so the writer's single retry fails too. Assert that the operation and the log fail visibly and that the input is not left pending. (Not executed in the reproduction.)
- Each post-admission reason. Repeat test 1 for
pending_interruptsfromprompt,runToolandreset, and for theresolverejections (no_pending_interrupts,busy,use_answer,mixed_runs,unknown_question,conflict). The reproduction exercises onlyfollowUpwithpending_interrupts. The other paths call the samereject(). - Dedupe unchanged. After a rejection, resending the same id with the same payload returns
duplicate, which replays the rejection. Resending it with a different payload returnsconflict. Check both locally and through the offer lane (applyOffer/refuseOffer).
Impact
A caller that reads the log to decide what happened to an input cannot tell "admitted, will run" from "admitted, about to be rejected". Neither can another host that shares the log. One pattern this breaks is "resend under a new id once the old one was rejected, do nothing while it is pending". A consumer that treats a logged input with no outcome as taken can acknowledge an input that is then rejected, and it is never resent. After a crash between the appends, the input instead runs during recovery and fails. This report makes no claim about how often this happens in production. The reproduction only shows that the orderings are reachable and deterministic on 0.1.0.
- Ngôn ngữ chính
- TypeScript
- Star
- 3.2k
- Fork
- 372
- Merge trung bình
- 1 ngày 5 giờ
- Pull request đã merge (30 ngày)
- 233
Chuẩn bị môi trường
- Không có Dockerfile hay tệp Docker Compose
- Có mẫu pull request
- Đọc hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của TanStack/ai
-
openai-base: a Responses max_output_tokens cutoff ends as RUN_ERROR without usage instead of finishReason 'length'Có thể đã có người làm @tombeckenham đã nhận 1 ngày trước. Đang mởhas-pr waiting-on: maintainer
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 74/100
TanStack/ai#1916 · 1 bình luận · 1 người được giao ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 4/5 1-2 ngày Mức phù hợp với người mới 58/100
Maintainer thường phản hồi trong vòng 1 ngày
-
`ChatEngine.handleTextMessageContentEvent` is O(n²) in answer length: `startsWith(before)` copies the whole accumulated text on every chunkCó thể đã có người làm @tombeckenham đã nhận hôm nay. Đang mởwaiting-on: maintainer
TanStack/ai#1951 · 1 người được giao ·
Maintainer thường phản hồi trong vòng 1 ngày
-
openai-base: streamed `apply_patch` calls reach the app with an empty `diff`Có thể đã có người làm @tombeckenham đã nhận hôm nay. Đang mởhas-pr waiting-on: maintainer
TanStack/ai#1947 · 1 người được giao ·
Maintainer thường phản hồi trong vòng 1 ngày
-
needsApproval client tools can't be approved without a server (custom connection adapters)Có thể đã có người làm @jherr đã nhận hôm nay. Đang mởwaiting-on: maintainer
TanStack/ai#1945 · 1 người được giao ·
Maintainer thường phản hồi trong vòng 1 ngày
Issue tương tự
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
components-web-app/docs#307 ·
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
localstack/localstack-mcp-server#112 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
area:frontend kind:bug priority:p1 ready
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 85/100
Maintainer thường phản hồi trong vòng 1 ngày
-
bug ready-for-agent
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 73/100
PhyberApex/kuroshiro#1687 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Performance (Web Vitals) collector: LCP/FCP from background tabs are not discarded, inflating p75Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 74/100
umami-software/umami#4720 ·
Maintainer thường phản hồi trong vòng 3 ngày