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
5 changes: 5 additions & 0 deletions .changeset/adopt-cloud-work.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@gemstack/the-framework': minor
---

A cloud run's record now follows the branch its session actually worked on (#1601). A web run hands the task to claude.ai and ends; the session does the work on a `claude/*` branch of the cloud's own naming, never the designated run branch β€” so every surface keyed to the recorded branch (the session row, PR resolution, CI watch, merge) said "nothing committed" while the work sat on origin, and the armed draft PR never opened. The hand-off now pushes an anchor β€” an empty commit unique to the run, minted without moving any branch β€” as the ref the session clones at, so the session's branch is recognizable later by plain ancestry. A daemon pass matches each settled web run to the one `claude/*` head descending from its anchor and patches the run's archive with the branch and its PR, opening the armed draft PR itself when the session never did. Web-run teardown stops pushing the empty `tf-agent-*` branch to origin (the scratch-ref sweep clears the ones already there, and learns that an anchor tip β€” an empty commit on a landed parent β€” holds no work).
1 change: 1 addition & 0 deletions FEATURES-SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ happens while nobody is at the keyboard.
- Answer a cloud agent's question from the dashboard (typed back into claude.ai)
- Browser-bridge token setting
- Web runs trust the project for Claude Code automatically β€” no manual trust step
- A cloud run's row follows the session's real branch and PR, with its armed draft PR opened when the session opens none

## Notifications

Expand Down
2 changes: 1 addition & 1 deletion packages/the-framework/src/agent-telemetry.test.SPEC.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Tests that the session id surfaces the moment a turn starts (so a stopped turn cannot lose the resume handle), never repeats for the same id, and that the agent's opening event records the model it was started with.
Tests that the session id surfaces the moment a turn starts (so a stopped turn cannot lose the resume handle), never repeats for the same id, that the agent's opening event records the model it was started with, and that a hand-off carrying an anchor commit reports it as its own event while one without reports none.

## Before modifying/creating SPEC.md files

Expand Down
15 changes: 15 additions & 0 deletions packages/the-framework/src/agent-telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,21 @@ test('the result repeating the announced id does not emit a second session-updat
assert.equal(events.filter(e => e.kind === 'session-update').length, 1)
})

test('a result carrying the hand-off anchor emits it as its own event (#1601)', () => {
// The anchor is how the daemon later recognizes which `claude/*` branch is this run's, and
// only an event reaches the meta the daemon reads after this process is gone.
const events: FrameworkEvent[] = []
handler(events).onDriverEvent({ type: 'result', text: 'done', sessionId: 's1', anchorSha: 'a'.repeat(40) })
assert.deepEqual(
events.filter(e => e.kind === 'cloud-anchor'),
[{ kind: 'cloud-anchor', sha: 'a'.repeat(40) }],
)
// And a result without one β€” every non-cloud driver β€” emits none.
const bare: FrameworkEvent[] = []
handler(bare).onDriverEvent({ type: 'result', text: 'done' })
assert.equal(bare.filter(e => e.kind === 'cloud-anchor').length, 0)
})

