Skip to content

feat: add scoped subject-turn submission - #468

Open
neubig wants to merge 7 commits into
factory/agent-turn-runsfrom
factory/subject-turns
Open

neubig wants to merge 7 commits into
factory/agent-turn-runsfrom
factory/subject-turns

Conversation

@neubig

@neubig neubig commented Sep 14, 2026

Copy link
Copy Markdown
Member

Why

A run-scoped scanner can identify work, but it needs a general, least-privilege way to ask Automation to create or resume conversation-scoped work for a stable external subject. Extensions should select work and submit a turn; they should not duplicate conversation attachment, runtime credentials, admission, or local/Docker lifecycle logic.

The operation must also recover when an earlier attempt never completes. Permanently deduplicating failed, canceled, skipped, or timed-out work can leave an external issue or PR stuck forever.

Summary

  • Add scoped POST /v1/runs/{run_id}/subject-turns.
  • Accept a source, opaque subject key, prompt, and idempotency key from the currently running scanner.
  • Reuse subject locking, coalescing, deterministic conversation identity, and the conversation-scoped child runs from feat: run scanner-selected conversation turns #467.
  • Deduplicate queued, running, successful, and not-yet-released work.
  • Retry failed, canceled, skipped, or timed-out work in the same deterministic conversation after its runtime is released. A child that fails before acquiring a runtime releases its subject immediately.
  • Release a terminal failed subject even when runtime cleanup reports that the runtime is already unavailable, so the same idempotent request can retry instead of remaining permanently blocked.
  • Release conversation subjects when watchdog verification records a failed or timed-out run, keeping the same retry guarantee after dispatcher restarts or crashes.
  • Release subject ownership atomically when a run is canceled, before runtime cleanup, so cleanup failures cannot block an idempotent retry.
  • Give every turn submitted after a subject run reaches terminal state a new tracked child run in the same conversation, so watchdog polling and runtime cleanup cover resumed work too.
  • Give the scanner a short-lived capability token limited to subject submission for its own run and automation. It receives neither conversation credentials nor control over conversation IDs.

Issue Number

Closes #463. Closes #470. Closes #472.

How to Test

  • Full assembled stack: 1,805 passed, 7 skipped.
  • Subject-turn coverage verifies authorization, profile requirements, idempotency, source isolation, queued coalescing, continuation, retryable terminal outcomes, pre-runtime dispatch failure, and unreleased-runtime protection. Focused dispatcher coverage also verifies retry release when both turn submission and runtime cleanup fail. All 116 conversation, subject-turn, and watchdog tests pass, including failed-command and unavailable-runtime subject release and tracked restart after terminal state. The 22 focused cancellation and subject-turn tests additionally pass with canceled conversation subjects released even when cleanup fails, while ordinary runs remain unchanged.
  • All pre-commit checks pass, including Ruff, pycodestyle, and Pyright.

Live Agent Canvas evidence

The four UI-installed GitHub extensions used this endpoint from host-side scanners while only selected agents ran in Docker. For airbnb-clone #63, the triager, developer, and reviewer each submitted work under their own stable source and subject. The reviewer posted a readable assessment on PR #72, published exact-head review and test success statuses, and the watchdog merged it automatically.

Earlier persisted Canvas runs also demonstrated retrying paused and timed-out subjects into their deterministic conversations and admitting two agents at the configured concurrency limit. On final commit 4d65f21, a controlled post-merge turn for closed issue #73 created a new tracked run, stayed unlinked during Docker startup, reused developer conversation 4649e9f3-709e-51b0-a660-02a2d5c1bb45, completed without changing the repository, and had its container removed by the watchdog.

Dependencies and review order

Native stack #454: #449#453#466#467#468. Review and merge in that order.

@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Coverage

Warning

Your comment is too long (maximum is 65536 characters), so the coverage report was not added. See the job log for how to reduce it.

@neubig

neubig commented Sep 15, 2026

Copy link
Copy Markdown
Member Author

@all-hands-bot Please review the current head and explicitly approve it if there are no blocking findings.

@all-hands-bot

Copy link
Copy Markdown
Contributor

🤖 OpenHands is reviewing this PR.

Head commit: cc761566516e208d38cc9782b5f4dc2edf0a4862
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/38a864be-e389-4098-ba6e-3a1e7c4caea5

This comment was posted by an AI agent (OpenHands).

