@@ -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