@@ -51,10 +51,18 @@ func runRepos(req RunRequest) []*repoprep.RepoSpec {
5151// workspace layout (WI-449): the slug's last segment ("owner/core-tests" ->
5252// "core-tests"), disambiguated with a numeric suffix if two repos share a name.
5353func repoDirNames (repos []* repoprep.RepoSpec ) []string {
54- names := make ([]string , len (repos ))
55- seen := make (map [string ]int , len (repos ))
56- for i , r := range repos {
57- base := r .RepoSlug
54+ slugs := make ([]string , len (repos ))
55+ for i , repo := range repos {
56+ slugs [i ] = repo .RepoSlug
57+ }
58+ return repoWorkspaceDirNames (slugs )
59+ }
60+
61+ func repoWorkspaceDirNames (slugs []string ) []string {
62+ names := make ([]string , len (slugs ))
63+ seen := make (map [string ]int , len (slugs ))
64+ for i , slug := range slugs {
65+ base := slug
5866 if idx := strings .LastIndex (base , "/" ); idx >= 0 && idx < len (base )- 1 {
5967 base = base [idx + 1 :]
6068 }
@@ -85,156 +93,161 @@ func queueBuffer(capacity int) int {
8593// claimNext admits queued work; preamble failures finalize in place, and shutdown drains queued runs.
8694func (s * RunService ) claimNext () * ClaimedJob {
8795 for {
88- var job queuedJob
89- select {
90- case job = <- s .queue :
91- case <- s .shutdownCh :
92- // Drain still-queued runs as canceled, then stop. Channel
93- // receive is safe across workers; whichever worker wins
94- // finalizes the run.
95- for {
96- select {
97- case j := <- s .queue :
98- s .finalize (j .runID , models .AgentRunStatusCanceled , "shutdown before admission" )
99- s .wg .Done ()
100- default :
101- return nil
102- }
103- }
96+ job , ok := s .dequeueClaimJob ()
97+ if ! ok {
98+ return nil
10499 }
105100
106- // Per-run context, wired to shutdown so RunService.Cancel and
107- // process shutdown both reach the in-flight runner.
108- runCtx , cancel := context .WithCancel (context .Background ())
109- s .registerCancel (job .runID , cancel )
110- go func () {
111- select {
112- case <- s .shutdownCh :
113- cancel ()
114- case <- runCtx .Done ():
115- }
116- }()
117-
118- now := s .now ()
119- transitioned , err := s .repo .MarkRunningIfQueued (runCtx , job .runID , "" , now )
120- if err != nil {
121- s .logger .Printf ("run service: mark running run=%d: %v" , job .runID , err )
122- s .failClaim (job , cancel , fmt .Sprintf ("mark running: %v" , err ), false )
101+ runCtx , cancel , ok := s .beginClaim (job )
102+ if ! ok {
123103 continue
124104 }
125- if ! transitioned {
126- // The row left 'queued' while the job sat on the in-memory
127- // channel — canceled via the API (WI-341) or otherwise already
128- // terminal. The terminal status (and its lifecycle event) is
129- // recorded by whoever made that transition; just release the
130- // run's accounting and move on instead of executing it anyway.
131- s .logger .Printf ("run service: skipping run=%d: no longer queued at dequeue" , job .runID )
132- cancel ()
133- s .unregisterCancel (job .runID )
134- s .wg .Done ()
105+
106+ st := claimState {req : job .req , ephemeral : job .req .Ephemeral , cancel : cancel }
107+ if err := s .prepareClaimRepos (runCtx , job , & st ); err != nil {
108+ s .failClaim (job , cancel , "prepare checkout failed" , true )
135109 continue
136110 }
137- if err := s .repo .AppendEvent (runCtx , job .runID , "lifecycle" , `{"phase":"running"}` ); err != nil {
138- s .logger .Printf ("run service: append running event run=%d: %v" , job .runID , err )
139- }
140111
141- st := claimState {req : job .req , ephemeral : job .req .Ephemeral , cancel : cancel }
112+ env , err := s .buildClaimEnv (runCtx , job , & st )
113+ if err != nil {
114+ s .logger .Printf ("run service: mint ws token run=%d: %v" , job .runID , err )
115+ s .failClaim (job , cancel , fmt .Sprintf ("mint ws token: %v" , err ), false )
116+ continue
117+ }
118+ return s .finishClaim (runCtx , job , & st , env )
119+ }
120+ }
142121
143- repos := runRepos (job .req )
144- if len (repos ) > 0 {
145- // Single repo → the checkout dir itself is the agent's cwd (the
146- // pre-WI-449 layout, unchanged). Multiple repos → each is checked
147- // out as a sibling dir under a shared per-run workspace root that
148- // becomes the cwd, so the agent sees every bound repo at once.
149- multi := len (repos ) > 1
150- if multi {
151- st .workspaceRoot = s .preparer .RunWorkspaceDir (job .runID )
152- }
153- dirNames := repoDirNames (repos )
154- prepFailed := false
155- for i , rspec := range repos {
156- spec := * rspec
157- if multi {
158- spec .DestDir = filepath .Join (st .workspaceRoot , dirNames [i ])
159- }
160- pw , err := s .preparer .Prepare (runCtx , spec , job .runID )
161- if err != nil {
162- s .logger .Printf ("run service: prepare checkout run=%d repo=%s: %v" , job .runID , rspec .RepoSlug , err )
163- prepFailed = true
164- break
165- }
166- st .repos = append (st .repos , rspec )
167- st .checkouts = append (st .checkouts , pw )
168- _ = s .repo .AppendEvent (runCtx , job .runID , "lifecycle" , fmt .Sprintf (
169- `{"phase":"worktree_ready","repo":%q,"path":%q,"branch":%q,"base_commit":%q}` ,
170- rspec .RepoSlug , pw .Path , pw .Branch , pw .BaseCommit ))
171- }
172- if prepFailed {
173- // A partial multi-repo checkout is unusable; clean up what we
174- // prepared and fail visibly. Checkout-prep failure fires the
175- // post-run hook (matches the prior inline behavior).
176- for _ , pw := range st .checkouts {
177- _ = s .preparer .Cleanup (context .Background (), pw )
178- }
179- if st .workspaceRoot != "" {
180- s .preparer .CleanupWorkspaceDir (job .runID )
181- }
182- s .failClaim (job , cancel , "prepare checkout failed" , true )
183- continue
184- }
185- // The primary checkout (index 0) drives the single-repo-compatible
186- // path: its branch is the grant ref, and it's the cwd for a
187- // single-repo run.
188- primary := st .checkouts [0 ]
189- st .branch = primary .Branch
190- st .baseCommit = primary .BaseCommit
191- if multi {
192- st .path = st .workspaceRoot
193- } else {
194- st .path = primary .Path
122+ func (s * RunService ) dequeueClaimJob () (queuedJob , bool ) {
123+ select {
124+ case job := <- s .queue :
125+ return job , true
126+ case <- s .shutdownCh :
127+ for {
128+ select {
129+ case job := <- s .queue :
130+ s .finalize (job .runID , models .AgentRunStatusCanceled , "shutdown before admission" )
131+ s .wg .Done ()
132+ default :
133+ return queuedJob {}, false
195134 }
196135 }
136+ }
137+ }
197138
198- // Caller-supplied env first; the orchestrator's own injections
199- // (WS_TOKEN) overwrite on conflict so a confused caller cannot
200- // smuggle in its own token. The token mint + grant snapshot (bound to
201- // the minted token, git ref = the prepared worktree branch) is the
202- // shared preamble the remote claim path also runs (WI-195).
203- env := make ( map [ string ] string , len ( job . req . Env ) + 1 )
204- for k , v := range job . req . Env {
205- env [ k ] = v
139+ func ( s * RunService ) beginClaim ( job queuedJob ) ( runCtx context. Context , cancel context. CancelFunc , claimed bool ) {
140+ runCtx , cancel = context . WithCancel ( context . Background ())
141+ s . registerCancel ( job . runID , cancel )
142+ go func () {
143+ select {
144+ case <- s . shutdownCh :
145+ cancel ()
146+ case <- runCtx . Done ():
206147 }
207- if job .req .Token != nil {
208- // Per-repo push refs: each grant may push only its prepared branch
209- // (WI-449). Built from the parallel repos/checkouts slices.
210- refByRepo := make (map [string ]string , len (st .checkouts ))
211- for i , pw := range st .checkouts {
212- refByRepo [st .repos [i ].RepoSlug ] = pw .Branch
148+ }()
149+
150+ transitioned , err := s .repo .MarkRunningIfQueued (runCtx , job .runID , "" , s .now ())
151+ if err != nil {
152+ s .logger .Printf ("run service: mark running run=%d: %v" , job .runID , err )
153+ s .failClaim (job , cancel , fmt .Sprintf ("mark running: %v" , err ), false )
154+ return nil , nil , false
155+ }
156+ if ! transitioned {
157+ s .logger .Printf ("run service: skipping run=%d: no longer queued at dequeue" , job .runID )
158+ cancel ()
159+ s .unregisterCancel (job .runID )
160+ s .wg .Done ()
161+ return nil , nil , false
162+ }
163+ if err := s .repo .AppendEvent (runCtx , job .runID , "lifecycle" , `{"phase":"running"}` ); err != nil {
164+ s .logger .Printf ("run service: append running event run=%d: %v" , job .runID , err )
165+ }
166+ return runCtx , cancel , true
167+ }
168+
169+ func (s * RunService ) prepareClaimRepos (ctx context.Context , job queuedJob , state * claimState ) error {
170+ repos := runRepos (job .req )
171+ if len (repos ) == 0 {
172+ return nil
173+ }
174+
175+ multi := len (repos ) > 1
176+ if multi {
177+ state .workspaceRoot = s .preparer .RunWorkspaceDir (job .runID )
178+ }
179+ dirNames := repoDirNames (repos )
180+ for i , repo := range repos {
181+ spec := * repo
182+ if multi {
183+ spec .DestDir = filepath .Join (state .workspaceRoot , dirNames [i ])
184+ }
185+ prepared , err := s .preparer .Prepare (ctx , spec , job .runID )
186+ if err != nil {
187+ s .logger .Printf ("run service: prepare checkout run=%d repo=%s: %v" , job .runID , repo .RepoSlug , err )
188+ for _ , checkout := range state .checkouts {
189+ _ = s .preparer .Cleanup (context .Background (), checkout )
213190 }
214- token , err := s .mintTokenAndGrants (runCtx , job .runID , * job .req .Token , job .req .Grants , refByRepo )
215- if err != nil {
216- s .logger .Printf ("run service: mint ws token run=%d: %v" , job .runID , err )
217- // Token-mint failure does not fire the hook (matches the
218- // prior inline behavior).
219- s .failClaim (job , cancel , fmt .Sprintf ("mint ws token: %v" , err ), false )
220- continue
191+ if state .workspaceRoot != "" {
192+ s .preparer .CleanupWorkspaceDir (job .runID )
221193 }
222- env ["WS_TOKEN" ] = token
223- applyLLMProxyEnv (env , job .req .Grants , job .runID , token )
194+ return err
224195 }
196+ state .repos = append (state .repos , repo )
197+ state .checkouts = append (state .checkouts , prepared )
198+ _ = s .repo .AppendEvent (ctx , job .runID , "lifecycle" , fmt .Sprintf (
199+ `{"phase":"worktree_ready","repo":%q,"path":%q,"branch":%q,"base_commit":%q}` ,
200+ repo .RepoSlug , prepared .Path , prepared .Branch , prepared .BaseCommit ))
201+ }
202+
203+ primary := state .checkouts [0 ]
204+ state .branch = primary .Branch
205+ state .baseCommit = primary .BaseCommit
206+ state .path = primary .Path
207+ if multi {
208+ state .path = state .workspaceRoot
209+ }
210+ return nil
211+ }
225212
226- s .claimsMu .Lock ()
227- s .claims [job .runID ] = & st
228- s .claimsMu .Unlock ()
213+ func (s * RunService ) buildClaimEnv (ctx context.Context , job queuedJob , state * claimState ) (map [string ]string , error ) {
214+ env := make (map [string ]string , len (job .req .Env )+ 1 )
215+ for key , value := range job .req .Env {
216+ env [key ] = value
217+ }
218+ if job .req .Token == nil {
219+ return env , nil
220+ }
229221
230- initialPrompt := s .initialPrompt
231- if job .req .InitialPrompt != "" {
232- initialPrompt = job .req .InitialPrompt
233- }
234- // Per-binding instructions + skills index ride as a suffix on top of
235- // whichever base prompt won (WI-258).
236- initialPrompt += job .req .InitialPromptSuffix
237- return & ClaimedJob {Spec : JobSpec {RunID : job .runID , WorkspacePath : st .path , Env : env , InitialPrompt : initialPrompt , Kind : job .req .JobKind , Image : job .req .JobImage }, Ctx : runCtx }
222+ refByRepo := make (map [string ]string , len (state .checkouts ))
223+ for i , checkout := range state .checkouts {
224+ refByRepo [state .repos [i ].RepoSlug ] = checkout .Branch
225+ }
226+ token , err := s .mintTokenAndGrants (ctx , job .runID , * job .req .Token , job .req .Grants , refByRepo )
227+ if err != nil {
228+ return nil , err
229+ }
230+ env ["WS_TOKEN" ] = token
231+ applyLLMProxyEnv (env , job .req .Grants , job .runID , token )
232+ return env , nil
233+ }
234+
235+ func (s * RunService ) finishClaim (ctx context.Context , job queuedJob , state * claimState , env map [string ]string ) * ClaimedJob {
236+ s .claimsMu .Lock ()
237+ s .claims [job .runID ] = state
238+ s .claimsMu .Unlock ()
239+
240+ initialPrompt := s .initialPrompt
241+ if job .req .InitialPrompt != "" {
242+ initialPrompt = job .req .InitialPrompt
243+ }
244+ initialPrompt += job .req .InitialPromptSuffix
245+ return & ClaimedJob {
246+ Spec : JobSpec {
247+ RunID : job .runID , WorkspacePath : state .path , Env : env ,
248+ InitialPrompt : initialPrompt , Kind : job .req .JobKind , Image : job .req .JobImage ,
249+ },
250+ Ctx : ctx ,
238251 }
239252}
240253
0 commit comments