Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions apps/agent-orchestrator/src/agents/nats-agent-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,14 @@ export interface AgentOrchestratorChannel {
* - during ordinary work the up subject carries every `opencode_event`
* (`opencode-swe-agent`'s `subscribeEvents`), i.e. near-continuous;
* - the remote-control wait — the one place a turn blocks on a human —
* heartbeats "still running…" every 20s (`claude-runner.ts`) and caps
* itself at 30 min anyway.
* narrates each transcript entry as it appears and, while the session is
* quiet, heartbeats every 20s (`claude-runner.ts`).
*
* That runner owns the decision about whether a remote-control turn is stuck,
* because it is the only component that can see the session's actual activity;
* this bound never fires for one. It deliberately does NOT cap total duration
* (it used to cap at 30 min — see issue #149, where that killed a healthy
* turn), so do not restore a wall-clock cap here either.
*
* Note that an agent asking a question does NOT sit inside `awaitReply`:
* `ask()` publishes `reply{final:false}` (`agent-runtime`'s `runtime.ts`),
Expand Down
285 changes: 273 additions & 12 deletions apps/claude-code-swe-agent/src/claude-runner-remote-control.test.ts

Large diffs are not rendered by default.

447 changes: 404 additions & 43 deletions apps/claude-code-swe-agent/src/claude-runner.ts

Large diffs are not rendered by default.

44 changes: 44 additions & 0 deletions apps/claude-code-swe-agent/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,36 @@ export interface AgentToolConfig {
* selects which invocation shape to use.
*/
remoteControlEnabled: boolean;
/**
* How long a Remote Control session may produce NO transcript activity
* before its turn is given up on (`0`/unset uses claude-runner.ts's
* default). An operational setting rather than an internal detail for the
* same reason `AGENT_REPLY_ACK_TIMEOUT_MS` is (ADR 0033): getting it wrong
* either strands a wedged pod or kills healthy work, and neither should
* need a rebuild to correct.
*/
remoteControlIdleTimeoutMs: number;
/**
* How long a Remote Control session reporting `status: "idle"` may stay that
* way before its turn is given up on (`0`/unset uses the default). Much
* shorter than the silence bound above, because the session is not being
* quiet -- it is reporting that it is not working.
*/
remoteControlIdleStatusGraceMs: number;
/**
* How long a Remote Control session reporting `status: "waiting"` may stay
* blocked on a prompt before its turn is given up on (`0`/unset uses the
* default). Sized by human reaction time -- it is the window someone has to
* take over the session at its claude.ai URL and answer.
*/
remoteControlWaitingTimeoutMs: number;
/**
* Optional ABSOLUTE cap on a Remote Control turn. Unset means no cap: the
* idle bound above ends a stuck turn and the Job's `activeDeadlineSeconds`
* is the wall-clock ceiling. Setting this reintroduces the behaviour behind
* issue #149, so it exists only as an escape hatch.
*/
remoteControlMaxWaitMs: number;
/**
* The `~/.claude/.credentials.json` blob this run was launched with, as
* injected by agent-orchestrator (`CLAUDE_LOGIN_CREDENTIALS_JSON`, the same
Expand Down Expand Up @@ -124,6 +154,16 @@ function normalizePem(value: string | undefined): string {
return value.includes("\\n") ? value.replace(/\\n/g, "\n") : value;
}

/**
* Parses an optional positive-integer env var, yielding `0` ("not set") for
* anything absent, non-numeric, or non-positive -- so a typo falls back to the
* documented default rather than silently disabling a bound.
*/
function positiveInt(value: string | undefined): number {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
}

export function loadToolConfig(env: NodeJS.ProcessEnv = process.env): AgentToolConfig {
return {
githubToken: env.GITHUB_TOKEN ?? "",
Expand All @@ -140,6 +180,10 @@ export function loadToolConfig(env: NodeJS.ProcessEnv = process.env): AgentToolC
workdir: env.SWE_WORKDIR ?? `/tmp/swe-${randomUUID()}`,
homeDir: env.SWE_HOME ?? "/tmp/home",
remoteControlEnabled: env.CLAUDE_REMOTE_CONTROL === "true",
remoteControlIdleTimeoutMs: positiveInt(env.CLAUDE_REMOTE_CONTROL_IDLE_TIMEOUT_MS),
remoteControlIdleStatusGraceMs: positiveInt(env.CLAUDE_REMOTE_CONTROL_IDLE_STATUS_GRACE_MS),
remoteControlWaitingTimeoutMs: positiveInt(env.CLAUDE_REMOTE_CONTROL_WAITING_TIMEOUT_MS),
remoteControlMaxWaitMs: positiveInt(env.CLAUDE_REMOTE_CONTROL_MAX_WAIT_MS),
loginCredentialsJson: env.CLAUDE_LOGIN_CREDENTIALS_JSON ?? "",
credentialsWritebackUrl: env.CLAUDE_CREDENTIALS_WRITEBACK_URL ?? "",
credentialsWritebackToken: env.CLAUDE_CREDENTIALS_WRITEBACK_TOKEN ?? "",
Expand Down
9 changes: 8 additions & 1 deletion apps/claude-code-swe-agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,14 @@ async function handler(session: AgentSession): Promise<AgentReply> {
onProgress: (message: string, stage: string) => void session.progress(clip(message, 500), { stage }),
};
const outcome = toolConfig.remoteControlEnabled
? await runClaudeTurnRemoteControlled(prompt, { ...runOpts, runId: session.runId })
? await runClaudeTurnRemoteControlled(prompt, {
...runOpts,
runId: session.runId,
...(toolConfig.remoteControlIdleTimeoutMs ? { idleTimeoutMs: toolConfig.remoteControlIdleTimeoutMs } : {}),
...(toolConfig.remoteControlIdleStatusGraceMs ? { idleStatusGraceMs: toolConfig.remoteControlIdleStatusGraceMs } : {}),
...(toolConfig.remoteControlWaitingTimeoutMs ? { waitingTimeoutMs: toolConfig.remoteControlWaitingTimeoutMs } : {}),
...(toolConfig.remoteControlMaxWaitMs ? { maxWaitMs: toolConfig.remoteControlMaxWaitMs } : {}),
})
: await runClaudeTurn(prompt, runOpts);

// Runs on every outcome, including a failed one: the CLI may well have
Expand Down
16 changes: 16 additions & 0 deletions charts/community-components/templates/agent-claude-code-swe.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,22 @@ spec:
# `runClaudeTurnRemoteControlled` (claude-runner.ts).
- name: CLAUDE_REMOTE_CONTROL
value: "true"
{{- if .Values.claudeCodeSweAgent.remoteControl.idleTimeoutMs }}
- name: CLAUDE_REMOTE_CONTROL_IDLE_TIMEOUT_MS
value: {{ .Values.claudeCodeSweAgent.remoteControl.idleTimeoutMs | quote }}
{{- end }}
{{- if .Values.claudeCodeSweAgent.remoteControl.idleStatusGraceMs }}
- name: CLAUDE_REMOTE_CONTROL_IDLE_STATUS_GRACE_MS
value: {{ .Values.claudeCodeSweAgent.remoteControl.idleStatusGraceMs | quote }}
{{- end }}
{{- if .Values.claudeCodeSweAgent.remoteControl.waitingTimeoutMs }}
- name: CLAUDE_REMOTE_CONTROL_WAITING_TIMEOUT_MS
value: {{ .Values.claudeCodeSweAgent.remoteControl.waitingTimeoutMs | quote }}
{{- end }}
{{- if .Values.claudeCodeSweAgent.remoteControl.maxWaitMs }}
- name: CLAUDE_REMOTE_CONTROL_MAX_WAIT_MS
value: {{ .Values.claudeCodeSweAgent.remoteControl.maxWaitMs | quote }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
6 changes: 6 additions & 0 deletions charts/community-components/values-ci-all.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ claudeCodeSweAgent:
- claude-remote
remoteControl:
enabled: true
# Set here purely so validate-crds renders these two template branches;
# the values are the production defaults spelled out (see values.yaml).
idleTimeoutMs: "1200000"
idleStatusGraceMs: "90000"
waitingTimeoutMs: "300000"
maxWaitMs: ""

# E2E-only test double. Validated here anyway: it is a real chart template that
# renders a real Agent CR, and the acknowledgement flag below is exactly the
Expand Down
25 changes: 25 additions & 0 deletions charts/community-components/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,31 @@ claudeCodeSweAgent:
# baked into this catalog entry.
credentialsSecretName: ""
credentialsSecretKey: ""
# FALLBACK bound, used only while `claude agents --json` reports no status
# for the session: how long it may produce NO transcript activity (no
# assistant message, no tool call, no tool result) before its turn is
# declared stuck. Bounds SILENCE, not total duration. Empty uses
# claude-runner.ts's default (20 minutes).
idleTimeoutMs: ""
# How long a session REPORTING "idle" may stay that way before its turn is
# declared stuck. Much shorter than idleTimeoutMs above, because it is much
# better evidence: the session is not being quiet, it is saying it is not
# working. (A session that is working reports "busy" and is never cut off,
# however long a single tool call keeps its transcript static.) Empty uses
# claude-runner.ts's default (90 seconds).
idleStatusGraceMs: ""
# How long a session REPORTING "waiting" -- blocked on a prompt or dialog
# only a human can clear -- may stay blocked. Nothing in a headless Job
# will ever answer it, so this is purely the window someone has to take
# over the session at its claude.ai URL (which the run's own first comment
# links). Empty uses claude-runner.ts's default (5 minutes).
waitingTimeoutMs: ""
# Escape hatch only: an ABSOLUTE cap on a turn's total duration. Empty
# (the default) means no cap -- the idle bound above ends a stuck turn and
# the AgentRun Job's activeDeadlineSeconds is the wall-clock ceiling.
# Setting this reintroduces the behaviour behind issue #149, where a
# healthy half-hour coding turn was killed and reported as a timeout.
maxWaitMs: ""

# github: runs a single allowlisted gh CLI command (issue/pr/repo/release/
# search/workflow/run), preferably authenticated as the calling user's own
Expand Down
Loading