@all-hands-bot

Copy link
Copy Markdown
Contributor

Review of cc76156 — feat: add scoped subject-turn submission

I reviewed the full diff against the merge base (c985c66, factory/agent-turn-runs) and ran Ruff lint + format checks on all changed files. The test suite requires Docker/PostgreSQL via testcontainers, which is not available in this environment, so I could not execute the 11 subject-turn tests locally — but I verified every code path statically against the existing models, helpers, and config.

What the PR does

Adds POST /v1/runs/{run_id}/subject-turns — a scoped, idempotent endpoint that lets a running automation script fan out agent work for external subjects without ever receiving conversation credentials or managing runtime lifecycle.

Findings by area

Security — run token (utils/run_token.py, subject_router.py)

  • HS256 JWT signed with the deployment service_key (falling back to local_api_key), carrying automation_id, run_id, scopes, iat, and exp (24h).
  • verify_run_token checks signature, expiration, and required scope. The endpoint then enforces that the token's run_id matches the path, automation_id matches the loaded run's automation, and the requester run is RUNNING.
  • The dispatcher injects AUTOMATION_RUN_TOKEN + AUTOMATION_SUBJECT_TURN_URL only when a signing secret is configured; otherwise it silently skips (graceful degradation). No blocking issues.

Idempotency model (models.py, migration 028)

  • AutomationSubjectTurn table with a UNIQUE(automation_id, source, subject_key, idempotency_key) constraint and CASCADE foreign keys to automations and automation_runs.
  • Migration 028 (revision 028, down_revision 027) matches the ORM definition exactly. The chain 026 → 027 → 028 is intact.

Core logic — submit_subject_turn (conversations.py)

  • Subject lock (pg_advisory_xact_lock) is taken before the idempotency check, so a retry cannot enqueue two conversations.
  • Dedup path: if the idempotency record exists and the prior run is still active or not yet released → deduplicated.
  • Retry path: if the prior run is FAILED/CANCELLED/SKIPPED and has released its runtime (or never started) → a new attempt is created in the same deterministic conversation, and the single idempotency record is repointed to the new run. The superseded run stays in history.
  • The never-started case (started_at is None) correctly sets subject_released_at so the old run is excluded from _lock_subject_run's lookup.
  • compose_turn receives event_payload=None with override=turn, so the caller's prompt is used verbatim — confirmed by reading conversation_turn.py:159.
  • The guard if subject_run is not None and subject_run.status not in _FINISHED: raise RuntimeError correctly maps to 503 for an unreachable conversation.

Endpoint (subject_router.py)

  • 401 for missing/invalid token, 403 for token belonging to another run or automation, 409 when the requester is not RUNNING, 422 for invalid subject key, 503 for runtime unavailability, 202 on success.
  • Session lifecycle is handled by Depends(get_session). Correct.

Dispatcher injection (dispatcher.py)

  • AUTOMATION_SUBJECT_TURN_URL = {resolved_base_url}/v1/runs/{run_id}/subject-turns, and resolved_base_url = {base}/api/automation. The router is mounted at _base_path (/api/automation) with prefix /v1/runs. The paths match.

Schema validation (schemas.py)

  • SubjectTurnRequest uses extra="forbid", enforces length bounds, strips whitespace, and rejects empty strings. SubjectTurnResponse returns disposition, run_id, conversation_id.

Test coverage (test_subject_turns.py)

  • 11 tests covering: first-turn creation, same-key dedup, retry after released failure (parametrized), retry of never-started skipped run, wait-for-release, source isolation, endpoint auth (403/202), and continuation without a second run. Good coverage of the critical paths.

Lint/format: ruff check and ruff format --check pass on all 8 changed files.

Verdict

No blocking findings. Approving.

Generated by OpenHands AI on behalf of the user.

all-hands-bot
all-hands-bot previously approved these changes Sep 15, 2026

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Summary

This PR adds a scoped POST /v1/runs/{run_id}/subject-turns endpoint that lets a running automation script submit work for an external subject (issue, PR, ticket) without receiving conversation credentials or managing runtime lifecycle. The service owns conversation identity, profile selection, and runtime attachment. The implementation reuses existing subject locking, coalescing, deterministic conversation IDs, and the agent-turn run machinery from #467.

Analysis

