Skip to content

fix: headless job durability#22

Merged
jorisjonkers-dev-agents[bot] merged 9 commits into
mainfrom
fix/headless-durability
Jul 11, 2026
Merged

fix: headless job durability#22
jorisjonkers-dev-agents[bot] merged 9 commits into
mainfrom
fix/headless-durability

Conversation

@jorisjonkers-dev-agents

Copy link
Copy Markdown
Contributor

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 own REQUIRES_NEW transaction before calling the gateway. After success the session is updated to RUNNING in a second REQUIRES_NEW transaction. If the gateway call fails, a third REQUIRES_NEW transaction marks the placeholder FAILED so STARTING rows never linger for reconciliation.

Idle endpoint mismatch (IdleScaleDownScheduler): headless sessions store the gateway job id in gatewayAgentId. The stale-runner recycle path called gateway.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 on session.runMode == "HEADLESS": for headless sessions it calls gateway.pollHeadlessJob and 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 optional scope: String? parameter through the entire recall stack. SendUserInputCommandHandler derives scope via ScopeInference.scopeFor(workspace) (returns "project:<slug>" or "agent:<name>") and passes it to contextBuilder.augment(). LightRagClient accepts the parameter for interface compliance but ignores it (graph-based, no scope concept).

…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.
@jorisjonkers-dev-agents jorisjonkers-dev-agents Bot added the type: bug Something is broken or behaving incorrectly. label Jul 10, 2026
- 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: bug Something is broken or behaving incorrectly.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Headless/council run durability + idle-endpoint mismatch + RAG scope loss

1 participant