fix(compact): address manual compaction review
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 子调用索引
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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` 之间,位置替换会使其在检查点之后保持可见。
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user