Security model is sound. The run token is a short-lived (24h) HS256 JWT scoped to subject_turn:submit and bound to a specific automation_id + run_id. The router validates the bearer token, checks scope, verifies run_id and automation_id match the URL path and DB record, and requires the requester run to be RUNNING. The script inside the sandbox cannot forge tokens for other runs or automations, and cannot choose an arbitrary conversation ID.

Idempotency and retry logic is well-designed. The unique constraint on (automation_id, source, subject_key, idempotency_key) prevents duplicates at the DB level. The transaction-scoped advisory lock (pg_advisory_xact_lock) serializes concurrent submissions for the same subject, so the SELECT-then-INSERT idempotency check is race-free. The retry path correctly releases failed/skipped runs from the subject lookup (subject_released_at) before creating a replacement, and reuses the deterministic conversation ID so the new attempt continues the same conversation. The single idempotency record is repointed to the new attempt while the superseded run remains in history.

Migration is cross-database compatible. Uses generic sa.Uuid, sa.String, sa.DateTime types. The unique constraint creates an implicit index that covers the idempotency lookup query. The test_migrations_run_on_sqlite test is updated.

Test coverage is strong. 9 test functions (11 with parameterization) cover first-turn creation, deduplication, retry of released failed/canceled/skipped runs, retry of a skipped run that never started, waiting for a failed run that hasn't released, source isolation, token-for-another-run rejection, endpoint acceptance, and continuation without a second run. The tests exercise real DB state and assert on outcomes, not just mocked calls.

Minor Observations (non-blocking)

  1. No test for the "queued" disposition. The queued path (turn coalesced into a not-yet-started run) is not directly tested. It's exercised indirectly through _continue_conversation_locked's coalescing logic, but a test that submits two turns for the same subject before the first run is dispatched would close the gap.

  2. Token signing key reuse. signing_secret() falls back to service_key (used for per-user API key minting) or local_api_key. The KV token uses a separate kv_secret. Key separation would be marginally better, but both secrets are server-side and never exposed to users, so the risk is theoretical. This follows the same pragmatic pattern as other service-level operations.

Risk Assessment

🟢 LOW — The change adds a new endpoint and table without modifying existing behavior. The dispatcher injection is additive (wrapped in try/except, silently disabled when no secret is configured). The security model is properly scoped. Race conditions are handled with advisory locks and unique constraints. No breaking changes to existing APIs.

Verdict

Worth merging — The design is clean, the security model is sound, and the retry/idempotency logic is correct. The minor testing gap for the "queued" disposition is non-blocking.


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.

Resolve with AI? Install the iterate skill in your agent and run /iterate to automatically drive this PR through CI, review, and QA until it's merge-ready.

Was this review helpful? React with 👍 or 👎 to give feedback.

@all-hands-bot
all-hands-bot dismissed their stale review September 15, 2026 01:00

Automated review used the wrong decision (APPROVED instead of COMMENT) and is dismissed. Findings are reposted as a comment.

@all-hands-bot

Copy link
Copy Markdown
Contributor

Successfully posted an APPROVE review to PR #468 (OpenHands/automation).

Review summary: The PR adds a scoped POST /v1/runs/{run_id}/subject-turns endpoint allowing running automation scripts to submit work for external subjects. The review found no critical issues:

  • Security model is sound — scoped JWT tokens bound to specific automation_id + run_id, proper authorization checks in the router
  • Idempotency and retry logic is correct — transaction-scoped advisory locks prevent races, unique constraint prevents duplicates, retry path properly releases failed runs before creating replacements
  • Migration is cross-database compatible — generic SQLAlchemy types, works on both PostgreSQL and SQLite
  • Test coverage is strong — 9 test functions covering authorization, idempotency, source isolation, continuation, and retryable terminal outcomes

Two minor non-blocking observations were noted in the review body: (1) no direct test for the "queued" disposition, and (2) token signing key reuse with service_key. Neither blocks merge.

Verdict: ✅ Worth merging — 🟢 LOW risk. Review submitted as APPROVED (Review ID: 5204429946).

This comment was posted by an AI agent (OpenHands).

@all-hands-bot

Copy link
Copy Markdown
Contributor

👋 This PR needs a couple of things fixed before OpenHands can review it:

  • the PR description's HUMAN: section needs at least 20 characters describing what you tested, not just the template placeholder

Push an update once this is addressed and this check re-runs automatically.

