Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/the-framework/src/cloud-work.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,18 @@ test('runs outside the pass: non-web, still running, no anchor, already adopted,
assert.deepEqual(git.calls, [], 'nothing waiting means not even a fetch')
})

test('the archive is only asked for the window, so an old history costs no reads (#1607)', async () => {
// The pass runs on a cadence forever while the archive only grows, so the window has to reach
// the store rather than being applied after every record has already been parsed.
const asked: number[] = []
const { d } = deps([webRun()])
await adoptCloudWork(CWD, {
...d,
agents: async (_cwd, since) => (asked.push(since), [webRun()]),
})
assert.deepEqual(asked, [NOW - CLOUD_ADOPTION_WINDOW_MS])
})

test('a run already adopted but still owed its armed PR keeps being asked about (#1601)', async () => {
// The session pushed its branch but had not opened a PR when the branch was adopted; a later
// pass finds the PR (or opens the armed draft) without re-recording the branch.
Expand Down
12 changes: 7 additions & 5 deletions packages/the-framework/src/cloud-work.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { nodeGitRunner, type GitRunner } from './project.js'
import { ghPrsForBranchOrThrow, pickAgentPr, type LinkedPr } from './dashboard/gh.js'
import { openRemoteBranchPullRequest, type HandoffResult } from './dashboard/agent-handoff.js'
import { agentBranchName } from './branch-names.js'
import { listAgents, startedAtFromAgentId, type AgentMeta, type ArchivePatch } from './store/index.js'
import { listAgents, nodeStoreFs, startedAtFromAgentId, type AgentMeta, type ArchivePatch } from './store/index.js'
import { patchArchivedAgentOnDataBranch } from './archived-agent-patch.js'
import { errorMessage } from './error-message.js'

Expand Down Expand Up @@ -51,8 +51,8 @@ export interface CloudWorkDeps {
git?: GitRunner
/** The branch's full PR history; a listing that fails must throw (default {@link ghPrsForBranchOrThrow}). */
prs?: (cwd: string, branch: string) => Promise<LinkedPr[]>
/** The project's run records (default {@link listAgents}). */
agents?: (cwd: string) => Promise<AgentMeta[]>
/** The project's run records, none older than `since` in epoch ms (default {@link listAgents}). */
agents?: (cwd: string, since: number) => Promise<AgentMeta[]>
/** Record the adopted branch and PR on the run's archive (default {@link patchArchivedAgentOnDataBranch}). */
patch?: (cwd: string, agentId: string, patch: ArchivePatch, message: string) => Promise<boolean>
/** Open the armed draft PR for a remote-only branch (default {@link openRemoteBranchPullRequest}). */
Expand Down Expand Up @@ -146,13 +146,15 @@ async function headsDescendingFrom(git: GitRunner, cwd: string, anchor: string):
export async function adoptCloudWork(cwd: string, deps: CloudWorkDeps = {}): Promise<CloudWorkResult> {
const git = deps.git ?? nodeGitRunner()
const prs = deps.prs ?? ghPrsForBranchOrThrow
const agents = deps.agents ?? listAgents
const agents = deps.agents ?? ((project: string, since: number) => listAgents(project, nodeStoreFs(), since))
const patchArchive = deps.patch ?? patchArchivedAgentOnDataBranch
const openPr = deps.openPr ?? openRemoteBranchPullRequest
const now = deps.now ? deps.now() : Date.now()
const result: CloudWorkResult = { adopted: [], failed: [] }

const waiting = waitingRuns(await agents(cwd).catch((): AgentMeta[] => []), now)
// Only the window's runs are asked for: an archive grows without bound, and everything older
// than the window is a record `waitingRuns` would drop anyway — so it is never read (#1607).
const waiting = waitingRuns(await agents(cwd, now - CLOUD_ADOPTION_WINDOW_MS).catch((): AgentMeta[] => []), now)
if (waiting.length === 0) return result

try {
Expand Down
27 changes: 27 additions & 0 deletions packages/the-framework/src/store/agent-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,33 @@ test('the history lists every user, and the runs archived before this shipped (#
assert.deepEqual((await listAgents(CWD, fs)).map(agent => agent.id), ['r3', 'r2', 'r1'])
})

test('a `since` skips the older runs by filename, without reading them (#1607)', async () => {
// The cost this exists to remove: the adoption poll wants the last 48h and an archive holds
// years, so the read loop spent every pass parsing records its caller was about to discard.
const at = (iso: string) => JSON.stringify({ version: 1, status: 'done', id: agentIdFromStartedAt(iso), startedAt: iso, updatedAt: iso })
const old = agentIdFromStartedAt('2026-07-04T00:00:00.000Z')
const recent = agentIdFromStartedAt('2026-07-06T00:00:00.000Z')
const fs = memFs({
[archiveAt(old, 'json')]: at('2026-07-04T00:00:00.000Z'),
[archiveAt(recent, 'json')]: at('2026-07-06T00:00:00.000Z'),
[archiveAt('hand-picked', 'json')]: JSON.stringify({ version: 1, status: 'done', id: 'hand-picked', startedAt: AT, updatedAt: AT }),
})
const read: string[] = []
const spy = { ...fs, read: async (path: string) => (read.push(path), fs.read(path)) }

const listed = await listAgents(CWD, spy, Date.parse('2026-07-05T00:00:00.000Z'))

assert.deepEqual(listed.map(agent => agent.id).sort(), ['hand-picked', recent].sort())
assert.equal(
read.some(path => path.includes(old)),
false,
'the run outside the window is never opened',
)
// An id that is not one of our timestamps cannot be dated from its name, so it is still read:
// rejecting it unread would hide it from every caller that passes a window.
assert.equal(read.some(path => path.includes('hand-picked')), true)
})

test('a run archived under both schemes is listed once (#1179)', async () => {
// An agent archived before #1179 and re-archived after exists in both places; the history is a list
// of sessions, not of files.
Expand Down
32 changes: 27 additions & 5 deletions packages/the-framework/src/store/agent-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -864,15 +864,34 @@ export async function archivedAgentPaths(cwd: string, agentId: string, fs: Store
/** Newest run first: an id sorts chronologically, so the id order IS the time order (no parse). */
const byIdDesc = (a: { id: string }, b: { id: string }): number => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0)

/**
* Whether an `<id>.json` archive entry is older than `since` going by its *name* — an id is the
* run's start time, so the filename dates the record and most of a long history can be rejected
* before it is ever read (#1607).
*
* Only a name that parses as one of our ids can reject: an id handed in from outside is not a
* date, and is read like any other. The id is allocated when the run is spawned and `startedAt`
* written when it first opens its store, so the name can be the older of the two by the length of
* a spawn — a caller filtering on `startedAt` sees a record drop out at most that much early.
*/
function namedBefore(name: string, since: number): boolean {
const startedAt = startedAtFromAgentId(name.slice(0, -'.json'.length))
return startedAt !== undefined && Date.parse(startedAt) < since
}

/**
* Every `agents/*.json` archived meta with the path it was read from, torn/half-written entries
* skipped. The one home of the archived-history read loop, shared by {@link listAgents} and the
* boot reconcile. A missing/unreadable dir throws to the caller, as both callers always let it.
*
* `since` (epoch ms) drops the runs that started before it without reading them; see
* {@link namedBefore}.
*/
async function readArchivedMetaEntries(fs: StoreFs, agentsDir: string): Promise<Array<{ path: string; meta: AgentMeta }>> {
async function readArchivedMetaEntries(fs: StoreFs, agentsDir: string, since?: number): Promise<Array<{ path: string; meta: AgentMeta }>> {
const entries: Array<{ path: string; meta: AgentMeta }> = []
for (const name of await fs.readdir(agentsDir)) {
if (!name.endsWith('.json')) continue
if (since !== undefined && namedBefore(name, since)) continue
const path = join(agentsDir, name)
try {
entries.push({ path, meta: JSON.parse(await fs.read(path)) as AgentMeta })
Expand All @@ -898,11 +917,11 @@ function isDeadRunningAgent(meta: AgentMeta | undefined, isAlive: (pid: number)
* `agents/` and the close into the user's committed one, so an agent can sit in both places and the
* history must show it once. The user directories are searched first, so the committed copy wins.
*/
async function readAllArchivedMetaEntries(fs: StoreFs, cwd: string): Promise<Array<{ path: string; meta: AgentMeta }>> {
async function readAllArchivedMetaEntries(fs: StoreFs, cwd: string, since?: number): Promise<Array<{ path: string; meta: AgentMeta }>> {
const seen = new Set<string>()
const entries: Array<{ path: string; meta: AgentMeta }> = []
for (const agentsDir of await archiveDirs(fs, cwd)) {
for (const entry of await readArchivedMetaEntries(fs, agentsDir).catch(() => [])) {
for (const entry of await readArchivedMetaEntries(fs, agentsDir, since).catch(() => [])) {
if (seen.has(entry.meta.id)) continue
seen.add(entry.meta.id)
entries.push(entry)
Expand All @@ -915,9 +934,12 @@ async function readAllArchivedMetaEntries(fs: StoreFs, cwd: string): Promise<Arr
* List a project's archived agents, most-recent first: every user's committed archive plus the
* transient `agents/`. The id sorts chronologically so no timestamp parse is needed. Missing or
* unreadable dir/entries are skipped, never thrown.
*
* `since` (epoch ms) is for a caller that only wants recent runs — a poll on a cadence, not the
* history list. It is answered from the filenames, so the records it excludes cost no read at all.
*/
export async function listAgents(cwd: string, fs: StoreFs = nodeStoreFs()): Promise<AgentMeta[]> {
const entries = await readAllArchivedMetaEntries(fs, cwd)
export async function listAgents(cwd: string, fs: StoreFs = nodeStoreFs(), since?: number): Promise<AgentMeta[]> {
const entries = await readAllArchivedMetaEntries(fs, cwd, since)
return entries.map(entry => entry.meta).sort(byIdDesc)
}

Expand Down
Loading