fix(feedback): harden backend consistency

This commit is contained in:
ZiyaZhang
2026-08-10 21:40:03 -07:00
parent 3cffc77719
commit c3d0fe1bf9
15 changed files with 131 additions and 80 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md
2026-08-10-message-feedback-sidecar.md: 2c6fe72174e4683793980727da672948793eb45f
2026-08-10-message-feedback-sidecar.zh.md: ee67d0d694e0502f4f4770484d9505eaa43ad232
2026-08-10-message-feedback-sidecar.md: a741a7e6b9fe422c9aafb68d9b099466399e1ee9
2026-08-10-message-feedback-sidecar.zh.md: da176e13586bb93ebadcefc0afb44c21ce386e08
@@ -18,9 +18,9 @@ Every usable row is bound to the inspected Session header identity `{createdAt,
`put` accepts a target only when `SessionPersistence.inspect()` observes a non-empty, append-origin `assistant/message` with that `MessageId`. Replacement-origin messages, empty usage-only assistant records, and non-assistant targets are rejected. Inspection is the cold-safe authority: it neither publishes or resumes an Agent nor commits cold-log repair merely to validate feedback. A cold `listSnapshots()` preflight classifies definite absence; inspection failure for a catalogued Session remains an infrastructure failure. A request in the narrow live-detach-to-header-materialization interval can therefore return `session-not-found`, and the caller retries after retirement materialization.
Before `put` commits a sidecar row, it puts the target log behind a durability barrier. A matching live Session passes through the canonical `ctx.sessions.flush` checkpoint; a catalogued cold Session is physically re-read from sequence zero through `SessionPersistence.readFrom`. The resulting observation's header identity and target are checked again. A missing flush participant, changed identity, vanished target, or cold physical-read failure prevents the sidecar write, so a committed feedback item never precedes the durable assistant message it references.
Before `put` commits a sidecar row, it puts the target log behind a durability barrier. A matching live Session passes through the canonical `ctx.sessions.flush` checkpoint, then both live and cold paths are physically read from sequence zero through `SessionPersistence.readFrom`. The resulting observation's header identity and target are checked again. A missing flush participant, changed identity, vanished target, or physical-read failure prevents the sidecar write, so a committed feedback item never precedes the durable assistant message it references.
Each message item carries its own opaque version plus Host-assigned `createdAt` and `updatedAt` timestamps. `put` compares the caller's `ifVersion` only with the addressed item, so editing one message does not invalidate another. An exact retry of an already committed desired value returns that stored item before a stale `ifVersion` becomes a conflict, preserving its version and timestamps; a material update preserves `createdAt`, replaces the version, and keeps `updatedAt` from moving backward. An already-absent delete is likewise successful. Versions are tokens for equality, not counters callers may order or synthesize.
Each message item carries its own opaque version plus Host-assigned `createdAt` and `updatedAt` timestamps. `put` compares the caller's `ifVersion` only with the addressed item, so editing one message does not invalidate another. The comparison is strict even when the desired value already matches, preventing a stale request from crossing an ABA value cycle; a conflict returns the authoritative current item so callers can reconcile without a second read. A matching-version no-op preserves the version and timestamps, while a material update preserves `createdAt`, replaces the version, and keeps `updatedAt` from moving backward. An already-absent delete is likewise successful. Versions are tokens for equality, not counters callers may order or synthesize.
A per-Session mutation queue encloses lifecycle inspection, sidecar read, conflict evaluation, and whole-row write. This makes one service instance's mutations serial and preserves the per-message compare-and-swap contract inside one Host process. The underlying storage-domain API provides no cross-process conditional write, so the implementation claims no cross-process linearizability or lost-update protection.
@@ -18,9 +18,9 @@ Status: implemented
`put` 只接受由 `SessionPersistence.inspect()` 观测到的非空、append-origin `assistant/message`,且其 `MessageId` 必须与目标相同。replacement-origin 消息、仅承载 usage 的空 assistant 记录以及非 assistant 目标都会被拒绝。检查使用 cold-safe 权威路径:它不会仅为验证反馈而发布或恢复 Agent,也不会提交 cold 日志修复。cold 路径由 `listSnapshots()` 预检明确不存在;已进入目录的 Session 若检查失败,仍按基础设施故障处理。因此,请求若恰落在 live detach 到 header materialization 的极短窗口,可能返回 `session-not-found`,调用方在 retirement materialization 后重试。
`put` 提交伴随记录前,会先让目标日志通过 durability barrier。身份匹配的 live Session 经过权威 `ctx.sessions.flush` checkpoint;已进入目录的 cold Session 则通过 `SessionPersistence.readFrom` 从序列零做物理复读。后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或 cold 物理读取失败都会阻止伴随记录写入,因此已提交反馈绝不会先于它引用的持久 assistant 消息。
`put` 提交伴随记录前,会先让目标日志通过 durability barrier。身份匹配的 live Session 经过权威 `ctx.sessions.flush` checkpoint,随后 live 与 cold 路径都会通过 `SessionPersistence.readFrom` 从序列零做物理复读。后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或物理读取失败都会阻止伴随记录写入,因此已提交反馈绝不会先于它引用的持久 assistant 消息。
每个消息条目都携带自己的 opaque version,以及 Host 分配的 `createdAt``updatedAt` 时间戳。`put` 只把调用方的 `ifVersion` 与目标条目比较,因此编辑一条消息不会使另一条消息失效。对已提交目标值的完全相同重试,会在陈旧 `ifVersion` 形成冲突返回已存条目,并保留 version 与时间戳;实质更新保留 `createdAt`、替换 version,并保证 `updatedAt` 不倒退。删除已经不存在的条目也同样成功。version 是只能做相等比较的 token,不是调用方可以排序或自行合成的计数器。
每个消息条目都携带自己的 opaque version,以及 Host 分配的 `createdAt``updatedAt` 时间戳。`put` 只把调用方的 `ifVersion` 与目标条目比较,因此编辑一条消息不会使另一条消息失效。即使目标值已经相同,比较仍然严格执行,从而防止陈旧请求穿过 ABA 值循环;冲突返回权威当前条目,调用方无需二次读取即可协调。携带匹配 version 的无变化请求会保留 version 与时间戳;实质更新保留 `createdAt`、替换 version,并保证 `updatedAt` 不倒退。删除已经不存在的条目也同样成功。version 是只能做相等比较的 token,不是调用方可以排序或自行合成的计数器。
按 Session 划分的变更队列覆盖生命周期检查、伴随记录读取、冲突判断与整行写入。这使同一个服务实例的变更串行化,并在单个 Host 进程内保持逐消息 compare-and-swap 契约。底层 storage-domain API 不提供跨进程条件写,因此实现不承诺跨进程线性一致性或防止丢失更新。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/feedback.md
feedback.md: 4de02753ef179b13cae90b51ba965c42e2f519ee
feedback.zh.md: 88e51cdb5c07e8dd7499f0ac2449b6b6ecc3388d
feedback.md: e5c99ee639403ec3da0cf5845ec00f5ea1a5817d
feedback.zh.md: 407d111da59af1d9a5c99c0e3d06d70df9051a13
+4 -4
View File
@@ -10,7 +10,7 @@ Source: [`packages/feedback/message-feedback/src/types.ts`](../../packages/feedb
One Session sidecar row contains its header identity `{createdAt, cwd}` and feedback items keyed by `MessageId`. Each item carries a positive or negative rating, an optional note, Host-assigned `createdAt`/`updatedAt` timestamps, and its own opaque version. Versions are compared only for equality and only against the addressed message; callers do not order or synthesize them.
`put` is optimistic and retry-safe. An exact retry of the already stored desired value returns that item before a stale `ifVersion` is treated as a conflict. Deleting an already absent item also succeeds. A per-Session queue encloses inspection, read, conflict evaluation, and whole-row write, so these guarantees cover concurrent calls in one Host process.
`put` uses strict optimistic concurrency: every request for an existing item must match its current `ifVersion`, including a no-op. A conflict returns the authoritative current item (or `null`), so a caller can reconcile a lost response or a concurrent edit without another read. Deleting an already absent item succeeds. A per-Session queue encloses inspection, read, conflict evaluation, and whole-row write, so these guarantees cover concurrent calls in one Host process.
## Target and lifecycle authority
@@ -20,7 +20,7 @@ The stored `{createdAt, cwd}` identity must match the inspected header. A mismat
## Persistence and Remote contract
The service stores whole Session rows in the `message_feedback` storage domain through `ctx.storageDomain`. Before `put` commits a row that references a target message, a matching live target passes through the canonical `ctx.sessions.flush` checkpoint; a catalogued cold target is physically re-read from sequence zero through `SessionPersistence.readFrom`. The resulting observation is revalidated before the sidecar write, so the durable target log always precedes its sidecar commit. `maxNoteBytes` is required and bounds note text by UTF-8 bytes; the Web Host composition sets `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` unary Remote contract through `GatewayService` and `@Remote`; the generated Cordis surface below is the method-level authority.
The service stores whole Session rows in the `message_feedback` storage domain through `ctx.storageDomain`. Before `put` commits a row that references a target message, a matching live target passes through the canonical `ctx.sessions.flush` checkpoint; both live and cold paths are then physically read from sequence zero through `SessionPersistence.readFrom`. The resulting observation is revalidated before the sidecar write, so the durable target log always precedes its sidecar commit. `maxNoteBytes` is required and bounds note text by UTF-8 bytes; the Web Host composition sets `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` unary Remote contract through `GatewayService` and `@Remote`; the generated Cordis surface below is the method-level authority.
## Boundaries and limitations
@@ -56,8 +56,8 @@ Storage-domain sidecar service. It inspects persisted Session history and never
/**
* Create or replace feedback for one derived append-origin assistant
* message. An exact desired-value retry returns the stored item before its
* stale or `null` version is considered a conflict.
* message. Every request must match the addressed item's current version;
* a matching no-op returns the stored item without changing its revision.
* @param request - target, desired value, and observed item version.
* @returns the committed item or an explicit business failure.
*/
+4 -4
View File
@@ -10,7 +10,7 @@
每个 Session 的一条伴随记录包含 header 身份 `{createdAt, cwd}` 和以 `MessageId` 为键的反馈条目。每个条目携带好评或差评、可选备注、Host 分配的 `createdAt`/`updatedAt` 时间戳及自己的 opaque version。version 只能用于相等比较,且只与目标消息比较;调用方不能排序或自行合成它。
`put` 采用乐观并发并可安全重试。若目标值已经存储,完全相同的重试会先返回该条目,再考虑陈旧 `ifVersion` 是否构成冲突。删除已经不存在的条目同样成功。按 Session 划分的队列覆盖检查、读取、冲突判断与整行写入,因此这些保证适用于单个 Host 进程中的并发调用。
`put` 采用严格乐观并发:已有条目的每次请求都必须匹配当前 `ifVersion`,即使请求不会改变目标值。冲突会返回权威当前条目(不存在时为 `null`),因此调用方无需额外读取,即可协调丢失响应或并发编辑。删除已经不存在的条目同样成功。按 Session 划分的队列覆盖检查、读取、冲突判断与整行写入,因此这些保证适用于单个 Host 进程中的并发调用。
## 目标与生命周期权威
@@ -20,7 +20,7 @@
## 持久化与 Remote 契约
服务通过 `ctx.storageDomain``message_feedback` 存储域中保存完整 Session 行。`put` 提交引用目标消息的伴随记录前,身份匹配的 live 目标先经过权威 `ctx.sessions.flush` checkpoint已进入目录的 cold 目标则通过 `SessionPersistence.readFrom` 从序列零做物理复读。写入伴随记录前会再次校验所得观测,因此目标日志的持久提交始终先于其伴随记录。`maxNoteBytes` 为必填项,按 UTF-8 字节限制备注文本;Web Host 组合将其设为 `8192`。该包通过 `GatewayService``@Remote` 发布 Host `messageFeedback.list``messageFeedback.put``messageFeedback.delete` 一元 Remote 契约;下方生成的 Cordis surface 是方法级权威。
服务通过 `ctx.storageDomain``message_feedback` 存储域中保存完整 Session 行。`put` 提交引用目标消息的伴随记录前,身份匹配的 live 目标先经过权威 `ctx.sessions.flush` checkpoint随后 live 与 cold 路径都会通过 `SessionPersistence.readFrom` 从序列零做物理复读。写入伴随记录前会再次校验所得观测,因此目标日志的持久提交始终先于其伴随记录。`maxNoteBytes` 为必填项,按 UTF-8 字节限制备注文本;Web Host 组合将其设为 `8192`。该包通过 `GatewayService``@Remote` 发布 Host `messageFeedback.list``messageFeedback.put``messageFeedback.delete` 一元 Remote 契约;下方生成的 Cordis surface 是方法级权威。
## 边界与限制
@@ -56,8 +56,8 @@ Storage-domain sidecar service. It inspects persisted Session history and never
/**
* Create or replace feedback for one derived append-origin assistant
* message. An exact desired-value retry returns the stored item before its
* stale or `null` version is considered a conflict.
* message. Every request must match the addressed item's current version;
* a matching no-op returns the stored item without changing its revision.
* @param request - target, desired value, and observed item version.
* @returns the committed item or an explicit business failure.
*/
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/feedback/message-feedback/README.md
README.md: 0fda4fb535e5186c252377383520671cd574b364
README.zh.md: 151db5c10048e6ff32d3f7a4ddbcba986d87597a
README.md: 948cdcd2ef0fa2d1bcea68fdc40f16be08e963a8
README.zh.md: 14e62eea0a182bc60be2c370653d1cb031d6b8a8
+4 -4
View File
@@ -31,7 +31,7 @@ Each stored row carries the inspected Session header identity `{createdAt, cwd}`
`SessionPersistence.inspect()` supplies a cold-safe observation without publishing or resuming an Agent and without committing cold repair. For a Session without a live owner, `listSnapshots()` first decides definite absence; an `inspect()` failure for a catalogued Session remains an infrastructure failure rather than being guessed into `session-not-found`. `put` accepts only a non-empty, append-origin `assistant/message` with the requested `MessageId`; replacement-origin messages, empty usage-only assistant records, and non-assistant records return `target-not-found`.
After initial validation, `put` establishes a durability barrier before writing the sidecar. A matching live Session commits through the canonical `ctx.sessions.flush` checkpoint; a catalogued cold Session is physically re-read from sequence zero through `SessionPersistence.readFrom`. The resulting observation's header identity and target are validated again. A missing flush participant, changed identity, vanished target, or cold physical-read failure prevents the sidecar commit, so durable feedback never precedes the durable target message.
After initial validation, `put` establishes a durability barrier before writing the sidecar. A matching live Session commits through the canonical `ctx.sessions.flush` checkpoint, then both live and cold paths are physically read from sequence zero through `SessionPersistence.readFrom`. The resulting observation's header identity and target are validated again. A missing flush participant, changed identity, vanished target, or physical-read failure prevents the sidecar commit, so durable feedback never precedes the durable target message.
Message feedback is not Session-log content or a Session projection. It emits no `feedback/record` event, does not enter model history, and does not trigger `FEEDBACK_ONLY` telemetry release.
@@ -45,13 +45,13 @@ The same three `MessageFeedbackService` methods are published by `GatewayService
| `put` | `MessageFeedbackPutRequest { sessionId, messageId, rating, note?, ifVersion }` | committed `MessageFeedbackItem` | `session-not-found`, `target-not-found`, `version-conflict`, `note-blank`, `note-too-large` |
| `delete` | `MessageFeedbackDeleteRequest { sessionId, messageId, ifVersion }` | `MessageFeedbackDeleteValue { absent: true }` | `session-not-found`, `version-conflict` |
`MessageFeedbackVersionConflict` returns the caller's `expected` token and the current `actual` token, each nullable where absence is meaningful. `MessageFeedbackNoteTooLarge` returns both `maxBytes` and `actualBytes`. The Client Remote aggregate does not mount the generated client contribution yet; Host callers can use the service/Remote contract without that client assembly.
`MessageFeedbackVersionConflict` returns the authoritative `current` item, or `null` when no item exists. This lets a caller reconcile the current rating, note, and version without a second `list` request. `MessageFeedbackNoteTooLarge` returns both `maxBytes` and `actualBytes`. The Client Remote aggregate does not mount the generated client contribution yet; Host callers can use the service/Remote contract without that client assembly.
## Compare-and-set and idempotency
`ifVersion: null` requests creation only; a material update requires the exact current item version. The check is per message rather than per Session, so changing one item does not conflict with another. Every material create or update assigns a fresh opaque UUID token.
`ifVersion: null` requests creation only; every request for an existing item requires its exact current version, including a no-op whose desired value already matches. The check is per message rather than per Session, so changing one item does not conflict with another. Every material create or update assigns a fresh opaque UUID token, preventing stale writes from crossing an ABA value cycle.
An exact desired-value retry is recognized before `ifVersion` comparison. It returns the already stored item with unchanged version and timestamps, so a caller may safely retry after losing a success response even with the now-stale token or original `null`. `delete` ignores `ifVersion` when the item is already absent and always returns the stable `{ absent: true }` postcondition after success.
A matching-version no-op returns the already stored item with unchanged version and timestamps. After a lost success response, a retry with the old token receives `version-conflict.current`; the caller can compare that authoritative item with its desired value without an extra read. `delete` ignores `ifVersion` when the item is already absent and always returns the stable `{ absent: true }` postcondition after success.
A per-Session promise queue encloses inspection, durability validation, sidecar read, comparison, and whole-row write. These semantics serialize concurrent mutations through one service instance; storage-domain itself has no cross-process conditional write.
@@ -31,7 +31,7 @@
`SessionPersistence.inspect()` 提供 cold-safe 观测,不发布或恢复 Agent,也不提交 cold repair。对于没有 live owner 的 Session,系统先用 `listSnapshots()` 判定明确不存在;已进入目录的 Session 若 `inspect()` 失败,仍属于基础设施故障,不会被猜测成 `session-not-found``put` 只接受具有指定 `MessageId` 的非空、append-origin `assistant/message`replacement-origin 消息、仅承载 usage 的空 assistant 记录与非 assistant 记录都返回 `target-not-found`
初步校验后,`put` 在写入伴随记录前建立 durability barrier。身份匹配的 live Session 通过权威 `ctx.sessions.flush` checkpoint 提交;已进入目录的 cold Session 则通过 `SessionPersistence.readFrom` 从序列零做物理复读。后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或 cold 物理读取失败都会阻止伴随记录提交,因此持久反馈绝不会先于其持久目标消息。
初步校验后,`put` 在写入伴随记录前建立 durability barrier。身份匹配的 live Session 通过权威 `ctx.sessions.flush` checkpoint 提交,随后 live 与 cold 路径都会通过 `SessionPersistence.readFrom` 从序列零做物理复读。后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或物理读取失败都会阻止伴随记录提交,因此持久反馈绝不会先于其持久目标消息。
message feedback 不是 Session 日志内容或 Session 投影。它不发出 `feedback/record` 事件,不进入模型历史,也不触发 `FEEDBACK_ONLY` 遥测释放。
@@ -45,13 +45,13 @@ message feedback 不是 Session 日志内容或 Session 投影。它不发出 `f
| `put` | `MessageFeedbackPutRequest { sessionId, messageId, rating, note?, ifVersion }` | 已提交的 `MessageFeedbackItem` | `session-not-found``target-not-found``version-conflict``note-blank``note-too-large` |
| `delete` | `MessageFeedbackDeleteRequest { sessionId, messageId, ifVersion }` | `MessageFeedbackDeleteValue { absent: true }` | `session-not-found``version-conflict` |
`MessageFeedbackVersionConflict` 返回调用方的 `expected` token 与当前 `actual` token;在表示不存在时,两者可以为 null`MessageFeedbackNoteTooLarge` 同时返回 `maxBytes``actualBytes`。客户端 Remote 聚合尚未挂载生成的客户端 contribution;Host 调用方无需该客户端组装即可使用 service/Remote 契约。
`MessageFeedbackVersionConflict` 返回权威 `current` 条目;条目不存在时为 `null`。调用方无需额外执行 `list`,即可协调当前 rating、note 与 version`MessageFeedbackNoteTooLarge` 同时返回 `maxBytes``actualBytes`。客户端 Remote 聚合尚未挂载生成的客户端 contribution;Host 调用方无需该客户端组装即可使用 service/Remote 契约。
## Compare-and-set 与幂等性
`ifVersion: null` 表示仅当条目不存在时才创建;实质更新要求与当前条目 version 完全一致。检查按消息而非按 Session 进行,因此修改一个条目不会与另一个条目冲突。每次实质创建或更新都会分配新的 opaque UUID token。
`ifVersion: null` 表示仅当条目不存在时才创建;已有条目的每次请求都必须与其当前 version 完全一致,即使目标值已经相同、不会产生实质更新。检查按消息而非按 Session 进行,因此修改一个条目不会与另一个条目冲突。每次实质创建或更新都会分配新的 opaque UUID token,防止陈旧写入穿过 ABA 值循环
系统会在比较 `ifVersion` 之前识别与目标值完全相同的重试。它返回已存条目,version 与时间戳均不变,因此调用方在成功响应丢失后,使用当前已陈旧的 token 或原始 `null` 也可安全重试。条目已不存在时,`delete` 忽略 `ifVersion`;成功后始终返回稳定的 `{ absent: true }` 后置条件。
携带匹配 version 的无变化请求会返回已存条目,version 与时间戳均不变成功响应丢失后,使用 token 重试会得到 `version-conflict.current`;调用方无需额外读取,即可把权威当前值与目标值比较。条目已不存在时,`delete` 忽略 `ifVersion`;成功后始终返回稳定的 `{ absent: true }` 后置条件。
按 Session 划分的 promise 队列覆盖检查、持久性校验、伴随记录读取、比较与整行写入。这些语义会串行化经由同一服务实例的并发变更;storage-domain 自身没有跨进程条件写。
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-message-feedback",
"description": "Lifecycle-bound per-message rating and note sidecar for the DeepSeek Harness",
"version": "0.0.1",
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
+12 -17
View File
@@ -192,8 +192,8 @@ export class MessageFeedbackService extends GatewayService {
/**
* Create or replace feedback for one derived append-origin assistant
* message. An exact desired-value retry returns the stored item before its
* stale or `null` version is considered a conflict.
* message. Every request must match the addressed item's current version;
* a matching no-op returns the stored item without changing its revision.
* @param request - target, desired value, and observed item version.
* @returns the committed item or an explicit business failure.
*/
@@ -228,14 +228,14 @@ export class MessageFeedbackService extends GatewayService {
const items = current?.items ?? EMPTY_ITEMS
const index = items.findIndex(item => item.messageId === request.messageId)
const existing = items[index]
if (request.ifVersion !== (existing?.version ?? null)) {
return rejected(this.versionConflict(existing ?? null))
}
if (existing !== undefined
&& existing.rating === request.rating
&& existing.note === note.value) {
return success(snapshotItem(existing))
}
if (request.ifVersion !== (existing?.version ?? null)) {
return rejected(this.versionConflict(request, existing?.version ?? null))
}
const now = Date.now()
const item = snapshotItem({
@@ -278,7 +278,7 @@ export class MessageFeedbackService extends GatewayService {
return success<MessageFeedbackDeleteValue>(Object.freeze({ absent: true }))
}
if (request.ifVersion !== existing.version) {
return rejected(this.versionConflict(request, existing.version))
return rejected(this.versionConflict(existing))
}
await table.put(
@@ -298,7 +298,8 @@ export class MessageFeedbackService extends GatewayService {
private async inspectSession(sessionId: SessionId): Promise<KnownSession> {
if (this.ctx.sessions.get(sessionId) === undefined) {
const snapshots = await this.ctx.sessionPersistence.listSnapshots()
if (!snapshots.some(snapshot => snapshot.header.id === sessionId)) {
if (!snapshots.some(snapshot => snapshot.header.id === sessionId)
&& this.ctx.sessions.get(sessionId) === undefined) {
return rejected({ code: 'session-not-found', sessionId })
}
}
@@ -327,7 +328,7 @@ export class MessageFeedbackService extends GatewayService {
`message-feedback: no durability listener participated for live session '${inspection.meta.id}'`,
)
}
return inspection
return await this.ctx.sessionPersistence.readFrom(inspection.meta.id, 0)
}
return await this.ctx.sessionPersistence.readFrom(inspection.meta.id, 0)
}
@@ -343,17 +344,11 @@ export class MessageFeedbackService extends GatewayService {
return success(note)
}
/** Build a conflict branch without exposing an orderable version. */
private versionConflict(
request: Pick<MessageFeedbackPutRequest, 'sessionId' | 'messageId' | 'ifVersion'>,
actual: MessageFeedbackVersion | null,
): MessageFeedbackVersionConflict {
/** Return the authoritative item needed to reconcile one failed comparison. */
private versionConflict(current: MessageFeedbackItem | null): MessageFeedbackVersionConflict {
return {
code: 'version-conflict',
sessionId: request.sessionId,
messageId: request.messageId,
expected: request.ifVersion,
actual,
current: current === null ? null : snapshotItem(current),
}
}
@@ -89,12 +89,8 @@ export interface MessageFeedbackTargetNotFound {
/** A material mutation did not match the addressed item's current version. */
export interface MessageFeedbackVersionConflict {
readonly code: 'version-conflict'
readonly sessionId: SessionId
readonly messageId: MessageId
/** Version supplied by the caller (`null` means create-only). */
readonly expected: MessageFeedbackVersion | null
/** Current version, or `null` when the item does not exist. */
readonly actual: MessageFeedbackVersion | null
/** Authoritative current item, or `null` when it does not exist. */
readonly current: MessageFeedbackItem | null
}
/** A supplied note contains no non-whitespace character. */
@@ -117,6 +117,7 @@ class TestPersistence extends SessionPersistence {
inspectCalls = 0
readFromCalls = 0
onReadFrom: (() => void) | undefined
onListSnapshots: (() => void | Promise<void>) | undefined
locate(_meta: SessionHeader): SessionLocation | undefined { return undefined }
create(_meta: SessionHeader): Promise<void> { return Promise.resolve() }
@@ -155,11 +156,12 @@ class TestPersistence extends SessionPersistence {
return Promise.resolve([...this.durable.values()].map(value => value.meta))
}
listSnapshots(): Promise<SessionPersistenceSnapshot[]> {
return Promise.resolve([...this.durable.values()].map((value, index) => ({
async listSnapshots(): Promise<SessionPersistenceSnapshot[]> {
await this.onListSnapshots?.()
return [...this.durable.values()].map((value, index) => ({
header: value.meta,
revision: SessionPersistenceRevision(`test:${index}:${value.events.length}`),
})))
}))
}
persist(session: Session): void {
@@ -68,6 +68,25 @@ describe('MessageFeedbackService public contract', () => {
await expect(ctx.messageFeedback.list({ sessionId: fixture.session.id })).rejects.toBe(corruption)
})
it('rechecks live ownership before returning a cold catalog miss', async () => {
const { ctx, persistence } = await harness()
const sessionId = SessionId('catalog-live-race')
const listed = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
persistence.onListSnapshots = async () => {
listed.resolve(undefined)
await release.promise
}
const pending = ctx.messageFeedback.list({ sessionId })
await listed.promise
ctx.sessions.create(sessionId, { meta: { createdAt: 1_700_000_000_001 } })
release.resolve(undefined)
await expect(pending).resolves.toEqual({ ok: true, value: { items: [] } })
expect(persistence.inspectCalls).toBe(1)
})
it('returns session-not-found from mutations and conflicts on an observed version for an absent item', async () => {
const { ctx, persistence } = await harness()
const missing = SessionId('missing-mutations')
@@ -100,13 +119,7 @@ describe('MessageFeedbackService public contract', () => {
ifVersion: expected,
})).resolves.toEqual({
ok: false,
error: {
code: 'version-conflict',
sessionId: fixture.session.id,
messageId: fixture.assistantMessageIds[0],
expected,
actual: null,
},
error: { code: 'version-conflict', current: null },
})
})
@@ -154,7 +167,7 @@ describe('MessageFeedbackService public contract', () => {
sessionId: fixture.session.id,
messageId,
rating: 'negative',
ifVersion: null,
ifVersion: updated.version,
}))
expect(retry).toEqual(updated)
@@ -320,13 +333,7 @@ describe('MessageFeedbackService item concurrency', () => {
ifVersion: first.version,
})).resolves.toEqual({
ok: false,
error: {
code: 'version-conflict',
sessionId: fixture.session.id,
messageId: firstId,
expected: first.version,
actual: updated.version,
},
error: { code: 'version-conflict', current: updated },
})
const listed = await ctx.messageFeedback.list({ sessionId: fixture.session.id })
@@ -335,6 +342,41 @@ describe('MessageFeedbackService item concurrency', () => {
expect(listed.value.items[1]?.version).toBe(second.version)
})
it('rejects a stale put even when the current value has returned to the same state', async () => {
const { ctx, persistence } = await harness()
const fixture = messageFixture('put-aba')
persistence.persist(fixture.session)
const messageId = fixture.assistantMessageIds[0]
const first = expectItem(await ctx.messageFeedback.put({
sessionId: fixture.session.id,
messageId,
rating: 'positive',
ifVersion: null,
}))
const second = expectItem(await ctx.messageFeedback.put({
sessionId: fixture.session.id,
messageId,
rating: 'negative',
ifVersion: first.version,
}))
const current = expectItem(await ctx.messageFeedback.put({
sessionId: fixture.session.id,
messageId,
rating: 'positive',
ifVersion: second.version,
}))
await expect(ctx.messageFeedback.put({
sessionId: fixture.session.id,
messageId,
rating: 'positive',
ifVersion: first.version,
})).resolves.toEqual({
ok: false,
error: { code: 'version-conflict', current },
})
})
it('makes delete retries stable and prevents delete/recreate ABA', async () => {
const { ctx, persistence } = await harness()
const fixture = messageFixture('delete-aba')
@@ -351,9 +393,9 @@ describe('MessageFeedbackService item concurrency', () => {
sessionId: fixture.session.id,
messageId,
ifVersion: staleVersion(),
})).resolves.toMatchObject({
})).resolves.toEqual({
ok: false,
error: { code: 'version-conflict', actual: created.version },
error: { code: 'version-conflict', current: created },
})
const request = {
sessionId: fixture.session.id,
@@ -376,9 +418,9 @@ describe('MessageFeedbackService item concurrency', () => {
ifVersion: null,
}))
expect(recreated.version).not.toBe(created.version)
await expect(ctx.messageFeedback.delete(request)).resolves.toMatchObject({
await expect(ctx.messageFeedback.delete(request)).resolves.toEqual({
ok: false,
error: { code: 'version-conflict', actual: recreated.version },
error: { code: 'version-conflict', current: recreated },
})
})
@@ -449,7 +491,7 @@ describe('MessageFeedbackService durability ordering', () => {
})
})
it('commits a live target checkpoint before the sidecar write without a cold-log reread', async () => {
it('commits and physically verifies a live target checkpoint before the sidecar write', async () => {
const { ctx, persistence } = await harness()
const session = ctx.sessions.create(SessionId('live-checkpoint'), {
meta: { createdAt: 30, cwd: '/live' },
@@ -463,7 +505,7 @@ describe('MessageFeedbackService durability ordering', () => {
ctx.on('domain/changed', (change) => {
if (change.domain === 'message_feedback') order.push('sidecar:durable')
})
persistence.onReadFrom = () => { order.push('unexpected:cold-read') }
persistence.onReadFrom = () => { order.push('session:verified') }
expectItem(await ctx.messageFeedback.put({
sessionId: session.id,
@@ -471,14 +513,14 @@ describe('MessageFeedbackService durability ordering', () => {
rating: 'positive',
ifVersion: null,
}))
expect(order).toEqual(['session:durable', 'sidecar:durable'])
expect(persistence.readFromCalls).toBe(0)
expect(order).toEqual(['session:durable', 'session:verified', 'sidecar:durable'])
expect(persistence.readFromCalls).toBe(1)
expect(persistence.durable.get(session.id)?.events).toContainEqual(
expect.objectContaining({ type: 'assistant/message' }),
)
})
it('fails closed when a live checkpoint fails or has no participant', async () => {
it('fails closed when a live checkpoint fails, has no participant, or is not physically durable', async () => {
const failed = await harness()
const failedSession = failed.ctx.sessions.create(SessionId('live-flush-failure'))
const failedFixture = appendMessageFixture(failedSession)
@@ -508,6 +550,22 @@ describe('MessageFeedbackService durability ordering', () => {
ok: true,
value: { items: [] },
})
const noDurability = await harness()
const unpersistedSession = noDurability.ctx.sessions.create(SessionId('live-unpersisted'))
const unpersistedFixture = appendMessageFixture(unpersistedSession)
noDurability.ctx.on('session/flush', () => {})
await expect(noDurability.ctx.messageFeedback.put({
sessionId: unpersistedSession.id,
messageId: unpersistedFixture.assistantMessageIds[0],
rating: 'positive',
ifVersion: null,
})).rejects.toThrow(/not found/u)
expect(noDurability.persistence.durable.has(unpersistedSession.id)).toBe(false)
await expect(noDurability.ctx.messageFeedback.list({ sessionId: unpersistedSession.id })).resolves.toEqual({
ok: true,
value: { items: [] },
})
})
it('finishes the captured live checkpoint when the Session detaches mid-flush', async () => {
@@ -537,7 +595,7 @@ describe('MessageFeedbackService durability ordering', () => {
expect(ctx.sessions.get(session.id)).toBeUndefined()
release.resolve(undefined)
expectItem(await pending)
expect(persistence.readFromCalls).toBe(0)
expect(persistence.readFromCalls).toBe(1)
await expect(ctx.messageFeedback.list({ sessionId: session.id })).resolves.toMatchObject({
ok: true,
value: { items: [{ messageId: fixture.assistantMessageIds[0] }] },
@@ -596,7 +596,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: '@Remote(\'put\') put(request: MessageFeedbackPutRequest): Promise<MessageFeedbackPutResult>',
jsDoc: '/**\n * Create or replace feedback for one derived append-origin assistant\n * message. An exact desired-value retry returns the stored item before its\n * stale or `null` version is considered a conflict.\n * @param request - target, desired value, and observed item version.\n * @returns the committed item or an explicit business failure.\n */',
jsDoc: '/**\n * Create or replace feedback for one derived append-origin assistant\n * message. Every request must match the addressed item\'s current version;\n * a matching no-op returns the stored item without changing its revision.\n * @param request - target, desired value, and observed item version.\n * @returns the committed item or an explicit business failure.\n */',
},
{
signature: '@Remote(\'delete\') delete(request: MessageFeedbackDeleteRequest): Promise<MessageFeedbackDeleteResult>',
@@ -2423,7 +2423,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'MessageFeedbackVersionConflict',
declaration: 'export interface MessageFeedbackVersionConflict {\n readonly code: \'version-conflict\';\n readonly sessionId: SessionId;\n readonly messageId: MessageId;\n readonly expected: MessageFeedbackVersion | null;\n readonly actual: MessageFeedbackVersion | null;\n}',
declaration: 'export interface MessageFeedbackVersionConflict {\n readonly code: \'version-conflict\';\n readonly current: MessageFeedbackItem | null;\n}',
},
{
name: 'MessageId',