Execution: reclaim the directories of Ended Tickets after a retention
This commit is contained in:
Vendored
+6
@@ -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' } } },
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user