@@ -51,9 +51,9 @@ const assistantText = "I have made progress on the feature."
5151// Each scenario yields one scheduler turn before its first idle publish so that
5252// fiber can acquire the PubSub subscription. No business event exists yet, so
5353// outcome completion remains separately observed through public state/events.
54- const mkAssistant = ( ) =>
54+ const mkAssistant = ( id ?: string ) =>
5555 ( {
56- info : { role : "assistant" , time : { created : Date . now ( ) } } ,
56+ info : { id , role : "assistant" , time : { created : Date . now ( ) } } ,
5757 parts : [ { type : "text" , text : assistantText } ] ,
5858 } ) as never
5959
@@ -1793,3 +1793,144 @@ describe("GoalLoop — judge-chain defect degrades into the parse budget (GOAL-F
17931793 } ) ,
17941794 )
17951795} )
1796+
1797+ // issue #285 / GOAL-FP-01-21: the boot scan must not re-judge a boundary the
1798+ // crashed process already judged and committed. The process-local
1799+ // evaluatedRevisions map dies with the process, so the DURABLE gate is the
1800+ // goal row's last_judged_msg: updateAfterJudge records the judged assistant
1801+ // message ID on every continue commit, and the scan path skips evaluation
1802+ // while the session window still ends on that same message. Live idle events
1803+ // are never gated (each dispatched continuation produces a fresh assistant
1804+ // message, so the live path always sees a new boundary).
1805+ describe ( "GoalLoop — boot scan must not re-evaluate an already-judged boundary (issue #285)" , ( ) => {
1806+ let judgeCalls = 0
1807+ let continuationCalls = 0
1808+ let boundaryID = "msg_boundary_a"
1809+ const reset = ( ) => {
1810+ judgeCalls = 0
1811+ continuationCalls = 0
1812+ boundaryID = "msg_boundary_a"
1813+ }
1814+
1815+ const sessionMock = Layer . mock ( Session . Service , {
1816+ messages : ( ) => Effect . succeed ( [ mkAssistant ( boundaryID ) ] ) ,
1817+ } )
1818+ const promptMock = Layer . mock ( SessionPrompt . Service , withIdleAdmission ( {
1819+ prompt : ( ) => Effect . die ( "the direct prompt path is not exercised in this scenario" ) ,
1820+ promptIfIdle : ( ) =>
1821+ Effect . sync ( ( ) => {
1822+ continuationCalls += 1
1823+ return Option . none ( )
1824+ } ) ,
1825+ } ) )
1826+ const judgeMock = Layer . succeed (
1827+ GoalLoopJudgeLLM ,
1828+ GoalLoopJudgeLLM . of ( {
1829+ call : ( ) =>
1830+ Effect . sync ( ( ) => {
1831+ judgeCalls += 1
1832+ return JSON . stringify ( { verdict : "continue" , reason : "more work" } )
1833+ } ) ,
1834+ } ) ,
1835+ )
1836+
1837+ const boundaryLayer = GoalLoop . layer . pipe (
1838+ Layer . provide ( sessionMock ) ,
1839+ Layer . provide ( promptMock ) ,
1840+ Layer . provide ( Layer . mock ( Provider . Service , { } ) ) ,
1841+ Layer . provide ( judgeMock ) ,
1842+ Layer . provideMerge ( Goal . defaultLayer ) ,
1843+ Layer . provideMerge ( SessionStatus . defaultLayer ) ,
1844+ Layer . provideMerge ( EventV2Bridge . defaultLayer ) ,
1845+ Layer . provideMerge ( SessionAutomationLease . defaultLayer ) ,
1846+ Layer . provideMerge ( Database . defaultLayer ) ,
1847+ )
1848+ const it = testEffect ( boundaryLayer )
1849+
1850+ const seedSessionRow = ( sessionID : SessionID , directory : string ) =>
1851+ Effect . gen ( function * ( ) {
1852+ const { db } = yield * Database . Service
1853+ const projectID = ProjectSchema . ID . make ( Bun . randomUUIDv7 ( ) )
1854+ yield * db . insert ( ProjectTable ) . values ( {
1855+ id : projectID ,
1856+ worktree : AbsolutePath . make ( directory ) ,
1857+ sandboxes : [ AbsolutePath . make ( directory ) ] ,
1858+ } )
1859+ yield * db . insert ( SessionTable ) . values ( {
1860+ id : sessionID ,
1861+ project_id : projectID ,
1862+ slug : "test-session" ,
1863+ directory,
1864+ title : "test session" ,
1865+ version : "1" ,
1866+ time_created : Date . now ( ) ,
1867+ time_updated : Date . now ( ) ,
1868+ } )
1869+ } )
1870+
1871+ // Commits one continue evaluation ahead of the (re)boot — models a process
1872+ // that crashed right after the commit, before the continuation produced an
1873+ // assistant message.
1874+ const commitPriorBoundary = ( sid : SessionID ) =>
1875+ Effect . gen ( function * ( ) {
1876+ const goal = yield * Goal . Service
1877+ const before = yield * goal . load ( sid )
1878+ const result = yield * goal . updateAfterJudge (
1879+ sid ,
1880+ "continue" ,
1881+ "pre-crash commit" ,
1882+ false ,
1883+ { goalID : before ! . goal_id ?? "legacy" , revision : before ! . revision ?? 0 } ,
1884+ boundaryID ,
1885+ )
1886+ expect ( result ?. state . turns_used ) . toBe ( 1 )
1887+ return result ?. state
1888+ } )
1889+
1890+ it . instance ( "scan with an unchanged boundary skips re-evaluation (no inflation)" , ( ) =>
1891+ Effect . gen ( function * ( ) {
1892+ reset ( )
1893+ const loop = yield * GoalLoop . Service
1894+ const goal = yield * Goal . Service
1895+ const directory = ( yield * TestInstance ) . directory
1896+ const sid = SessionID . descending ( )
1897+ yield * seedSessionRow ( sid , directory )
1898+ yield * goal . set ( sid , "boundary guard" , 5 )
1899+ yield * commitPriorBoundary ( sid )
1900+
1901+ yield * loop . init ( )
1902+ // Negative assertion: the scan runs in a forked fiber with no readiness
1903+ // signal on the skip path, so a bounded wait stands in for polling.
1904+ yield * Effect . sleep ( "300 millis" )
1905+ expect ( judgeCalls ) . toBe ( 0 )
1906+ expect ( continuationCalls ) . toBe ( 0 )
1907+ const g = yield * goal . load ( sid )
1908+ expect ( g ?. turns_used ) . toBe ( 1 )
1909+ } ) ,
1910+ )
1911+
1912+ it . instance ( "scan proceeds once the window advanced past the boundary" , ( ) =>
1913+ Effect . gen ( function * ( ) {
1914+ reset ( )
1915+ const loop = yield * GoalLoop . Service
1916+ const goal = yield * Goal . Service
1917+ const directory = ( yield * TestInstance ) . directory
1918+ const sid = SessionID . descending ( )
1919+ yield * seedSessionRow ( sid , directory )
1920+ yield * goal . set ( sid , "boundary guard" , 5 )
1921+ yield * commitPriorBoundary ( sid )
1922+ // The continuation finished and produced a NEW assistant message.
1923+ boundaryID = "msg_boundary_b"
1924+
1925+ yield * loop . init ( )
1926+ yield * pollWithTimeout (
1927+ Effect . sync ( ( ) => ( judgeCalls >= 1 ? true : undefined ) ) ,
1928+ "scan never re-evaluated the advanced boundary" ,
1929+ "5 seconds" ,
1930+ )
1931+ const g = yield * goal . load ( sid )
1932+ expect ( g ?. turns_used ) . toBe ( 2 )
1933+ expect ( continuationCalls ) . toBe ( 1 )
1934+ } ) ,
1935+ )
1936+ } )
0 commit comments