Skip to content

Commit b503998

Browse files
committed
One triage at a time through a routine lock on tf-data, not a pinned branch (#1659)
The pinned branch stopped guarding anything once a triage committed nothing on it (#1644): it never reached origin, and locally it was a name the agent checked from its prompt — every false abort spent a started agent. The sweep now takes routines/<name>.lock.md on the data branch before a triage starts, through the same write funnel as ticket claims, and drops it when the run ends, whatever the ending. A held lock stands the routine down naming the holder (machine + mint time) with no agent started. Fixed 4 h expiry, no heartbeat; a daemon frees its own locks on boot when their runs are gone. Run now reaches the same path via { lock } (was { pinned }). Gone with it: the prompts' "branch already exists → abort" line, stale-branch.ts and its sweep release, the pinnedBranch plumbing.
1 parent 867e53c commit b503998

16 files changed

Lines changed: 625 additions & 306 deletions

FEATURES-SPEC.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,9 @@ happens while nobody is at the keyboard.
129129
- CI watch: merge a PR once its checks pass
130130
- CI watch: one fix agent per red head commit, max two attempts
131131
- Reclaim the checkout of an agent whose work is on the remote — never by publishing what a `handoff: local` agent refused to
132-
- An agent that committed nothing leaves no branch behind: its empty branch goes with its checkout, never pushed, so a pinned routine is not stood down by its own last run — and the run-id branch it started on goes too, once the branch it moved to holds everything the run-id branch did
132+
- An agent that committed nothing leaves no branch behind: its empty branch goes with its checkout, never pushed — and the run-id branch it started on goes too, once the branch it moved to holds everything the run-id branch did
133133
- A directory under `branches/` that git does not know as a worktree is never committed, pushed, linked or deleted through — it is reported and left alone, so a leftover can never stand in for your own checkout
134-
- Release a pinned routine branch left behind by a closed PR — before the schedule fires the routine, and before its "Run now" does
134+
- One triage at a time, across machines: a routine lock (`routines/<name>.lock.md` on the data branch) taken by the daemon before the run starts and released when it ends, whatever the ending; a held lock stands the routine down naming the machine holding it, with no agent spent; a lock left by a dead machine expires after four hours, and a daemon frees its own on boot
135135
- The agent drains its own TODO backlog, one entry per turn
136136

137137
## Spending

packages/the-framework/dashboard/components/RoutineWork.test.tsx

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -194,22 +194,21 @@ describe('RoutineWork (#1159)', () => {
194194
expect((await hoverTooltip(await runNowOf(PLAN_JOB))).textContent).toMatch(/Starts up to 1 agent in gemstack/)
195195
})
196196

197-
// #1643: a routine pinned to a branch goes through the sweep too — one agent still, but the
198-
// sweep releases a stale copy of its branch before the start, and a plain start never did: the
199-
// agent read the leftover as a triage already pending and aborted, on every click.
200-
test("a pinned routine's Run now asks the sweep for it by its branch, not a plain start (#1643)", async () => {
201-
const pinned = AUTO_PM_ROUTINES.filter(job => job.pinnedBranch !== undefined)
202-
expect(pinned.length).toBeGreaterThan(0)
203-
renderCard()
204-
for (const job of pinned) {
197+
// #1643/#1659: a routine that holds a lock goes through the sweep too — one agent still, but
198+
// the sweep takes the lock before the start, and a plain start would run unguarded.
199+
test("a locked routine's Run now asks the sweep for it by its lock, not a plain start (#1643/#1659)", async () => {
200+
const locked = AUTO_PM_ROUTINES.filter(job => job.lock !== undefined)
201+
expect(locked.length).toBeGreaterThan(0)
202+
renderCard()
203+
for (const job of locked) {
205204
fireEvent.click(await runNowOf(job))
206-
// By the branch the job declares, never its name, and scoped to the picked project like
205+
// By the lock the job declares, never its name, and scoped to the picked project like
207206
// the plan click: the routine is that project's own work.
208207
await waitFor(() =>
209-
expect(sendAutoPmSweep).toHaveBeenCalledWith({ only: { pinned: job.pinnedBranch }, projectId: 'p1' }),
208+
expect(sendAutoPmSweep).toHaveBeenCalledWith({ only: { lock: job.lock }, projectId: 'p1' }),
210209
)
211210
}
212-
expect(sendAutoPmSweep).toHaveBeenCalledTimes(pinned.length)
211+
expect(sendAutoPmSweep).toHaveBeenCalledTimes(locked.length)
213212
expect(start).not.toHaveBeenCalled()
214213
})
215214

packages/the-framework/dashboard/components/RoutineWork.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,22 +53,21 @@ const NO_PROJECTS: ProjectSummary[] = []
5353
/**
5454
* The sweep a routine's Run now asks for, when it is a sweep rather than a plain start — or
5555
* nothing, for the routine a plain start serves exactly. Decided by what the job declares about
56-
* itself (it drains, fans out, or pins a branch), never by its name, so a renamed routine keeps
56+
* itself (it drains, fans out, or holds a lock), never by its name, so a renamed routine keeps
5757
* its path.
5858
*
5959
* The two that fan out (#1204) go because only the sweep can: it claims the work before each
6060
* agent starts — a queue entry for a drain, a ticket lock for planning — and a plain start could
61-
* only ever be one agent. A routine pinned to a branch (#1643) goes for the sweep's other
62-
* preparation: it releases a stale copy of that branch before the start, and without it the
63-
* agent read the leftover as a triage already pending and aborted, on every click.
61+
* only ever be one agent. A routine that holds a lock (#1643/#1659) goes for the same claim: the
62+
* sweep takes `routines/<name>.lock.md` before the start, and a plain start would run unguarded.
6463
*
6564
* The drain visits every project, which is what its tooltip says and why it sends no id. The
6665
* rest are the picked project's own work, so they carry one.
6766
*/
6867
function narrowedSweep(job: AutoPmJob, projectId: string): { only: AutoPmOnly; projectId?: string } | undefined {
6968
if (job.drains) return { only: 'drain' }
7069
if (job.fansOut) return { only: 'plan', projectId }
71-
if (job.pinnedBranch !== undefined) return { only: { pinned: job.pinnedBranch }, projectId }
70+
if (job.lock !== undefined) return { only: { lock: job.lock }, projectId }
7271
return undefined
7372
}
7473

packages/the-framework/prompts/presets/triage_consensual.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,3 @@
33
2. Add tickets to TODO_AGENTS.md
44

55
Always set <SESSION_NAME> to triage-consensual
6-
- If branch tf-<SESSION_NAME> already exists, abort and tell user that the branch already exists and that triage is already pending.

packages/the-framework/prompts/presets/triage_quick.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,3 @@ Look at `tickets/*` and choose tickets to work on next:
44
- With sensible prioritization, and consider bumping the priority of lowest effort tickets (e.g. to make `effort: 0` the next tasks agents work on)
55

66
Always set <SESSION_NAME> to triage-quick
7-
- If branch tf-<SESSION_NAME> already exists, abort and tell user the branch already exists and triage is already pending.

packages/the-framework/src/auto-pm.test.ts

Lines changed: 149 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1476,115 +1476,202 @@ test('a drain-only sweep works the queue and never borrows the tick for the rota
14761476
assert.equal(empty.report().outcomes[0]?.message, 'the queue is empty, so there is nothing to drain')
14771477
})
14781478

1479-
test('a pinned job has its stale branch released before it fires (#1293)', async () => {
1480-
const order: string[] = []
1479+
// #1659: the routine lock. A triage rewrites the shared queue and may take hours, so the sweep
1480+
// takes `routines/<name>.lock.md` on the data branch before the start — the daemon decides, and
1481+
// no agent is spent finding out — and gives it back when the run ends, whatever the ending.
1482+
1483+
const LOCKED_JOB: AutoPmJob = {
1484+
name: 'triage-quick',
1485+
prompt: 'Triage.',
1486+
label: 'Triage quick wins',
1487+
lock: 'triage-quick',
1488+
}
1489+
1490+
/** A lock that behaves like the real one: taken once, standing every later taker down until released. */
1491+
function fakeLock(order: string[] = []) {
1492+
let held = false
1493+
const lockRoutine: AutoPmDeps['lockRoutine'] = async (_project, lock) => {
1494+
order.push(`lock:${lock}`)
1495+
if (held) return { ok: false, reason: `${lock} is already running on laptop (since T0)` }
1496+
held = true
1497+
return { ok: true }
1498+
}
1499+
const releaseRoutine: AutoPmDeps['releaseRoutine'] = async (_project, lock) => {
1500+
order.push(`release:${lock}`)
1501+
held = false
1502+
return true
1503+
}
1504+
return { order, lockRoutine, releaseRoutine, isHeld: () => held }
1505+
}
1506+
1507+
test('a locked job takes its lock before it starts, and releases it when the run ends (#1659)', async () => {
1508+
const lock = fakeLock()
1509+
let settled = false
14811510
const { loop } = harness({
1482-
jobs: [{ name: 'triage-quick', prompt: 'p', pinnedBranch: 'the-framework/triage-quick' }],
1483-
releasePinned: async (_project, branch) => {
1484-
order.push(`release:${branch}`)
1485-
},
1511+
jobs: [LOCKED_JOB],
1512+
cooldownMs: 0,
1513+
lockRoutine: lock.lockRoutine,
1514+
releaseRoutine: lock.releaseRoutine,
14861515
start: async (_project, job) => {
1487-
order.push(`start:${job.name}`)
1516+
lock.order.push(`start:${job.name}`)
14881517
return 'run-1'
14891518
},
1519+
promote: async () => ({ settled, promoted: false }),
1520+
})
1521+
await loop.tick()
1522+
assert.deepEqual(lock.order, ['lock:triage-quick', 'start:triage-quick'])
1523+
// Still running: the lock stands, and the next sweep stands down on it rather than starting another.
1524+
await loop.tick()
1525+
assert.deepEqual(lock.order, ['lock:triage-quick', 'start:triage-quick', 'lock:triage-quick'])
1526+
assert.equal(loop.report().outcomes[0]?.message, 'triage-quick is already running on laptop (since T0)')
1527+
settled = true
1528+
await loop.tick()
1529+
loop.stop()
1530+
// Released on the ending itself — no `no-commits` condition, no PR: a triage never opens one —
1531+
// and the same sweep may take it again.
1532+
assert.deepEqual(lock.order.slice(3), ['release:triage-quick', 'lock:triage-quick', 'start:triage-quick'])
1533+
})
1534+
1535+
test('a held lock stands the job down naming its holder, with no agent started (#1659)', async () => {
1536+
const { loop, ran } = harness({
1537+
jobs: [LOCKED_JOB],
1538+
cooldownMs: 0,
1539+
lockRoutine: async () => ({ ok: false, reason: 'triage-quick is already running on other-machine (since 2026-08-23T10:00:00.000Z)' }),
14901540
})
14911541
await loop.tick()
14921542
loop.stop()
1493-
assert.deepEqual(order, ['release:the-framework/triage-quick', 'start:triage-quick'])
1543+
assert.deepEqual(ran, [])
1544+
assert.equal(loop.report().outcomes[0]?.started, false)
1545+
assert.equal(loop.report().outcomes[0]?.message, 'triage-quick is already running on other-machine (since 2026-08-23T10:00:00.000Z)')
14941546
})
14951547

1496-
test('an unpinned job never asks for a release, and a failing release does not stop the start (#1293)', async () => {
1548+
test('a refused start gives the lock back, and a failed release is retried next sweep (#1659)', async () => {
14971549
const releases: string[] = []
1498-
const unpinned = harness({
1499-
releasePinned: async (_project, branch) => {
1500-
releases.push(branch)
1550+
const refused = harness({
1551+
jobs: [LOCKED_JOB],
1552+
cooldownMs: 0,
1553+
lockRoutine: async () => ({ ok: true }),
1554+
releaseRoutine: async (_project, lock) => {
1555+
releases.push(lock)
1556+
return true
15011557
},
1558+
start: async () => undefined,
15021559
})
1503-
await unpinned.loop.tick()
1504-
unpinned.loop.stop()
1505-
assert.deepEqual(releases, [])
1506-
assert.deepEqual(unpinned.ran, ['first'])
1507-
1508-
// A release that could not happen leaves the routine exactly as jammed as it was; the job's own
1509-
// abort guard decides, exactly as before the seam existed.
1510-
const failing = harness({
1511-
jobs: [{ name: 'triage-quick', prompt: 'p', pinnedBranch: 'the-framework/triage-quick' }],
1512-
releasePinned: async () => {
1513-
throw new Error('gh is down')
1560+
await refused.loop.tick()
1561+
refused.loop.stop()
1562+
assert.deepEqual(releases, ['triage-quick'], 'no run will ever release a lock taken for a start that never happened')
1563+
1564+
let ok = false
1565+
const lock = fakeLock()
1566+
const flaky = harness({
1567+
jobs: [LOCKED_JOB],
1568+
cooldownMs: 0,
1569+
lockRoutine: lock.lockRoutine,
1570+
releaseRoutine: async (project, name) => (ok ? lock.releaseRoutine!(project, name) : false),
1571+
promote: async () => ({ settled: true, promoted: false }),
1572+
})
1573+
await flaky.loop.tick()
1574+
await flaky.loop.tick()
1575+
assert.deepEqual(flaky.ran, ['triage-quick'], 'the lock still stands while its release has not landed')
1576+
assert.ok(lock.isHeld())
1577+
ok = true
1578+
await flaky.loop.tick()
1579+
flaky.loop.stop()
1580+
assert.deepEqual(flaky.ran, ['triage-quick', 'triage-quick'], 'the retried release lands, and the routine may run again')
1581+
})
1582+
1583+
test('an unlocked job never asks for a lock, and a loop wired without the seam starts unguarded (#1659)', async () => {
1584+
const locks: string[] = []
1585+
const unlocked = harness({
1586+
lockRoutine: async (_project, lock) => {
1587+
locks.push(lock)
1588+
return { ok: true }
15141589
},
15151590
})
1516-
await failing.loop.tick()
1517-
failing.loop.stop()
1518-
assert.deepEqual(failing.ran, ['triage-quick'])
1591+
await unlocked.loop.tick()
1592+
unlocked.loop.stop()
1593+
assert.deepEqual(locks, [])
1594+
assert.deepEqual(unlocked.ran, ['first'])
1595+
1596+
const unwired = harness({ jobs: [LOCKED_JOB] })
1597+
await unwired.loop.tick()
1598+
unwired.loop.stop()
1599+
assert.deepEqual(unwired.ran, ['triage-quick'])
15191600
})
15201601

1521-
// #1643: Run now on a pinned routine reaches the same release-then-start the sweep does. It used
1522-
// to be a plain start outside the sweep, so a leftover copy of the branch — which the sweep
1523-
// releases before firing — made the agent abort as "triage already pending" on every click.
1602+
test("a previous daemon's dead locks are released on the project's first sweep only (#1659)", async () => {
1603+
const boots: string[] = []
1604+
const { loop } = harness({
1605+
releaseDeadLocks: async project => {
1606+
boots.push(project.id)
1607+
},
1608+
})
1609+
await loop.tick()
1610+
await loop.tick()
1611+
loop.stop()
1612+
assert.deepEqual(boots, ['p1'])
1613+
})
15241614

1525-
const PINNED_JOB: AutoPmJob = {
1526-
name: 'triage-quick',
1527-
prompt: 'Triage.',
1528-
label: 'Triage quick wins',
1529-
pinnedBranch: 'the-framework/triage-quick',
1530-
}
1615+
// #1643: Run now on a locked routine reaches the same lock-then-start the sweep does. It used to
1616+
// be a plain start outside the sweep, which ran unguarded.
15311617

1532-
test('a sweep narrowed to a pinned job releases its branch, then starts exactly one agent (#1643)', async () => {
1618+
test('a sweep narrowed to a locked job takes its lock, then starts exactly one agent (#1643/#1659)', async () => {
15331619
const order: string[] = []
15341620
const { loop } = harness({
15351621
// The rotation is on another job's turn, so a tick that ignored the narrowing would start
1536-
// that one instead — the release-then-start below is the click's doing, not the cycle's.
1537-
jobs: [{ name: 'update', prompt: 'Update.' }, PINNED_JOB],
1622+
// that one instead — the lock-then-start below is the click's doing, not the cycle's.
1623+
jobs: [{ name: 'update', prompt: 'Update.' }, LOCKED_JOB],
15381624
cooldownMs: 0,
15391625
// Room for three, so a single start is the routine's own shape and not the cap's doing.
15401626
concurrency: async () => 3,
1541-
releasePinned: async (_project, branch) => {
1542-
order.push(`release:${branch}`)
1627+
lockRoutine: async (_project, lock) => {
1628+
order.push(`lock:${lock}`)
1629+
return { ok: true }
15431630
},
15441631
start: async (_project, job) => {
15451632
order.push(`start:${job.name}`)
15461633
return `run-${order.length}`
15471634
},
15481635
})
1549-
await loop.tick({ onDemand: true, only: { pinned: 'the-framework/triage-quick' }, projectId: 'p1' })
1636+
await loop.tick({ onDemand: true, only: { lock: 'triage-quick' }, projectId: 'p1' })
15501637
loop.stop()
1551-
assert.deepEqual(order, ['release:the-framework/triage-quick', 'start:triage-quick'])
1638+
assert.deepEqual(order, ['lock:triage-quick', 'start:triage-quick'])
15521639
})
15531640

1554-
test('a switched-off pinned routine stands the click down, and so does every other gate (#1643)', async () => {
1555-
const off = harness({ jobs: [PINNED_JOB], cooldownMs: 0, optedOut: async () => ['triage-quick'] })
1556-
await off.loop.tick({ onDemand: true, only: { pinned: 'the-framework/triage-quick' }, projectId: 'p1' })
1641+
test('a switched-off locked routine stands the click down, and so does every other gate (#1643)', async () => {
1642+
const off = harness({ jobs: [LOCKED_JOB], cooldownMs: 0, optedOut: async () => ['triage-quick'] })
1643+
await off.loop.tick({ onDemand: true, only: { lock: 'triage-quick' }, projectId: 'p1' })
15571644
off.loop.stop()
15581645
assert.deepEqual(off.ran, [], 'an unticked box is not overridden by the click')
15591646
assert.equal(off.loop.report().outcomes[0]?.message, 'Triage quick wins is switched off')
15601647

1561-
// A branch nothing pins is said as such, not as a setting the user could go and undo.
1562-
const unknown = harness({ jobs: [PINNED_JOB], cooldownMs: 0 })
1563-
await unknown.loop.tick({ onDemand: true, only: { pinned: 'the-framework/nobody' }, projectId: 'p1' })
1648+
// A lock nothing holds is said as such, not as a setting the user could go and undo.
1649+
const unknown = harness({ jobs: [LOCKED_JOB], cooldownMs: 0 })
1650+
await unknown.loop.tick({ onDemand: true, only: { lock: 'nobody' }, projectId: 'p1' })
15641651
unknown.loop.stop()
15651652
assert.deepEqual(unknown.ran, [])
1566-
assert.equal(unknown.loop.report().outcomes[0]?.message, 'no routine is pinned to the-framework/nobody')
1653+
assert.equal(unknown.loop.report().outcomes[0]?.message, 'no routine holds the nobody lock')
15671654

15681655
// The click skips the master switch and the cooldown, not the cap (#1204/#1642).
1569-
const capped = harness({ jobs: [PINNED_JOB], cooldownMs: 0, activeAgents: () => ['run-live (pid 111)'] })
1570-
await capped.loop.tick({ onDemand: true, only: { pinned: 'the-framework/triage-quick' }, projectId: 'p1' })
1656+
const capped = harness({ jobs: [LOCKED_JOB], cooldownMs: 0, activeAgents: () => ['run-live (pid 111)'] })
1657+
await capped.loop.tick({ onDemand: true, only: { lock: 'triage-quick' }, projectId: 'p1' })
15711658
capped.loop.stop()
15721659
assert.deepEqual(capped.ran, [], 'a live agent at the cap holds the click like it holds the sweep')
15731660
})
15741661

1575-
test('a sweep narrowed to a pinned job never falls through to the drain or another rotation job (#1643)', async () => {
1662+
test('a sweep narrowed to a locked job never falls through to the drain or another rotation job (#1643)', async () => {
15761663
// The queue is full, so the queue-picked mode would drain; the rotation is on another job's
1577-
// turn, so the index would fire that one. The click named the pinned routine and gets it alone
1664+
// turn, so the index would fire that one. The click named the locked routine and gets it alone
15781665
// — and the scheduled tick after it still gets the rotation job it was owed.
15791666
const other: AutoPmJob = { name: 'update', prompt: 'Update.' }
15801667
let entries = ['work one']
15811668
const { loop, ran } = harness({
1582-
jobs: [other, PINNED_JOB],
1669+
jobs: [other, LOCKED_JOB],
15831670
cooldownMs: 0,
15841671
queue: async () => entries,
15851672
drainJob: { name: 'drain', prompt: 'Work the queue.', drains: true },
15861673
})
1587-
await loop.tick({ onDemand: true, only: { pinned: 'the-framework/triage-quick' }, projectId: 'p1' })
1674+
await loop.tick({ onDemand: true, only: { lock: 'triage-quick' }, projectId: 'p1' })
15881675
assert.deepEqual(ran, ['triage-quick'], 'neither the full queue nor the rotation index took the click')
15891676
entries = []
15901677
await loop.tick()
@@ -1602,15 +1689,12 @@ test('only the drain job lands its own PRs (#1216)', () => {
16021689
}
16031690
})
16041691

1605-
test('the triage jobs declare exactly the branch their prompts pin (#1293)', () => {
1606-
const pinned = AUTO_PM_JOBS.filter(job => job.pinnedBranch !== undefined)
1607-
assert.deepEqual(pinned.map(job => job.name), ['triage-quick', 'triage-consensual'])
1608-
for (const job of pinned) {
1609-
// The release targets the branch the prompt's abort guard tests, so the two must not drift.
1610-
assert.equal(job.pinnedBranch, `tf-${job.name}`)
1611-
assert.ok(
1612-
job.prompt.includes(`Always set <SESSION_NAME> to ${job.name}`),
1613-
`${job.name} must pin the session name its release targets`,
1614-
)
1692+
test('the triage jobs hold a lock named after them, and their prompts no longer abort on a branch (#1659)', () => {
1693+
const locked = AUTO_PM_JOBS.filter(job => job.lock !== undefined)
1694+
assert.deepEqual(locked.map(job => job.name), ['triage-quick', 'triage-consensual'])
1695+
for (const job of locked) {
1696+
assert.equal(job.lock, job.name)
1697+
// The daemon decides; the prompt's "branch already exists → abort" spent an agent to find out.
1698+
assert.ok(!job.prompt.includes('already exists'), `${job.name} must not carry the branch abort`)
16151699
}
16161700
})

0 commit comments

Comments
 (0)