fix: headless job durability#22
Merged
Merged
Conversation
…ch, RAG scope forwarding (#14) Three coupled fixes for headless job reliability: 1. StartHeadlessJobCommandHandler: remove single @transactional block and replace with two independent REQUIRES_NEW transactions. Phase 1 persists a STARTING session before the gateway call so a crash during the round-trip leaves a reconcilable row. Phase 2 updates to RUNNING with the returned job id. Gateway failure marks the STARTING row FAILED via a third REQUIRES_NEW transaction so the row never lingers. 2. IdleScaleDownScheduler: staleRunnerSafeToRecycle now branches by runMode. Headless sessions call gateway.pollHeadlessJob and treat any non-RUNNING status as terminal (safe to recycle). Previously the interactive /agents/{id} endpoint was called with a headless job id, returning 404 and blocking scale-down forever. Any gateway exception returns false (conservative — wait for next sweep). 3. RAG scope forwarding: RetrievalPort.retrieve, KnowledgeMcpTransport.recall, KnowledgeRecallClient, LightRagClient, ContextBuilder.augment and ContextBuilder.dedupedAndFiltered all accept an optional scope parameter. SendUserInputCommandHandler derives scope via ScopeInference.scopeFor(workspace) and passes it through so knowledge recall is narrowed to the workspace's project or agent scope.
- IdleScaleDownSchedulerHeadlessTest: fix ktlint violations (multiline constructor params, blank lines before declarations, IdleScaleDownRuntime args on separate lines) - ContextBuilderTest.FakeSource: add scope parameter to retrieve() override to match the updated RetrievalPort interface signature
buildMap<String, Any?> lambda body was multiline on same line as val assignment; ktlint requires the opening lambda to start on a new line.
- StartHeadlessJobCommandHandlerTest: replace RuntimeException throw statement (TooGenericExceptionThrown) with IllegalStateException - IdleScaleDownSchedulerHeadlessTest.FixedClock: add explicit ZoneId return type to getZone() (HasPlatformType)
…ocation The @transactional(REQUIRES_NEW) methods were called via self-invocation on 'this', which bypasses the Spring AOP proxy. The commit guarantee was false: saveStartingSession, persistSession, and markSessionFailed would join or create an outer transaction rather than committing independently. Extract the three transactional DB operations into a separate @component (HeadlessJobSessionPersistence) injected into StartHeadlessJobCommandHandler. Spring proxies external bean calls, so each method now commits in its own independent transaction as intended. Tests mock HeadlessJobSessionPersistence directly to verify phase 1/2 sequencing without a Spring container.
- map gateway HeadlessStatus to session status correctly in HeadlessJobSessionPersistence.persistSession (COMPLETED/CANCELLED → STOPPED, FAILED → FAILED, RUNNING → RUNNING); previously always wrote RUNNING regardless of the returned job status - wire ContextBuilder into StartHeadlessJobCommandHandler so headless prompts receive workspace-scoped RAG augmentation, matching the behaviour of SendUserInputCommandHandler for interactive sessions - add contextBuilder mock stubs to all affected unit tests - add test covering the COMPLETED → STOPPED mapping
This was referenced Jul 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #14
Three coupled fixes for headless session reliability.
Two-phase commit (
StartHeadlessJobCommandHandler): the previous implementation wrapped the gateway call and DB write in a single transaction. A crash between the two left no DB record — the STARTING session was never written so the caller had no session id to poll, and the gateway job ran untracked. The fix saves a STARTING placeholder in its ownREQUIRES_NEWtransaction before calling the gateway. After success the session is updated to RUNNING in a secondREQUIRES_NEWtransaction. If the gateway call fails, a thirdREQUIRES_NEWtransaction marks the placeholder FAILED so STARTING rows never linger for reconciliation.Idle endpoint mismatch (
IdleScaleDownScheduler): headless sessions store the gateway job id ingatewayAgentId. The stale-runner recycle path calledgateway.agentIdle(workspace, agentId)which hits the interactive/agents/{id}endpoint. With a headless job id that endpoint returns 404, which the code treated as "unknown idle state" and blocked scale-down indefinitely. The fix branches onsession.runMode == "HEADLESS": for headless sessions it callsgateway.pollHeadlessJoband treats any non-RUNNING terminal status as safe-to-recycle. Any exception from the gateway returns false (conservative — next sweep retries).RAG scope forwarding (
ContextBuilder,KnowledgeMcpTransport,KnowledgeRecallClient,LightRagClient,RetrievalPort):ContextBuilder.augment()accepted no scope, so all KB recall queries were sent without a scope filter. The fix threads an optionalscope: String?parameter through the entire recall stack.SendUserInputCommandHandlerderives scope viaScopeInference.scopeFor(workspace)(returns"project:<slug>"or"agent:<name>") and passes it tocontextBuilder.augment(). LightRagClient accepts the parameter for interface compliance but ignores it (graph-based, no scope concept).