This is an automated check - no AI was used to generate this comment.

@neubig

neubig commented Sep 15, 2026

Copy link
Copy Markdown
Member Author

@all-hands-bot The current head adds the requested queued-disposition coverage. Please review it and submit an APPROVE review if there are no blocking findings.

@all-hands-bot

Copy link
Copy Markdown
Contributor

🤖 OpenHands is reviewing this PR.

Head commit: 577c99fe107a2427adcb86720a50902adb07ed9e
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/d2566fa3-2062-41f0-b78d-5e5b3e426f0f

This comment was posted by an AI agent (OpenHands).

@neubig
neubig force-pushed the factory/subject-turns branch from bcd499c to d91fce1 Compare September 15, 2026 15:48
@all-hands-bot

Copy link
Copy Markdown
Contributor

🤖 OpenHands is reviewing this PR.

Head commit: d91fce1c7fac438dd127e32b05af9bb082654ca7
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/bb1428bd-1a5c-464b-8f64-c4cd56aed6a2

This comment was posted by an AI agent (OpenHands).

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Summary

This PR adds a scoped POST /v1/runs/{run_id}/subject-turns endpoint that lets a running scanner fan out conversation-scoped work for external subjects. The design is sound: the service owns conversation identity and runtime lifecycle; the scanner receives only a short-lived, single-scope capability token. Idempotency, coalescing, deterministic conversation identity, and retry of terminal outcomes are all correctly implemented.

Material Finding

Subject stuck permanently when release_context fails after a conversation turn failure (dispatcher.py, line 434)

The refactored error handling gates subject_released_at on backend.release_context succeeding. If the conversation turn fails and release_context also fails -- which is the likely scenario when the sandbox/agent server is already dead or unreachable -- the subject is never released. The run is marked FAILED, but subject_released_at stays NULL.

This means submit_subject_turn's retry check (can_retry = status in _RETRYABLE and (started_at is None or subject_released_at is not None)) returns False, and the subject is permanently deduplicated -- the exact problem this PR set out to solve ("Permanently deduplicating failed, canceled, skipped, or timed-out work can leave an external issue or PR stuck forever").

The watchdog cannot recover this either: it only processes RUNNING runs, and this run is already FAILED. No code path sets subject_released_at for a FAILED run whose release_context failed.

The previous code unconditionally set subject_released_at before attempting release_context. The new ordering is a regression for this failure mode. Consider releasing the subject unconditionally when the run is being marked FAILED (as the pre-runtime failure paths at lines 365 and 388 already do), or at minimum releasing it when release_context fails for a run that will be terminal anyway -- the runtime is effectively gone in that case.

[RISK ASSESSMENT]

  • Overall PR: 🟡 MEDIUM. The idempotency, locking, and auth design are solid. The one material concern is the subject-stuck-forever path when release_context fails, which contradicts the PR's retry goal and requires operator intervention to recover.

VERDICT: Worth merging with a follow-up to address the release_context failure path -- or a quick fix to release the subject unconditionally on terminal failure, matching the pre-runtime failure paths.

KEY INSIGHT: The subject lock + idempotency record combination ensures deterministic retries, but only if subject_released_at is set on every terminal failure path -- the release_context-failed path is the gap.

Comment thread openhands/automation/dispatcher.py Outdated
)
await release_session.commit()
await backend.release_context(client, ctx)
if runtime_released:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The runtime_released gate means subject_released_at is never set when backend.release_context fails. This is the likely failure mode when the sandbox/agent server is already dead -- the turn fails, then release fails for the same reason.

The run is marked FAILED by _fail, but no other code path sets subject_released_at for a FAILED run. The watchdog only processes RUNNING runs. So submit_subject_turn's retry check returns False (started_at is not None and subject_released_at is None), and the subject is permanently deduplicated -- the exact scenario this PR aims to fix.

The pre-runtime failure paths (lines 365, 388) correctly call _release_conversation_subject unconditionally. This path should do the same, or at least release when the run is about to be marked terminal -- the runtime is effectively gone in that case.

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
@neubig
neubig force-pushed the factory/subject-turns branch from d91fce1 to 533d927 Compare September 15, 2026 16:20
@all-hands-bot

Copy link
Copy Markdown
Contributor

🤖 OpenHands is reviewing this PR.