test('a result with a fresh id still emits, the pre-#1322 path unchanged', () => {
const events: FrameworkEvent[] = []
handler(events).onDriverEvent({ type: 'result', text: 'done', sessionId: 's2' })
Expand Down
3 changes: 3 additions & 0 deletions packages/the-framework/src/agent-telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ export function createDriverEventHandler(opts: DriverEventHandlerOptions): Drive
}
emit({ kind: 'driver', event })
if (event.type !== 'result') return
// The hand-off anchor (#1601) reaches the meta the same way the session id does: it is a
// fact about the run the daemon needs after this process is gone, so only an event carries it.
if (event.anchorSha) emit({ kind: 'cloud-anchor', sha: event.anchorSha })
if (event.sessionId && event.sessionId !== lastSessionId) {
lastSessionId = event.sessionId
// A driver that knows its session's real URL (#1317, the cloud hand-off) beats the
Expand Down
9 changes: 9 additions & 0 deletions packages/the-framework/src/archived-agent-patch.SPEC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Records a fact learned about a run after it ended β€” the pull request its work is on, the branch a cloud session landed it on β€” onto the run's archived record, as a commit on the data branch.

## TLDR

- The write goes through the data branch's one write funnel β€” sync with origin, patch, commit, push β€” so the fact is shared with every machine and survives the next sync; written straight into the checkout it would be wiped within a minute, since the sync hard-resets a dirty checkout.

## Before modifying/creating SPEC.md files

You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md
5 changes: 5 additions & 0 deletions packages/the-framework/src/archived-agent-patch.test.SPEC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Covers the archive patch against real git: a patch lands as a commit on the data branch, pushed to origin, with the checkout left clean and the fact surviving the next sync; a run with no archive is reported as not patched and commits nothing.

## Before modifying/creating SPEC.md files

You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md
73 changes: 73 additions & 0 deletions packages/the-framework/src/archived-agent-patch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { dataWorktreePath, withDataBranch } from './data-branch.js'
import { patchArchivedAgentOnDataBranch } from './archived-agent-patch.js'

const git = promisify(execFile)

/** A real repo with a bare origin and one archived run seeded on the data branch. */
async function repoWithArchive(): Promise<{ project: string; cleanup: () => Promise<void> }> {
const project = await mkdtemp(join(tmpdir(), 'framework-archive-patch-'))
const origin = await mkdtemp(join(tmpdir(), 'framework-archive-patch-origin-'))
await git('git', ['init', '-q', '--bare'], { cwd: origin })
await git('git', ['init', '-q', '-b', 'main'], { cwd: project })
await git('git', ['config', 'user.email', 'test@example.com'], { cwd: project })
await git('git', ['config', 'user.name', 'Test'], { cwd: project })
await git('git', ['config', 'commit.gpgsign', 'false'], { cwd: project })
await git('git', ['remote', 'add', 'origin', origin], { cwd: project })
const seeded = await withDataBranch(project, 'seed', async dir => {
await mkdir(join(dir, 'agents', 'u'), { recursive: true })
const meta = { version: 1, status: 'done', id: 'r1', startedAt: '2026-08-20T10:00:00.000Z', updatedAt: '2026-08-20T10:00:00.000Z', branch: 'tf-agent-r1' }
await writeFile(join(dir, 'agents', 'u', 'r1.json'), JSON.stringify(meta))
await writeFile(join(dir, 'agents', 'u', 'r1.jsonl'), '')
})
assert.ok(seeded.ok && seeded.pushed, 'the fixture archive must land on origin')
return {
project,
cleanup: async () => {
await rm(project, { recursive: true, force: true })
await rm(origin, { recursive: true, force: true })
},
}
}

test('an archive patch lands as a commit on the data branch, pushed, leaving the checkout clean (#1601)', async () => {
const { project, cleanup } = await repoWithArchive()
try {
const dir = dataWorktreePath(project)
assert.equal(
await patchArchivedAgentOnDataBranch(project, 'r1', { branch: 'claude/fix-it', pr: { number: 7, url: 'https://x/pull/7' } }, '[The Framework] adopt r1'),
true,
)
const meta = JSON.parse(await readFile(join(dir, 'agents', 'u', 'r1.json'), 'utf8')) as { branch: string; pr: { number: number } }
assert.equal(meta.branch, 'claude/fix-it')
assert.equal(meta.pr.number, 7)
// Committed, not merely written: a dirty data checkout is what the next sync hard-resets.
assert.equal((await git('git', ['status', '--porcelain'], { cwd: dir })).stdout.trim(), '')
assert.equal((await git('git', ['log', '-1', '--format=%s'], { cwd: dir })).stdout.trim(), '[The Framework] adopt r1')
assert.equal((await git('git', ['rev-list', '--count', 'origin/tf-data..tf-data'], { cwd: dir })).stdout.trim(), '0', 'and pushed')
// The sync the daemon runs a minute later keeps it.
await withDataBranch(project, '[The Framework] data sync', async () => {})
const after = JSON.parse(await readFile(join(dir, 'agents', 'u', 'r1.json'), 'utf8')) as { branch: string }
assert.equal(after.branch, 'claude/fix-it')
} finally {
await cleanup()
}
})

test('a run with no archive is reported as not patched, and nothing is committed (#1601)', async () => {
const { project, cleanup } = await repoWithArchive()
try {
const dir = dataWorktreePath(project)
const before = (await git('git', ['rev-parse', 'HEAD'], { cwd: dir })).stdout.trim()
assert.equal(await patchArchivedAgentOnDataBranch(project, 'nope', { branch: 'claude/x' }, '[The Framework] adopt nope'), false)
assert.equal((await git('git', ['rev-parse', 'HEAD'], { cwd: dir })).stdout.trim(), before)
} finally {
await cleanup()
}
})
26 changes: 26 additions & 0 deletions packages/the-framework/src/archived-agent-patch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { withDataBranch } from './data-branch.js'
import { patchArchivedAgent, type ArchivePatch } from './store/index.js'

/**
* Patch a settled run's archived record on the data branch (#1601): synced with origin, patched,
* committed, pushed β€” the same funnel every other data write goes through (#1582).
*
* A patch written straight into the data checkout is not a fact yet: the next sync's rebase
* refuses a dirty tree and the funnel hard-resets it, so the patch is gone within a minute and
* no other machine ever saw it. Seen live on the cloud-work adoption before it went through here.
*
* True when the record now carries the patch, committed; a push that could not go out rides the
* next cycle, which is the funnel's owed-push rule. False when the run has no archive to patch.
*/
export async function patchArchivedAgentOnDataBranch(
cwd: string,
agentId: string,
patch: ArchivePatch,
message: string,
): Promise<boolean> {
let patched = false
const result = await withDataBranch(cwd, message, async () => {
patched = await patchArchivedAgent(cwd, agentId, patch)
})
return patched && (result.ok || result.committed)
}
5 changes: 3 additions & 2 deletions packages/the-framework/src/cloud-scratch-refs.SPEC.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
Deletes the two dead refs every Claude-web hand-off leaves on origin β€” the pre-hand-off `cloud-*` ref and the run branch β€” once it is provably safe, so they stop accumulating one pair per web run.
Deletes the dead refs web runs leave on origin β€” the `cloud-*` ref a hand-off pushes for the session to clone at, and any run branch that holds no work β€” once it is provably safe, so they stop accumulating.

## TLDR

- A web run pushes a `cloud-*` ref for the cloud session to clone at, and its run branch reaches origin when the worktree is reclaimed. The session then works on its own branch and opens its PR from there, so nothing ever consumes either ref again.
- A web run pushes a `cloud-*` ref for the cloud session to clone at; the session then works on its own branch and opens its PR from there, so nothing ever consumes the ref again. A web run's own branch never reaches origin β€” its checkout is reclaimed without a push once the cloud session has what it needs β€” while a local run's branch does, and is swept only once its work has landed.
- The driver must not delete its own ref: it only learns "session created", never "clone finished", and a ref deleted in between strands the session. So the daemon sweeps instead, hourly, and waits out the race.
- A ref goes only when every gate clears: it is about a day old, its commits are already on the default branch (the proof it holds no work β€” this is what protects a local run's branch carrying unmerged commits), it has no open pull request, and its agent is not one the daemon is still running.
- The hand-off anchor is the one tip the default branch never absorbs β€” an empty commit no merge ever lands β€” so it clears the work gate its own way: a tip that changes nothing against its parent, on a parent that landed, holds no work.
- A run branch's age is in its name; a `cloud-*` ref's is not, so the sweep remembers when it first saw one and ages it from there β€” which also keeps refs pushed by another machine safe, since each machine only deletes what it has itself watched for a day.
- Conservative and quiet: anything unprovable simply stays for the next pass, and only actual deletions (and failures) are announced.

Expand Down
2 changes: 1 addition & 1 deletion packages/the-framework/src/cloud-scratch-refs.test.SPEC.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Covers the cloud-scratch sweep: only the driver's exact ref shapes are ever candidates, each safety gate (age, work landed, open PR, busy agent) keeps a ref on its own, a `cloud-*` ref is first watched for a day before it may go, records are pruned when refs disappear, a refused deletion is retried without restarting the clock, a repo without a remote sweeps nothing, and the daemon-facing service announces deletions and failures but not kept refs.
Covers the cloud-scratch sweep: only the driver's exact ref shapes are ever candidates, each safety gate (age, work landed, open PR, busy agent) keeps a ref on its own, a hand-off anchor tip (an empty commit on a landed parent) still counts as landed while a tip that changes anything does not, a `cloud-*` ref is first watched for a day before it may go, records are pruned when refs disappear, a refused deletion is retried without restarting the clock, a repo without a remote sweeps nothing, and the daemon-facing service announces deletions and failures but not kept refs.

## Before modifying/creating SPEC.md files

Expand Down
62 changes: 62 additions & 0 deletions packages/the-framework/src/cloud-scratch-refs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ function fakeGit(opts: {
defaultBranch?: string
/** The shas reachable from the default branch ('all' for every one). Default 'all'. */
landed?: Set<string> | 'all'
/** Locally-known commits (#1601): sha -> its tree and parent. Everything else is not local. */
commits?: Record<string, { tree: string; parent?: string }>
refuseDeletes?: boolean
noRemote?: boolean
}) {
Expand All @@ -54,6 +56,29 @@ function fakeGit(opts: {
if (landed === 'all' || landed.has(args[2]!)) return ''
throw new Error('exit 1') // not an ancestor
}
if (args[0] === 'cat-file') {
const sha = /^([0-9a-f]+)\^\{commit\}$/.exec(args[2] ?? '')?.[1]
if (sha && opts.commits?.[sha]) return ''
throw new Error('exit 1') // not a local object
}
if (args[0] === 'rev-parse') {
const match = /^([0-9a-f]+)(\^?)(?:\^\{tree\})?$/.exec(args[1] ?? '')
const commit = match ? opts.commits?.[match[1]!] : undefined
if (!match || !commit) throw new Error('exit 128')
const spec = args[1]!
if (spec.endsWith('^^{tree}')) {
const parent = commit.parent ? opts.commits?.[commit.parent] : undefined
if (!parent) throw new Error('exit 128')
return `${parent.tree}\n`
}
if (spec.endsWith('^{tree}')) return `${commit.tree}\n`
if (spec.endsWith('^')) {
if (!commit.parent) throw new Error('exit 128')
return `${commit.parent}\n`
}
throw new Error(`unexpected rev-parse ${spec}`)
}
if (args[0] === 'fetch') throw new Error('exit 128') // the canned origin serves no objects
if (args[0] === 'push' && args[2] === '--delete') {
if (opts.refuseDeletes) throw new Error('remote hung up')
deleted.push(args[3]!)
Expand Down Expand Up @@ -189,6 +214,43 @@ test('a cloud-* ref watched for less than the safe age is kept', async () => {
assert.deepEqual(result.kept, [{ ref: CLOUD_REF, reason: 'young' }])
})

test('a hand-off anchor tip still counts as holding no work: empty commit, landed parent (#1601)', async () => {
// The anchor is an empty commit on top of the run's HEAD, and no merge ever lands it β€” a
// squash merge rewrites the session's history without it β€” so reachability alone would keep
// its ref forever.
const ANCHOR_SHA = 'c'.repeat(40)
const { git, deleted } = fakeGit({
heads: { main: MAIN_SHA, [CLOUD_REF]: ANCHOR_SHA },
landed: new Set([SHA, MAIN_SHA]),
commits: {
[ANCHOR_SHA]: { tree: 'tree1', parent: SHA },
[SHA]: { tree: 'tree1' },
},
})
const { files, fs } = memFs()
seenState(files, { [CLOUD_REF]: new Date(NOW - SCRATCH_REF_SAFE_AGE_MS).toISOString() })
const result = await sweepCloudScratchRefs('/repo', { git, fs, prs: noPrs, now: () => NOW })
assert.deepEqual(result.deleted, [CLOUD_REF])
assert.deepEqual(deleted, [CLOUD_REF])
})

test('a tip that changes something against its parent is not an anchor: kept as holding work (#1601)', async () => {
const TIP = 'c'.repeat(40)
const { git, deleted } = fakeGit({
heads: { main: MAIN_SHA, [CLOUD_REF]: TIP },
landed: new Set([SHA, MAIN_SHA]),
commits: {
[TIP]: { tree: 'tree2', parent: SHA },
[SHA]: { tree: 'tree1' },
},
})
const { files, fs } = memFs()
seenState(files, { [CLOUD_REF]: new Date(NOW - SCRATCH_REF_SAFE_AGE_MS).toISOString() })
const result = await sweepCloudScratchRefs('/repo', { git, fs, prs: noPrs, now: () => NOW })
assert.deepEqual(deleted, [])
assert.deepEqual(result.kept, [{ ref: CLOUD_REF, reason: 'holds-work' }])
})

test('a record for a ref no longer on origin is pruned β€” someone else already deleted it', async () => {
const { git } = fakeGit({ heads: { main: MAIN_SHA } })
const { files, fs } = memFs()
Expand Down
36 changes: 35 additions & 1 deletion packages/the-framework/src/cloud-scratch-refs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,37 @@ async function landed(git: GitRunner, cwd: string, sha: string, defaultBranch: s
return false
}

/**
* Whether `sha` is an empty commit sitting on a landed parent β€” the hand-off anchor's shape
* (#1601): its tree is its parent's tree, so it holds no work of its own, and the parent being
* on the default branch means everything under it landed. The commit object may not be local
* (another machine pushed the ref), so the ref is fetched first when needed; anything still
* unprovable reads as "holds work", which keeps the ref for a later sweep.
*/
async function emptyTipOnLandedParent(
git: GitRunner,
cwd: string,
ref: string,
sha: string,
defaultBranch: string | undefined,
heads: RemoteHead[],
): Promise<boolean> {
const present = await git(['cat-file', '-e', `${sha}^{commit}`], cwd).then(
() => true,
() => false,
)
if (!present && !(await git(['fetch', 'origin', `refs/heads/${ref}`], cwd).then(() => true, () => false))) return false
try {
const tree = (await git(['rev-parse', `${sha}^{tree}`], cwd)).trim()
const parent = (await git(['rev-parse', `${sha}^`], cwd)).trim()
const parentTree = (await git(['rev-parse', `${sha}^^{tree}`], cwd)).trim()
if (!tree || !parent || tree !== parentTree) return false
return await landed(git, cwd, parent, defaultBranch, heads)
} catch {
return false
}
}

/**
* Sweep one repo's origin for the dead refs cloud hand-offs left behind (#1547), deleting the
* ones that clear every gate. Never throws: a repo with no remote (or offline) sweeps nothing,
Expand Down Expand Up @@ -255,7 +286,10 @@ export async function sweepCloudScratchRefs(cwd: string, deps: ScratchSweepDeps

for (const { ref, sha } of candidates) {
// The work gate first: it is local and free, and it is the one that must never be wrong.
if (!(await landed(git, cwd, sha, defaultBranch, heads))) {
// A tip the default branch never absorbs still clears it when it is a hand-off anchor
// (#1601): an empty commit whose parent landed changes nothing, and no merge ever lands
// the anchor itself β€” a squash merge rewrites the session's history without it.
if (!(await landed(git, cwd, sha, defaultBranch, heads)) && !(await emptyTipOnLandedParent(git, cwd, ref, sha, defaultBranch, heads))) {
result.kept.push({ ref, reason: 'holds-work' })
continue
}
Expand Down
Loading
Loading