actionagent 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +76 -73
- data/app/assets/builds/action_agent_recorder.js +78 -0
- data/app/assets/builds/action_agent_replay.js +226 -0
- data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
- data/app/controllers/action_agent/api/agents_controller.rb +1 -0
- data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
- data/app/controllers/action_agent/api/base_controller.rb +63 -2
- data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
- data/app/controllers/action_agent/api/code_sessions_controller.rb +71 -12
- data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
- data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
- data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +58 -7
- data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
- data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
- data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
- data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
- data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
- data/app/controllers/action_agent/api/members_controller.rb +36 -0
- data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
- data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
- data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
- data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
- data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
- data/app/controllers/action_agent/api/projects_controller.rb +512 -0
- data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
- data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
- data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
- data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
- data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
- data/app/controllers/action_agent/api/sandboxes_controller.rb +148 -8
- data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
- data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
- data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
- data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
- data/app/controllers/action_agent/dashboard_controller.rb +17 -6
- data/app/controllers/action_agent/session_player_controller.rb +58 -0
- data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
- data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +627 -20
- data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
- data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
- data/app/jobs/action_agent/agent_execution_job.rb +9 -39
- data/app/jobs/action_agent/agent_resume_job.rb +84 -0
- data/app/jobs/action_agent/code_session_job.rb +39 -10
- data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
- data/app/jobs/action_agent/exploration_job.rb +80 -0
- data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
- data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
- data/app/jobs/action_agent/project_setup_job.rb +21 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
- data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
- data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
- data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
- data/app/models/action_agent/agent.rb +72 -26
- data/app/models/action_agent/agent_run.rb +134 -23
- data/app/models/action_agent/agent_template.rb +35 -1
- data/app/models/action_agent/api_key.rb +14 -0
- data/app/models/action_agent/catalog_scenario.rb +75 -0
- data/app/models/action_agent/code_session.rb +21 -5
- data/app/models/action_agent/draft_pull_request.rb +131 -0
- data/app/models/action_agent/evaluation.rb +148 -0
- data/app/models/action_agent/evaluation_run.rb +143 -53
- data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
- data/app/models/action_agent/exploration.rb +847 -0
- data/app/models/action_agent/github_installation.rb +159 -0
- data/app/models/action_agent/input_request.rb +291 -0
- data/app/models/action_agent/model_pricing.rb +214 -34
- data/app/models/action_agent/project.rb +789 -0
- data/app/models/action_agent/project_secret.rb +339 -0
- data/app/models/action_agent/provider_key.rb +207 -4
- data/app/models/action_agent/recording_event.rb +145 -0
- data/app/models/action_agent/recording_snapshot.rb +7 -7
- data/app/models/action_agent/sandbox_session.rb +300 -16
- data/app/models/action_agent/scenario_catalog.rb +172 -0
- data/app/models/action_agent/scenario_product.rb +57 -0
- data/app/models/action_agent/scenario_set.rb +138 -0
- data/app/models/action_agent/session_recording.rb +121 -14
- data/app/models/action_agent/telemetry_trace.rb +74 -19
- data/app/models/concerns/action_agent/ownable.rb +84 -13
- data/app/serializers/action_agent/evaluation_serializer.rb +41 -10
- data/app/serializers/action_agent/input_request_serializer.rb +47 -0
- data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
- data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
- data/app/services/action_agent/agent_execution_service.rb +450 -72
- data/app/services/action_agent/agent_scorecard.rb +50 -21
- data/app/services/action_agent/agent_sync.rb +5 -2
- data/app/services/action_agent/agent_tool_roster.rb +19 -4
- data/app/services/action_agent/agent_toolbox.rb +215 -9
- data/app/services/action_agent/browser_live_ticket.rb +78 -0
- data/app/services/action_agent/browser_sidecar.rb +213 -0
- data/app/services/action_agent/browser_sign_in.rb +163 -0
- data/app/services/action_agent/browser_tool_redaction.rb +92 -0
- data/app/services/action_agent/claude_code_auth.rb +70 -4
- data/app/services/action_agent/codex_session_events.rb +37 -0
- data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
- data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
- data/app/services/action_agent/evaluation_fix.rb +101 -0
- data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
- data/app/services/action_agent/evaluation_report_import.rb +84 -5
- data/app/services/action_agent/evaluation_run_cost.rb +609 -0
- data/app/services/action_agent/evaluation_runner_service.rb +29 -10
- data/app/services/action_agent/evaluation_standing.rb +152 -0
- data/app/services/action_agent/explorer_execution_service.rb +517 -0
- data/app/services/action_agent/github_client.rb +405 -64
- data/app/services/action_agent/live_updates.rb +31 -0
- data/app/services/action_agent/local_sandbox_backend.rb +1263 -102
- data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
- data/app/services/action_agent/mcp_client.rb +6 -2
- data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
- data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
- data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
- data/app/services/action_agent/playwright_mcp_client.rb +7 -0
- data/app/services/action_agent/project_boot_fix.rb +309 -0
- data/app/services/action_agent/project_capabilities.rb +200 -0
- data/app/services/action_agent/project_install_pull_request.rb +225 -0
- data/app/services/action_agent/project_preflight.rb +178 -0
- data/app/services/action_agent/project_secret_discovery.rb +142 -0
- data/app/services/action_agent/project_setup.rb +429 -0
- data/app/services/action_agent/provider_credentials.rb +102 -0
- data/app/services/action_agent/recording_event_ingest.rb +181 -0
- data/app/services/action_agent/runtime_tool_roster.rb +52 -0
- data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
- data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
- data/app/services/action_agent/sandbox_browser.rb +299 -0
- data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
- data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
- data/app/services/action_agent/sandbox_mail.rb +103 -0
- data/app/services/action_agent/sandbox_manifest.rb +166 -13
- data/app/services/action_agent/sandbox_orchestrator.rb +290 -23
- data/app/services/action_agent/sandbox_patch.rb +253 -0
- data/app/services/action_agent/scenario_catalog_import.rb +255 -0
- data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
- data/app/services/action_agent/scenario_evaluation_runner.rb +121 -17
- data/app/services/action_agent/secret_scrubber.rb +33 -4
- data/app/services/action_agent/session_index.rb +270 -0
- data/app/services/action_agent/session_recording_service.rb +11 -0
- data/app/services/action_agent/session_timeline.rb +505 -0
- data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
- data/app/views/action_agent/session_player/show.html.erb +19 -0
- data/config/routes.rb +153 -2
- data/lib/action_agent/claude_login_process.rb +107 -0
- data/lib/action_agent/engine.rb +55 -5
- data/lib/action_agent/secret_requests.rb +80 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +513 -22
- data/lib/generators/action_agent/install_generator.rb +45 -5
- data/lib/generators/action_agent/templates/action_agent.rb.erb +161 -0
- data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -0
- data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
- data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
- data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
- data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
- data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
- data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
- data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
- data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
- data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
- data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
- data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
- data/lib/tasks/action_agent.rake +33 -0
- data/lib/tasks/browser.rake +22 -0
- data/lib/tasks/sandbox.rake +1 -1
- data/lib/tasks/scenario_catalogs.rake +71 -0
- metadata +100 -2
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
module Api
|
|
5
|
+
# The browser of a project's sandbox, for the endpoints that drive it
|
|
6
|
+
# from the engine: an exploration's start and the test account step.
|
|
7
|
+
module ProjectBrowser
|
|
8
|
+
# The tool groups the project's browser starts with: the explorer's
|
|
9
|
+
# locator and verify tools come from "testing".
|
|
10
|
+
CAPABILITIES = %w[testing].freeze
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
# The project's ready sandbox, or nil after rendering 409 when it has
|
|
15
|
+
# none.
|
|
16
|
+
def ready_project_sandbox!(project)
|
|
17
|
+
sandbox = project.current_sandbox_session
|
|
18
|
+
return sandbox if sandbox && project.sandbox_state == "ready"
|
|
19
|
+
|
|
20
|
+
render json: { error: "Boot the project's sandbox first", code: "sandbox_not_ready", sandbox_state: project.sandbox_state },
|
|
21
|
+
status: :conflict
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# [sandbox, whether its browser was started now] with the browser
|
|
26
|
+
# running: the one already running, or one started headless with the
|
|
27
|
+
# project's saved sign-in after the host's quota allows
|
|
28
|
+
# :browser_minutes. Nil after rendering why it cannot run.
|
|
29
|
+
def ensure_project_browser!(project, sandbox)
|
|
30
|
+
return [ sandbox, false ] if sandbox.browser_running?
|
|
31
|
+
|
|
32
|
+
enforce_quota!(:browser_minutes)
|
|
33
|
+
return nil if performed?
|
|
34
|
+
|
|
35
|
+
sandbox, started = SandboxBrowser.ensure_running!(sandbox, capabilities: CAPABILITIES,
|
|
36
|
+
recording_url: ->(recording) { events_api_session_recording_url(recording) },
|
|
37
|
+
storage_state: project.saved_storage_state)
|
|
38
|
+
[ sandbox, started ]
|
|
39
|
+
rescue SandboxBrowser::Error => e
|
|
40
|
+
render json: { error: SecretScrubber.scrub(e.message, project.scrub_values), code: "browser_unavailable" },
|
|
41
|
+
status: :unprocessable_entity
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
module Api
|
|
5
|
+
# Who may decide a project's secrets and the code that reads them. Each
|
|
6
|
+
# method answers whether the signed-in user may, and renders the 403
|
|
7
|
+
# first when not, so a caller returns unless it is true.
|
|
8
|
+
#
|
|
9
|
+
# :manage_project_secrets is always asked about a ProjectSecret, and
|
|
10
|
+
# :manage_credentials about the ProviderKey a secret hands over.
|
|
11
|
+
module ProjectSecretAuthorization
|
|
12
|
+
# The sources a person may give a secret. A value that is not secret
|
|
13
|
+
# (ProjectSecret source "setup_assistant") is set only by the setup
|
|
14
|
+
# assistant.
|
|
15
|
+
PERSON_SECRET_SOURCES = %w[entered organization_key].freeze
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# Whether every one of +entries+ names a source a person may give, or
|
|
20
|
+
# none. Renders 422 first when not.
|
|
21
|
+
def secret_sources_allowed!(entries)
|
|
22
|
+
refused = entries.find do |entry|
|
|
23
|
+
source = entry.respond_to?(:permit) || entry.is_a?(Hash) ? entry[:source] || entry["source"] : nil
|
|
24
|
+
source.present? && !PERSON_SECRET_SOURCES.include?(source.to_s)
|
|
25
|
+
end
|
|
26
|
+
return true if refused.nil?
|
|
27
|
+
|
|
28
|
+
render json: { error: "A secret's source must be one of #{PERSON_SECRET_SOURCES.join(", ")}", code: "invalid_source" },
|
|
29
|
+
status: :unprocessable_entity
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Setting +secret+ needs :manage_project_secrets. A secret that takes
|
|
34
|
+
# the organization's stored provider key also needs
|
|
35
|
+
# :manage_credentials, because the repository's code can then read a
|
|
36
|
+
# key the provider keys API never reveals.
|
|
37
|
+
def authorize_secret!(secret)
|
|
38
|
+
return false unless authorize_action!(:manage_project_secrets, secret)
|
|
39
|
+
|
|
40
|
+
key = secret.organization_key? ? secret.organization_provider_key : nil
|
|
41
|
+
key.nil? || authorize_action!(:manage_credentials, key)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Handing +project+'s secrets to other code, such as the code at a new
|
|
45
|
+
# ref, needs what setting each of them needs.
|
|
46
|
+
def authorize_secret_handover!(project)
|
|
47
|
+
project.secrets.to_a.all? { |secret| authorize_secret!(secret) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Deleting +project+ deletes its secrets, which needs
|
|
51
|
+
# :manage_project_secrets for each.
|
|
52
|
+
def authorize_secrets_removal!(project)
|
|
53
|
+
project.secrets.to_a.all? { |secret| authorize_action!(:manage_project_secrets, secret) }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -7,8 +7,10 @@ module ActionAgent
|
|
|
7
7
|
def perform(run_id)
|
|
8
8
|
run = AgentRun.find(run_id)
|
|
9
9
|
# Anything already finished — complete, failed or cancelled — is never
|
|
10
|
-
# executed again
|
|
11
|
-
|
|
10
|
+
# executed again, and neither is a run waiting for input, which only
|
|
11
|
+
# AgentResumeJob continues. A retry of a failed run would re-run the
|
|
12
|
+
# generation.
|
|
13
|
+
return if run.finished? || run.awaiting_input?
|
|
12
14
|
|
|
13
15
|
run.update!(status: :running, started_at: Time.current)
|
|
14
16
|
run.add_log("Starting execution", level: :info)
|
|
@@ -19,30 +21,13 @@ module ActionAgent
|
|
|
19
21
|
# Build the agent class dynamically based on configuration
|
|
20
22
|
result = execute_agent(agent_record, run)
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
run.
|
|
24
|
-
|
|
25
|
-
output_metadata: result[:metadata],
|
|
26
|
-
status: :complete,
|
|
27
|
-
completed_at: Time.current,
|
|
28
|
-
duration_ms: ((Time.current - run.started_at) * 1000).to_i,
|
|
29
|
-
input_tokens: result.dig(:usage, :input_tokens),
|
|
30
|
-
output_tokens: result.dig(:usage, :output_tokens),
|
|
31
|
-
total_tokens: result.dig(:usage, :total_tokens)
|
|
32
|
-
)
|
|
33
|
-
run.add_log("Execution completed successfully", level: :info)
|
|
24
|
+
run.unless_cancelled do
|
|
25
|
+
run.record_result!(result, segment_started_at: run.started_at)
|
|
26
|
+
run.add_log(result[:input_requests].present? ? "Execution paused for input" : "Execution completed successfully", level: :info)
|
|
34
27
|
end
|
|
35
28
|
rescue => e
|
|
36
|
-
|
|
37
|
-
run.
|
|
38
|
-
status: :failed,
|
|
39
|
-
completed_at: Time.current,
|
|
40
|
-
error_message: e.message,
|
|
41
|
-
error_backtrace: e.backtrace&.first(10)&.join("\n"),
|
|
42
|
-
# Same as the synchronous path: the class is what distinguishes
|
|
43
|
-
# a refusal from a crash.
|
|
44
|
-
output_metadata: run.output_metadata.to_h.merge("error_class" => e.class.name)
|
|
45
|
-
)
|
|
29
|
+
run.unless_cancelled do
|
|
30
|
+
run.record_failure!(e)
|
|
46
31
|
run.add_log("Execution failed: #{e.message}", level: :error)
|
|
47
32
|
end
|
|
48
33
|
raise
|
|
@@ -56,20 +41,5 @@ module ActionAgent
|
|
|
56
41
|
def execute_agent(agent_record, run)
|
|
57
42
|
AgentExecutionService.call(agent_record, run)
|
|
58
43
|
end
|
|
59
|
-
|
|
60
|
-
# A cancel that arrived while the service was running must survive it:
|
|
61
|
-
# the run's terminal state is written under a row lock after re-reading
|
|
62
|
-
# the status, so a cancelled run is never flipped back to complete or
|
|
63
|
-
# failed by the job that was still executing it.
|
|
64
|
-
def finish_unless_cancelled(run)
|
|
65
|
-
run.with_lock do
|
|
66
|
-
if run.cancelled?
|
|
67
|
-
run.add_log("Execution finished after cancellation; result discarded", level: :info)
|
|
68
|
-
return
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
yield
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
44
|
end
|
|
75
45
|
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Continues a run that paused for input, once every request of the pause
|
|
5
|
+
# is answered or declined.
|
|
6
|
+
#
|
|
7
|
+
# Its only argument is the id of one of the pause's requests. The answers
|
|
8
|
+
# are read and decrypted here, so none of them, `:secret` answers
|
|
9
|
+
# included, is ever a job argument or reaches the queue's storage. Two
|
|
10
|
+
# answers that settle a pause together each enqueue this job, and only the
|
|
11
|
+
# first to take the run's lock resumes it.
|
|
12
|
+
class AgentResumeJob < ApplicationJob
|
|
13
|
+
queue_as :agents
|
|
14
|
+
|
|
15
|
+
# Raised, and recorded as the run's failure, when agent execution was
|
|
16
|
+
# turned off after the pause was settled.
|
|
17
|
+
class ExecutionDisabled < StandardError; end
|
|
18
|
+
|
|
19
|
+
def perform(input_request_id)
|
|
20
|
+
request = InputRequest.find_by(id: input_request_id)
|
|
21
|
+
run = request&.subject
|
|
22
|
+
return unless run.is_a?(AgentRun)
|
|
23
|
+
|
|
24
|
+
pause = claim_pause(run, request)
|
|
25
|
+
return unless pause
|
|
26
|
+
|
|
27
|
+
segment_started_at = Time.current
|
|
28
|
+
secrets = pause.select { |paused| paused.kind == "secret" }.filter_map(&:answer)
|
|
29
|
+
|
|
30
|
+
begin
|
|
31
|
+
raise ExecutionDisabled, "Agent execution is disabled on this dashboard" unless ActionAgent.execution_enabled?
|
|
32
|
+
|
|
33
|
+
result = AgentExecutionService.call(
|
|
34
|
+
run.agent, run,
|
|
35
|
+
resume: {
|
|
36
|
+
checkpoint: request.checkpoint_data,
|
|
37
|
+
answers: pause.to_h { |paused| [ paused.tool_call_id, paused.resume_answer ] },
|
|
38
|
+
secrets: secrets
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
run.unless_cancelled do
|
|
43
|
+
run.record_result!(result, segment_started_at: segment_started_at)
|
|
44
|
+
run.add_log(result[:input_requests].present? ? "Execution paused for input" : "Execution completed successfully", level: :info)
|
|
45
|
+
end
|
|
46
|
+
rescue => e
|
|
47
|
+
message = ActiveAgent::InputRequest.scrub(e.message, secrets)
|
|
48
|
+
run.unless_cancelled do
|
|
49
|
+
run.record_failure!(e, message: message)
|
|
50
|
+
run.add_log("Execution failed: #{message}", level: :error)
|
|
51
|
+
end
|
|
52
|
+
ensure
|
|
53
|
+
forget_secret_answers(pause)
|
|
54
|
+
run.broadcast_update
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# Moves the run from awaiting_input to running when every request of the
|
|
61
|
+
# pause is settled, under the run's row lock, and returns the pause's
|
|
62
|
+
# requests in call order; nil when this job is not the one to resume it,
|
|
63
|
+
# or when the request's pause is not the one the run waits on.
|
|
64
|
+
def claim_pause(run, request)
|
|
65
|
+
run.with_lock do
|
|
66
|
+
next nil unless run.awaiting_input? && request.current_pause?
|
|
67
|
+
|
|
68
|
+
pause = request.pause_requests.order(:id).to_a
|
|
69
|
+
next nil unless pause.all? { |paused| paused.answered? || paused.declined? }
|
|
70
|
+
|
|
71
|
+
run.update!(status: :running)
|
|
72
|
+
pause
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A secret answer has reached its tool once the resume ran, so it is not
|
|
77
|
+
# kept any longer than that.
|
|
78
|
+
def forget_secret_answers(pause)
|
|
79
|
+
pause.select { |paused| paused.kind == "secret" && paused.answer.present? }.each do |paused|
|
|
80
|
+
paused.update_columns(answer: nil)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -24,13 +24,15 @@ module ActionAgent
|
|
|
24
24
|
# connection and the Claude Code key.
|
|
25
25
|
secrets = code_session.secrets
|
|
26
26
|
result_event = nil
|
|
27
|
+
codex_events = CodexSessionEvents.new if code_session.runner == "codex"
|
|
27
28
|
stop_sent = false
|
|
28
29
|
orchestrator = SandboxOrchestrator.new
|
|
29
30
|
sandbox = code_session.sandbox_session
|
|
30
31
|
|
|
31
32
|
# Queued behind a Stop (the cleanup job shares this queue): never start
|
|
32
|
-
# Claude Code, with the owner's credential, in a stopped sandbox.
|
|
33
|
-
|
|
33
|
+
# Claude Code, with the owner's credential, in a stopped sandbox. A
|
|
34
|
+
# project's boot fix runs in the checkout its failed boot kept.
|
|
35
|
+
unless (sandbox.reload.ready? && sandbox.active?) || ProjectBootFix.runs_in_failed_boot?(code_session)
|
|
34
36
|
return fail!(code_session, "The sandbox was stopped before the session started")
|
|
35
37
|
end
|
|
36
38
|
|
|
@@ -41,6 +43,10 @@ module ActionAgent
|
|
|
41
43
|
# has no process to stop; an event means the process exists now, so
|
|
42
44
|
# the stop is sent again, once.
|
|
43
45
|
code_session.append_event!(event, secrets: secrets)
|
|
46
|
+
if codex_events
|
|
47
|
+
event = codex_events.consume(event)
|
|
48
|
+
code_session.append_event!(event, secrets: secrets) if event
|
|
49
|
+
end
|
|
44
50
|
if code_session.cancelled? && !stop_sent
|
|
45
51
|
stop_sent = true
|
|
46
52
|
stop(orchestrator, sandbox, code_session)
|
|
@@ -52,14 +58,37 @@ module ActionAgent
|
|
|
52
58
|
end
|
|
53
59
|
|
|
54
60
|
finish(code_session, outcome.to_h, result_event, secrets)
|
|
61
|
+
verify(code_session)
|
|
62
|
+
resume_boot(code_session)
|
|
55
63
|
rescue StandardError => e
|
|
56
64
|
message = SecretScrubber.scrub(e.message.to_s, secrets || safe_secrets(code_session))
|
|
57
|
-
Rails.logger.error("
|
|
65
|
+
Rails.logger.error("Code session #{code_session_id} failed: #{message}")
|
|
58
66
|
fail!(code_session, message) if code_session
|
|
67
|
+
resume_boot(code_session) if code_session
|
|
59
68
|
end
|
|
60
69
|
|
|
61
70
|
private
|
|
62
71
|
|
|
72
|
+
# A project's boot fix resumes the boot it fixed (ProjectBootFix). Its
|
|
73
|
+
# failure is the boot's, not the session's, so it is logged and left on
|
|
74
|
+
# the project rather than raised.
|
|
75
|
+
def resume_boot(code_session)
|
|
76
|
+
ProjectBootFix.after_session(code_session.reload)
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
Rails.logger.warn("Code session #{code_session.id}: the project's boot was not resumed: #{e.class}: #{e.message}")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A fix session that changed the checkout is verified next. If that
|
|
82
|
+
# cannot even be queued, the error is the session's, so the sandbox is
|
|
83
|
+
# not left waiting for a verification that will never come.
|
|
84
|
+
def verify(code_session)
|
|
85
|
+
return unless code_session.succeeded? && code_session.evaluation_run_id && code_session.diff.present?
|
|
86
|
+
|
|
87
|
+
VerifyEvaluationFixJob.perform_later(code_session.id)
|
|
88
|
+
rescue StandardError => e
|
|
89
|
+
code_session.update_columns(verification_error: "Verification could not be queued: #{e.class}")
|
|
90
|
+
end
|
|
91
|
+
|
|
63
92
|
def start(code_session)
|
|
64
93
|
now = Time.current
|
|
65
94
|
claimed = CodeSession.where(id: code_session.id, status: CodeSession.statuses[:queued])
|
|
@@ -83,7 +112,7 @@ module ActionAgent
|
|
|
83
112
|
else
|
|
84
113
|
code_session.assign_attributes(
|
|
85
114
|
status: :failed,
|
|
86
|
-
error_message: failure_message(result_event, outcome, secrets),
|
|
115
|
+
error_message: failure_message(result_event, outcome, secrets, label: code_session.runner == "codex" ? "Codex" : "Claude Code"),
|
|
87
116
|
finished_at: Time.current
|
|
88
117
|
)
|
|
89
118
|
end
|
|
@@ -105,8 +134,8 @@ module ActionAgent
|
|
|
105
134
|
|
|
106
135
|
# What went wrong, in Claude Code's own words when it reported a failure
|
|
107
136
|
# and from the process otherwise.
|
|
108
|
-
def failure_message(result_event, outcome, secrets)
|
|
109
|
-
message = reported_failure(result_event) || process_failure(result_event, outcome)
|
|
137
|
+
def failure_message(result_event, outcome, secrets, label: "Claude Code")
|
|
138
|
+
message = reported_failure(result_event) || process_failure(result_event, outcome, label: label)
|
|
110
139
|
SecretScrubber.scrub(message, secrets).truncate(MAX_ERROR_MESSAGE)
|
|
111
140
|
end
|
|
112
141
|
|
|
@@ -119,12 +148,12 @@ module ActionAgent
|
|
|
119
148
|
"Claude Code stopped: #{result_event['subtype']}"
|
|
120
149
|
end
|
|
121
150
|
|
|
122
|
-
def process_failure(result_event, outcome)
|
|
151
|
+
def process_failure(result_event, outcome, label: "Claude Code")
|
|
123
152
|
status = outcome[:exit_status]
|
|
124
153
|
headline =
|
|
125
|
-
if !status.nil? && status != 0 then "
|
|
126
|
-
elsif result_event.nil? then "
|
|
127
|
-
else "
|
|
154
|
+
if !status.nil? && status != 0 then "#{label} exited with status #{status}"
|
|
155
|
+
elsif result_event.nil? then "#{label} ended without reporting a result"
|
|
156
|
+
else "#{label} did not finish"
|
|
128
157
|
end
|
|
129
158
|
|
|
130
159
|
[ headline, outcome[:stderr_tail].to_s.strip.presence ].compact.join(": ")
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Runs one queued publish of a DraftPullRequest through
|
|
5
|
+
# DraftPullRequestPublisher, after asking ActionAgent.permission_checker
|
|
6
|
+
# again whether the user who asked for it may publish pull requests. A
|
|
7
|
+
# project's install pull request is published with the files the project
|
|
8
|
+
# generates (ProjectInstallPullRequest).
|
|
9
|
+
#
|
|
10
|
+
# Takes the record's id and nothing else: the token is minted while the
|
|
11
|
+
# job runs, and never passes through the queue. Not retried, since a
|
|
12
|
+
# publish writes to GitHub.
|
|
13
|
+
class DraftPullRequestJob < ApplicationJob
|
|
14
|
+
queue_as :sandboxes
|
|
15
|
+
|
|
16
|
+
def perform(draft_pull_request_id)
|
|
17
|
+
# Claimed atomically, so a second job for the same record finds it no
|
|
18
|
+
# longer queued.
|
|
19
|
+
claimed = DraftPullRequest.where(id: draft_pull_request_id, status: "queued")
|
|
20
|
+
.update_all(status: "publishing", updated_at: Time.current)
|
|
21
|
+
return unless claimed == 1
|
|
22
|
+
|
|
23
|
+
record = DraftPullRequest.find(draft_pull_request_id)
|
|
24
|
+
user = record.publisher
|
|
25
|
+
if ActionAgent.permitted?(user, :publish_pull_request, record)
|
|
26
|
+
ProjectInstallPullRequest.publisher_for(record, user: user).publish!(record)
|
|
27
|
+
else
|
|
28
|
+
record.update!(status: "failed", error_code: "forbidden", error_message: "You do not have permission to publish pull requests")
|
|
29
|
+
end
|
|
30
|
+
broadcast(record)
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
Rails.logger.error("[ActionAgent] publishing draft pull request #{draft_pull_request_id} failed: #{e.class}: #{e.message}")
|
|
33
|
+
DraftPullRequest.where(id: draft_pull_request_id, status: %w[queued publishing])
|
|
34
|
+
.update_all(status: "failed", error_code: "github_error", error_message: "The publish failed unexpectedly", updated_at: Time.current)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def broadcast(record)
|
|
40
|
+
LiveUpdates.broadcast("sandbox_#{record.sandbox_session.session_id}", type: "pull_request", id: record.id, status: record.status)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Walks a project's app with the explorer agent for one pending explorer
|
|
5
|
+
# Exploration (see Api::ProjectExplorationsController and
|
|
6
|
+
# ExplorerExecutionService), then records how it ended:
|
|
7
|
+
#
|
|
8
|
+
# - the explorer finished, ran out of budget or was stopped: the run is
|
|
9
|
+
# complete and the exploration moves to review with its candidates
|
|
10
|
+
# - anything else went wrong: the run and the exploration fail, and the
|
|
11
|
+
# exploration keeps the candidates it found
|
|
12
|
+
#
|
|
13
|
+
# A browser the start launched for the walk is stopped afterwards, which
|
|
14
|
+
# completes its recording and stops its minutes. That includes a walk that
|
|
15
|
+
# never began, because it was stopped before the job ran or its run is
|
|
16
|
+
# gone.
|
|
17
|
+
class ExplorationJob < ApplicationJob
|
|
18
|
+
queue_as :agents
|
|
19
|
+
|
|
20
|
+
def perform(exploration_id, stop_browser = false)
|
|
21
|
+
exploration = Exploration.find_by(id: exploration_id)
|
|
22
|
+
return unless exploration&.source == "explorer"
|
|
23
|
+
|
|
24
|
+
begin
|
|
25
|
+
begin_walk(exploration)
|
|
26
|
+
ensure
|
|
27
|
+
stop(exploration) if stop_browser
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def begin_walk(exploration)
|
|
34
|
+
return unless exploration.status == "pending"
|
|
35
|
+
|
|
36
|
+
run = exploration.agent_run
|
|
37
|
+
return exploration.fail_walk!("The explorer's run is missing") if run.nil? || run.finished?
|
|
38
|
+
|
|
39
|
+
exploration.start_walk!
|
|
40
|
+
run.update!(status: :running, started_at: Time.current)
|
|
41
|
+
walk(exploration, run)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def walk(exploration, run)
|
|
45
|
+
result = ExplorerExecutionService.new(exploration, run).walk!
|
|
46
|
+
complete(run, result[:output], result)
|
|
47
|
+
exploration.finish_walk!(reason: result[:stop_reason])
|
|
48
|
+
rescue ExplorerExecutionService::WalkCutShort => e
|
|
49
|
+
complete(run, e.message, {})
|
|
50
|
+
exploration.finish_walk!(reason: e.reason)
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
message = SecretScrubber.scrub(e.message, exploration.scrub_secrets)
|
|
53
|
+
run.update!(status: :failed, completed_at: Time.current, error_message: message,
|
|
54
|
+
output_metadata: run.output_metadata.to_h.merge("error_class" => e.class.name))
|
|
55
|
+
exploration.fail_walk!(message)
|
|
56
|
+
ensure
|
|
57
|
+
run.broadcast_update
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def complete(run, output, result)
|
|
61
|
+
run.update!(
|
|
62
|
+
output: output,
|
|
63
|
+
output_metadata: result[:metadata].to_h.merge("stop_reason" => result[:stop_reason], "summary" => result[:summary]).compact,
|
|
64
|
+
status: :complete,
|
|
65
|
+
completed_at: Time.current,
|
|
66
|
+
duration_ms: ((Time.current - run.started_at) * 1000).to_i,
|
|
67
|
+
input_tokens: result.dig(:usage, :input_tokens),
|
|
68
|
+
output_tokens: result.dig(:usage, :output_tokens),
|
|
69
|
+
total_tokens: result.dig(:usage, :total_tokens)
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def stop(exploration)
|
|
74
|
+
sandbox = exploration.sandbox_session&.reload
|
|
75
|
+
SandboxBrowser.stop(sandbox) if sandbox && SandboxBrowser::STARTED.include?(sandbox.browser_status)
|
|
76
|
+
rescue SandboxBrowser::Error => e
|
|
77
|
+
Rails.logger.warn("[ActionAgent] exploration #{exploration.id}: could not stop the browser it started: #{e.message}")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Expires the requests for input left unanswered past their `expires_at`,
|
|
5
|
+
# and fails their runs (InputRequest.expire_overdue!). An answer, the
|
|
6
|
+
# request list and a run's own page expire the requests they reach; this
|
|
7
|
+
# job reaches the rest.
|
|
8
|
+
#
|
|
9
|
+
# Not scheduled by default. To schedule it:
|
|
10
|
+
#
|
|
11
|
+
# # config/recurring.yml
|
|
12
|
+
# input_request_expiry:
|
|
13
|
+
# class: ActionAgent::InputRequestExpiryJob
|
|
14
|
+
# schedule: every 15 minutes
|
|
15
|
+
class InputRequestExpiryJob < ApplicationJob
|
|
16
|
+
queue_as :default
|
|
17
|
+
|
|
18
|
+
def perform
|
|
19
|
+
InputRequest.expire_overdue!
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Runs a project's evaluation (Api::ProjectsController#run_evaluation)
|
|
5
|
+
# against the project's sandbox once it serves. The run was created
|
|
6
|
+
# pending; while the sandbox boots, the job checks again every
|
|
7
|
+
# POLL_INTERVAL rather than holding a worker, and fails the run when the
|
|
8
|
+
# boot fails, stops, or takes longer than BOOT_WAIT.
|
|
9
|
+
#
|
|
10
|
+
# Every replay also reaches the sandbox's browser (see
|
|
11
|
+
# ScenarioEvaluationRunner), whose recording posts to the dashboard
|
|
12
|
+
# mounted at +mount_url+ when the run starts it.
|
|
13
|
+
class ProjectEvaluationJob < ApplicationJob
|
|
14
|
+
queue_as :agents
|
|
15
|
+
|
|
16
|
+
POLL_INTERVAL = 10.seconds
|
|
17
|
+
BOOT_WAIT = 1.hour
|
|
18
|
+
|
|
19
|
+
def perform(project_id, run_id, mount_url = nil)
|
|
20
|
+
run = EvaluationRun.find_by(id: run_id)
|
|
21
|
+
return unless run&.pending?
|
|
22
|
+
|
|
23
|
+
project = Project.find_by(id: project_id)
|
|
24
|
+
return fail_run(run, "The project was deleted before the run started") if project.nil?
|
|
25
|
+
|
|
26
|
+
sandbox = project.current_sandbox_session
|
|
27
|
+
case project.sandbox_state
|
|
28
|
+
when "ready"
|
|
29
|
+
start(run, sandbox, mount_url)
|
|
30
|
+
when "booting"
|
|
31
|
+
if run.created_at <= BOOT_WAIT.ago
|
|
32
|
+
fail_run(run, "The project's sandbox did not finish booting within #{BOOT_WAIT.inspect}")
|
|
33
|
+
else
|
|
34
|
+
self.class.set(wait: POLL_INTERVAL).perform_later(project_id, run_id, mount_url)
|
|
35
|
+
end
|
|
36
|
+
when "failed"
|
|
37
|
+
reason = SecretScrubber.scrub(sandbox.error_message.to_s.lines.first.to_s.strip, project.scrub_values)
|
|
38
|
+
fail_run(run, "The project's sandbox failed to boot#{": #{reason}" if reason.present?}")
|
|
39
|
+
else
|
|
40
|
+
fail_run(run, "The project's sandbox stopped before the run started: run the evaluation again")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def start(run, sandbox, mount_url)
|
|
47
|
+
evaluation = run.evaluation
|
|
48
|
+
if ActionAgent.scenario_evaluation_adapter_resolver&.call(evaluation).respond_to?(:call)
|
|
49
|
+
return fail_run(run, "This install replays scenarios through its own adapter, which cannot reach a project's sandbox")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
evaluation.run!(run: run, sandbox_id: sandbox.session_id, browser: true, mount_url: mount_url)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def fail_run(run, message)
|
|
56
|
+
run.update!(status: :failed, error_message: message, completed_at: Time.current)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Starts help for a project once a boot of the project failed: a Claude
|
|
5
|
+
# Code session in the failed checkout when the owner's Claude Code is
|
|
6
|
+
# connected (see ProjectBootFix.after_boot_failed), and the setup assistant
|
|
7
|
+
# otherwise (see ProjectSetup.after_boot_failed). Takes ids only; a project or
|
|
8
|
+
# sandbox deleted since, or a sandbox that is no longer the project's
|
|
9
|
+
# current one, starts nothing.
|
|
10
|
+
class ProjectSetupJob < ApplicationJob
|
|
11
|
+
queue_as :agents
|
|
12
|
+
|
|
13
|
+
def perform(project_id, sandbox_session_id)
|
|
14
|
+
project = Project.find_by(id: project_id)
|
|
15
|
+
sandbox = SandboxSession.find_by(id: sandbox_session_id)
|
|
16
|
+
return if project.nil? || sandbox.nil? || !sandbox.failed?
|
|
17
|
+
|
|
18
|
+
ProjectBootFix.after_boot_failed(project, sandbox) || ProjectSetup.after_boot_failed(project, sandbox)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -15,6 +15,7 @@ module ActionAgent
|
|
|
15
15
|
# after a failed terminate, nothing would ever know the process (or
|
|
16
16
|
# container) was still there. cleanup_expired! retries these.
|
|
17
17
|
handle = sandbox.cloud_run_job_id.presence || derived_handle(sandbox)
|
|
18
|
+
stop_browser(sandbox)
|
|
18
19
|
return if handle.present? && !released?(sandbox, handle)
|
|
19
20
|
|
|
20
21
|
# Optionally delete old sandbox records
|
|
@@ -52,15 +53,20 @@ module ActionAgent
|
|
|
52
53
|
# indistinguishable from an unreleased one, and a terminate of a
|
|
53
54
|
# sandbox that is gone is a cheap no-op, so the window is what bounds
|
|
54
55
|
# the retries.
|
|
56
|
+
#
|
|
57
|
+
# A failed checkout is among them for a day once its expiry has passed:
|
|
58
|
+
# a boot with keep_on_failure keeps its workspace and databases for
|
|
59
|
+
# SandboxSession#resume_boot! until then. Its status stays failed.
|
|
55
60
|
RETRY_UNRECORDED_FOR = 1.day
|
|
56
61
|
RETRY_UNRECORDED_LIMIT = 100
|
|
57
62
|
|
|
58
63
|
def self.unrecorded_checkouts
|
|
59
64
|
return [] unless SandboxOrchestrator.new.derives_handles?
|
|
60
65
|
|
|
61
|
-
SandboxSession.
|
|
62
|
-
|
|
63
|
-
|
|
66
|
+
checkouts = SandboxSession.by_type("app_runtime").where(cloud_run_job_id: [ nil, "" ])
|
|
67
|
+
expired = checkouts.expired.where(updated_at: RETRY_UNRECORDED_FOR.ago..)
|
|
68
|
+
kept = checkouts.failed.where(expires_at: RETRY_UNRECORDED_FOR.ago..Time.current)
|
|
69
|
+
expired.or(kept).order(updated_at: :desc).limit(RETRY_UNRECORDED_LIMIT).pluck(:id)
|
|
64
70
|
rescue StandardError, LoadError => e
|
|
65
71
|
Rails.logger.warn("[ActionAgent] sandbox backend unavailable to the reaper: #{e.message}")
|
|
66
72
|
[]
|
|
@@ -81,6 +87,17 @@ module ActionAgent
|
|
|
81
87
|
nil
|
|
82
88
|
end
|
|
83
89
|
|
|
90
|
+
# A checkout's browser, which a backend may run apart from the sandbox
|
|
91
|
+
# itself. Best effort: the :local backend's terminate stops it as well.
|
|
92
|
+
def stop_browser(sandbox)
|
|
93
|
+
return unless sandbox.app_runtime?
|
|
94
|
+
|
|
95
|
+
orchestrator = SandboxOrchestrator.new
|
|
96
|
+
orchestrator.stop_browser(sandbox) if orchestrator.supports?(:stop_browser)
|
|
97
|
+
rescue StandardError, LoadError => e
|
|
98
|
+
Rails.logger.warn("[ActionAgent] could not stop the browser of sandbox #{sandbox.session_id}: #{e.message}")
|
|
99
|
+
end
|
|
100
|
+
|
|
84
101
|
# Whether the backend behind +handle+ let go of it.
|
|
85
102
|
#
|
|
86
103
|
# In development a sandbox other than a checkout was only simulated (see
|