Skip to content

feat(runner): detect stalled agent runs via event inactivity - #6595

Open
guyoron1 wants to merge 9 commits into
fullsend-ai:mainfrom
guyoron1:feat/runner-stall-watchdog
Open

feat(runner): detect stalled agent runs via event inactivity#6595
guyoron1 wants to merge 9 commits into
fullsend-ai:mainfrom
guyoron1:feat/runner-stall-watchdog

Conversation

@guyoron1

@guyoron1 guyoron1 commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Heyaa : )

This one came out of watching a wedged run burn its entire global timeout while the heartbeat cheerfully printed "agent running" the whole time — the process was dead and nothing could tell.

The runtime now watches the normalized event stream instead: every AgentEvent is proof of life, half a timeout of silence warns once per stall episode (::warning:: in CI, printer everywhere), a full timeout kills and fails distinctly with ErrStalled + "stalled": true in metrics.json. Config: FULLSEND_STALL_TIMEOUT (Go duration, default 10m, 0 disables), resolved by the CLI and handed over in RunParams — runtimes don't read env themselves (#6526). A malformed value is reported and the default applies.

Design points:

  • The kill is the existing kill path — the cancel that sandbox.ExecStreamReader already returns. No second termination mechanism.
  • One guard in the shared stream helper covers both streaming runtimes (claude, pi); dummy/opencode stream no events and ignore the field.
  • Default is 10m, not Cloudflare-style 60s: we don't request partial messages, so events arrive per assistant turn / per completed tool call — a single long tool call is legitimately silent for minutes.
  • The watchdog derives no context and never rebinds the caller's ctx (a body-scope rebind once made every successful run report "cancelled"); a source-reading regression test pins that for both runtimes.

Tests: the full watchdog matrix (flowing events never kill / silence kills exactly once / 0 disables / warn-once-per-episode, rearmed by the next event / no annotations outside CI), resolveStallTimeout cases, stalled,omitempty marshalling. go build, go vet, and the touched packages pass.