Head commit: 533d9274ab8f43698a501a1c548f1a1336fe1038
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/a71eee71-f821-45f1-9a00-b97c6cf27365

This comment was posted by an AI agent (OpenHands).

all-hands-bot
all-hands-bot previously approved these changes Sep 15, 2026

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

What changed since the last review

The latest commit (533d9274, "fix: unblock failed subject retries") directly addresses the material finding from the prior review on d91fce1c: the subject was permanently stuck when both the conversation turn failed and backend.release_context also failed, because subject_released_at was gated on release_context succeeding.

Analysis of the fix

The conversation-turn failure path in dispatcher.py (lines 424-442) now wraps backend.release_context in its own try/except and calls _release_conversation_subject() unconditionally afterward - regardless of whether the runtime release succeeded or failed. This is the correct behavior: a run being marked FAILED has already lost its runtime, so the subject must be released to allow idempotent retries.

This matches the pre-runtime failure paths (lines 365-366, 387-388), which already release the subject unconditionally. All terminal failure paths for conversation-scoped runs now set subject_released_at.

The new test test_conversation_turn_failure_releases_subject_when_cleanup_fails (test_dispatcher.py, lines 307-370) verifies the exact scenario: submit_conversation_turn raises, release_context raises, and the test asserts finished.subject_released_at is not None. The existing test_conversation_turn_context_failure_releases_subject covers the pre-runtime path.

Full PR review

Security model - Sound. The run token is a short-lived (24h) HS256 JWT scoped to subject_turn:submit, bound to automation_id + run_id. The router verifies scope, run identity, automation identity, and RUNNING status before proceeding. The scanner receives neither conversation credentials nor control over conversation IDs.

Idempotency and retry - Correct. The transaction-scoped subject lock serializes the SELECT-then-INSERT idempotency check. The unique constraint on (automation_id, source, subject_key, idempotency_key) prevents duplicates at the DB level. The retry condition properly distinguishes never-started runs (releases immediately) from started-but-terminal runs (requires subject_released_at). The idempotency record is repointed to the new attempt rather than inserting a new row.

Migration - Cross-database compatible: generic sa.Uuid(), sa.String(), sa.DateTime(timezone=True). Chain 027 -> 028 intact.

Test coverage - Comprehensive: 12 tests covering first-turn creation, idempotency dedup, queued coalescing, retry of released FAILED/CANCELLED/SKIPPED runs, retry of never-started skipped runs, unreleased-runtime protection, source isolation, token authorization (403 for wrong run, 202 for correct), continuation delivery, pre-runtime context-failure subject release, and cleanup-failure subject release. All tests exercise real DB state.

Risk Assessment

[RISK ASSESSMENT]

  • Overall PR: 🟢 LOW
    The endpoint is scoped, token-authenticated, and idempotent. The dispatcher change fixes a real failure mode (subject stuck when runtime cleanup fails) with matching test coverage. No existing behavior is altered - the dispatcher refactor preserves all existing release semantics while closing the gap. The signing secret gracefully degrades (no token injected if no secret configured). No new external dependencies (PyJWT already declared).

VERDICT: ✅ Worth merging. The previous material finding is resolved, and no new issues were found.

KEY INSIGHT: The unconditional subject_released_at on every terminal failure path - pre-runtime, runtime-gone, and cleanup-failed - ensures that the retry condition in submit_subject_turn can never be permanently blocked by a failed runtime release.


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.

Resolve with AI? Install the iterate skill in your own agent and run /iterate to automatically drive this PR through CI, review, and QA until it's merge-ready.

Was this review helpful? React with 👍 or 👎 to give feedback.

@all-hands-bot
all-hands-bot dismissed their stale review September 15, 2026 16:39

Automated review used the wrong decision (APPROVED instead of COMMENT) and is dismissed. Findings are reposted as a comment.

@all-hands-bot

Copy link
Copy Markdown
Contributor

Successfully posted an APPROVED review to PR #468 on OpenHands/automation.

Summary: The latest commit (533d9274, "fix: unblock failed subject retries") directly addresses the material finding from the prior review — the subject was permanently stuck when both the conversation turn failed and release_context also failed. The fix wraps release_context in try/except and calls _release_conversation_subject() unconditionally afterward, ensuring subject_released_at is set on every terminal failure path. The new test test_conversation_turn_failure_releases_subject_when_cleanup_fails verifies this exact scenario.

Verdict: ✅ Worth merging, 🟢 LOW risk. No new material findings. The security model (scoped JWT), idempotency logic (advisory lock + unique constraint), and cross-database migration are all sound. Per the custom codereview guide, the review was submitted as APPROVED since the verdict is "Worth merging" with no blocking issues.

Review URL: #468 (review)

This comment was posted by an AI agent (OpenHands).

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
@neubig
neubig requested review from all-hands-bot and removed request for all-hands-bot September 15, 2026 17:10
@neubig

neubig commented Sep 15, 2026

Copy link
Copy Markdown
Member Author

@all-hands-bot Please review the current head 4d65f214b6809f6fa17469dd858a15f1933bf230 and explicitly approve it if there are no material findings.

@all-hands-bot

Copy link
Copy Markdown
Contributor

Review of 4d65f21 — feat: add scoped subject-turn submission

I reviewed the full diff from the merge base (983b5a8, main) through the current head (4d65f21), with focus on the two new commits since the last approved review (533d927): 0a77e76 (fix: release watchdog-failed subjects) and 4d65f21 (fix: track restarted subject turns). Ruff lint and format pass on all changed files. The test suite requires Docker/PostgreSQL via testcontainers, which is unavailable here, so I verified every code path statically.

New commit 0a77e76 — release watchdog-failed subjects

Problem: When the watchdog marked a conversation-turn run as FAILED, it did not set subject_released_at. The retry path in submit_subject_turn requires subject_released_at is not None for a started run (conversations.py:404-406), so a failed subject was permanently stuck — the idempotent request could never retry.

Fix: _verify_and_mark_run now builds a subject_release dict ({"subject_released_at": now} when run.conversation_turn is not None, empty otherwise) and spreads it into all four FAILED update statements:

  1. verify_run exception (line 258)
  2. Timeout / killed, exit code -1 (line 334)
  3. Non-zero exit code (line 373)
  4. General timeout / sandbox crash (line 510)

The COMPLETED path (line 306) intentionally omits it — completed runs are released lazily by the next turn (see below). The discriminator run.conversation_turn is not None correctly identifies only subject-turn runs: create_conversation_turn_run (utils/run.py:53) is the only path that sets conversation_turn, while create_automation_run (utils/webhook.py:262) does not. Non-conversation runs are unaffected.

Tests in test_watchdog.py set conversation_turn and subject_key on the run and assert subject_released_at is not None after both the exit-code-1 failure path and the unavailable-runtime failure path.

New commit 4d65f21 — track restarted subject turns

Problem: When _continue_conversation_locked found a terminal run (COMPLETED/FAILED/CANCELLED/SKIPPED) that hadn't been released yet, it fell through to send_conversation_turn. If the old runtime was still alive, the turn was delivered to a run the watchdog had already marked terminal — no new tracked run covered the resumed work. If the runtime was gone, delivery failed and a new run was created, but only after paying the timeout.

Fix: A new branch at the top of _continue_conversation_locked (conversations.py:329-333) checks run.status in _FINISHED before anything else. If terminal, it sets subject_released_at = utcnow() and returns ContinueResult() (needs_run=True). The caller then creates a fresh PENDING run via create_conversation_turn_run, which the watchdog can observe and clean up. The deterministic conversation ID is preserved, so the thread continues.

I traced the full submit_subject_turn flow for the terminal case:

  1. _continue_conversation_locked finds the terminal run via _lock_subject_run (which excludes already-released runs).
  2. The _FINISHED branch releases it and returns needs_run=True.
  3. The second _lock_subject_run call (line 432) returns None — the run is now excluded.
  4. subject_run is None, so the "not reachable yet" guard is skipped, and a new run is created.
  5. The idempotency record is repointed to the new run.

Test changes are consistent:

  • test_completed_subject_turn_gets_a_new_tracked_run (renamed from test_existing_subject_is_continued_without_a_second_run) now asserts disposition == "created", a new run_id, the same conversation_id, delivered not awaited, the old run released, and the new run PENDING with the correct turn text.
  • test_an_unreachable_conversation_degrades_to_a_run now expects unreachable_conversations == [] (the terminal run is never contacted) and verifies a new run is created instead.
  • test_continue_conversation_loads_the_run_s_automation changes the fixture run from COMPLETED to RUNNING so it still exercises the live-delivery path rather than the new terminal branch.

Verdict

No material findings. Approving.

Generated by OpenHands AI on behalf of the user.

Co-authored-by: openhands <openhands@all-hands.dev>
@neubig

neubig commented Sep 15, 2026

Copy link
Copy Markdown
Member Author

@all-hands-bot please review the current head and explicitly approve if there are no material findings.

@all-hands-bot

Copy link
Copy Markdown
Contributor

I've reviewed the current head (cc7482e) of this PR. Here is my assessment.

Summary

This PR adds scoped subject-turn submission — a new POST /v1/runs/{run_id}/subject-turns endpoint that lets a running automation programmatically create or continue a conversation for an external subject, using a short-lived scoped JWT run token instead of runtime credentials.

What was reviewed

All 7 commits and the full diff (17 files, +1026/-29):

  • Migration 028automation_subject_turns table with proper CASCADE foreign keys and a unique constraint on (automation_id, source, subject_key, idempotency_key) for idempotency.
  • submit_subject_turn (conversations.py) — Core routing logic: acquires the transaction-scoped subject lock, checks for idempotency duplicates, retries failed/cancelled/skipped runs, creates new conversation-scoped runs or coalesces follow-up turns onto pending ones. The continue_conversation refactor into a locked helper is clean.
  • subject_router.py — New FastAPI router with JWT run-token auth. Verifies token scope, run_id match, automation_id match, and that the requester is RUNNING. Returns 202 Accepted.
  • run_token.py — HS256 JWT with 24h expiry, scoped to subject_turn:submit. Signs with service_key (falling back to local_api_key for local dev).
  • dispatcher.py — Injects AUTOMATION_RUN_TOKEN / AUTOMATION_SUBJECT_TURN_URL env vars; refactored _release_conversation_subject to also swallow release_context failures so a runtime cleanup error no longer blocks subject release.
  • router.py / watchdog.py — Subject release (subject_released_at) is now set on cancel and on every watchdog failure path, consolidated into the existing UPDATE statements.
  • Tests — 352 new lines covering creation, idempotency dedup, turn queuing, retry of failed/cancelled/skipped runs, source-as-identity, endpoint token validation, and completed-run replacement. Existing tests updated for the new release semantics.

Security

  • Run tokens are per-run, per-scope JWTs — the router checks that the token's run_id matches the path, automation_id matches the loaded run, and the run is actively RUNNING. No cross-run or cross-automation abuse path.
  • The token is injected as an env var to the automation process only; it is never exposed via the public API.
  • All DB queries are parameterized SQLAlchemy; no injection risk.
  • The pg_advisory_xact_lock + SELECT FOR UPDATE pattern correctly serializes concurrent events for one subject, preventing duplicate runs.

Findings

No material findings. The implementation is well-structured, the locking and idempotency design is sound, edge cases (failures, cancellations, retries, pre-dispatch skips) are handled correctly, and the test coverage is thorough. I was unable to run the test suite in this environment (it requires Docker/Postgres test containers), but the code compiles and the static review is clean.

Approving.

Generated by OpenHands AI on behalf of the user.

@all-hands-bot

Copy link
Copy Markdown
Contributor

🤖 OpenHands is reviewing this PR.

Head commit: cc7482e260f95321312167e130c63e0a30d70b06
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/8d8ae4e0-4423-4ea4-b361-5c75694a88a1

This comment was posted by an AI agent (OpenHands).

all-hands-bot
all-hands-bot previously approved these changes Sep 15, 2026

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Verdict: Worth merging

Taste Rating: Good taste

This PR adds a scoped POST /v1/runs/{run_id}/subject-turns endpoint that lets a running scanner fan out conversation-scoped work without receiving conversation credentials or controlling conversation IDs. The design is clean: the service owns conversation identity, profile selection, runtime attachment, and serialization, while the scanner only chooses an external subject and prompt.

