1- import { randomUUID } from 'node:crypto' ;
2- import { execFile , execFileSync } from 'node:child_process' ;
3- import { existsSync , mkdtempSync , readdirSync , rmSync , writeFileSync } from 'node:fs' ;
1+ import { randomBytes , randomUUID } from 'node:crypto' ;
2+ import { execFile , execFileSync , spawn } from 'node:child_process' ;
3+ import { existsSync , mkdtempSync , readdirSync , readFileSync , rmSync , writeFileSync } from 'node:fs' ;
44import { tmpdir } from 'node:os' ;
55import { join } from 'node:path' ;
66import { promisify } from 'node:util' ;
@@ -14,11 +14,13 @@ import { attemptRef, openRunnerRepository } from '../runner/runner-repository.ts
1414import { ReviewService } from '../runner/review.ts' ;
1515import { Store } from '../runner/store.ts' ;
1616import { createTaskWorkspace } from '../runner/workspace.ts' ;
17+ import { removeRunnerObjects } from './fixtures/docker-sweep.ts' ;
1718import { fixtureGit as git } from './fixtures/git.ts' ;
1819
1920// F2b against the real lane D (#87): each item runs in fresh task storage cloned from the runner-owned repository, an
2021// agent edits the work volume, and the runner's audited commit is taken into that repository for the next item.
21- const RUNNER_OWNER = '0123456789abcdef0123456789abcdef' ;
22+ // Each run has its own runner owner, so the sweep after this file removes only what this run left behind.
23+ const RUNNER_OWNER = randomBytes ( 16 ) . toString ( 'hex' ) ;
2224const identity = { repositoryId : 'repo' , taskId : 'task' , planId : 'plan' } ;
2325const plan : Plan = { schema_version : 1 , issue : 1 , revision : 1 , summary : 'Two items' , questions : [ ] , items : [
2426 { id : 'P1' , title : 'First' , intent : 'Change a' , files : [ { path : 'a.ts' , kind : 'edit' , renamed_from : null , change : 'x' } ] , acceptance : [ { type : 'check' , text : 'a' } ] , depends_on : [ ] } ,
@@ -29,9 +31,15 @@ let imageId = '';
2931const roots : string [ ] = [ ] , stores : Store [ ] = [ ] ;
3032beforeAll ( ( ) => { imageId = buildAgentImage ( ) ; } , 10 * 60_000 ) ;
3133afterAll ( ( ) => {
32- for ( const store of stores ) store . close ( ) ;
33- for ( const root of roots ) rmSync ( root , { recursive : true , force : true } ) ;
34- } ) ;
34+ try {
35+ for ( const store of stores ) store . close ( ) ;
36+ for ( const root of roots ) rmSync ( root , { recursive : true , force : true } ) ;
37+ } finally {
38+ // Then remove the task storage this run still owns, even when a test failed or the cleanup above threw. The path
39+ // identity probe carries no owner labels, so it is not found here. It may not run before a hook timeout.
40+ removeRunnerObjects ( RUNNER_OWNER ) ;
41+ }
42+ } , 4 * 60_000 ) ;
3543const docker = ( ...args : string [ ] ) => execFileSync ( 'docker' , args , { encoding : 'utf8' , stdio : [ 'ignore' , 'pipe' , 'pipe' ] } ) . trim ( ) ;
3644
3745async function setup ( agent : Record < string , string > ) {
@@ -148,22 +156,63 @@ describe('real task workspace (#87)', () => {
148156} ) ;
149157
150158describe ( 'task storage path identity (#162)' , ( ) => {
151- // Only volumes made with the probe's own tmpfs options count, so other users of the daemon do not change the result.
152- // Precondition: no other probe runs on this daemon during the test (another runner's startup, or this file run from
153- // another checkout); its short-lived container and volume would match too. The docker project runs files one at a
154- // time, and no other file probes.
155- const probeVolumes = ( ) => new Set ( docker ( 'volume' , 'ls' , '-q' ) . split ( '\n' ) . filter ( Boolean ) . filter ( volume => {
156- try { return docker ( 'volume' , 'inspect' , '--format' , '{{json .Options}}' , volume ) . includes ( 'size=1048576,nr_inodes=64,' ) ; }
157- catch { return false ; } // removed between the listing and the inspect
158- } ) ) ;
159+ // The probe names its container itself and gives it an anonymous volume, with no labels. This test finds both from
160+ // the name the probe gave: a shim on PATH records it, and the daemon's events give that container's ID and the
161+ // volumes mounted in it. Another probe on the daemon (another runner's startup, or this file run from another
162+ // checkout) or a container a dead runner left behind does not count.
163+ async function watchedProbe ( ) {
164+ const shim = mkdtempSync ( join ( tmpdir ( ) , 'docker-shim-' ) ) ; roots . push ( shim ) ;
165+ const names = join ( shim , 'names' ) , realDocker = execFileSync ( 'sh' , [ '-c' , 'command -v docker' ] , { encoding : 'utf8' } ) . trim ( ) ;
166+ // Not a fake: it records the argument after each separate `--name` (the form the probe uses; if that changed, no name
167+ // is recorded and the test fails) and runs the real docker with the same arguments, so every exit status, stdout
168+ // and stderr the probe reads is Docker's own.
169+ writeFileSync ( join ( shim , 'docker' ) , [ '#!/bin/sh' , 'prev=' ,
170+ `for arg; do if [ "$prev" = --name ]; then printf '%s\\n' "$arg" >> '${ names } '; fi; prev="$arg"; done` ,
171+ `exec '${ realDocker } ' "$@"` ] . join ( '\n' ) , { mode : 0o755 } ) ;
172+ // --since replays from the daemon's event buffer, so nothing between this call and the stream's start is lost. The
173+ // minute of margin covers a daemon clock behind this one; it is harmless, as events are matched by the probe's own
174+ // name and IDs.
175+ const events : { Type : string ; Action : string ; Actor : { ID : string ; Attributes : Record < string , string > } } [ ] = [ ] ;
176+ const stream = spawn ( realDocker , [ 'events' , '--since' , String ( Math . floor ( Date . now ( ) / 1000 ) - 60 ) , '--format' ,
177+ '{{json .}}' , '--filter' , 'type=container' , '--filter' , 'type=volume' ] , { stdio : [ 'ignore' , 'pipe' , 'inherit' ] } ) ;
178+ let buffered = '' ;
179+ stream . stdout . setEncoding ( 'utf8' ) . on ( 'data' , ( chunk : string ) => {
180+ const lines = ( buffered + chunk ) . split ( '\n' ) ; buffered = lines . pop ( ) ! ;
181+ for ( const line of lines ) if ( line ) events . push ( JSON . parse ( line ) ) ;
182+ } ) ;
183+ const path = process . env . PATH ;
184+ process . env . PATH = `${ shim } :${ path } ` ;
185+ try {
186+ const identity = await probeTaskStoragePathIdentity ( imageId ) ;
187+ const recorded = readFileSync ( names , 'utf8' ) . split ( '\n' ) . filter ( Boolean ) ;
188+ expect ( recorded ) . toEqual ( [ expect . stringMatching ( / ^ c o d e b o o s t - p a t h - i d e n t i t y - [ 0 - 9 a - f - ] { 36 } $ / ) ] ) ;
189+ const container = ( ) => events . find ( event => event . Type === 'container' && event . Action === 'create'
190+ && event . Actor . Attributes . name === recorded [ 0 ] ) ?. Actor . ID ;
191+ const volumes = ( ) => [ ...new Set ( events . filter ( event => event . Type === 'volume' && event . Action === 'mount'
192+ && event . Actor . Attributes . container === container ( ) ) . map ( event => event . Actor . ID ) ) ] ;
193+ // Wait for the daemon to report the container and its volumes destroyed, for up to 10 s; absence is still
194+ // proved by inspect below, so a destroy that never comes fails there.
195+ const destroyed = ( type : string , id : string | undefined ) =>
196+ events . some ( event => event . Type === type && event . Action === 'destroy' && event . Actor . ID === id ) ;
197+ for ( const until = performance . now ( ) + 10_000 ; performance . now ( ) < until ; ) {
198+ if ( container ( ) && volumes ( ) . length && destroyed ( 'container' , container ( ) )
199+ && volumes ( ) . every ( volume => destroyed ( 'volume' , volume ) ) ) break ;
200+ await new Promise ( resolve => setTimeout ( resolve , 100 ) ) ;
201+ }
202+ return { identity, container : container ( ) , volumes : volumes ( ) } ;
203+ } finally { process . env . PATH = path ; stream . kill ( ) ; }
204+ }
159205 it ( 'measures the tmpfs the agent edits, whatever the host folds, and leaves no container or volume behind' , async ( ) => {
160- const before = probeVolumes ( ) ;
206+ const probe = await watchedProbe ( ) ;
161207 // Task storage is a tmpfs in the daemon's Linux kernel: case-sensitive, and names are compared as bytes. A macOS
162208 // host folder would report the reverse, which is why the probe does not read the host.
163- expect ( await probeTaskStoragePathIdentity ( imageId ) ) . toEqual ( { caseSensitive : true , unicodeNormalization : 'none' } ) ;
209+ expect ( probe . identity ) . toEqual ( { caseSensitive : true , unicodeNormalization : 'none' } ) ;
210+ // The events found the probe's container and its one volume, so the absence checks below look at real objects.
211+ expect ( probe . container ) . toMatch ( / ^ [ 0 - 9 a - f ] { 64 } $ / ) ;
212+ expect ( probe . volumes ) . toEqual ( [ expect . stringMatching ( / ^ [ 0 - 9 a - f ] { 64 } $ / ) ] ) ;
164213 // In any state: a container created but never started would outlive --rm.
165- expect ( docker ( 'ps ' , '-aq ' , '--filter' , 'name=codeboost-path-identity-' ) ) . toBe ( '' ) ;
166- expect ( [ ... probeVolumes ( ) ] . filter ( volume => ! before . has ( volume ) ) ) . toEqual ( [ ] ) ;
214+ expect ( ( ) => docker ( 'container ' , 'inspect ' , probe . container ! ) ) . toThrow ( / N o s u c h c o n t a i n e r / ) ;
215+ expect ( ( ) => docker ( ' volume' , 'inspect' , probe . volumes [ 0 ] ! ) ) . toThrow ( / n o s u c h v o l u m e / i ) ;
167216 } , 120_000 ) ;
168217 it ( 'removes a never-started probe container and its volume with the cleanup\'s own rm' , ( ) => {
169218 // The failure path's claim: a container created but never started (a killed client) loses its anonymous volume to
0 commit comments