Non-goals: per-dimension timeouts (dimensions run inside one CLI process the runner can't see into), no change to the heartbeat or the global timeout.

@github-actions

Copy link
Copy Markdown

E2E tests did not run

E2E tests run automatically for org/repo members and collaborators on pull requests.

For other contributors, a maintainer must add the ok-to-test label after the latest push.

See E2E testing guide for details.

1 similar comment
@github-actions

Copy link
Copy Markdown

E2E tests did not run

E2E tests run automatically for org/repo members and collaborators on pull requests.

For other contributors, a maintainer must add the ok-to-test label after the latest push.

See E2E testing guide for details.

@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown

Site preview

Preview: https://0ddee7a2-site.fullsend-ai.workers.dev

Commit: 811acf1b9d75865b467c2dffaf3617f01c88ec77

@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.07042% with 7 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/runtime/codex_run.go 62.50% 1 Missing and 2 partials ⚠️
internal/runtime/pi_run.go 62.50% 1 Missing and 2 partials ⚠️
internal/runtime/claude.go 87.50% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@guyoron1
guyoron1 force-pushed the feat/runner-stall-watchdog branch from ffe01bf to 0339139 Compare September 1, 2026 11:53
@guyoron1
guyoron1 marked this pull request as ready for review September 1, 2026 11:54
@guyoron1
guyoron1 requested a review from a team as a code owner September 1, 2026 11:54
@qodo-code-review

qodo-code-review Bot commented Sep 1, 2026

Copy link
Copy Markdown

PR Summary by Qodo

Detect stalled agent runs from event-stream inactivity

✨ Enhancement 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Detects stalled Claude and Pi runs from normalized event-stream inactivity.
• Warns midway, then cancels wedged sandbox commands through the existing termination path.
• Adds configurable timeouts, distinct errors, metrics, documentation, and regression coverage.
Diagram

graph TD
  CONFIG["Stall configuration"] --> PARAMS["RunParams"] --> RUNTIME["Claude / Pi"] --> EVENTS["Agent events"] --> WATCH{"Silence threshold?"}
  WATCH -- "Half timeout" --> WARN["Warn once"]
  WATCH -- "Full timeout" --> CANCEL["Sandbox cancel"] --> RESULT["Error and metrics"]
Loading
High-Level Assessment

The shared event-inactivity watchdog is the appropriate design because normalized events provide stronger liveness evidence than the wall-clock heartbeat, while reusing ExecStreamReader's cancellation avoids competing termination mechanisms. Runtime-specific watchdogs would duplicate behavior, and deriving another context would risk altering caller-owned lifecycle semantics.

Files changed (10) +462 / -2

Enhancement (5) +209 / -2
run.goPropagate stall settings and report stalled runs +18/-0

Propagate stall settings and report stalled runs

• Resolves the stall timeout, warns when configuration is invalid, and passes it through RunParams. Maps ErrStalled to a specific failure message and records stalled=true in aggregate metrics.

internal/cli/run.go

claude.goAttach inactivity watchdog to Claude event streaming +15/-0

Attach inactivity watchdog to Claude event streaming

• Starts the shared watchdog using the sandbox command's existing cancel function and resets it for every normalized event. Disarms it before process reaping and prioritizes ErrStalled over the resulting wait error.

internal/runtime/claude.go

pi_run.goAttach inactivity watchdog to Pi event streaming +14/-0

Attach inactivity watchdog to Pi event streaming

• Integrates the shared watchdog with Pi's normalized event handler and existing sandbox cancellation path. Stops monitoring when the stream closes and returns the distinct stall error before generic process errors.

internal/runtime/pi_run.go

runtime.goExpose stall timeout through RunParams +9/-2

Expose stall timeout through RunParams

• Adds StallTimeout to the runtime invocation contract so the CLI owns environment resolution and streaming runtimes receive a duration directly.

internal/runtime/runtime.go

stall.goImplement shared event-inactivity watchdog +153/-0

Implement shared event-inactivity watchdog

• Introduces ErrStalled and a concurrency-safe watchdog that warns once after half the configured silence window and cancels once at the full timeout. Supports CI annotations, printer warnings, event-based rearming, idempotent shutdown, and disabled operation.

internal/runtime/stall.go

Tests (2) +214 / -0
run_overrides_test.goTest stall timeout resolution and metric serialization +43/-0

Test stall timeout resolution and metric serialization

• Covers unset, valid, trimmed, disabled, malformed, bare-number, and negative timeout values. Verifies stalled is omitted when false and serialized when true.

internal/cli/run_overrides_test.go

stall_test.goCover watchdog timing, warnings, and cancellation semantics +171/-0

Cover watchdog timing, warnings, and cancellation semantics

• Tests active streams, silent-stream termination, disabled operation, warning rearming, CI annotation behavior, and single-fire cancellation. Adds a regression guard ensuring Claude and Pi reuse the sandbox cancel function without rebinding the caller context.

internal/runtime/stall_test.go

Documentation (2) +8 / -0
run.mdDocument stall watchdog behavior and metrics +7/-0

Document stall watchdog behavior and metrics

• Explains warning and termination thresholds, FULLSEND_STALL_TIMEOUT configuration, and the rationale for the 10-minute default. Documents the conditional stalled field in metrics.json.

docs/cli/run.md

runtimes.mdAdd stall timeout to runtime configuration matrix +1/-0

Add stall timeout to runtime configuration matrix

• Documents FULLSEND_STALL_TIMEOUT as an environment-only runtime setting, including its default and disable value.

docs/runtimes.md

Other (1) +31 / -0
run_overrides.goResolve configurable stall timeout +31/-0

Resolve configurable stall timeout

• Adds FULLSEND_STALL_TIMEOUT parsing with a 10-minute default and support for disabling via zero. Invalid or negative values return the default alongside a descriptive error.

internal/cli/run_overrides.go

@qodo-code-review

qodo-code-review Bot commented Sep 1, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (2) 📜 Skill insights (0)

Grey Divider


Action required

1. Large events trigger false stalls ✓ Resolved 🐞 Bug ☼ Reliability ⭐ New
Description
Both stream parsers discard JSON lines exceeding 1 MiB before invoking the new liveness hook. A
runtime producing valid oversized events as its only output can therefore remain active while the
watchdog observes silence and terminates the run as stalled.
Code

internal/runtime/claude_progress.go[R157-159]

+		if onLine != nil {
+			onLine()
+		}
Relevance

●●● Strong

Accepted runtime parser precedents favor preserving metrics and liveness across all valid stream
activity.

PR-#3186
PR-#6147

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The shared stream buffer is capped at 1 MiB and its documentation says larger lines are skipped. In
both parsers, the oversized-line branch consumes the line and continues before reaching the newly
added onLine callback, while the callback is what is wired to stall.note.

internal/runtime/event.go[3-5]
internal/runtime/claude_progress.go[135-159]
internal/runtime/pi_progress.go[453-479]
internal/runtime/claude.go[158-161]
internal/runtime/pi_run.go[550-554]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The Claude and Pi parsers skip lines exceeding `streamBufSize` before calling the watchdog liveness hook. Valid oversized runtime events therefore do not reset the watchdog and can cause an active run to be killed as stalled.

## Issue Context
Both parsers intentionally consume and discard oversized NDJSON lines. Liveness should be recorded when a complete oversized line is consumed even if it remains excluded from semantic event parsing.

## Fix Focus Areas
- internal/runtime/claude_progress.go[135-159]
- internal/runtime/pi_progress.go[453-479]
- internal/runtime/stall_test.go[182-235]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Watchdog fires after disarm ✓ Resolved 🐞 Bug ☼ Reliability
Description
stop() and note() do not synchronize with the ticker's load-and-fire sequence, so a pending tick
can set fired and kill the command after the stream was disarmed or after a new event was
recorded. Claude and Pi then trust stalledErr() and can report a normally completed or active run
as stalled.
Code

internal/runtime/stall.go[R132-135]

+			case silence >= w.timeout:
+				w.fired.Store(true)
+				w.kill()
+				return
Relevance

●●● Strong

Unsynchronized cancellation and event handling can misclassify successful runs; runtime cancellation
correctness findings were accepted.

PR-#1982
PR-#3186

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
note() independently stores a timestamp and stop() only closes a channel, while watch() may
already have selected a ticker value and loaded the old timestamp before either operation. Both
runtime integrations immediately disarm before Wait and later classify the result solely from
fired, so the race changes successful execution into ErrStalled.

internal/runtime/stall.go[82-97]
internal/runtime/stall.go[124-140]
internal/runtime/claude.go[160-172]
internal/runtime/pi_run.go[552-563]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The watchdog's ticker can fire using stale activity state after `note()` records a new event, or after `stop()` disarms the watchdog. This can kill and classify a healthy or completed run as stalled.

## Issue Context
Make stopping, event recording, and transition to the fired state mutually synchronized. Whichever operation wins synchronization should determine the result; once stopped, the watchdog must never transition to fired.

## Fix Focus Areas
- internal/runtime/stall.go[82-140]
- internal/runtime/stall_test.go[47-128]
- internal/runtime/claude.go[160-172]
- internal/runtime/pi_run.go[552-563]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Valid durations are dropped ✓ Resolved 🐞 Bug ≡ Correctness ⭐ New
Description
Adding FULLSEND_STALL_TIMEOUT to the scaffold override list subjects it to an ASCII allowlist that
rejects valid Go durations such as +5m, 1µs, and 1μs. Those repository-variable overrides
never reach the CLI, so it silently resolves the timeout from another source or uses the 15-minute
default.
Code

internal/scaffold/fullsend-repo/.github/scripts/setup-agent-env.sh[45]

+  override_keys=(FULLSEND_RUNTIME FULLSEND_MODEL FULLSEND_EFFORT FULLSEND_FALLBACK_MODELS FULLSEND_PI_PROVIDER FULLSEND_PI_MODEL FULLSEND_STALL_TIMEOUT)
Relevance

●●● Strong

This is a deterministic configuration bug: the shell filter rejects durations accepted by Go
parsing.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The newly listed key is forwarded only when its value matches [A-Za-z0-9._/@:,-]+, which excludes
+, µ, and μ. The CLI delegates parsing to time.ParseDuration, so its accepted configuration
language is broader than the scaffold permits; skipped values subsequently resolve as unset and use
the default.

internal/scaffold/fullsend-repo/.github/scripts/setup-agent-env.sh[45-54]
internal/cli/run_overrides.go[124-136]
internal/scaffold/fullsend-repo/.github/scripts/setup-agent-env-test.sh[54-60]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Repository-variable forwarding rejects some values accepted by Go's `time.ParseDuration`, despite documenting `FULLSEND_STALL_TIMEOUT` as a Go duration. Rejected values are skipped before reaching the CLI.

## Issue Context
Preserve the scaffold's injection protection while supporting leading `+` and Go's supported microsecond spellings, or validate this key with duration-specific logic.

## Fix Focus Areas
- internal/scaffold/fullsend-repo/.github/scripts/setup-agent-env.sh[45-54]
- internal/scaffold/fullsend-repo/.github/scripts/setup-agent-env-test.sh[54-61]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. stallTimeout guard lacks tests 📘 Rule violation ▣ Testability ⭐ New
Description
The new branch that disables the watchdog when its timeout meets or exceeds the global run timeout
has no corresponding behavioral test. Regressions could silently leave runs unprotected or disable
valid watchdog configurations.
Code

internal/cli/run.go[1856]

+	if stallTimeout >= timeout {
Relevance

●● Moderate

Dedicated branch tests are often accepted, but recent coverage-only findings were also rejected.

PR-#5961
PR-#6753

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1062049 requires assertions covering new or modified Go logic. The newly added
comparison at internal/cli/run.go:1856 introduces a distinct deactivation path, but repository
tests do not exercise or assert this condition.

Rule 1062049: Require tests for new or modified Go logic
internal/cli/run.go[1856-1864]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Add behavioral tests for the new stall-timeout deactivation condition.

## Issue Context
Verify that a stall timeout equal to or greater than the run timeout is disabled, while a smaller positive timeout remains enabled. Assert the resulting runtime parameters or observable behavior rather than only the log text.

## Fix Focus Areas
- internal/cli/run.go[1852-1864]
- internal/cli/run_test.go[1-1]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Events cannot suppress warnings ✓ Resolved 🐞 Bug ◔ Observability
Description
The warning path locks only after calculating silence, while note() updates lastEvent without that
mutex. An event arriving between those operations ends the stall episode but can still be followed
by a misleading inactivity warning.
Code

internal/runtime/stall.go[R165-168]

+				w.mu.Lock()
+				if w.state.Load() == watchdogArmed {
+					w.warn(fmt.Sprintf("no agent events for %s", w.timeout/2))
+				}
Relevance

●●● Strong

Concrete concurrency race can emit misleading warnings; team accepts similar observability
correctness fixes.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
watch() snapshots lastEvent and computes silence before acquiring mu, whereas note() independently
stores a new timestamp. Therefore note() can record activity after the snapshot but before lines
165-168 emit the warning, and the armed-state check does not detect that activity.

internal/runtime/stall.go[102-108]
internal/runtime/stall.go[155-169]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
An event can update `lastEvent` after the watchdog calculates stale silence but before it emits the warning, causing a false stall warning after activity resumes.

## Issue Context
The newly added mutex synchronizes warning emission with `stop()`, but `note()` does not participate in that synchronization. Ensure an event that completes `note()` prevents a warning based on the preceding inactivity window.

## Fix Focus Areas
- internal/runtime/stall.go[102-108]
- internal/runtime/stall.go[155-169]
- internal/runtime/stall_test.go[130-151]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View medium (3)
6. Warning survives watchdog disarm ✓ Resolved 🐞 Bug ◔ Observability
Description
After stop() transitions the watchdog to stopped and closes w.stopped, a queued ticker event may
still win the select and emit a stall warning because the warning branch never verifies the armed
state. A normally completed run can therefore produce a misleading local warning or GitHub Actions
annotation after its event stream has ended.
Code

internal/runtime/stall.go[R144-146]

+		case <-ticker.C:
+			last := w.lastEvent.Load()
+			silence := time.Since(w.start) - time.Duration(last)
Relevance

●●● Strong

Concrete watchdog lifecycle race; accepted history favors fixing runtime observability and
concurrency bugs.

PR-#3186
PR-#284

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
stop() changes the state and closes w.stopped, but the watch loop selects independently between
that closed channel and ticker events. If the ticker case is selected, it calculates silence and
calls warn without checking or synchronizing against watchdogStopped; the lifecycle state only
gates the kill CAS.

internal/runtime/stall.go[102-110]
internal/runtime/stall.go[140-156]
internal/runtime/stall.go[161-169]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The watchdog can emit a stale inactivity warning after `stop()` has disarmed it because Go may select an already queued ticker event even when the stopped channel is also ready.

## Issue Context
The lifecycle CAS protects the kill path, but the warning path is outside that synchronization. Ensure a disarm that wins before warning emission suppresses the warning; merely checking state without synchronization can retain a check-then-warn race.

## Fix Focus Areas
- internal/runtime/stall.go[102-110]
- internal/runtime/stall.go[140-156]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Wall clock skews silence ✓ Resolved 🐞 Bug ☼ Reliability
Description
The watchdog stores time.Now().UnixNano(), reconstructs it with time.Unix, and therefore
discards Go's monotonic timestamp before measuring silence. A host wall-clock adjustment can
prematurely kill an active run or postpone stall detection beyond the configured timeout.
Code

internal/runtime/stall.go[R129-130]

+			last := w.lastEvent.Load()
+			silence := now.Sub(time.Unix(0, last))
Relevance

●●● Strong

Elapsed-time watchdogs should preserve monotonic timing; accepted runtime changes consistently
address timing and concurrency correctness.

PR-#1982
PR-#3186

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Initialization and event updates reduce time.Now() to Unix nanoseconds, and the ticker path
reconstructs a wall-only time.Time before subtraction. Consequently now.Sub cannot use the
monotonic component that normally protects elapsed-duration calculations from wall-clock changes.

internal/runtime/stall.go[43-54]
internal/runtime/stall.go[77-87]
internal/runtime/stall.go[124-130]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The watchdog calculates elapsed inactivity using Unix wall-clock timestamps, so clock corrections affect the configured duration. Preserve monotonic `time.Time` values when calculating silence.

## Issue Context
`time.Now()` carries a monotonic component, but converting it to `UnixNano` and reconstructing it with `time.Unix` removes that component. Store the complete timestamp under synchronization or use another monotonic elapsed-time design.

## Fix Focus Areas
- internal/runtime/stall.go[43-54]
- internal/runtime/stall.go[77-87]
- internal/runtime/stall.go[124-140]
- internal/runtime/stall_test.go[47-128]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Runtime guide not consulted 📘 Rule violation ⛨ Security
Description
The PR changes the runtime.Runtime execution contract by adding RunParams.StallTimeout and
watchdog integration, but neither updates docs/contributing/runtime-implementation.md nor states
in the PR description that the guide was consulted. The new event-inactivity and cancellation
requirements are therefore undocumented for runtime implementers.
Code

internal/runtime/runtime.go[R49-52]

+	// StallTimeout terminates the run when the event stream stays silent for
+	// this long. Timeout is wall-clock and cannot tell a wedged agent from a
+	// thinking one, so without this a wedge is billed for the full window.
+	// Zero disables the watchdog. The CLI resolves it from
Relevance

●●● Strong

Behavioral runtime contract changes warrant updating implementation guidance; similar runtime
documentation improvements were accepted.

PR-#1780
PR-#2727

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rule 2889480 requires evidence that the runtime implementation guide was consulted whenever a
runtime.Runtime backend receives a significant behavioral change. The added StallTimeout
contract and its Claude/Pi integrations change runtime termination behavior, while the guide's
runtime-interface section contains no corresponding watchdog contract and the supplied PR
description does not reference the guide.

Rule 2889480: Consult runtime implementation guide when modifying runtime.Runtime backends
internal/runtime/runtime.go[49-55]
internal/runtime/claude.go[110-135]
internal/runtime/pi_run.go[488-528]
docs/contributing/runtime-implementation.md[120-131]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The runtime implementation guide does not document the new stall-watchdog contract, and the PR description does not state that the guide was consulted.

## Issue Context
`RunParams` now carries an event-inactivity timeout, and streaming runtime implementations must reset the watchdog from normalized events, disarm it when the stream ends, and use the `ExecStreamReader` cancellation path. Update the guide accordingly and amend the PR description to explicitly reference `docs/contributing/runtime-implementation.md`.

## Fix Focus Areas
- internal/runtime/runtime.go[49-55]
- internal/runtime/claude.go[110-135]
- internal/runtime/pi_run.go[488-528]
- docs/contributing/runtime-implementation.md[120-131]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Compliance rules (platform): 72 rules
Review mode: ⚖️ Balanced

Grey Divider

Tip of the day
💡 Did you know, you can show, collapse, or hide each part of a finding: code, evidence, and all

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Previous reviews

Review updated until commit 811acf1

Results up to commit 0339139 ⚖️ Balanced


🐞 Bugs (0) 📘 Rule violations (1) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Action required
1. Watchdog fires after disarm ✓ Resolved 🐞 Bug ☼ Reliability
Description
stop() and note() do not synchronize with the ticker's load-and-fire sequence, so a pending tick
can set fired and kill the command after the stream was disarmed or after a new event was
recorded. Claude and Pi then trust stalledErr() and can report a normally completed or active run
as stalled.
Code

internal/runtime/stall.go[R132-135]

+			case silence >= w.timeout:
+				w.fired.Store(true)
+				w.kill()
+				return
Relevance

●●● Strong

Unsynchronized cancellation and event handling can misclassify successful runs; runtime cancellation
correctness findings were accepted.

PR-#1982
PR-#3186

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
note() independently stores a timestamp and stop() only closes a channel, while watch() may
already have selected a ticker value and loaded the old timestamp before either operation. Both
runtime integrations immediately disarm before Wait and later classify the result solely from
fired, so the race changes successful execution into ErrStalled.

internal/runtime/stall.go[82-97]
internal/runtime/stall.go[124-140]
internal/runtime/claude.go[160-172]
internal/runtime/pi_run.go[552-563]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The watchdog's ticker can fire using stale activity state after `note()` records a new event, or after `stop()` disarms the watchdog. This can kill and classify a healthy or completed run as stalled.

## Issue Context
Make stopping, event recording, and transition to the fired state mutually synchronized. Whichever operation wins synchronization should determine the result; once stopped, the watchdog must never transition to fired.

## Fix Focus Areas
- internal/runtime/stall.go[82-140]
- internal/runtime/stall_test.go[47-128]
- internal/runtime/claude.go[160-172]
- internal/runtime/pi_run.go[552-563]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended
2. Runtime guide not consulted 📘 Rule violation ⛨ Security
Description
The PR changes the runtime.Runtime execution contract by adding RunParams.StallTimeout and
watchdog integration, but neither updates docs/contributing/runtime-implementation.md nor states
in the PR description that the guide was consulted. The new event-inactivity and cancellation
requirements are therefore undocumented for runtime implementers.
Code

internal/runtime/runtime.go[R49-52]

+	// StallTimeout terminates the run when the event stream stays silent for
+	// this long. Timeout is wall-clock and cannot tell a wedged agent from a
+	// thinking one, so without this a wedge is billed for the full window.
+	// Zero disables the watchdog. The CLI resolves it from
Relevance

●●● Strong

Behavioral runtime contract changes warrant updating implementation guidance; similar runtime
documentation improvements were accepted.

PR-#1780
PR-#2727

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rule 2889480 requires evidence that the runtime implementation guide was consulted whenever a
runtime.Runtime backend receives a significant behavioral change. The added StallTimeout
contract and its Claude/Pi integrations change runtime termination behavior, while the guide's
runtime-interface section contains no corresponding watchdog contract and the supplied PR
description does not reference the guide.

Rule 2889480: Consult runtime implementation guide when modifying runtime.Runtime backends
internal/runtime/runtime.go[49-55]
internal/runtime/claude.go[110-135]
internal/runtime/pi_run.go[488-528]
docs/contributing/runtime-implementation.md[120-131]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The runtime implementation guide does not document the new stall-watchdog contract, and the PR description does not state that the guide was consulted.

## Issue Context
`RunParams` now carries an event-inactivity timeout, and streaming runtime implementations must reset the watchdog from normalized events, disarm it when the stream ends, and use the `ExecStreamReader` cancellation path. Update the guide accordingly and amend the PR description to explicitly reference `docs/contributing/runtime-implementation.md`.

## Fix Focus Areas
- internal/runtime/runtime.go[49-55]
- internal/runtime/claude.go[110-135]
- internal/runtime/pi_run.go[488-528]
- docs/contributing/runtime-implementation.md[120-131]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Wall clock skews silence ✓ Resolved 🐞 Bug ☼ Reliability
Description
The watchdog stores time.Now().UnixNano(), reconstructs it with time.Unix, and therefore
discards Go's monotonic timestamp before measuring silence. A host wall-clock adjustment can
prematurely kill an active run or postpone stall detection beyond the configured timeout.
Code

internal/runtime/stall.go[R129-130]

+			last := w.lastEvent.Load()
+			silence := now.Sub(time.Unix(0, last))
Relevance

●●● Strong

Elapsed-time watchdogs should preserve monotonic timing; accepted runtime changes consistently
address timing and concurrency correctness.

PR-#1982
PR-#3186

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Initialization and event updates reduce time.Now() to Unix nanoseconds, and the ticker path
reconstructs a wall-only time.Time before subtraction. Consequently now.Sub cannot use the
monotonic component that normally protects elapsed-duration calculations from wall-clock changes.

internal/runtime/stall.go[43-54]
internal/runtime/stall.go[77-87]
internal/runtime/stall.go[124-130]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The watchdog calculates elapsed inactivity using Unix wall-clock timestamps, so clock corrections affect the configured duration. Preserve monotonic `time.Time` values when calculating silence.

## Issue Context
`time.Now()` carries a monotonic component, but converting it to `UnixNano` and reconstructing it with `time.Unix` removes that component. Store the complete timestamp under synchronization or use another monotonic elapsed-time design.

## Fix Focus Areas
- internal/runtime/stall.go[43-54]
- internal/runtime/stall.go[77-87]
- internal/runtime/stall.go[124-140]
- internal/runtime/stall_test.go[47-128]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Results up to commit 2b1e60b ⚖️ Balanced


🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Remediation recommended
1. Warning survives watchdog disarm ✓ Resolved 🐞 Bug ◔ Observability
Description
After stop() transitions the watchdog to stopped and closes w.stopped, a queued ticker event may
still win the select and emit a stall warning because the warning branch never verifies the armed
state. A normally completed run can therefore produce a misleading local warning or GitHub Actions
annotation after its event stream has ended.
Code

internal/runtime/stall.go[R144-146]

+		case <-ticker.C:
+			last := w.lastEvent.Load()
+			silence := time.Since(w.start) - time.Duration(last)
Relevance

●●● Strong

Concrete watchdog lifecycle race; accepted history favors fixing runtime observability and
concurrency bugs.

PR-#3186
PR-#284

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
stop() changes the state and closes w.stopped, but the watch loop selects independently between
that closed channel and ticker events. If the ticker case is selected, it calculates silence and
calls warn without checking or synchronizing against watchdogStopped; the lifecycle state only
gates the kill CAS.

internal/runtime/stall.go[102-110]
internal/runtime/stall.go[140-156]
internal/runtime/stall.go[161-169]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The watchdog can emit a stale inactivity warning after `stop()` has disarmed it because Go may select an already queued ticker event even when the stopped channel is also ready.

## Issue Context
The lifecycle CAS protects the kill path, but the warning path is outside that synchronization. Ensure a disarm that wins before warning emission suppresses the warning; merely checking state without synchronization can retain a check-then-warn race.

## Fix Focus Areas
- internal/runtime/stall.go[102-110]
- internal/runtime/stall.go[140-156]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Results up to commit 957481a ⚖️ Balanced


🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Remediation recommended
1. Events cannot suppress warnings ✓ Resolved 🐞 Bug ◔ Observability
Description
The warning path locks only after calculating silence, while note() updates lastEvent without that
mutex. An event arriving between those operations ends the stall episode but can still be followed
by a misleading inactivity warning.
Code

internal/runtime/stall.go[R165-168]

+				w.mu.Lock()
+				if w.state.Load() == watchdogArmed {
+					w.warn(fmt.Sprintf("no agent events for %s", w.timeout/2))
+				}
Relevance

●●● Strong

Concrete concurrency race can emit misleading warnings; team accepts similar observability
correctness fixes.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
watch() snapshots lastEvent and computes silence before acquiring mu, whereas note() independently
stores a new timestamp. Therefore note() can record activity after the snapshot but before lines
165-168 emit the warning, and the armed-state check does not detect that activity.

internal/runtime/stall.go[102-108]
internal/runtime/stall.go[155-169]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
An event can update `lastEvent` after the watchdog calculates stale silence but before it emits the warning, causing a false stall warning after activity resumes.

## Issue Context
The newly added mutex synchronizes warning emission with `stop()`, but `note()` does not participate in that synchronization. Ensure an event that completes `note()` prevents a warning based on the preceding inactivity window.

## Fix Focus Areas
- internal/runtime/stall.go[102-108]
- internal/runtime/stall.go[155-169]
- internal/runtime/stall_test.go[130-151]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment on lines +49 to +52
// StallTimeout terminates the run when the event stream stays silent for
// this long. Timeout is wall-clock and cannot tell a wedged agent from a
// thinking one, so without this a wedge is billed for the full window.
// Zero disables the watchdog. The CLI resolves it from

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

1. Runtime guide not consulted 📘 Rule violation ⛨ Security

The PR changes the runtime.Runtime execution contract by adding RunParams.StallTimeout and
watchdog integration, but neither updates docs/contributing/runtime-implementation.md nor states
in the PR description that the guide was consulted. The new event-inactivity and cancellation
requirements are therefore undocumented for runtime implementers.
Agent Prompt
## Issue description
The runtime implementation guide does not document the new stall-watchdog contract, and the PR description does not state that the guide was consulted.

## Issue Context
`RunParams` now carries an event-inactivity timeout, and streaming runtime implementations must reset the watchdog from normalized events, disarm it when the stream ends, and use the `ExecStreamReader` cancellation path. Update the guide accordingly and amend the PR description to explicitly reference `docs/contributing/runtime-implementation.md`.

## Fix Focus Areas
- internal/runtime/runtime.go[49-55]
- internal/runtime/claude.go[110-135]
- internal/runtime/pi_run.go[488-528]
- docs/contributing/runtime-implementation.md[120-131]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread internal/runtime/stall.go
Comment thread internal/runtime/stall.go Outdated
@guyoron1
guyoron1 marked this pull request as draft September 1, 2026 12:15
@guyoron1
guyoron1 marked this pull request as ready for review September 1, 2026 12:48
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 0339139

@guyoron1
guyoron1 force-pushed the feat/runner-stall-watchdog branch from d81237a to 2b1e60b Compare September 2, 2026 06:04
@guyoron1

guyoron1 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

/review

@qodo-code-review

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Warning

/review is deprecated. Use /agentic_review instead (removal date not yet scheduled).

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Misclassified Timeout

The watchdog has no awareness that the global timeout or another cancellation source may have already terminated the command. If command shutdown or stream draining overlaps the stall threshold, the watchdog can transition to fired and cause Run to report ErrStalled even though the global timeout was the actual cause. Consider disarming it when the command context is canceled, or otherwise preserving which cancellation source won.

case silence >= w.timeout:
	if w.state.CompareAndSwap(watchdogArmed, watchdogFired) {
		w.kill()
	}
	return

@guyoron1

guyoron1 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

/agentic_review

Comment thread internal/runtime/stall.go
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 2b1e60b

@guyoron1

guyoron1 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

/agentic_review

Comment thread internal/runtime/stall.go Outdated
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 957481a

@rh-hemartin

Copy link
Copy Markdown
Member

Any way we can fold this with the heartbeat? Or the other way around: any way to fold the heart beat into this? They serve similar purposes and the watchdog could be reporting "agent working: x seconds since last event" each 30 seconds (as the heartbeat interval is 30 seconds).

@waynesun09 waynesun09 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review sweep — 5 findings posted inline (no approval/request-changes; comment only):

  • HIGH internal/runtime/stall.go:46 — the stall kill releases the local openshell client; nothing signals the agent inside the sandbox (verified against OpenShell v0.0.116 source)
  • HIGH internal/runtime/pi_run.go:528 — liveness counted at the AgentEvent level, so streaming tool output (pi tool_execution_update, Claude user tool_result) reads as silence
  • HIGH internal/cli/run_overrides.go:115 — default 10m equals Claude Code's BASH_MAX_TIMEOUT_MS ceiling; previously-successful runs now fail; not marked breaking
  • MEDIUM docs/runtimes.md:88FULLSEND_STALL_TIMEOUT documented as a CI repo-variable override but not in the setup-agent-env.sh allowlist
  • MEDIUM internal/cli/run.go:1852 — stall timeout not bounded by the run timeout; a no-op for timeout_minutes <= 10 harnesses (triage, prioritize)

Comment thread internal/runtime/stall.go Outdated
// agent is alive, so a wedged process is indistinguishable from a thinking
// one until the global timeout expires — and gets billed for the difference.
// Every event the stream parser emits is proof of life: note() records it,
// half a timeout of silence logs a warning, and a full timeout of silence

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[HIGH] Stall kill releases the local openshell client, but nothing signals the agent inside the sandbox

The design claim here (stall.go:46-48, claude.go:110-112, pi_run.go:488-490, the PR body's "the existing kill path... No second termination mechanism", and docs/cli/run.md:63 "terminates the sandbox command — the same kill the global timeout uses") does not hold against the OpenShell source at the pinned v0.0.116.

cancel() from sandbox.ExecStreamReader (internal/sandbox/sandbox.go:1222-1226) cancels an exec.CommandContext, which SIGKILLs only the local openshell sandbox exec client. Server side, handle_exec_sandbox (crates/openshell-server/src/grpc/sandbox.rs:1189-1256) runs the exec in a detached tokio::spawn; stream_exec_over_relay -> run_exec_with_russh (sandbox.rs:2295) writes with let _ = tx.send(...) and leaves its loop only on ChannelMsg::Close/ExitStatus — the only tx.closed() check in that file is in the watch handler (sandbox.rs:1090), not the exec path. In the supervisor, spawn_pipe_exec (crates/openshell-supervisor-process/src/ssh.rs:1360) hands the std::process::Child to a wait() thread with no kill_on_drop, and channel_close (ssh.rs:534) / SshHandler::drop (ssh.rs:460) abort only main_output_task. The server-side timeout_seconds wrapper does not signal the child either — on expiry it drops the russh future and reports exit 124. So neither cancel path terminates the in-sandbox sh -c claude|pi ...; in both cases the process actually dies only when the deferred sandbox.Delete at internal/cli/run.go:1565-1581 tears the sandbox down.

Consequences for the stall path specifically: after a "stalled" verdict runAgent returns (run.go:2030) and collectOpenshellLogs plus the post-failure workspace download run against a still-live agent that is still writing the workspace, running hooks and spending tokens; under --keep-sandbox (run.go:1569) the agent keeps running in the kept sandbox with nothing left to stop it. Practical exposure in the normal path is bounded to the seconds before teardown, but the documented "no in-sandbox process survives the kill" property is inverted and the docs promise it. The PR body's non-goals (per-dimension timeouts, heartbeat, global timeout) don't defer this.

Suggestion: have the watchdog's kill terminate the process inside the sandbox first, then cancel the client. origin/main already has the primitive: killStrayProcesses/clearStrayProcesses in internal/runtime/stray_processes.go TERM->KILLs the sandbox user's processes via sandbox.Exec while sparing the keep-alive (mind its documented sandboxMu serialization; on main it only runs from ClearIterationArtifacts, i.e. the next iteration, which a stalled run never reaches). Alternatively send TERM to the exec'd process group via a short sandbox.Exec. Then reword stall.go:46-48, claude.go:110-112, pi_run.go:488-490, the PR body and docs/cli/run.md:63 to state what actually happens (client released; in-sandbox process killed by the sweep / torn down with the sandbox) — and note the same caveat applies to the global timeout today.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right that the cancel SIGKILLs only the local exec client — verified. What terminates the agent is the deferred sandbox.Delete registered before the agent loop (run.go:1565-1582), which runs on every return path including the stall one, so teardown is prompt; 815582c documents that chain in stall.go and run.md instead of adding new kill machinery (stray_processes.go doesn't exist on this branch or main). Residual: --keep-sandbox skips Delete by design and leaves the agent running — identical to the global-timeout path today; happy to address that as a follow-up if you want it.

var lastResult *ResultEvent
innerHandler := handler
handler = func(evt AgentEvent) {
stall.note()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[HIGH] Liveness is counted at the AgentEvent level, so actively streaming tool output is treated as silence

stall.note() is called only from the normalized-event handler (here and claude.go:135), not per stream line. On pi, tool_execution_update lines — emitted continuously while a tool streams output — are explicitly discarded by parsePiStream (internal/runtime/pi_progress.go:625: "Lifecycle / intermediate events — no AgentEvent mapping", alongside turn_start/turn_end), and pi's bash tool has no command timeout (docs/contributing/runtime-implementation.md:448). So a code-role run whose test suite streams output for 10+ minutes is killed with "no runtime events" while raw JSON lines are flowing, and the whole run fails (run.go:2030 returns; no retry iteration).

On Claude, parseClaudeStream (internal/runtime/claude_progress.go:149-283) has cases only for system, stream_event, result and assistantuser/tool_result lines produce nothing — and fullsend passes --verbose --output-format stream-json without --include-partial-messages (claude.go:324-325), so the silent window for one tool call is tool runtime + result round-trip + the entire next model turn including thinking.

docs/cli/run.md:63 ("without a single agent event") and the warning text therefore misdescribe these cases: the process is demonstrably alive and the watchdog reports it as wedged.

Suggestion: count liveness at the parser level, not the AgentEvent level: give both parsers a per-line liveness callback (e.g. onLine func() or a LivenessEvent{} AgentEvent the renderer/metrics ignore) invoked for every successfully unmarshalled line — including pi tool_execution_update/turn_* and Claude user tool_result messages — and call stall.note() from it, keeping the semantic events unchanged. Add tests that a stream of pi tool_execution_update lines and Claude user tool_result lines keeps the watchdog quiet, and fix the run.md wording.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 815582c at the root — both stream parsers now invoke a per-line hook after every successful envelope unmarshal (including pi tool_execution_update/turn_* and Claude tool_result lines) and Run passes stall.note, so any well-formed stream line resets the clock. Garbage/blank lines don't count; tests cover both runtimes.

Comment thread internal/cli/run_overrides.go Outdated
// a slow clone) is legitimately silent for minutes. The default is
// deliberately generous; repos that know their event cadence can tune it
// down with FULLSEND_STALL_TIMEOUT.
const defaultStallTimeout = 10 * time.Minute

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[HIGH] Default-on 10m stall timeout equals Claude Code's bash ceiling and kills previously-successful runs; PR is not marked breaking

defaultStallTimeout = 10 * time.Minute is enabled by default, and the justifying comment ("a single long tool call ... is legitimately silent for minutes") never checks how long a tool call may legitimately run. Per the Claude Code environment-variables reference, BASH_MAX_TIMEOUT_MS ("Maximum timeout the model can set for long-running bash commands") defaults to 600000 ms — exactly 10 minutes — and the model routinely requests it for test suites; pi's bash tool has no timeout at all (docs/contributing/runtime-implementation.md:448).

Because tool completion is not counted as liveness (see the pi_run.go:528 thread), the observed silence for such a call is tool runtime + result round-trip + the next assistant turn, which always exceeds the default; the 30s poll cadence adds at most 30s of grace. A healthy run that uses the documented bash ceiling is therefore killed as stalled with the shipped default — the same run that succeeded before this change now fails with ErrStalled.

COMMITS.md ("Breaking changes") lists "Default values change in ways that alter existing behavior" as breaking and AGENTS.md:16 makes a missing ! an important-severity review finding, yet the title is a plain feat(runner) with no BREAKING CHANGE: trailer.

To be clear, the PR is right that there is no --include-partial-messages so events are per assistant message, and that system/api_retry is mapped to RetryEvent (claude_progress.go:161) so API backoff keeps the watchdog fed.

Suggestion: either (a) make tool completion / streaming output count as liveness (the parser-level fix) so the silent window is bounded by tool runtime alone, and document the relationship to BASH_MAX_TIMEOUT_MS in docs/cli/run.md, or (b) keep AgentEvent liveness but choose a default with headroom above the bash ceiling (e.g. 15m) and state the derivation in the comment. In either case mention BASH_MAX_TIMEOUT_MS in the run.md tuning paragraph so a repo that raises it knows to raise the watchdog, and if the default stays at/near 10m mark the PR feat(runner)! with a BREAKING CHANGE: trailer naming FULLSEND_STALL_TIMEOUT=0 as the opt-out.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 815582c — default raised to 15m with the BASH_MAX_TIMEOUT_MS 600000ms ceiling named as the derivation in the comment and docs. With line-level liveness (other thread) streaming tools never look silent; the 15m floor covers genuinely quiet calls above the bash ceiling.

Comment thread docs/runtimes.md Outdated
| Runtime | `--runtime` | `FULLSEND_RUNTIME` | `runtime:` on the agent's `agents:` entry | `runtime:` in `.fullsend/config.yaml` (repo default) |
| Model | `--model` | `FULLSEND_MODEL` (`FULLSEND_PI_MODEL` is a lower-precedence alias on pi) | `model:` on the agent's `agents:` entry | harness `model:`, then agent frontmatter `model:` |
| Effort | `--effort` | `FULLSEND_EFFORT` | `effort:` on the agent's `agents:` entry | harness `effort:` |
| Stall timeout | — | `FULLSEND_STALL_TIMEOUT` (default `10m`, `0` disables) | — | — |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[MEDIUM] This row presents FULLSEND_STALL_TIMEOUT as a CI repository variable, but the passthrough allowlist does not include it

This row sits directly above the sentence on line 91, "In CI these are repository variables of the same name, plain or role-prefixed (TRIAGE_FULLSEND_MODEL)". That passthrough is FULLSEND_REPO_VARS: ${{ toJSON(vars) }} in .github/workflows/reusable-dispatch.yml (lines 667/794/929/1209) feeding internal/scaffold/fullsend-repo/.github/scripts/setup-agent-env.sh, whose override_keys allowlist at line 45 is FULLSEND_RUNTIME FULLSEND_MODEL FULLSEND_EFFORT FULLSEND_FALLBACK_MODELS FULLSEND_PI_PROVIDER FULLSEND_PI_MODEL on both this branch and origin/main; only allowlisted keys reach GITHUB_ENV. A repo that sets a FULLSEND_STALL_TIMEOUT or CODE_FULLSEND_STALL_TIMEOUT Actions variable silently keeps the 10m default with no warning.

The script is scaffold-shipped from this repo (internal/scaffold/scaffold.go:125 and vendorcontent.go:136 special-case it; ADR 0035 lists setup-agent-env.sh as "upstream infrastructure ... referenced directly from upstream"), and fullsend-ai/agents has no setup-agent-env.sh under .github/scripts, so the fix is an in-repo edit.

Suggestion: add FULLSEND_STALL_TIMEOUT to override_keys in setup-agent-env.sh:45 (the value regex ^[A-Za-z0-9._/@:,-]+$ already admits Go durations such as 10m, 90s, 0), extend setup-agent-env-test.sh and the key list in internal/scaffold/scaffold_test.go:753 — or, if that is out of scope for this PR, move the row out of the override table / add a sentence that the stall timeout is currently process-env only and not yet a repository-variable override.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 815582c — FULLSEND_STALL_TIMEOUT added to override_keys (role-prefixed variants work via the generic per-key handling); setup-agent-env-test.sh and scaffold_test.go extended.

Comment thread internal/cli/run.go Outdated
timeout = 30 * time.Minute
}

stallTimeout, stallErr := resolveStallTimeout(os.Getenv)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[MEDIUM] Stall timeout is not bounded by the run's own timeout, so the watchdog is a no-op for harnesses with timeout_minutes <= 10

timeout is derived from h.TimeoutMinutes at 1847-1850 and stallTimeout from resolveStallTimeout here, with no relationship between them. ExecStreamReader wraps the command in context.WithTimeout(ctx, timeout) (sandbox.go:1222), so when stallTimeout >= timeout the global context fires first, the stream ends, stall.stop() runs before Wait (claude.go:162, pi_run.go:554) and the run reports a plain timeout, never ErrStalled — the watchdog can only ever emit its half-way warning.

This is not hypothetical for the fleet: fullsend-ai/agents harness/triage.yaml and harness/prioritize.yaml both set timeout_minutes: 10, equal to the default, so for those two roles the kill can never fire before the global timeout — precisely the "wedged run burns its entire global timeout" case the PR body describes. Nothing in docs/cli/run.md tells a repo that FULLSEND_STALL_TIMEOUT must be strictly shorter than timeout_minutes to have any effect.

Suggestion: because equality is still a no-op (the ctx timer wins the race), a plain min(stallTimeout, timeout) is not enough: clamp the effective stall timeout to a fraction of timeout (e.g. timeout/2) when the configured value is not strictly shorter, or emit a startup StepWarn when stallTimeout >= timeout and document in run.md that the stall timeout must be shorter than timeout_minutes for the kill to ever fire.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 815582c — when the stall timeout is not strictly below the run timeout the watchdog is not armed and a StepInfo says so; no clamping, existing configs change only by the log line. Relationship documented in runtimes.md and run.md.

@guyoron1

guyoron1 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

/agentic_review

Comment thread internal/cli/run.go Outdated
if stallErr != nil {
printer.StepWarn(fmt.Sprintf("Stall watchdog: %v; using %s", stallErr, stallTimeout))
}
if stallTimeout >= timeout {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

1. stalltimeout guard lacks tests 📘 Rule violation ▣ Testability

The new branch that disables the watchdog when its timeout meets or exceeds the global run timeout
has no corresponding behavioral test. Regressions could silently leave runs unprotected or disable
valid watchdog configurations.
Agent Prompt
## Issue description
Add behavioral tests for the new stall-timeout deactivation condition.

## Issue Context
Verify that a stall timeout equal to or greater than the run timeout is disabled, while a smaller positive timeout remains enabled. Assert the resulting runtime parameters or observable behavior rather than only the log text.

## Fix Focus Areas
- internal/cli/run.go[1852-1864]
- internal/cli/run_test.go[1-1]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 53ae715 — the decision is extracted to effectiveStallTimeout() and TestEffectiveStallTimeout asserts parameter behavior across below/equal/above/zero/default; no log-text assertions.

Comment thread internal/runtime/claude_progress.go
Comment thread internal/scaffold/fullsend-repo/.github/scripts/setup-agent-env.sh Outdated
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 815582c

@guyoron1

guyoron1 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 53ae715

@guyoron1
guyoron1 force-pushed the feat/runner-stall-watchdog branch from 53ae715 to f02a4cf Compare September 3, 2026 06:56
@guyoron1

guyoron1 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit f02a4cf

@guyoron1

guyoron1 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

@rh-hemartin They solve different problems, so I left them separate: the heartbeat (internal/cli/run.go:3777, 30s ticker) reports elapsed wall-clock since the run started, regardless of whether the agent is alive — that's the exact gap the watchdog closes (see the design note at internal/runtime/stall.go:42-53).

Swapping the heartbeat's line from "time since start" to "time since last event" (using the watchdog's lastEvent) is a reasonable follow-up, but it changes an existing, already-consumed output for every run, watchdog-enabled or not — didn't want to fold that into a PR whose actual ask is "detect and kill a stall". Happy to do it as a separate PR if you want it.

@rh-hemartin

Copy link
Copy Markdown
Member

I understand the difference, I don't think that with your addition the heartbeat has any value. I would bring to discussion if we want the heartbeat after we merge this stall detection.

@waynesun09 waynesun09 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review sweep — 3 findings (no approval/request-changes; comment only).

[HIGH] Codex runtime is completely excluded from stall-watchdog coverage (internal/runtime/codex_run.go:483, not touched by this PR's diff, so posted here instead of inline)

Verified against PR head f02a4cf: CodexRuntime.Run (codex_run.go:413-537) uses the identical shape as ClaudeRuntime.Run/PiRuntime.Run — it calls sandbox.ExecStreamReader to get stdout/execCmd/cancel, wraps a handler, and drains via parseCodexStream(reader, handler) — but never calls startStallWatchdog and never references params.StallTimeout anywhere in the file (confirmed via grep: only claude.go and pi_run.go call startStallWatchdog(params.StallTimeout, ...)). RunParams' doc comment in runtime.go:54 says "Runtimes that stream no events ignore it", which is inaccurate for codex since it is architecturally a third streaming runtime with the same NDJSON-over-ExecStreamReader shape as pi — a wedged codex run still burns the full global timeout with no ErrStalled signal while claude/pi runs of the same scenario are now caught early.

Suggestion: either wire the same startStallWatchdog/stall.note()/stalledErr() pattern into CodexRuntime.Run (mirroring pi_run.go/claude.go), or explicitly scope codex out in the PR description's non-goals and correct the RunParams/runtime.go doc comment so the gap is documented instead of silently implied not to exist.

Two other findings are posted as inline comments on internal/cli/run.go:2175 and internal/cli/run_overrides.go:168.

Comment thread internal/cli/run.go Outdated
if runErr != nil {
attachIterationContent("error")
finalizeAgentSpan(agentSpan, runErr, iteration, exitCode, rt.System(), rt.Name(), &metrics, "")
if errors.Is(runErr, agentruntime.ErrStalled) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[MEDIUM] run.go's stall-timeout integration wiring has no test coverage

Verified against PR head f02a4cf: grep for ErrStalled/Stalled in internal/cli/*_test.go shows only TestAggregateMetrics_StalledOmittedWhenFalse (a JSON-marshal/omitempty test) and the pure-function tests TestResolveStallTimeout/TestEffectiveStallTimeout in run_overrides_test.go. Nothing exercises the actual wiring inside runAgent itself: resolving FULLSEND_STALL_TIMEOUT via os.Getenv at run.go:2000, the StepInfo "watchdog inactive" branch at 2004-2009, passing StallTimeout into RunParams at 2146, or the errors.Is(runErr, agentruntime.ErrStalled) branch at 2175-2181 that sets aggMetrics.Stalled and prints the stall-specific StepFail message. This is distinct from the already-resolved thread on run.go:1856 (which was about testing the effectiveStallTimeout decision function in isolation, fixed via TestEffectiveStallTimeout) — the integration-level branches in runAgent remain unexercised by any test.

Suggestion: add a fake/dummy runtime that returns agentruntime.ErrStalled from Run() and assert runAgent sets aggMetrics.Stalled and emits the stall-specific message, to get direct coverage of the new branches in run.go rather than relying only on the pure-function unit tests.

// timeout's context, so a stall timeout at or above it always loses the race
// to the global deadline. Below it, the configured value stands unchanged —
// no clamping or deriving, existing configs keep their behavior.
func effectiveStallTimeout(stall, run time.Duration) time.Duration {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[MEDIUM] effectiveStallTimeout's disable check ignores the watchdog's own poll-interval detection latency

Verified against PR head f02a4cf: stall.go's own comment (lines 21-26) states detection lands within roughly 5% of the threshold, capped by a 30s poll interval (stallMaxPoll = 30 * time.Second). effectiveStallTimeout (run_overrides.go:168-173) only disarms when stall >= run, not when stall + (poll latency) >= run. So a configuration where stall is just under run (e.g. stall=14m50s, run=15m) stays "armed" per this check, but the watchdog may not actually detect and fire until up to stallMaxPoll after the stall threshold is crossed — potentially after the global context deadline already won the race. In that near-boundary zone the watchdog appears active but often provides no real protection, which is inconsistent with the PR's own stated detection-latency model.

Suggestion: change the disable condition to account for detection latency, e.g. stall + stallMaxPoll >= run (or an equivalent margin), so "inactive" determination matches the watchdog's actual worst-case detection time rather than the nominal threshold.

The heartbeat prints "Agent running (Xs elapsed)" whether or not the
agent is alive, so a wedged process is indistinguishable from a thinking
one and burns the entire global timeout before anyone learns it was
dead. The global timeout is wall-clock; nothing watched the event stream.

The runtime now runs a watchdog seated on the normalized event stream:
every event is proof of life, half a timeout of silence warns once per
stall episode (::warning:: in CI, the printer everywhere), and a full
timeout of silence terminates the sandbox command through the cancel
ExecStreamReader already returns -- the same kill the global timeout
uses, not a second mechanism -- and fails the run with ErrStalled.
runAgent maps that sentinel to a specific failure line and records
"stalled": true in metrics.json.

FULLSEND_STALL_TIMEOUT (Go duration, default 10m, 0 disables) is
resolved by the CLI and handed to the runtime in RunParams, since
runtimes do not read env themselves (fullsend-ai#6526). 10m rather than a
Cloudflare-style 60s because fullsend does not request partial
messages: Claude Code's stream-json emits one event per assistant
message and per completed tool call, so a single long tool call is
legitimately silent for minutes.

The watchdog derives no context of its own and never rebinds the
caller's ctx -- a body-scope `ctx, cancel := context.WithCancel(ctx)`
is what once made every successful run report as "cancelled" -- and a
source-reading regression test pins that for both streaming runtimes.

Non-goals: per-dimension timeouts (dimensions run inside one CLI
process the runner cannot see into), and no change to the heartbeat or
the global timeout.

Signed-off-by: guy oron <goron@redhat.com>
…c time

stop() and the ticker's fire branch both wrote fired/lastEvent from
separate atomics with no ordering between them, so a tick already past
the select when stop() closed the channel could still set fired=true
after disarm — misclassifying a healthy or just-finished run as
stalled. Replaced fired+stopOnce with a single state (armed/stopped/
fired) that stop() and the fire branch both reach only via
CompareAndSwap from armed, so exactly one wins.

Also swapped the UnixNano/time.Unix round-trip for time.Since(start),
which keeps Go's monotonic clock reading instead of discarding it, so
a wall-clock step can no longer perturb the silence calculation.

Signed-off-by: guy oron <goron@redhat.com>
The select in watch() can draw an already-queued tick even when the
stopped channel is closed, and only the kill path was protected by the
state CAS — so a run that had just completed normally could still emit
a misleading inactivity warning or CI annotation. A bare state check
before warning would shrink the window but keep a check-then-warn race,
so stop()'s disarm and the warning's check+emit now serialize on a
mutex: once stop() returns, no warning can follow.

Signed-off-by: guy oron <goron@redhat.com>
- re-check lastEvent under mu before the half-timeout warning, so an
  event arriving after the tick computed its silence suppresses the
  stale warning
- count liveness per well-formed stream line (parseClaudeStreamLines /
  parsePiStreamLines feed stall.note), so streaming tool output and
  lifecycle lines with no AgentEvent mapping keep the watchdog quiet
- raise the default stall timeout to 15m: Claude Code's bash ceiling
  (BASH_MAX_TIMEOUT_MS, 600000ms) makes a 10m tool call legitimate, so
  the default needs headroom above it
- allowlist FULLSEND_STALL_TIMEOUT in setup-agent-env.sh so the
  documented CI repository variable actually reaches the runner
- skip arming the watchdog (with a log line) when the stall timeout is
  not below the run timeout, where the global deadline always fires
  first and the watchdog could never act
- document the real kill chain: the cancel kills the local openshell
  exec client; the in-sandbox agent dies when the deferred sandbox
  teardown deletes the sandbox, and survives under --keep-sandbox

Signed-off-by: guy oron <goron@redhat.com>
- count a fully consumed oversized stream line (> streamBufSize) as
  liveness in both parsers: it is excluded from semantic parsing, but a
  runtime writing megabytes is alive and must not be killed as stalled
- extract the stall-vs-run-timeout disable decision into
  effectiveStallTimeout and cover it with a behavioral test (disabled at
  or above the run timeout, untouched below, 0 stays 0)
- validate FULLSEND_STALL_TIMEOUT repository variables with a
  duration-shaped pattern in setup-agent-env.sh: the shared charset
  rejected valid Go durations (+5m, 1µs, 1μs), silently dropping the
  override; µ/μ are matched as literal alternations so the check stays
  byte-safe in any locale, and injection protection is preserved

Signed-off-by: guy oron <goron@redhat.com>
- the stall kill only released the local `openshell sandbox exec`
  client, which is all the global timeout does and signals nothing
  inside the sandbox: the wedged agent kept writing the workspace and
  spending tokens until teardown, and indefinitely under
  --keep-sandbox. stallKill now runs the stray-process sweep over a
  second exec channel first (OpenShell exposes no signal API), then
  cancels; a failed sweep is reported and still releases the client
- arm the watchdog in CodexRuntime.Run: it has the same
  ExecStreamReader -> handler -> parse shape as the other two streaming
  runtimes but never read params.StallTimeout, so a wedged codex run
  burned its whole global timeout with no ErrStalled.
  parseCodexStreamLines gives it the same per-line liveness hook, so
  item.started/item.updated progress lines are not mistaken for silence
- derive the file list in the wiring tests from the ExecStreamReader
  call sites, so a fourth streaming runtime cannot ship unguarded the
  way codex did
- export StallDetectionLatency (the poll interval watch() ticks at) as
  the one source of truth for how late the kill can land
- correct the RunParams doc comment: it said runtimes that stream no
  events ignore StallTimeout, which read as "no streaming runtime is
  missing"

Signed-off-by: guy oron <goron@redhat.com>
- effectiveStallTimeout compared the threshold, not the kill: the
  watchdog polls, so a stall just under the run timeout (14m50s against
  15m) was reported as armed while the global deadline usually won the
  race. Compare against stall + StallDetectionLatency instead, and name
  the interval in the "inactive" line
- cover the run.go wiring: fold the resolve/warn/disarm decision into
  runStallTimeout and the ErrStalled verdict into noteStalledRun, both
  exercised directly — the branches sat inside runAgent, which no test
  reaches past sandbox creation
- test the case the fleet actually hits: harnesses with
  timeout_minutes: 10 get no watchdog at the 15m default

Signed-off-by: guy oron <goron@redhat.com>
The kill was documented as leaving the in-sandbox agent running until
teardown; it now terminates it. Name codex as a covered runtime, and
say that the stall timeout must clear timeout_minutes by more than the
poll interval — so a harness at 10 minutes or less has no stall
protection at the default.

Signed-off-by: guy oron <goron@redhat.com>
The unit tests cover the watchdog, stallKill and the line hooks in
isolation; nothing showed they compose. Drive the real ClaudeRuntime.Run
against a fake openshell on PATH -- the stub shape claude_test.go
already uses -- and assert that a stream which goes quiet ends with the
sandbox swept exactly once and ErrStalled returned. Deleting the sweep
from stallKill, or the stalledErr check from claude.go, fails it.

TestStreamingRuntimesArmTheWatchdog stays, now labelled for what it is:
a source-shape guard covering all three runtimes at once, not a
behavioural test. Its cost is a rename or a reflowed call breaking it;
what it buys is catching a fourth streaming runtime shipped unguarded,
which is how codex shipped unguarded here.

Also: the malformed-value warning goes through the printer, which run.go
points at stdout, so run.md saying "reported on stderr" was wrong.

Signed-off-by: guy oron <goron@redhat.com>
@guyoron1
guyoron1 force-pushed the feat/runner-stall-watchdog branch from f02a4cf to 811acf1 Compare September 6, 2026 06:02
@guyoron1

guyoron1 commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

@waynesun09 @rh-hemartin rebased onto main (conflicts: docs/cli/run.md, internal/cli/run.go, both kept beside per_model_usage) and pushed 909517f, d0c05b8, 31f6fcc, 811acf1.

  • Codex arms the watchdog with the same per-line liveness hook, and RunParams names the covered runtimes.
  • A stall kills the agent inside the sandbox: your stray-process sweep on a second exec channel, then the cancel.
  • Not breaking: the default stays 15m, clear of BASH_MAX_TIMEOUT_MS (600000ms); 0 disables it.
  • FULLSEND_STALL_TIMEOUT is allowlisted in setup-agent-env.sh; the arm/disarm check accounts for poll latency.
  • TestClaudeRuntime_Run_StallSweepsTheSandboxOnce drives the real Run path. TestStreamingRuntimesArmTheWatchdog still pins source text, labelled a source-shape guard, and covers pi and codex. runStallTimeout/noteStalledRun have direct tests; the runAgent call sites stay uncovered.

Deferred: the heartbeat, separate output and PR.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants