问题:createAutofixTeammate 生成 teammate UUID 作为 monitor lock 的 key, 但 registerRemoteAgentTask 内部生成的 framework taskId 是另一个 UUID。 CCR session 自然完成时框架调 clearActiveMonitor(frameworkTaskId) guard 失败,lock 永不释放,导致后续 /autofix-pr 报 "already monitoring"。 修复(Phase 1 of remote-agent completion loop): - monitorState 新增 updateActiveMonitor(partial) 原子更新 - callAutofixPr 在 register 后 swap lock 的 taskId 到 framework 分配的 id - RemoteAgentTask 引入 registerCompletionHook 注册式 API(参考已有的 registerCompletionChecker 模式),在 5 个完成路径调 runCompletionHook - autofix-pr 命令模块自己注册 cleanup hook,避免 framework 反向依赖 command 模块 测试: - monitorState 新增 4 个测试(updateActiveMonitor 行为 + bug 复现/修复) - launchAutofixPr 新增 3 个端到端回归测试(taskId swap + hook 触发 + subsequent launch 不报 already monitoring) 完整分析与 Phase 2/3 改造方案见 docs/features/remote-agent-completion-analysis.md。
118 lines
3.9 KiB
TypeScript
118 lines
3.9 KiB
TypeScript
import { beforeEach, describe, expect, test } from 'bun:test'
|
|
import {
|
|
clearActiveMonitor,
|
|
getActiveMonitor,
|
|
isMonitoring,
|
|
setActiveMonitor,
|
|
trySetActiveMonitor,
|
|
updateActiveMonitor,
|
|
} from '../monitorState.js'
|
|
|
|
function makeState(
|
|
overrides?: Partial<Parameters<typeof setActiveMonitor>[0]>,
|
|
) {
|
|
return {
|
|
taskId: 'task-1',
|
|
owner: 'acme',
|
|
repo: 'myrepo',
|
|
prNumber: 42,
|
|
abortController: new AbortController(),
|
|
startedAt: Date.now(),
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe('monitorState', () => {
|
|
beforeEach(() => {
|
|
clearActiveMonitor()
|
|
})
|
|
|
|
test('getActiveMonitor returns null when nothing set', () => {
|
|
expect(getActiveMonitor()).toBeNull()
|
|
})
|
|
|
|
test('setActiveMonitor stores state and getActiveMonitor returns it', () => {
|
|
const state = makeState()
|
|
setActiveMonitor(state)
|
|
expect(getActiveMonitor()).toBe(state)
|
|
})
|
|
|
|
test('clearActiveMonitor resets state to null', () => {
|
|
setActiveMonitor(makeState())
|
|
clearActiveMonitor()
|
|
expect(getActiveMonitor()).toBeNull()
|
|
})
|
|
|
|
test('isMonitoring returns true for matching owner/repo/prNumber', () => {
|
|
setActiveMonitor(makeState())
|
|
expect(isMonitoring('acme', 'myrepo', 42)).toBe(true)
|
|
})
|
|
|
|
test('isMonitoring returns false when not monitoring', () => {
|
|
expect(isMonitoring('acme', 'myrepo', 42)).toBe(false)
|
|
})
|
|
|
|
test('setActiveMonitor throws when already active', () => {
|
|
setActiveMonitor(makeState())
|
|
expect(() => setActiveMonitor(makeState({ prNumber: 99 }))).toThrow(
|
|
/Monitor already active/,
|
|
)
|
|
})
|
|
|
|
test('clearActiveMonitor calls abort on the controller', () => {
|
|
const abortController = new AbortController()
|
|
setActiveMonitor(makeState({ abortController }))
|
|
clearActiveMonitor()
|
|
expect(abortController.signal.aborted).toBe(true)
|
|
})
|
|
|
|
test('trySetActiveMonitor returns true when no active monitor', () => {
|
|
expect(trySetActiveMonitor(makeState())).toBe(true)
|
|
expect(getActiveMonitor()).not.toBeNull()
|
|
})
|
|
|
|
test('trySetActiveMonitor returns false when monitor already active', () => {
|
|
expect(trySetActiveMonitor(makeState({ prNumber: 1 }))).toBe(true)
|
|
expect(trySetActiveMonitor(makeState({ prNumber: 2 }))).toBe(false)
|
|
// First state remains
|
|
expect(getActiveMonitor()?.prNumber).toBe(1)
|
|
})
|
|
|
|
test('updateActiveMonitor returns false when no active monitor', () => {
|
|
expect(updateActiveMonitor({ taskId: 'task-x' })).toBe(false)
|
|
expect(getActiveMonitor()).toBeNull()
|
|
})
|
|
|
|
test('updateActiveMonitor merges partial fields into the active monitor', () => {
|
|
setActiveMonitor(makeState({ taskId: 'tentative-uuid' }))
|
|
expect(updateActiveMonitor({ taskId: 'framework-task-id' })).toBe(true)
|
|
const after = getActiveMonitor()
|
|
expect(after?.taskId).toBe('framework-task-id')
|
|
// Other fields untouched
|
|
expect(after?.owner).toBe('acme')
|
|
expect(after?.repo).toBe('myrepo')
|
|
expect(after?.prNumber).toBe(42)
|
|
})
|
|
|
|
test('updateActiveMonitor with new taskId makes clearActiveMonitor recognise framework taskId', () => {
|
|
// Reproduce the latent bug scenario: lock acquired with one taskId,
|
|
// framework assigns a different one. Before the fix, the framework's
|
|
// clearActiveMonitor(frameworkTaskId) would no-op because guard fails.
|
|
setActiveMonitor(makeState({ taskId: 'teammate-uuid' }))
|
|
// Framework cleanup using its own taskId — would fail guard before the fix
|
|
clearActiveMonitor('framework-uuid')
|
|
expect(getActiveMonitor()).not.toBeNull()
|
|
// After updateActiveMonitor swaps the taskId, framework cleanup works
|
|
updateActiveMonitor({ taskId: 'framework-uuid' })
|
|
clearActiveMonitor('framework-uuid')
|
|
expect(getActiveMonitor()).toBeNull()
|
|
})
|
|
|
|
test('updateActiveMonitor does not change abortController identity', () => {
|
|
const ac = new AbortController()
|
|
setActiveMonitor(makeState({ abortController: ac, taskId: 'tentative' }))
|
|
updateActiveMonitor({ taskId: 'updated' })
|
|
expect(getActiveMonitor()?.abortController).toBe(ac)
|
|
})
|
|
})
|