fix(compact): address manual compaction review

This commit is contained in:
Hypatia May
2026-07-30 22:00:34 +08:00
parent df0c108e1b
commit db1cbf0bcc
16 changed files with 130 additions and 24 deletions

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 packages/client/runtime/README.md
README.md: fbb979ad410e520e01220519b57dd428bbda14f1
README.zh.md: 29e3f0ef46e4b016679cf17dc58d8fe1a67fca6c
README.md: 2d0e3228931f9a5dd467c996e69d1ea6a81352cc
README.zh.md: 07c0ac90e82d11c71581f96b2fab64cf686fdd52

View File

@@ -28,7 +28,7 @@ Because the projection is log-ordered, the node array is seq-monotonic by constr
## Request inspection
`SessionHistoryInspection.requests` is one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn.
`SessionHistoryInspection.requests` is one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan.
## Code Mode sub-dispatch index

View File

@@ -28,7 +28,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## 请求检查
`SessionHistoryInspection.requests` 是一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn``step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。
`SessionHistoryInspection.requests` 是一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn``step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。
## Code Mode 子调用索引

View File

@@ -367,6 +367,15 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
}
const type = sourceEvent.type as string
if (type === 'session/end-seed' && activeCompaction !== undefined) {
updateCompaction(activeCompaction, {
completedAt: sourceEvent.time,
status: 'error',
error: 'Compaction was interrupted before completion.',
})
activeCompaction = undefined
continue
}
if (type === 'compact/start') {
const event = sourceEvent as unknown as CompactionStartEvent
activeCompaction = requests.length

View File

@@ -116,6 +116,37 @@ describe('inspectRequests', () => {
}
})
it('interrupts an orphaned compaction at end-seed before projecting a new attempt', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'compact/start', { turn: null }),
at(1, 'session/end-seed', {}),
at(2, 'compact/start', { turn: null }),
at(3, 'compact/summary', {
summary: [{ type: 'text', text: 'replacement summary' }],
provider: 'fake',
model: 'compact-model',
}),
at(4, 'compact/end', { turn: null }),
]))
expect(snapshot.requests).toMatchObject([
{
purpose: 'compaction',
startSeq: 0,
status: 'error',
completedAt: 1_700_000_000_001,
error: 'Compaction was interrupted before completion.',
},
{
purpose: 'compaction',
startSeq: 2,
status: 'complete',
completedAt: 1_700_000_000_004,
summary: [{ type: 'text', text: 'replacement summary' }],
},
])
})
it('captures schemas for nested tool dispatches from the active request header', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'request/header', {

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 packages/compact/command-compact/README.md
README.md: 314b259025a2b7a13faf27f9d3935cca370224fe
README.zh.md: 9fb30c6720c3c3d69ed64242e995d953ec8a008f
README.md: 1445e76f8328a9ac1c5f9dd43094f1c1cd5d2ad4
README.zh.md: 0fb306afb3713e47fb17d2c914a4f691b63b77eb

View File

@@ -24,7 +24,7 @@ Expected `ManualCompactionError` codes become stable direct errors:
| `commit` | `Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.` |
| `persistence` | `Compaction finished, but the session could not be saved.` |
The busy result is intentionally process-scoped: a live unmatched marker blocks, while a marker older than the newest `session/end-seed` is stale and does not. Unexpected implementation failures reject dispatch. Cancellation remains authoritative; the backend completes its required close/flush cleanup, and the command settles internally as `Compaction cancelled.` while the command executor stops waiting with its cancellation error.
The busy result is intentionally process-scoped: a live unmatched marker blocks, while a marker older than the newest `session/end-seed` is stale and does not. Unexpected implementation failures reject dispatch. Cancellation remains authoritative; the backend completes its required close/flush cleanup, and the command settles internally as `Compaction cancelled.` while the command executor stops waiting with its cancellation error. Plugin disposal first unregisters `/compact`, then drains every handler that already started, so root teardown cannot pass an aborted command's close or flush boundary.
Prompts submitted while compaction runs remain accepted in the agent's ordinary FIFO with the same identity and wakeup facts. They start only after the compaction's explicit durability checkpoint and admission release. Idle injected context is not held: it may be logged between `compact/start` and `compact/end`, and positional replacement leaves it visible after the checkpoint.

View File

@@ -24,7 +24,7 @@
| `commit` | `Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.` |
| `persistence` | `Compaction finished, but the session could not be saved.` |
busy 结果有意限定在进程范围内:活动的未匹配标记会阻塞,而早于最新 `session/end-seed` 的标记已陈旧不会阻塞。意外实现故障会拒绝分发。取消仍具有最终决定权后端会完成必需的闭合flush 清理,命令内部以 `Compaction cancelled.` 结算,而命令执行器会因取消错误停止等待。
busy 结果有意限定在进程范围内:活动的未匹配标记会阻塞,而早于最新 `session/end-seed` 的标记已陈旧不会阻塞。意外实现故障会拒绝分发。取消仍具有最终决定权后端会完成必需的闭合flush 清理,命令内部以 `Compaction cancelled.` 结算,而命令执行器会因取消错误停止等待。插件处置会先注销 `/compact`,再等待所有已开始的处理器结算,因此根级 teardown 不会越过已中止命令的闭合或 flush 边界。
压缩运行期间提交的提示词仍会按 agent 的普通 FIFO 获得接纳,保留相同的身份与唤醒信息。它们仅在压缩的显式持久性检查点和接纳预留释放后启动。空闲注入的上下文不受阻塞:它可以记录在 `compact/start``compact/end` 之间,位置替换会使其在检查点之后保持可见。

View File

@@ -79,9 +79,25 @@ async function executeCompact(
* @param ctx - context carrying the command registry and the compaction seam.
*/
export function apply(ctx: Context): void {
ctx.commands.register({
name: 'compact',
description: 'Compact older conversation history',
handler: invocation => executeCompact(ctx, invocation),
})
const active = new Set<Promise<CommandResult>>()
const handler = (invocation: CommandInvocation): Promise<CommandResult> => {
const operation = executeCompact(ctx, invocation)
active.add(operation)
const retire = (): void => { active.delete(operation) }
// Both branches retire without rethrowing, so the derived observer promise
// cannot become an unhandled mirror of an expected handler rejection.
void operation.then(retire, retire)
return operation
}
ctx.effect(function* () {
// Yield drain before registration: composite teardown is LIFO, so no new
// invocation can enter while already-started handler promises quiesce.
yield async () => { await Promise.allSettled(active) }
yield ctx.commands.register({
name: 'compact',
description: 'Compact older conversation history',
handler,
})
}, 'command-compact lifecycle')
}

View File

@@ -204,4 +204,45 @@ describe('/compact human command', () => {
await expect(run(unexpected)).rejects.toBe(bug)
expectLastLifecycle(unexpected, '', { kind: 'error', text: bug.message })
})
it('drains an aborted handler through close and flush before plugin disposal settles', async () => {
const test = await harness()
const controller = new AbortController()
const abort = new Error('operator cancelled')
const started = Promise.withResolvers<undefined>()
const allowClose = Promise.withResolvers<undefined>()
const closed = Promise.withResolvers<undefined>()
const allowFlush = Promise.withResolvers<undefined>()
const flushed = Promise.withResolvers<undefined>()
test.compact.operation = async () => {
started.resolve(undefined)
await allowClose.promise
closed.resolve(undefined)
await allowFlush.promise
flushed.resolve(undefined)
throw abort
}
const execution = run(test, '', controller)
await started.promise
controller.abort(abort)
await expect(execution).rejects.toBe(abort)
let disposed = false
const disposal = test.plugin.dispose()
void disposal.then(() => { disposed = true })
await new Promise(resolve => setTimeout(resolve, 0))
expect(test.ctx.commands.find(test.agent, 'compact')).toBeUndefined()
expect(disposed).toBe(false)
allowClose.resolve(undefined)
await closed.promise
await new Promise(resolve => setTimeout(resolve, 0))
expect(disposed).toBe(false)
allowFlush.resolve(undefined)
await flushed.promise
await disposal
expect(disposed).toBe(true)
})
})

View File

@@ -24,13 +24,17 @@ export type CompactionTrigger = 'pressure' | 'context-overflow'
/** Expected failure classes for an explicit idle-session compaction request. */
export type ManualCompactionErrorCode = 'busy' | 'changed' | 'summary' | 'commit' | 'persistence'
/** Expected manual-compaction failure suitable for a direct human-command result. */
/**
* Expected manual-compaction failure suitable for a direct human-command result.
* Shared durable-lock entry assertions may also throw the `busy` subtype from
* automatic compaction paths.
*/
export class ManualCompactionError extends Error {
override readonly name = 'ManualCompactionError'
/**
* Create one classified manual-compaction failure.
* @param code - stable failure class for a human-command consumer.
* Create one classified compaction failure.
* @param code - stable failure class; `busy` may originate from any compaction entry path.
* @param message - backend diagnostic retained as the Error message.
* @param options - optional original failure.
*/