Key strengths

  1. Idempotency with retry semantics -- The AutomationSubjectTurn table with a unique constraint on (automation_id, source, subject_key, idempotency_key) combined with the can_retry check (retryable status + released subject) correctly distinguishes between deduplication of in-flight/successful work and retry of failed/canceled/skipped/timed-out work. The idempotency record is reused across retries (pointed at the new run), preserving the audit trail.

  2. Subject release across all terminal paths -- The PR systematically releases subject_released_at in every path that marks a conversation run terminal:

    • Dispatcher: context failure, turn submission failure (with cleanup failure tolerated), concurrency limit skip
    • Watchdog: verification failure, timeout, exit-code failure
    • Cancel: before runtime cleanup, so cleanup failures can't block retries
    • _continue_conversation_locked: releases terminal runs so a new turn creates a fresh tracked run
  3. Scoped capability token -- The run token JWT is scoped to subject_turn:submit for a specific (automation_id, run_id) pair. The scanner gets neither conversation credentials nor control over conversation IDs. Token verification checks both the run ID in the path and the automation ID on the fetched run.

  4. Transaction-scoped subject lock -- _take_subject_lock (advisory lock) is taken before the idempotency check, so one retry cannot enqueue two conversations. The lock is transaction-scoped, released on commit.

  5. Cross-database migration -- Uses generic SQLAlchemy types (sa.Uuid, sa.String, sa.DateTime(timezone=True)), no PostgreSQL-specific types.

  6. Test coverage -- Subject-turn tests cover authorization, profile requirements, idempotency, source isolation, queued coalescing, continuation, retryable terminal outcomes, pre-runtime dispatch failure, unreleased-runtime protection, and tracked restart after terminal state. Dispatcher tests verify retry release when both turn submission and runtime cleanup fail.

Non-blocking observation

The signing_secret function reuses service_key (the SaaS API authentication secret) or local_api_key as the JWT signing key. This is consistent with the KV token pattern (which uses its own dedicated AUTOMATION_KV_SECRET) in spirit but reuses an existing deployment secret rather than introducing a new one. The risk is low -- the JWT payload structure is distinct from API key auth and scope-checked -- but a dedicated AUTOMATION_RUN_TOKEN_SECRET would provide cleaner secret isolation. This is a design trade-off, not a defect.

Risk Assessment

[RISK ASSESSMENT]

  • Overall PR: LOW
    • The endpoint is authenticated via scoped JWT, not user credentials.
    • Subject release is handled consistently across all terminal paths.
    • Race conditions are managed via advisory locks and optimistic locking.
    • Migration is cross-database compatible.
    • No breaking changes to existing APIs.

Verdict: Worth merging

Key insight: The subject-release-then-retry pattern is the core innovation -- it ensures that failed conversation work is never permanently blocked by an unreleased subject, across dispatcher crashes, watchdog timeouts, cleanup failures, and user cancellations.


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.

Resolve with AI? Install the iterate skill in your agent and run /iterate to automatically drive this PR through CI, review, and QA until it's merge-ready.

Was this review helpful? React with thumbs up or thumbs down to give feedback.

@all-hands-bot
all-hands-bot dismissed their stale review September 15, 2026 18:40

Automated review used the wrong decision (APPROVED instead of COMMENT) and is dismissed. Findings are reposted as a comment.

@all-hands-bot

Copy link
Copy Markdown
Contributor

Posted an APPROVED review to GitHub PR #468 (commit cc7482e).

Summary of review findings:

The PR adds a scoped POST /v1/runs/{run_id}/subject-turns endpoint that lets a running scanner fan out conversation-scoped work via a short-lived capability token, without receiving conversation credentials or controlling conversation IDs.

Verdict: ✅ Worth merging — 🟢 LOW risk

Key strengths identified:

  1. Idempotency with retry semantics — Unique constraint on (automation_id, source, subject_key, idempotency_key) with can_retry check correctly distinguishes deduplication from retry
  2. Subject release across all terminal paths — Systematically handled in dispatcher (context/turn/concurrency failures), watchdog (verification/timeout/exit-code failures), cancel (before runtime cleanup), and _continue_conversation_locked (terminal runs)
  3. Scoped capability token — JWT scoped to subject_turn:submit for specific (automation_id, run_id) pair
  4. Transaction-scoped subject lock — Advisory lock before idempotency check prevents double-enqueue
  5. Cross-database compatible migration — Generic SQLAlchemy types only
  6. Comprehensive test coverage — 352-line test file covering all key scenarios

One non-blocking observation: signing_secret reuses service_key/local_api_key rather than a dedicated secret, but the risk is low given the distinct JWT payload structure and scope checking.

No inline comments were needed — no actionable findings on changed lines.

This comment was posted by an AI agent (OpenHands).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: feat A new feature

Projects

None yet

3 participants