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.
Files changed (168) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/action_agent.css +1 -1
  3. data/app/assets/builds/action_agent.js +76 -73
  4. data/app/assets/builds/action_agent_recorder.js +78 -0
  5. data/app/assets/builds/action_agent_replay.js +226 -0
  6. data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
  7. data/app/controllers/action_agent/api/agents_controller.rb +1 -0
  8. data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
  9. data/app/controllers/action_agent/api/base_controller.rb +63 -2
  10. data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
  11. data/app/controllers/action_agent/api/code_sessions_controller.rb +71 -12
  12. data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
  13. data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
  14. data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
  15. data/app/controllers/action_agent/api/evaluations_controller.rb +58 -7
  16. data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
  17. data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
  18. data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
  19. data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
  20. data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
  21. data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
  22. data/app/controllers/action_agent/api/members_controller.rb +36 -0
  23. data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
  24. data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
  25. data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
  26. data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
  27. data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
  28. data/app/controllers/action_agent/api/projects_controller.rb +512 -0
  29. data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
  30. data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
  31. data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
  32. data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
  33. data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
  34. data/app/controllers/action_agent/api/sandboxes_controller.rb +148 -8
  35. data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
  36. data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
  37. data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
  38. data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
  39. data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
  40. data/app/controllers/action_agent/dashboard_controller.rb +17 -6
  41. data/app/controllers/action_agent/session_player_controller.rb +58 -0
  42. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
  43. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +627 -20
  44. data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
  45. data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
  46. data/app/jobs/action_agent/agent_execution_job.rb +9 -39
  47. data/app/jobs/action_agent/agent_resume_job.rb +84 -0
  48. data/app/jobs/action_agent/code_session_job.rb +39 -10
  49. data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
  50. data/app/jobs/action_agent/exploration_job.rb +80 -0
  51. data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
  52. data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
  53. data/app/jobs/action_agent/project_setup_job.rb +21 -0
  54. data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
  55. data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
  56. data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
  57. data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
  58. data/app/models/action_agent/agent.rb +72 -26
  59. data/app/models/action_agent/agent_run.rb +134 -23
  60. data/app/models/action_agent/agent_template.rb +35 -1
  61. data/app/models/action_agent/api_key.rb +14 -0
  62. data/app/models/action_agent/catalog_scenario.rb +75 -0
  63. data/app/models/action_agent/code_session.rb +21 -5
  64. data/app/models/action_agent/draft_pull_request.rb +131 -0
  65. data/app/models/action_agent/evaluation.rb +148 -0
  66. data/app/models/action_agent/evaluation_run.rb +143 -53
  67. data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
  68. data/app/models/action_agent/exploration.rb +847 -0
  69. data/app/models/action_agent/github_installation.rb +159 -0
  70. data/app/models/action_agent/input_request.rb +291 -0
  71. data/app/models/action_agent/model_pricing.rb +214 -34
  72. data/app/models/action_agent/project.rb +789 -0
  73. data/app/models/action_agent/project_secret.rb +339 -0
  74. data/app/models/action_agent/provider_key.rb +207 -4
  75. data/app/models/action_agent/recording_event.rb +145 -0
  76. data/app/models/action_agent/recording_snapshot.rb +7 -7
  77. data/app/models/action_agent/sandbox_session.rb +300 -16
  78. data/app/models/action_agent/scenario_catalog.rb +172 -0
  79. data/app/models/action_agent/scenario_product.rb +57 -0
  80. data/app/models/action_agent/scenario_set.rb +138 -0
  81. data/app/models/action_agent/session_recording.rb +121 -14
  82. data/app/models/action_agent/telemetry_trace.rb +74 -19
  83. data/app/models/concerns/action_agent/ownable.rb +84 -13
  84. data/app/serializers/action_agent/evaluation_serializer.rb +41 -10
  85. data/app/serializers/action_agent/input_request_serializer.rb +47 -0
  86. data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
  87. data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
  88. data/app/services/action_agent/agent_execution_service.rb +450 -72
  89. data/app/services/action_agent/agent_scorecard.rb +50 -21
  90. data/app/services/action_agent/agent_sync.rb +5 -2
  91. data/app/services/action_agent/agent_tool_roster.rb +19 -4
  92. data/app/services/action_agent/agent_toolbox.rb +215 -9
  93. data/app/services/action_agent/browser_live_ticket.rb +78 -0
  94. data/app/services/action_agent/browser_sidecar.rb +213 -0
  95. data/app/services/action_agent/browser_sign_in.rb +163 -0
  96. data/app/services/action_agent/browser_tool_redaction.rb +92 -0
  97. data/app/services/action_agent/claude_code_auth.rb +70 -4
  98. data/app/services/action_agent/codex_session_events.rb +37 -0
  99. data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
  100. data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
  101. data/app/services/action_agent/evaluation_fix.rb +101 -0
  102. data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
  103. data/app/services/action_agent/evaluation_report_import.rb +84 -5
  104. data/app/services/action_agent/evaluation_run_cost.rb +609 -0
  105. data/app/services/action_agent/evaluation_runner_service.rb +29 -10
  106. data/app/services/action_agent/evaluation_standing.rb +152 -0
  107. data/app/services/action_agent/explorer_execution_service.rb +517 -0
  108. data/app/services/action_agent/github_client.rb +405 -64
  109. data/app/services/action_agent/live_updates.rb +31 -0
  110. data/app/services/action_agent/local_sandbox_backend.rb +1263 -102
  111. data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
  112. data/app/services/action_agent/mcp_client.rb +6 -2
  113. data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
  114. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
  115. data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
  116. data/app/services/action_agent/playwright_mcp_client.rb +7 -0
  117. data/app/services/action_agent/project_boot_fix.rb +309 -0
  118. data/app/services/action_agent/project_capabilities.rb +200 -0
  119. data/app/services/action_agent/project_install_pull_request.rb +225 -0
  120. data/app/services/action_agent/project_preflight.rb +178 -0
  121. data/app/services/action_agent/project_secret_discovery.rb +142 -0
  122. data/app/services/action_agent/project_setup.rb +429 -0
  123. data/app/services/action_agent/provider_credentials.rb +102 -0
  124. data/app/services/action_agent/recording_event_ingest.rb +181 -0
  125. data/app/services/action_agent/runtime_tool_roster.rb +52 -0
  126. data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
  127. data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
  128. data/app/services/action_agent/sandbox_browser.rb +299 -0
  129. data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
  130. data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
  131. data/app/services/action_agent/sandbox_mail.rb +103 -0
  132. data/app/services/action_agent/sandbox_manifest.rb +166 -13
  133. data/app/services/action_agent/sandbox_orchestrator.rb +290 -23
  134. data/app/services/action_agent/sandbox_patch.rb +253 -0
  135. data/app/services/action_agent/scenario_catalog_import.rb +255 -0
  136. data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
  137. data/app/services/action_agent/scenario_evaluation_runner.rb +121 -17
  138. data/app/services/action_agent/secret_scrubber.rb +33 -4
  139. data/app/services/action_agent/session_index.rb +270 -0
  140. data/app/services/action_agent/session_recording_service.rb +11 -0
  141. data/app/services/action_agent/session_timeline.rb +505 -0
  142. data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
  143. data/app/views/action_agent/session_player/show.html.erb +19 -0
  144. data/config/routes.rb +153 -2
  145. data/lib/action_agent/claude_login_process.rb +107 -0
  146. data/lib/action_agent/engine.rb +55 -5
  147. data/lib/action_agent/secret_requests.rb +80 -0
  148. data/lib/action_agent/version.rb +1 -1
  149. data/lib/action_agent.rb +513 -22
  150. data/lib/generators/action_agent/install_generator.rb +45 -5
  151. data/lib/generators/action_agent/templates/action_agent.rb.erb +161 -0
  152. data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -0
  153. data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
  154. data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
  155. data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
  156. data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
  157. data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
  158. data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
  159. data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
  160. data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
  161. data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
  162. data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
  163. data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
  164. data/lib/tasks/action_agent.rake +33 -0
  165. data/lib/tasks/browser.rake +22 -0
  166. data/lib/tasks/sandbox.rake +1 -1
  167. data/lib/tasks/scenario_catalogs.rake +71 -0
  168. 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. A retry of a failed run would re-run the generation.
11
- return if run.finished?
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
- finish_unless_cancelled(run) do
23
- run.update!(
24
- output: result[:output],
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
- finish_unless_cancelled(run) do
37
- run.update!(
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
- unless sandbox.reload.ready? && sandbox.active?
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("Claude Code session #{code_session_id} failed: #{message}")
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 "Claude Code exited with status #{status}"
126
- elsif result_event.nil? then "Claude Code ended without reporting a result"
127
- else "Claude Code did not finish"
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.expired.by_type("app_runtime").where(cloud_run_job_id: [ nil, "" ])
62
- .where(updated_at: RETRY_UNRECORDED_FOR.ago..)
63
- .order(updated_at: :desc).limit(RETRY_UNRECORDED_LIMIT).pluck(:id)
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