Execution: reclaim the directories of Ended Tickets after a retention

This commit is contained in:
2026-11-02 01:46:33 +08:00
parent b348d69beb
commit 3bee231da6
18 changed files with 326 additions and 18 deletions
+6
View File
@@ -112,6 +112,8 @@ export interface PanelServer {
failing: Extract<Problem, { kind: 'outgoing' }>['outgoing'][]
/** Sources whose poll loop the fixture reports as stopped, with how long ago their last round was. */
stalled: { id: string; title: Localized; ageMs: number; intervalMs: number }[]
/** What the execution root holds for Ended Tickets; the panel renders it as a readout. */
workspace: NonNullable<ApiResponse<'GET overview'>['workspace']>
}
export let server: PanelServer
@@ -204,6 +206,7 @@ const overviewView = (): ApiResponse<'GET overview'> => {
// The fixture panel renders the readout, so a poll round a few seconds old stands for a running Source.
liveness: { startedAt: '2026-09-22T00:00:00Z', uptimeMs: 3_600_000, lastPollAt: new Date(Date.now() - 4_000).toISOString(),
pollAgeMs: 4_000, polling: true },
workspace: server.workspace,
person: server.person, executor: { running: 2, queued: 0, maxWorkers: server.settings.executor.maxWorkers, idle: 1 },
flow: { received: 12, filtered: 2, triggers: 10, judged: 5, accepted: 1, evaluating: 0,
dropped: server.dropped.filter(item => item.dropped.at >= dayAgo()).length },
@@ -257,6 +260,9 @@ function initial(): PanelServer {
const defaults = { revision: 0, previousRevision: null, savedAt: '', source: 'defaults' }
return {
health: {}, down: false, pending: 0, opened: [], requests: [], misuse: [], queued: new Set(), dropped: [], failing: [], stalled: [],
// One ended Ticket outlived the retention, so the readout has something to report.
workspace: { root: '/srv/dsh-superagent/work', afterDays: 14, directories: 5, candidates: 1, bytes: 58 * 1024 * 1024,
lastScanAt: new Date(Date.now() - 600_000).toISOString(), unreadable: 0 },
setup: { machine: MACHINE, problem: null, feishuProblem: null,
accounts: { repository: 'acme/widgets', agent: 'agent-bot', feishu: { appId: 'cli_bot', brand: 'feishu', botId: 'ou_bot',
name: 'Widgets Bot' } } },
+15 -4
View File
@@ -11,8 +11,8 @@ it('leads the overview with the pending Tickets, then problems, the last day and
await mount()
expect(tab(zh['tab.overview']).getAttribute('aria-selected')).toBe('true')
expect(all('[role="tab"]').map(tab => tab.textContent)).toEqual(['概览', '来源', '触发', '评估', '执行', '设置'])
expect(all('.dt-section-title').map(title => title.textContent)).toEqual(['待处理工单2', '系统问题2', '存活', '过去 24 小时详情 ›',
'执行器详情 ›'])
expect(all('.dt-section-title').map(title => title.textContent)).toEqual(['待处理工单2', '系统问题2', '存活', '工单目录',
'过去 24 小时详情 ›', '执行器详情 ›'])
expect(all('.dt-cards .dt-ticket-stage').map(item => item.textContent)).toEqual(['待回答', '待处理失败'])
expect(all('.dt-cards .dt-att-what').map(item => item.textContent)).toEqual(['飞书 来源异常', '评估未完成:这个文案好奇怪'])
// Actions sit below the title and Threads, apart from the stage, each with its icon.
@@ -22,9 +22,11 @@ it('leads the overview with the pending Tickets, then problems, the last day and
expect(text()).toContain('需要确认按钮颜色')
// Durations are formatted by the interface language, so the readout is asserted by label, and only the polling word verbatim.
expect(all('.dt-stat-label').map(label => label.textContent)).toEqual([zh.livenessUptime, zh.livenessLastPoll, zh.livenessPolling,
zh.workspaceHeld, zh.workspaceCandidates, zh.workspaceDirCount, zh.reclaimAfterDays,
zh.flowReceivedLabel, zh.flowFilteredLabel, zh.flowJudgedLabel, zh.flowAcceptedLabel, zh.executingLabel, zh.queuedLabel,
zh.idleLabel])
expect(all('.dt-stat-value').map(value => value.textContent).slice(3)).toEqual(['12', '2', '5', '1', '2 / 3', '0', '1'])
expect(all('.dt-stat-value').map(value => value.textContent).slice(7)).toEqual(['12', '2', '5', '1', '2 / 3', '0', '1'])
expect(all('.dt-stat-value').map(value => value.textContent)[3]).toBe('58.0 MB')
expect(all('.dt-stat-value').map(value => value.textContent).slice(0, 3)).toEqual([expect.stringContaining('小时'), expect
.stringContaining('秒'), zh.livenessPolling])
expect(all('.dt-chip').map(chip => chip.textContent)[0]).toBe('GitHub#42CI 失败1')
@@ -134,9 +136,10 @@ it('words a refused retry in the interface language by its code', async () => {
it('shows the overview in English', async () => {
await mount(en)
expect(all('.dt-section-title').map(title => title.textContent)).toEqual([`${en.pendingTickets}2`, `${en.problems}2`,
en.livenessTitle, `${en.lastDay}${en.details}`, `${en['term.executor']}${en.details}`])
en.livenessTitle, en.workspaceTitle, `${en.lastDay}${en.details}`, `${en['term.executor']}${en.details}`])
expect(all('.dt-cards .dt-ticket-stage').map(item => item.textContent)).toEqual([en['stage.answer'], en['stage.failed']])
expect(all('.dt-stat-label').map(label => label.textContent)).toEqual([en.livenessUptime, en.livenessLastPoll, en.livenessPolling,
en.workspaceHeld, en.workspaceCandidates, en.workspaceDirCount, en.reclaimAfterDays,
en.flowReceivedLabel, en.flowFilteredLabel, en.flowJudgedLabel, en.flowAcceptedLabel, en.executingLabel, en.queuedLabel, en.idleLabel])
})
@@ -174,3 +177,11 @@ it('says when a Source stopped polling, with how long its last round was and the
expect(line).toBe(zh.problemStalled.replace('{source}', 'GitHub').replace('{age}', duration(26 * 60_000, 'zh')))
expect(text()).toContain(zh.problemStalledHint)
})
it('reads out what the ended Tickets still hold, and that nothing is ever removed when the retention is zero', async () => {
server.workspace = { root: '/srv/work', afterDays: 0, directories: 3, candidates: 0, bytes: 0, lastScanAt: new Date().toISOString(),
unreadable: 0 }
await mount()
expect(text()).toContain(zh.workspaceKeepForever)
expect(all('.dt-stat-value').map(value => value.textContent)).toContain('0')
})
+2 -2
View File
@@ -54,8 +54,8 @@ it('saves the Eval block on the Policy settings and the Executor with its execut
expect([...document.querySelectorAll('#dt-worker-model-efforts option')].map(option => option.getAttribute('value'))).toEqual(['high'])
await click(zh.save)
expect(server.settings.executor).toEqual({ maxWorkers: 5, directory: '/srv/work',
prepare: 'git worktree add "$DSH_SUPERAGENT_DIR"', blockedCommands: ['gh-personal', 'gh.other'], model: { provider: 'worker-provider',
model: 'worker-model', reasoningEffort: 'high' } })
prepare: 'git worktree add "$DSH_SUPERAGENT_DIR"', blockedCommands: ['gh-personal', 'gh.other'], reclaimAfterDays: 14,
model: { provider: 'worker-provider', model: 'worker-model', reasoningEffort: 'high' } })
expect(server.requests.filter(request => request.method === 'PUT').map(request => request.path)).toEqual(['settings/eval',
'settings/executor'])
})
+1 -1
View File
@@ -254,7 +254,7 @@ describe('settings migration', () => {
sources: { feishu: feishuSource({ chats: [{ chatId: CHAT, listen: 'mentions' }, { chatId: OBSERVED, listen: 'all' }], directMessages: false }), github: { enabled: true, config: { pollSeconds: 120 } } },
policy: { rules: [], sources: { feishu: { allowed: [{ id: MEMBER, name: 'A' }], admission: {} }, github: { allowed: [{ id: 'a' }], admission: {} } } },
eval: { mode: 'auto', timeoutSeconds: 90, model: { provider: 'p', model: 'm' } },
executor: { model: null, maxWorkers: 5, directory: '', prepare: '', blockedCommands: [] },
executor: { model: null, maxWorkers: 5, directory: '', prepare: '', blockedCommands: [], reclaimAfterDays: 14 },
})
expect(migrateSettings(migrated)).toEqual(migrated)
})
+69
View File
@@ -0,0 +1,69 @@
// Reclaiming the directories of Ended Tickets: what may go, and what the readout counts.
import { resolve } from 'node:path'
import { expect, it } from 'vitest'
import { reclaimable, within } from '../src/app/reclaim.ts'
import type { Ticket } from '../src/core/index.ts'
const DAY = 86_400_000
const root = '/srv/dsh-superagent/work'
const now = Date.parse('2026-10-31T00:00:00.000Z')
const at = (daysAgo: number) => new Date(now - daysAgo * DAY).toISOString()
/** A Ticket as the reclaimer reads it: an id, its workspace, and when it ended. */
function ticket(id: string, name: string, ended: string | undefined, directory = `${root}/${name}`): Ticket {
return { id, title: id, decisionId: 'd', channels: [], undelivered: [], createdAt: at(30), updatedAt: at(30),
mandate: { strength: 'explicit', policyVersion: 'v', basis: { via: 'listed' }, actor: { source: 'feishu', id: 'ou_alice' }, scope: '' },
origin: { triggerId: 'e1', triggerIds: ['e1'] }, assignments: [], inputs: [],
...ended ? { ended: { at: ended, by: 'executor' as const, outcome: 'completed' as const } } : {},
execution: { name, directory, assignment: '' } }
}
it('tells a path inside the execution root from one beside or above it', () => {
expect(within(root, `${root}/create-wiki-06e6e4ae`)).toBe(true)
expect(within(root, `${root}/a/b/c`)).toBe(true)
// A sibling whose name starts with the root's, a parent, and the root itself are not inside it.
expect(within(root, `${root}-other/ticket`)).toBe(false)
expect(within(root, '/srv/dsh-superagent')).toBe(false)
expect(within(root, root)).toBe(false)
expect(within(root, '/elsewhere/ticket')).toBe(false)
})
it('reclaims an Ended Ticket\'s own directory once it outlives the retention, and no other', () => {
const tickets = [ticket('t1', 'create-wiki-06e6e4ae', at(20)), ticket('t2', 'create-hi-7aa4cedc44b4', at(2))]
expect(reclaimable(tickets, root, now, 14).map(item => item.ticketId)).toEqual(['t1'])
// A Ticket still open holds its directory however old it is.
expect(reclaimable([ticket('t1', 'create-wiki-06e6e4ae', undefined)], root, now, 14)).toEqual([])
// A retention of zero keeps everything, which is how an operator opts out entirely.
expect(reclaimable(tickets, root, now, 0)).toEqual([])
// The retention is a whole number of days, so a Ticket ended a moment ago waits.
expect(reclaimable([ticket('t1', 'create-wiki-06e6e4ae', at(13))], root, now, 14)).toEqual([])
})
it('never reclaims a directory the Executor did not name under its root', () => {
// An operator's own directory outside the root: the Executor recorded it, but removing it would reach past the tree.
expect(reclaimable([ticket('t1', 'my-checkout', at(20), '/home/me/my-checkout')], root, now, 14)).toEqual([])
// A root that merely shares a prefix with the directory is not the parent of it.
expect(reclaimable([ticket('t1', 'ticket', at(20), `${root}-other/ticket`)], root, now, 14)).toEqual([])
// A directory the Executor recorded under a different name than the Ticket's own is not one it made.
expect(reclaimable([ticket('t1', 'create-wiki-06e6e4ae', at(20), `${root}/something-else`)], root, now, 14)).toEqual([])
// A Ticket that never got a directory, such as one never accepted, holds nothing.
const bare: Ticket = { ...ticket('t1', 'x', at(20)), execution: undefined }
expect(reclaimable([bare], root, now, 14)).toEqual([])
})
it('leaves a directory that an open Ticket uses, and lists each directory once', () => {
// A Ticket that ended is not removed while a newer open Ticket works in the same directory.
const shared = `${root}/create-wiki-06e6e4ae`
const tickets = [ticket('t1', 'create-wiki-06e6e4ae', at(20), shared), ticket('t2', 'create-wiki-06e6e4ae', undefined, shared)]
expect(reclaimable(tickets, root, now, 14)).toEqual([])
// Two ended Tickets naming the same directory reclaim it once, not twice.
const twice = [ticket('t1', 'create-wiki-06e6e4ae', at(20), shared), ticket('t2', 'create-wiki-06e6e4ae', at(25), shared)]
// Paths come back resolved, so the expectation is resolved too rather than spelled with the platform's separator.
expect(reclaimable(twice, root, now, 14)).toEqual([{ ticketId: 't1', directory: resolve(shared) }])
// Either order reclaims it once, named after whichever Ticket the list reaches first.
expect(reclaimable([...twice].reverse(), root, now, 14)).toEqual([{ ticketId: 't2', directory: resolve(shared) }])
})
it('ignores an Ended Ticket whose end time cannot be read', () => {
expect(reclaimable([ticket('t1', 'create-wiki-06e6e4ae', 'not a time')], root, now, 14)).toEqual([])
})
+8
View File
@@ -355,3 +355,11 @@ it('reports whether the service is alive and polling apart from whether any Trig
expect(overview(host, {}, 'v1', load, undefined, startedAt).liveness).toMatchObject({ polling: false, lastPollAt: null, pollAgeMs: null })
expect(overview(host, {}, 'v1', load).liveness).toMatchObject({ startedAt: null, uptimeMs: null, polling: false })
})
it('reports what the execution root holds for Ended Tickets apart from what any Trigger did', () => {
const host = views()
const workspace = { root: '/srv/work', afterDays: 14, directories: 5, candidates: 1, bytes: 1024, lastScanAt: at, unreadable: 0 }
expect(overview(host, {}, 'v1', load, undefined, undefined, workspace).workspace).toEqual(workspace)
// Before the first scan there is nothing to say, which the page renders as no readout rather than as zeroes.
expect(overview(host, {}, 'v1', load).workspace).toBeNull()
})