alysis-code 0.13.0__py3-none-any.whl
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.
- alysis_code/__init__.py +3 -0
- alysis_code/__main__.py +4 -0
- alysis_code/_build_info.py +14 -0
- alysis_code/account_login.py +468 -0
- alysis_code/agent/README.md +35 -0
- alysis_code/agent/__init__.py +11 -0
- alysis_code/agent/acceptance_contract.py +2217 -0
- alysis_code/agent/blast_radius.py +1403 -0
- alysis_code/agent/cache_keepalive.py +227 -0
- alysis_code/agent/completion_certificate.py +366 -0
- alysis_code/agent/completion_gate.py +306 -0
- alysis_code/agent/empty_response_stall.py +403 -0
- alysis_code/agent/errors.py +28 -0
- alysis_code/agent/llm_calls.py +475 -0
- alysis_code/agent/mutation_classification.py +227 -0
- alysis_code/agent/prompt_context.py +2508 -0
- alysis_code/agent/read_ledger.py +253 -0
- alysis_code/agent/regression_baseline.py +642 -0
- alysis_code/agent/reproduction_first.py +610 -0
- alysis_code/agent/sensitive_output.py +629 -0
- alysis_code/agent/session.py +3218 -0
- alysis_code/agent/steering.py +191 -0
- alysis_code/agent/subagent_execution.py +5177 -0
- alysis_code/agent/subagent_workspace.py +666 -0
- alysis_code/agent/tools_assembly.py +4728 -0
- alysis_code/agent/turn/__init__.py +101 -0
- alysis_code/agent/turn/core.py +8483 -0
- alysis_code/agent/turn/events.py +113 -0
- alysis_code/agent/turn/exploration.py +590 -0
- alysis_code/agent/turn/interventions.py +65 -0
- alysis_code/agent/turn/read_cache.py +420 -0
- alysis_code/agent/turn/snapshot.py +179 -0
- alysis_code/agent/turn_contract.py +661 -0
- alysis_code/agent/turn_path.py +129 -0
- alysis_code/agent/verification.py +2885 -0
- alysis_code/agent/verification_commands.py +512 -0
- alysis_code/agent/verification_evidence.py +738 -0
- alysis_code/agent_loop.py +693 -0
- alysis_code/agent_runtimes/__init__.py +51 -0
- alysis_code/agent_runtimes/base.py +114 -0
- alysis_code/agent_runtimes/builtins.py +129 -0
- alysis_code/agent_runtimes/codex_cli.py +664 -0
- alysis_code/agent_runtimes/host.py +263 -0
- alysis_code/agent_runtimes/registry.py +64 -0
- alysis_code/agent_runtimes/service.py +150 -0
- alysis_code/agentbox_client.py +416 -0
- alysis_code/agentbox_integration.py +310 -0
- alysis_code/alysis_cloud.py +152 -0
- alysis_code/approval_scope.py +276 -0
- alysis_code/assets/README.md +33 -0
- alysis_code/assets/__init__.py +126 -0
- alysis_code/assets/asset_read_core.py +281 -0
- alysis_code/assets/budget_allocator.py +456 -0
- alysis_code/assets/comprehender.py +759 -0
- alysis_code/assets/index.py +654 -0
- alysis_code/assets/ingestion.py +275 -0
- alysis_code/assets/legacy_migration.py +413 -0
- alysis_code/assets/models.py +263 -0
- alysis_code/assets/ocr.py +239 -0
- alysis_code/assets/owl/ascii/f-000.txt +13 -0
- alysis_code/assets/owl/ascii/f-001.txt +13 -0
- alysis_code/assets/owl/ascii/f-002.txt +13 -0
- alysis_code/assets/owl/ascii/f-003.txt +13 -0
- alysis_code/assets/owl/ascii/f-004.txt +13 -0
- alysis_code/assets/owl/ascii/f-005.txt +13 -0
- alysis_code/assets/owl/ascii/f-006.txt +13 -0
- alysis_code/assets/owl/ascii/f-007.txt +13 -0
- alysis_code/assets/owl/ascii/f-008.txt +13 -0
- alysis_code/assets/owl/ascii/f-009.txt +13 -0
- alysis_code/assets/owl/ascii/f-010.txt +13 -0
- alysis_code/assets/owl/ascii/f-011.txt +13 -0
- alysis_code/assets/owl/ascii/f-012.txt +13 -0
- alysis_code/assets/owl/ascii/f-013.txt +13 -0
- alysis_code/assets/owl/ascii/f-014.txt +13 -0
- alysis_code/assets/owl/ascii/f-015.txt +13 -0
- alysis_code/assets/owl/ascii/f-016.txt +13 -0
- alysis_code/assets/owl/ascii/f-017.txt +13 -0
- alysis_code/assets/owl/ascii/f-018.txt +13 -0
- alysis_code/assets/owl/ascii/f-019.txt +13 -0
- alysis_code/assets/owl/ascii/f-020.txt +13 -0
- alysis_code/assets/owl/index.html +98 -0
- alysis_code/assets/owl/show-owl.sh +761 -0
- alysis_code/assets/paths.py +49 -0
- alysis_code/assets/plan_binding.py +326 -0
- alysis_code/assets/planner_context.py +466 -0
- alysis_code/assets/planner_tools.py +184 -0
- alysis_code/assets/prompts.py +101 -0
- alysis_code/assets/replanner_context.py +239 -0
- alysis_code/assets/surface.py +521 -0
- alysis_code/assets/untrusted_content.py +48 -0
- alysis_code/assets/usage_logger.py +94 -0
- alysis_code/assets/worker_mirror.py +428 -0
- alysis_code/assets/worker_section.py +303 -0
- alysis_code/assets/worker_tools.py +468 -0
- alysis_code/atomic_io.py +83 -0
- alysis_code/auth_diagnostics.py +272 -0
- alysis_code/background_runner.py +366 -0
- alysis_code/branding.py +270 -0
- alysis_code/budget_policy.py +390 -0
- alysis_code/build_identity.py +465 -0
- alysis_code/builtin_hooks/__init__.py +7 -0
- alysis_code/builtin_hooks/notify_done_windows.py +65 -0
- alysis_code/bwrap_etc.py +76 -0
- alysis_code/cancellation.py +41 -0
- alysis_code/capabilities.py +137 -0
- alysis_code/chatgpt_codex_static_provider.py +133 -0
- alysis_code/cli.py +51 -0
- alysis_code/cli_impl/__init__.py +1 -0
- alysis_code/cli_impl/assets_cli.py +537 -0
- alysis_code/cli_impl/assets_modal.py +412 -0
- alysis_code/cli_impl/chat/__init__.py +156 -0
- alysis_code/cli_impl/chat/commands.py +2616 -0
- alysis_code/cli_impl/chat/loop.py +4508 -0
- alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
- alysis_code/cli_impl/chat/rendering.py +444 -0
- alysis_code/cli_impl/chat/state.py +124 -0
- alysis_code/cli_impl/chat_resume.py +830 -0
- alysis_code/cli_impl/chat_slash_completer.py +258 -0
- alysis_code/cli_impl/commands/__init__.py +11 -0
- alysis_code/cli_impl/commands/_shared.py +89 -0
- alysis_code/cli_impl/commands/auth.py +623 -0
- alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
- alysis_code/cli_impl/commands/chat_state.py +158 -0
- alysis_code/cli_impl/commands/chat_status.py +1248 -0
- alysis_code/cli_impl/commands/chat_terminal.py +942 -0
- alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
- alysis_code/cli_impl/commands/cli_common.py +1223 -0
- alysis_code/cli_impl/commands/cli_surface.py +77 -0
- alysis_code/cli_impl/commands/config.py +131 -0
- alysis_code/cli_impl/commands/conventions.py +85 -0
- alysis_code/cli_impl/commands/execution_helpers.py +350 -0
- alysis_code/cli_impl/commands/extensions.py +401 -0
- alysis_code/cli_impl/commands/forge.py +1282 -0
- alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
- alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
- alysis_code/cli_impl/commands/hooks.py +737 -0
- alysis_code/cli_impl/commands/ide_bridge.py +31 -0
- alysis_code/cli_impl/commands/mcp.py +700 -0
- alysis_code/cli_impl/commands/profile.py +453 -0
- alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
- alysis_code/cli_impl/commands/report.py +88 -0
- alysis_code/cli_impl/commands/root.py +1118 -0
- alysis_code/cli_impl/commands/sandbox.py +184 -0
- alysis_code/cli_impl/commands/server.py +54 -0
- alysis_code/cli_impl/commands/sessions.py +252 -0
- alysis_code/cli_impl/commands/skills.py +404 -0
- alysis_code/cli_impl/commands/startup.py +946 -0
- alysis_code/cli_impl/commands/tools.py +335 -0
- alysis_code/cli_impl/commands/update.py +364 -0
- alysis_code/cli_impl/commands/welcome.py +972 -0
- alysis_code/cli_impl/config_menu.py +3882 -0
- alysis_code/cli_impl/forge.py +4509 -0
- alysis_code/cli_impl/forge_recovery.py +485 -0
- alysis_code/cli_impl/setup_wizard.py +2409 -0
- alysis_code/cli_impl/tui/__init__.py +58 -0
- alysis_code/cli_impl/tui/app.py +4551 -0
- alysis_code/cli_impl/tui/config.py +32 -0
- alysis_code/cli_impl/tui/config_flow.py +2754 -0
- alysis_code/cli_impl/tui/config_overlay.py +566 -0
- alysis_code/cli_impl/tui/content.py +78 -0
- alysis_code/cli_impl/tui/footer.py +218 -0
- alysis_code/cli_impl/tui/forge_status.py +136 -0
- alysis_code/cli_impl/tui/markdown.py +244 -0
- alysis_code/cli_impl/tui/owl.py +109 -0
- alysis_code/cli_impl/tui/plan_meta.py +477 -0
- alysis_code/cli_impl/tui/setup_app.py +519 -0
- alysis_code/cli_impl/tui/setup_flow.py +1622 -0
- alysis_code/cli_impl/tui/state.py +101 -0
- alysis_code/cli_impl/tui/subagent_identity.py +66 -0
- alysis_code/cli_impl/tui/subagent_panel.py +186 -0
- alysis_code/cli_impl/tui/surface.py +796 -0
- alysis_code/cli_impl/tui/transcript.py +514 -0
- alysis_code/cli_impl/tui/update_prompt.py +79 -0
- alysis_code/cli_impl/tui/workspace_guard.py +384 -0
- alysis_code/clipboard.py +172 -0
- alysis_code/code_review.py +1211 -0
- alysis_code/compaction/__init__.py +28 -0
- alysis_code/compaction/conversation_compactor.py +2932 -0
- alysis_code/compaction/importance.py +177 -0
- alysis_code/compaction/settings.py +297 -0
- alysis_code/compaction/tool_output_offload.py +447 -0
- alysis_code/config.py +3509 -0
- alysis_code/conflict_auto_resolver.py +895 -0
- alysis_code/context/__init__.py +1 -0
- alysis_code/context/tool_schema_budgeter.py +220 -0
- alysis_code/crash_diagnostics.py +282 -0
- alysis_code/custom_tools/README.md +34 -0
- alysis_code/custom_tools/__init__.py +43 -0
- alysis_code/custom_tools/discovery.py +903 -0
- alysis_code/custom_tools/runtime.py +1516 -0
- alysis_code/custom_tools/session.py +227 -0
- alysis_code/custom_tools/trust.py +232 -0
- alysis_code/diff_paths.py +113 -0
- alysis_code/direction_change.py +293 -0
- alysis_code/dispatch_timing.py +306 -0
- alysis_code/durable_service_manager.py +1236 -0
- alysis_code/edit_discipline.py +659 -0
- alysis_code/error_text.py +73 -0
- alysis_code/execution_budget.py +411 -0
- alysis_code/execution_context.py +915 -0
- alysis_code/execution_deadline.py +1065 -0
- alysis_code/execution_shared.py +1904 -0
- alysis_code/extensions/README.md +30 -0
- alysis_code/extensions/__init__.py +93 -0
- alysis_code/extensions/activation.py +138 -0
- alysis_code/extensions/install.py +1436 -0
- alysis_code/extensions/manifest.py +487 -0
- alysis_code/extensions/models.py +74 -0
- alysis_code/extensions/paths.py +56 -0
- alysis_code/extensions/registry.json +4 -0
- alysis_code/extensions/registry.py +52 -0
- alysis_code/extensions/state.py +83 -0
- alysis_code/extensions/workspace_trust.py +101 -0
- alysis_code/failed_task_evidence.py +369 -0
- alysis_code/failure_category.py +315 -0
- alysis_code/feedback_report.py +1647 -0
- alysis_code/file_classification.py +485 -0
- alysis_code/forge.py +2064 -0
- alysis_code/forge_completion.py +362 -0
- alysis_code/forge_events.py +475 -0
- alysis_code/frontmatter_utils.py +95 -0
- alysis_code/git_evidence.py +1181 -0
- alysis_code/git_ops.py +560 -0
- alysis_code/git_safe.py +62 -0
- alysis_code/git_worktrees.py +190 -0
- alysis_code/hooks/README.md +33 -0
- alysis_code/hooks/__init__.py +67 -0
- alysis_code/hooks/audit.py +171 -0
- alysis_code/hooks/config.py +225 -0
- alysis_code/hooks/dispatcher.py +1110 -0
- alysis_code/hooks/models.py +447 -0
- alysis_code/hooks/trust.py +202 -0
- alysis_code/host_actions.py +543 -0
- alysis_code/host_browser.py +103 -0
- alysis_code/ide/__init__.py +5 -0
- alysis_code/ide/activity_events.py +399 -0
- alysis_code/ide/approvals.py +337 -0
- alysis_code/ide/artifacts.py +153 -0
- alysis_code/ide/browser_egress_proxy.py +1076 -0
- alysis_code/ide/cdp_websocket_transport.py +1192 -0
- alysis_code/ide/change_ledger.py +1721 -0
- alysis_code/ide/context_blocks.py +979 -0
- alysis_code/ide/event_stream.py +531 -0
- alysis_code/ide/forge_protocol.py +3112 -0
- alysis_code/ide/forge_request_ledger.py +737 -0
- alysis_code/ide/health.py +965 -0
- alysis_code/ide/managed_browser.py +2251 -0
- alysis_code/ide/management_protocol.py +3414 -0
- alysis_code/ide/mcp_oauth_coordinator.py +744 -0
- alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
- alysis_code/ide/prompt_queue.py +1070 -0
- alysis_code/ide/protocol.py +191 -0
- alysis_code/ide/resumable_swarm.py +1543 -0
- alysis_code/ide/session_search.py +295 -0
- alysis_code/ide/stdio_bridge.py +9935 -0
- alysis_code/ide/structured_state.py +1579 -0
- alysis_code/ide/swarm_protocol.py +816 -0
- alysis_code/integration_gate.py +506 -0
- alysis_code/interactive_input_guard.py +39 -0
- alysis_code/interactive_plan_mode.py +26 -0
- alysis_code/internal_artifacts.py +179 -0
- alysis_code/knowledge_base.py +1409 -0
- alysis_code/knowledge_capture.py +1190 -0
- alysis_code/knowledge_librarian.py +605 -0
- alysis_code/language_policy.py +34 -0
- alysis_code/litellm_static_provider.py +535 -0
- alysis_code/llm/__init__.py +1 -0
- alysis_code/llm/anthropic_messages.py +2288 -0
- alysis_code/llm/base.py +71 -0
- alysis_code/llm/cache_capabilities.py +985 -0
- alysis_code/llm/cache_control_blocks.py +244 -0
- alysis_code/llm/cache_policy.py +388 -0
- alysis_code/llm/factory.py +373 -0
- alysis_code/llm/gemini_generate_content.py +2652 -0
- alysis_code/llm/gemini_interactions.py +739 -0
- alysis_code/llm/metadata.py +450 -0
- alysis_code/llm/openai_compat.py +2947 -0
- alysis_code/llm/openai_responses.py +2604 -0
- alysis_code/llm/protocols.py +609 -0
- alysis_code/llm/provider_limits.py +525 -0
- alysis_code/llm/request_plan.py +389 -0
- alysis_code/llm/request_shape.py +238 -0
- alysis_code/llm/streaming.py +108 -0
- alysis_code/llm/temperature_compat.py +78 -0
- alysis_code/llm/types.py +195 -0
- alysis_code/llm/usage_normalization.py +222 -0
- alysis_code/llm_error_display.py +315 -0
- alysis_code/logging_redaction.py +326 -0
- alysis_code/managed_host_deadline.py +196 -0
- alysis_code/mcp/README.md +33 -0
- alysis_code/mcp/__init__.py +24 -0
- alysis_code/mcp/client.py +1137 -0
- alysis_code/mcp/config.py +597 -0
- alysis_code/mcp/errors.py +113 -0
- alysis_code/mcp/forge_scope.py +154 -0
- alysis_code/mcp/jsonrpc.py +214 -0
- alysis_code/mcp/manager.py +2308 -0
- alysis_code/mcp/models.py +666 -0
- alysis_code/mcp/oauth.py +972 -0
- alysis_code/mcp/oauth_runtime.py +310 -0
- alysis_code/mcp/oauth_store.py +276 -0
- alysis_code/mcp/prompts.py +329 -0
- alysis_code/mcp/resources.py +295 -0
- alysis_code/mcp/roots.py +106 -0
- alysis_code/mcp/server_requests.py +75 -0
- alysis_code/mcp/token_store.py +859 -0
- alysis_code/mcp/transport_http.py +1338 -0
- alysis_code/mcp/transport_stdio.py +1267 -0
- alysis_code/mcp/untrusted_content.py +119 -0
- alysis_code/merge_conflict_reviewer.py +729 -0
- alysis_code/model_catalog/__init__.py +1 -0
- alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
- alysis_code/model_metadata_policy.py +223 -0
- alysis_code/model_metadata_utils.py +103 -0
- alysis_code/model_registry.py +1420 -0
- alysis_code/model_router.py +147 -0
- alysis_code/permission_policy.py +1016 -0
- alysis_code/personas.py +451 -0
- alysis_code/pipeline_facts.py +233 -0
- alysis_code/plan_assistant.py +4763 -0
- alysis_code/plan_mode.py +393 -0
- alysis_code/plan_reconciliation.py +1228 -0
- alysis_code/plan_repair.py +652 -0
- alysis_code/plan_validation.py +1099 -0
- alysis_code/planning_constraints.py +904 -0
- alysis_code/policy.py +95 -0
- alysis_code/preview_server.py +457 -0
- alysis_code/process_reaping.py +566 -0
- alysis_code/profile_presets.py +1834 -0
- alysis_code/profiles.py +666 -0
- alysis_code/provider_auth/__init__.py +29 -0
- alysis_code/provider_auth/base.py +99 -0
- alysis_code/provider_auth/openai_codex.py +951 -0
- alysis_code/provider_auth/registry.py +76 -0
- alysis_code/provider_auth/store.py +125 -0
- alysis_code/provider_diagnostics.py +1209 -0
- alysis_code/provider_model_catalog.py +685 -0
- alysis_code/provider_telemetry.py +1699 -0
- alysis_code/provider_url.py +75 -0
- alysis_code/reasoning_contracts.py +911 -0
- alysis_code/remote_sync.py +350 -0
- alysis_code/replanning.py +1195 -0
- alysis_code/repo_scan.py +1152 -0
- alysis_code/request_estimation.py +296 -0
- alysis_code/review_gate.py +617 -0
- alysis_code/run_lock.py +1141 -0
- alysis_code/run_outcome.py +58 -0
- alysis_code/run_provenance.py +774 -0
- alysis_code/run_state.py +445 -0
- alysis_code/runtime_artifacts.py +116 -0
- alysis_code/runtime_context_features.py +78 -0
- alysis_code/runtime_kind.py +52 -0
- alysis_code/safety/__init__.py +11 -0
- alysis_code/safety/mcp_sanitize.py +29 -0
- alysis_code/safety/safe_http.py +297 -0
- alysis_code/safety/subagent_report.py +184 -0
- alysis_code/sandbox_doctor.py +682 -0
- alysis_code/sandbox_runner.py +1025 -0
- alysis_code/sandbox_settings.py +423 -0
- alysis_code/serialized_paths.py +355 -0
- alysis_code/server/__init__.py +3 -0
- alysis_code/server/app.py +367 -0
- alysis_code/server/auth.py +34 -0
- alysis_code/server/job_config.py +30 -0
- alysis_code/server/settings.py +215 -0
- alysis_code/server/store.py +193 -0
- alysis_code/server/worker_runner.py +657 -0
- alysis_code/service_persistence.py +355 -0
- alysis_code/session_artifacts.py +108 -0
- alysis_code/session_metrics.py +331 -0
- alysis_code/session_store.py +624 -0
- alysis_code/skills/README.md +34 -0
- alysis_code/skills/__init__.py +104 -0
- alysis_code/skills/conventions.py +84 -0
- alysis_code/skills/discovery.py +176 -0
- alysis_code/skills/eval_models.py +232 -0
- alysis_code/skills/eval_runner.py +372 -0
- alysis_code/skills/evals.py +1344 -0
- alysis_code/skills/install.py +293 -0
- alysis_code/skills/loader.py +118 -0
- alysis_code/skills/matching.py +103 -0
- alysis_code/skills/models.py +71 -0
- alysis_code/skills/paths.py +56 -0
- alysis_code/skills/prompting.py +500 -0
- alysis_code/skills/scaffold.py +142 -0
- alysis_code/skills/state.py +441 -0
- alysis_code/skills/transactions.py +125 -0
- alysis_code/skills/validation.py +304 -0
- alysis_code/step_budget.py +238 -0
- alysis_code/subagent_labels.py +49 -0
- alysis_code/subagents.py +1072 -0
- alysis_code/surface/__init__.py +80 -0
- alysis_code/surface/base.py +305 -0
- alysis_code/surface/console.py +387 -0
- alysis_code/surface/events.py +372 -0
- alysis_code/surface/hidden_surface.py +529 -0
- alysis_code/surface/noop_surface.py +219 -0
- alysis_code/surface/rich_surface.py +1555 -0
- alysis_code/surface/styles.py +67 -0
- alysis_code/surface/theme.py +455 -0
- alysis_code/surface/types.py +100 -0
- alysis_code/swarm_backend.py +926 -0
- alysis_code/swarm_orchestrator.py +4020 -0
- alysis_code/swarm_scheduler.py +441 -0
- alysis_code/swarm_trace.py +429 -0
- alysis_code/swarm_worker.py +2119 -0
- alysis_code/swarm_write_guard.py +348 -0
- alysis_code/task_dependencies.py +170 -0
- alysis_code/task_readiness.py +992 -0
- alysis_code/task_scope.py +2148 -0
- alysis_code/terminal_manager.py +762 -0
- alysis_code/terminal_ownership.py +460 -0
- alysis_code/text_normalization.py +30 -0
- alysis_code/token_budget.py +97 -0
- alysis_code/tools/README.md +34 -0
- alysis_code/tools/__init__.py +1 -0
- alysis_code/tools/artifacts.py +127 -0
- alysis_code/tools/availability.py +188 -0
- alysis_code/tools/fs.py +1456 -0
- alysis_code/tools/git.py +461 -0
- alysis_code/tools/history.py +229 -0
- alysis_code/tools/http_timeout.py +78 -0
- alysis_code/tools/image_generation.py +552 -0
- alysis_code/tools/registry.py +2936 -0
- alysis_code/tools/repo_map.py +476 -0
- alysis_code/tools/search.py +563 -0
- alysis_code/tools/shell.py +135 -0
- alysis_code/tools/symbols.py +1350 -0
- alysis_code/tools/test_discovery.py +643 -0
- alysis_code/tools/web.py +482 -0
- alysis_code/tools/web_search.py +2012 -0
- alysis_code/tools/web_search_dashscope.py +557 -0
- alysis_code/tools/web_search_ddgs.py +221 -0
- alysis_code/tools/web_search_provider_adapters.py +1429 -0
- alysis_code/tools/web_search_tavily.py +194 -0
- alysis_code/updates.py +933 -0
- alysis_code/usage_tracker.py +1990 -0
- alysis_code/verification_command_analysis.py +1004 -0
- alysis_code/verification_contract.py +574 -0
- alysis_code/verification_failure_summary.py +273 -0
- alysis_code/verification_repair.py +385 -0
- alysis_code/verify_gate.py +3129 -0
- alysis_code/web_research.py +1872 -0
- alysis_code/web_search_adapters.py +66 -0
- alysis_code/web_search_policy.py +27 -0
- alysis_code/workspace_binding.py +389 -0
- alysis_code/workspace_binding_ui.py +408 -0
- alysis_code/workspace_context.py +273 -0
- alysis_code/workspace_isolation.py +138 -0
- alysis_code/workspace_provisioning.py +455 -0
- alysis_code-0.13.0.dist-info/METADATA +507 -0
- alysis_code-0.13.0.dist-info/RECORD +458 -0
- alysis_code-0.13.0.dist-info/WHEEL +4 -0
- alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
- alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
- alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,1904 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from hashlib import sha256
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .agent.prompt_context import _build_user_message
|
|
14
|
+
from .branding import env_get
|
|
15
|
+
from .compaction.settings import resolve_compaction_settings
|
|
16
|
+
from .config import AppConfig
|
|
17
|
+
from .execution_budget import (
|
|
18
|
+
DEFAULT_EXECUTION_HEADROOM_RESERVE_TOKENS,
|
|
19
|
+
DEFAULT_EXECUTION_RESPONSE_RESERVE_TOKENS,
|
|
20
|
+
DEFAULT_EXECUTION_SAFETY_MARGIN_TOKENS,
|
|
21
|
+
DEFAULT_MINIMUM_EXECUTION_INSTRUCTION_BUDGET_TOKENS,
|
|
22
|
+
ExecutionPromptBudget,
|
|
23
|
+
compute_execution_prompt_budget_inputs,
|
|
24
|
+
)
|
|
25
|
+
from .execution_context import (
|
|
26
|
+
build_task_context_pack,
|
|
27
|
+
build_task_context_pack_result,
|
|
28
|
+
select_relevant_assets,
|
|
29
|
+
select_relevant_image_paths,
|
|
30
|
+
)
|
|
31
|
+
from .forge import RunPaths, ensure_execution_dirs
|
|
32
|
+
from .git_ops import GitOpsError, changed_files_since, diff_text_since
|
|
33
|
+
from .git_safe import build_git_process_env
|
|
34
|
+
from .knowledge_librarian import MaterializedKnowledgeSelection, prepare_relevant_knowledge
|
|
35
|
+
from .model_registry import ModelRegistry
|
|
36
|
+
from .request_estimation import estimate_request_tokens
|
|
37
|
+
from .runtime_artifacts import (
|
|
38
|
+
ROOT_RUNTIME_ARTIFACT_DIR_NAMES,
|
|
39
|
+
RUNTIME_ARTIFACT_DIR_NAMES,
|
|
40
|
+
is_runtime_artifact_path,
|
|
41
|
+
)
|
|
42
|
+
from .serialized_paths import safe_serialized_path
|
|
43
|
+
from .session_store import resolve_sessions_dir, sanitize_session_id
|
|
44
|
+
from .step_budget import (
|
|
45
|
+
AUTONOMOUS_STEP_BUDGET_POLICY,
|
|
46
|
+
DEFAULT_TASK_MAX_STEPS,
|
|
47
|
+
StepBudgetRequest,
|
|
48
|
+
StepBudgetResolution,
|
|
49
|
+
resolve_step_budget,
|
|
50
|
+
)
|
|
51
|
+
from .task_scope import is_non_material_untracked_path
|
|
52
|
+
from .token_budget import compute_input_budget
|
|
53
|
+
|
|
54
|
+
_SNAPSHOT_HASH_MAX_BYTES = 1 * 1024 * 1024
|
|
55
|
+
# Legacy env gate. The new asset system supersedes this and is enabled by
|
|
56
|
+
# default via cfg.assets.worker.inline_images. The env var continues to control
|
|
57
|
+
# the legacy plan["assets"][]-based image path injection until those code paths
|
|
58
|
+
# are removed in a future major version.
|
|
59
|
+
_TASK_IMAGES_ENV = "ALYSIS_TASK_IMAGES"
|
|
60
|
+
_DEFAULT_SNAPSHOT_EXCLUDES = RUNTIME_ARTIFACT_DIR_NAMES
|
|
61
|
+
_READ_ONLY_GIT_TIMEOUT_S = 5.0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class PreparedTaskExecutionInstruction:
|
|
66
|
+
instruction: str
|
|
67
|
+
artifact_text: str
|
|
68
|
+
budget: ExecutionPromptBudget
|
|
69
|
+
final_instruction_token_estimate: int
|
|
70
|
+
truncated: bool
|
|
71
|
+
truncation_strategy: str
|
|
72
|
+
subagents_enabled: bool
|
|
73
|
+
image_paths: tuple[str, ...]
|
|
74
|
+
startup_headroom: StartupHeadroomTelemetry | None = None
|
|
75
|
+
|
|
76
|
+
def to_budget_artifact_payload(self) -> dict[str, Any]:
|
|
77
|
+
payload = dict(self.budget.to_payload())
|
|
78
|
+
payload["final_instruction_token_estimate"] = self.final_instruction_token_estimate
|
|
79
|
+
payload["truncated"] = self.truncated
|
|
80
|
+
payload["truncation_strategy"] = self.truncation_strategy
|
|
81
|
+
if self.startup_headroom is not None:
|
|
82
|
+
payload.update(self.startup_headroom.to_payload())
|
|
83
|
+
return payload
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class StartupHeadroomTelemetry:
|
|
88
|
+
initial_request_token_estimate: int
|
|
89
|
+
initial_request_token_estimate_before_adjustment: int | None
|
|
90
|
+
compaction_budget_tokens: int
|
|
91
|
+
compaction_trigger_tokens: int
|
|
92
|
+
startup_target_tokens: int
|
|
93
|
+
startup_headroom_tokens: int
|
|
94
|
+
startup_headroom_adjustment_applied: bool
|
|
95
|
+
startup_headroom_adjustment_reason: str | None
|
|
96
|
+
|
|
97
|
+
def to_payload(self) -> dict[str, Any]:
|
|
98
|
+
payload: dict[str, Any] = {
|
|
99
|
+
"initial_request_token_estimate": self.initial_request_token_estimate,
|
|
100
|
+
"compaction_budget_tokens": self.compaction_budget_tokens,
|
|
101
|
+
"compaction_trigger_tokens": self.compaction_trigger_tokens,
|
|
102
|
+
"startup_target_tokens": self.startup_target_tokens,
|
|
103
|
+
"startup_headroom_tokens": self.startup_headroom_tokens,
|
|
104
|
+
"startup_headroom_adjustment_applied": self.startup_headroom_adjustment_applied,
|
|
105
|
+
"startup_headroom_adjustment_reason": self.startup_headroom_adjustment_reason,
|
|
106
|
+
}
|
|
107
|
+
if self.initial_request_token_estimate_before_adjustment is not None:
|
|
108
|
+
payload["initial_request_token_estimate_before_adjustment"] = (
|
|
109
|
+
self.initial_request_token_estimate_before_adjustment
|
|
110
|
+
)
|
|
111
|
+
return payload
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class PreparedExecutionKnowledge:
|
|
116
|
+
materialized: MaterializedKnowledgeSelection
|
|
117
|
+
prompt_section: str
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def manifest_path(self) -> Path:
|
|
121
|
+
return self.materialized.manifest_path
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def selected_dir(self) -> Path:
|
|
125
|
+
return self.materialized.selected_dir
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _task_execution_text(task: dict[str, Any] | None) -> str:
|
|
129
|
+
if not isinstance(task, dict):
|
|
130
|
+
return ""
|
|
131
|
+
parts: list[str] = []
|
|
132
|
+
for key in ("title", "description"):
|
|
133
|
+
value = task.get(key)
|
|
134
|
+
if value:
|
|
135
|
+
parts.append(str(value))
|
|
136
|
+
for key in ("acceptance_criteria", "estimated_files", "write_scope"):
|
|
137
|
+
value = task.get(key)
|
|
138
|
+
if isinstance(value, list):
|
|
139
|
+
parts.extend(str(item) for item in value if str(item).strip())
|
|
140
|
+
return "\n".join(parts)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _scope_patterns_are_test_only(patterns: list[str] | None, task: dict[str, Any] | None) -> bool:
|
|
144
|
+
raw_patterns = list(patterns or [])
|
|
145
|
+
if not raw_patterns and isinstance(task, dict):
|
|
146
|
+
raw_patterns = [str(item) for item in task.get("write_scope") or []]
|
|
147
|
+
normalized = [pattern.strip().replace("\\", "/").lower() for pattern in raw_patterns]
|
|
148
|
+
normalized = [pattern for pattern in normalized if pattern]
|
|
149
|
+
if not normalized:
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
def _is_test_pattern(pattern: str) -> bool:
|
|
153
|
+
leaf = pattern.rstrip("/").rsplit("/", 1)[-1]
|
|
154
|
+
return (
|
|
155
|
+
pattern.startswith("tests/")
|
|
156
|
+
or pattern.startswith("test/")
|
|
157
|
+
or "/tests/" in pattern
|
|
158
|
+
or "/test/" in pattern
|
|
159
|
+
or leaf.startswith("test_")
|
|
160
|
+
or leaf.endswith("_test.py")
|
|
161
|
+
or leaf.endswith(".test.js")
|
|
162
|
+
or leaf.endswith(".spec.js")
|
|
163
|
+
or leaf.endswith(".test.ts")
|
|
164
|
+
or leaf.endswith(".spec.ts")
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
return all(_is_test_pattern(pattern) for pattern in normalized)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _scope_patterns_are_docs_only(patterns: list[str] | None, task: dict[str, Any] | None) -> bool:
|
|
171
|
+
raw_patterns = list(patterns or [])
|
|
172
|
+
if not raw_patterns and isinstance(task, dict):
|
|
173
|
+
raw_patterns = [str(item) for item in task.get("write_scope") or []]
|
|
174
|
+
normalized = [pattern.strip().replace("\\", "/").lower() for pattern in raw_patterns]
|
|
175
|
+
normalized = [pattern for pattern in normalized if pattern]
|
|
176
|
+
if not normalized:
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
def _is_docs_pattern(pattern: str) -> bool:
|
|
180
|
+
leaf = pattern.rstrip("/").rsplit("/", 1)[-1]
|
|
181
|
+
return (
|
|
182
|
+
pattern in {"readme", "readme.md"}
|
|
183
|
+
or pattern.startswith("docs/")
|
|
184
|
+
or pattern.startswith("doc/")
|
|
185
|
+
or leaf.endswith(".md")
|
|
186
|
+
or leaf.endswith(".rst")
|
|
187
|
+
or leaf.endswith(".txt")
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
return all(_is_docs_pattern(pattern) for pattern in normalized)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _authoritative_verification_context_section(
|
|
194
|
+
commands: list[str] | None,
|
|
195
|
+
*,
|
|
196
|
+
task: dict[str, Any] | None = None,
|
|
197
|
+
allow_write_globs: list[str] | None = None,
|
|
198
|
+
) -> str:
|
|
199
|
+
cleaned = [str(command).strip() for command in commands or [] if str(command).strip()]
|
|
200
|
+
if not cleaned:
|
|
201
|
+
return ""
|
|
202
|
+
command_text = "\n".join(cleaned).casefold()
|
|
203
|
+
task_text = _task_execution_text(task).casefold()
|
|
204
|
+
test_only_scope = _scope_patterns_are_test_only(allow_write_globs, task)
|
|
205
|
+
docs_only_scope = _scope_patterns_are_docs_only(allow_write_globs, task)
|
|
206
|
+
doctest_related = "doctest" in command_text or "doctest" in task_text
|
|
207
|
+
packaging_related = any(
|
|
208
|
+
marker in task_text
|
|
209
|
+
for marker in (
|
|
210
|
+
"console script",
|
|
211
|
+
"entry point",
|
|
212
|
+
"entrypoint",
|
|
213
|
+
"installable",
|
|
214
|
+
"pip install",
|
|
215
|
+
"pyproject",
|
|
216
|
+
"setuptools",
|
|
217
|
+
"wheel",
|
|
218
|
+
"packaging",
|
|
219
|
+
)
|
|
220
|
+
) or any(marker in command_text for marker in ("pip ", "python -m build", "build ", "wheel"))
|
|
221
|
+
lines = [
|
|
222
|
+
"## Authoritative Verification",
|
|
223
|
+
"",
|
|
224
|
+
"The managed task has locked verification commands. Before the final response,",
|
|
225
|
+
"run `verify_run` with no arguments or run every command exactly, and make sure",
|
|
226
|
+
"every listed command passes. Do not claim success from an alternate command",
|
|
227
|
+
"while any listed command is still failing; fix the task or report a concrete",
|
|
228
|
+
"infrastructure blocker.",
|
|
229
|
+
"",
|
|
230
|
+
"If a verification failure points toward changing files outside the write scope,",
|
|
231
|
+
"first look for an in-scope repair that makes the allowed change self-contained",
|
|
232
|
+
"and executable. Report a blocker only after ruling out an in-scope fix.",
|
|
233
|
+
"",
|
|
234
|
+
"Do not leave temporary command-output files in the repository root. Use `/tmp`",
|
|
235
|
+
"or the command output shown by the tool, and remove scratch logs before finalizing.",
|
|
236
|
+
"",
|
|
237
|
+
]
|
|
238
|
+
if test_only_scope:
|
|
239
|
+
lines.extend(
|
|
240
|
+
[
|
|
241
|
+
"For test-only tasks, the tests are part of the deliverable. If verification",
|
|
242
|
+
"fails because the generated tests or test harness are wrong, fix the test",
|
|
243
|
+
"code within scope before reporting a blocker. Prefer pytest capture fixtures",
|
|
244
|
+
"such as `capsys`/`monkeypatch` over patching builtins or global stdout.",
|
|
245
|
+
"",
|
|
246
|
+
]
|
|
247
|
+
)
|
|
248
|
+
if doctest_related and docs_only_scope:
|
|
249
|
+
lines.extend(
|
|
250
|
+
[
|
|
251
|
+
"For docs/doctest-only tasks, a local import failure from a locked doctest",
|
|
252
|
+
"command is still actionable when the editable document can provide setup.",
|
|
253
|
+
"Use in-document doctest setup/import-path lines or another docs-only repair",
|
|
254
|
+
"so every locked command passes before declaring infrastructure blocked.",
|
|
255
|
+
"",
|
|
256
|
+
]
|
|
257
|
+
)
|
|
258
|
+
if packaging_related:
|
|
259
|
+
lines.extend(
|
|
260
|
+
[
|
|
261
|
+
"For packaging/install tasks, prefer in-scope project metadata repairs",
|
|
262
|
+
"such as `pyproject.toml` configuration. Do not create setup.cfg/setup.py",
|
|
263
|
+
"unless those files are already in scope; inspect build/install output",
|
|
264
|
+
"without committing diagnostic logs.",
|
|
265
|
+
"",
|
|
266
|
+
]
|
|
267
|
+
)
|
|
268
|
+
lines.extend(f"- `{command}`" for command in cleaned)
|
|
269
|
+
return "\n".join(lines)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@dataclass(frozen=True)
|
|
273
|
+
class ExecutionReportingDiff:
|
|
274
|
+
changed_files: tuple[str, ...]
|
|
275
|
+
patch_text: str
|
|
276
|
+
inspection_error: str | None = None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@dataclass(frozen=True)
|
|
280
|
+
class TaskLocalWorkspaceBaseline:
|
|
281
|
+
before_commit: str | None
|
|
282
|
+
before_snapshot: dict[str, str]
|
|
283
|
+
preexisting_changed_files: tuple[str, ...] = ()
|
|
284
|
+
snapshot_root: Path | None = None
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def had_preexisting_workspace_dirt(self) -> bool:
|
|
288
|
+
return bool(self.preexisting_changed_files)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@dataclass(frozen=True)
|
|
292
|
+
class _GitStatusEntry:
|
|
293
|
+
status: str
|
|
294
|
+
path: str
|
|
295
|
+
orig_path: str | None = None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _workspace_snapshot_signature(path: Path) -> str | None:
|
|
299
|
+
try:
|
|
300
|
+
stat = path.stat()
|
|
301
|
+
except OSError:
|
|
302
|
+
return None
|
|
303
|
+
if not path.is_file():
|
|
304
|
+
return None
|
|
305
|
+
# Small files use content hashing so snapshot-based task-local deltas do not report
|
|
306
|
+
# touched-and-restored paths as real changes. Large files keep the cheaper metadata fallback.
|
|
307
|
+
if stat.st_size <= _SNAPSHOT_HASH_MAX_BYTES:
|
|
308
|
+
try:
|
|
309
|
+
digest = sha256(path.read_bytes()).hexdigest()
|
|
310
|
+
except OSError:
|
|
311
|
+
digest = ""
|
|
312
|
+
if digest:
|
|
313
|
+
return f"sha256:{digest}"
|
|
314
|
+
ctime_ns = getattr(stat, "st_ctime_ns", int(stat.st_ctime * 1_000_000_000))
|
|
315
|
+
return f"meta:{stat.st_size}:{stat.st_mtime_ns}:{ctime_ns}"
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _workspace_snapshot_git_paths(root: Path) -> set[str] | None:
|
|
319
|
+
if shutil.which("git") is None:
|
|
320
|
+
return None
|
|
321
|
+
git_dir = root / ".git"
|
|
322
|
+
if not git_dir.exists():
|
|
323
|
+
return None
|
|
324
|
+
paths: set[str] = set()
|
|
325
|
+
for item in [*_git_cached_files(root), *_git_untracked_files(root)]:
|
|
326
|
+
normalized = _normalize_reporting_changed_path(root=root, path=item)
|
|
327
|
+
if normalized:
|
|
328
|
+
paths.add(normalized)
|
|
329
|
+
return paths
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _workspace_snapshot_walk_paths(root: Path) -> set[str]:
|
|
333
|
+
root_resolved = root.resolve()
|
|
334
|
+
paths: set[str] = set()
|
|
335
|
+
for current_root, dirnames, filenames in os.walk(root_resolved):
|
|
336
|
+
current_path = Path(current_root)
|
|
337
|
+
resolved_current = current_path.resolve()
|
|
338
|
+
rel_dir = (
|
|
339
|
+
resolved_current.relative_to(root_resolved).as_posix()
|
|
340
|
+
if resolved_current != root_resolved
|
|
341
|
+
else ""
|
|
342
|
+
)
|
|
343
|
+
kept_dirnames: list[str] = []
|
|
344
|
+
for dirname in dirnames:
|
|
345
|
+
rel_candidate = dirname if not rel_dir else f"{rel_dir}/{dirname}"
|
|
346
|
+
normalized = _normalize_reporting_changed_path(root=root_resolved, path=rel_candidate)
|
|
347
|
+
if normalized and is_runtime_artifact_path(normalized, root=root_resolved):
|
|
348
|
+
continue
|
|
349
|
+
kept_dirnames.append(dirname)
|
|
350
|
+
dirnames[:] = kept_dirnames
|
|
351
|
+
|
|
352
|
+
for filename in filenames:
|
|
353
|
+
rel_candidate = filename if not rel_dir else f"{rel_dir}/{filename}"
|
|
354
|
+
normalized = _normalize_reporting_changed_path(root=root_resolved, path=rel_candidate)
|
|
355
|
+
if not normalized:
|
|
356
|
+
continue
|
|
357
|
+
paths.add(normalized)
|
|
358
|
+
return paths
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def snapshot_workspace_tree(root: Path) -> dict[str, str]:
|
|
362
|
+
candidate_paths = _workspace_snapshot_git_paths(root)
|
|
363
|
+
if candidate_paths is None:
|
|
364
|
+
candidate_paths = _workspace_snapshot_walk_paths(root)
|
|
365
|
+
|
|
366
|
+
snapshot: dict[str, str] = {}
|
|
367
|
+
for rel_path in sorted(candidate_paths):
|
|
368
|
+
signature = _workspace_snapshot_signature(
|
|
369
|
+
_path_under_root(root=root.resolve(), rel_path=rel_path)
|
|
370
|
+
)
|
|
371
|
+
if signature is None:
|
|
372
|
+
continue
|
|
373
|
+
snapshot[rel_path] = signature
|
|
374
|
+
return snapshot
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _workspace_snapshot_changed_files(
|
|
378
|
+
*,
|
|
379
|
+
before: dict[str, str],
|
|
380
|
+
after: dict[str, str],
|
|
381
|
+
) -> tuple[str, ...]:
|
|
382
|
+
changed = {
|
|
383
|
+
rel_path
|
|
384
|
+
for rel_path in (set(before) | set(after))
|
|
385
|
+
if before.get(rel_path) != after.get(rel_path)
|
|
386
|
+
}
|
|
387
|
+
return tuple(sorted(changed))
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def safe_task_file_component(task_id: str) -> str:
|
|
391
|
+
safe = "".join(c if c.isalnum() or c in {"-", "_"} else "_" for c in task_id)
|
|
392
|
+
return safe or "task"
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def build_task_execution_instruction(
|
|
396
|
+
*,
|
|
397
|
+
plan: dict[str, Any],
|
|
398
|
+
task: dict[str, Any],
|
|
399
|
+
cfg: AppConfig | None = None,
|
|
400
|
+
role_model: str | None = None,
|
|
401
|
+
instruction_token_budget: int | None = None,
|
|
402
|
+
leading_sections: list[str] | None = None,
|
|
403
|
+
relevant_assets_section: str | None = None,
|
|
404
|
+
) -> str:
|
|
405
|
+
effective_model = (role_model or "").strip()
|
|
406
|
+
effective_cfg = cfg or AppConfig(model=effective_model)
|
|
407
|
+
if not effective_model:
|
|
408
|
+
effective_model = (effective_cfg.model or "").strip() or "unknown-model"
|
|
409
|
+
return build_task_context_pack(
|
|
410
|
+
cfg=effective_cfg,
|
|
411
|
+
plan=plan,
|
|
412
|
+
task=task,
|
|
413
|
+
role_model=effective_model,
|
|
414
|
+
instruction_token_budget=instruction_token_budget,
|
|
415
|
+
leading_sections=leading_sections,
|
|
416
|
+
relevant_assets_section=relevant_assets_section,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _estimate_initial_execution_request_tokens(
|
|
421
|
+
*,
|
|
422
|
+
root: Path,
|
|
423
|
+
prefix_messages: tuple[dict[str, Any], ...],
|
|
424
|
+
tool_list: tuple[dict[str, Any], ...],
|
|
425
|
+
instruction: str,
|
|
426
|
+
image_paths: tuple[str, ...],
|
|
427
|
+
) -> int:
|
|
428
|
+
user_message, _log_payload = _build_user_message(
|
|
429
|
+
root=root,
|
|
430
|
+
instruction=instruction,
|
|
431
|
+
image_paths=list(image_paths) or None,
|
|
432
|
+
)
|
|
433
|
+
return estimate_request_tokens(
|
|
434
|
+
[*prefix_messages, user_message],
|
|
435
|
+
list(tool_list),
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _resolve_startup_headroom_target(
|
|
440
|
+
*,
|
|
441
|
+
cfg: AppConfig,
|
|
442
|
+
role_model: str,
|
|
443
|
+
model_registry: ModelRegistry | None,
|
|
444
|
+
) -> tuple[int, int, int]:
|
|
445
|
+
registry = model_registry or ModelRegistry(cfg=cfg)
|
|
446
|
+
meta = registry.get(role_model)
|
|
447
|
+
compaction_settings = resolve_compaction_settings(cfg)
|
|
448
|
+
compaction_budget_tokens = compute_input_budget(
|
|
449
|
+
meta,
|
|
450
|
+
safety_margin=compaction_settings.safety_margin_tokens,
|
|
451
|
+
)
|
|
452
|
+
compaction_trigger_tokens = max(
|
|
453
|
+
0,
|
|
454
|
+
int(compaction_budget_tokens * compaction_settings.trigger_ratio),
|
|
455
|
+
)
|
|
456
|
+
compaction_target_tokens = max(
|
|
457
|
+
0,
|
|
458
|
+
int(compaction_budget_tokens * compaction_settings.target_ratio),
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
# Keep the first managed-exec request below the real execution compaction
|
|
462
|
+
# trigger with enough slack for a meaningful future compaction bundle.
|
|
463
|
+
removable_buffer_tokens = max(
|
|
464
|
+
256,
|
|
465
|
+
int(compaction_settings.execution_min_removable_tokens),
|
|
466
|
+
)
|
|
467
|
+
startup_target_tokens = max(
|
|
468
|
+
compaction_target_tokens,
|
|
469
|
+
compaction_trigger_tokens - removable_buffer_tokens,
|
|
470
|
+
)
|
|
471
|
+
if compaction_trigger_tokens > 0:
|
|
472
|
+
startup_target_tokens = min(
|
|
473
|
+
startup_target_tokens,
|
|
474
|
+
compaction_trigger_tokens - 1,
|
|
475
|
+
)
|
|
476
|
+
else:
|
|
477
|
+
startup_target_tokens = 0
|
|
478
|
+
return (
|
|
479
|
+
compaction_budget_tokens,
|
|
480
|
+
compaction_trigger_tokens,
|
|
481
|
+
max(0, startup_target_tokens),
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _apply_startup_headroom_preflight(
|
|
486
|
+
*,
|
|
487
|
+
root: Path,
|
|
488
|
+
effective_cfg: AppConfig,
|
|
489
|
+
effective_model: str,
|
|
490
|
+
plan: dict[str, Any],
|
|
491
|
+
task: dict[str, Any],
|
|
492
|
+
model_registry: ModelRegistry | None,
|
|
493
|
+
budget: ExecutionPromptBudget,
|
|
494
|
+
prefix_messages: tuple[dict[str, Any], ...],
|
|
495
|
+
tool_list: tuple[dict[str, Any], ...],
|
|
496
|
+
image_paths: tuple[str, ...],
|
|
497
|
+
leading_sections: list[str] | None,
|
|
498
|
+
relevant_assets_section: str | None,
|
|
499
|
+
initial_pack_result: Any,
|
|
500
|
+
) -> tuple[Any, StartupHeadroomTelemetry]:
|
|
501
|
+
compaction_budget_tokens, compaction_trigger_tokens, startup_target_tokens = (
|
|
502
|
+
_resolve_startup_headroom_target(
|
|
503
|
+
cfg=effective_cfg,
|
|
504
|
+
role_model=effective_model,
|
|
505
|
+
model_registry=model_registry,
|
|
506
|
+
)
|
|
507
|
+
)
|
|
508
|
+
initial_request_token_estimate = _estimate_initial_execution_request_tokens(
|
|
509
|
+
root=root,
|
|
510
|
+
prefix_messages=prefix_messages,
|
|
511
|
+
tool_list=tool_list,
|
|
512
|
+
instruction=initial_pack_result.content,
|
|
513
|
+
image_paths=image_paths,
|
|
514
|
+
)
|
|
515
|
+
if initial_request_token_estimate <= startup_target_tokens:
|
|
516
|
+
return initial_pack_result, StartupHeadroomTelemetry(
|
|
517
|
+
initial_request_token_estimate=initial_request_token_estimate,
|
|
518
|
+
initial_request_token_estimate_before_adjustment=None,
|
|
519
|
+
compaction_budget_tokens=compaction_budget_tokens,
|
|
520
|
+
compaction_trigger_tokens=compaction_trigger_tokens,
|
|
521
|
+
startup_target_tokens=startup_target_tokens,
|
|
522
|
+
startup_headroom_tokens=startup_target_tokens - initial_request_token_estimate,
|
|
523
|
+
startup_headroom_adjustment_applied=False,
|
|
524
|
+
startup_headroom_adjustment_reason=None,
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
empty_instruction_request_tokens = _estimate_initial_execution_request_tokens(
|
|
528
|
+
root=root,
|
|
529
|
+
prefix_messages=prefix_messages,
|
|
530
|
+
tool_list=tool_list,
|
|
531
|
+
instruction="",
|
|
532
|
+
image_paths=image_paths,
|
|
533
|
+
)
|
|
534
|
+
if empty_instruction_request_tokens >= startup_target_tokens:
|
|
535
|
+
return initial_pack_result, StartupHeadroomTelemetry(
|
|
536
|
+
initial_request_token_estimate=initial_request_token_estimate,
|
|
537
|
+
initial_request_token_estimate_before_adjustment=None,
|
|
538
|
+
compaction_budget_tokens=compaction_budget_tokens,
|
|
539
|
+
compaction_trigger_tokens=compaction_trigger_tokens,
|
|
540
|
+
startup_target_tokens=startup_target_tokens,
|
|
541
|
+
startup_headroom_tokens=startup_target_tokens - initial_request_token_estimate,
|
|
542
|
+
startup_headroom_adjustment_applied=False,
|
|
543
|
+
startup_headroom_adjustment_reason=(
|
|
544
|
+
"deterministic startup overhead already exceeds the startup target, so task-pack "
|
|
545
|
+
"reduction would not recover enough headroom"
|
|
546
|
+
),
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
adjusted_pack_result = initial_pack_result
|
|
550
|
+
adjusted_request_token_estimate = initial_request_token_estimate
|
|
551
|
+
for _attempt in range(3):
|
|
552
|
+
if adjusted_request_token_estimate <= startup_target_tokens:
|
|
553
|
+
break
|
|
554
|
+
|
|
555
|
+
current_instruction_request_tokens = max(
|
|
556
|
+
0,
|
|
557
|
+
adjusted_request_token_estimate - empty_instruction_request_tokens,
|
|
558
|
+
)
|
|
559
|
+
current_instruction_budget = adjusted_pack_result.instruction_token_estimate
|
|
560
|
+
if current_instruction_budget <= 0:
|
|
561
|
+
break
|
|
562
|
+
|
|
563
|
+
overflow_tokens = adjusted_request_token_estimate - startup_target_tokens
|
|
564
|
+
target_instruction_request_tokens = max(
|
|
565
|
+
0,
|
|
566
|
+
current_instruction_request_tokens - overflow_tokens,
|
|
567
|
+
)
|
|
568
|
+
if current_instruction_request_tokens > 0:
|
|
569
|
+
adjusted_instruction_budget = int(
|
|
570
|
+
current_instruction_budget
|
|
571
|
+
* target_instruction_request_tokens
|
|
572
|
+
/ current_instruction_request_tokens
|
|
573
|
+
)
|
|
574
|
+
else:
|
|
575
|
+
adjusted_instruction_budget = 0
|
|
576
|
+
adjusted_instruction_budget = min(
|
|
577
|
+
budget.final_instruction_budget,
|
|
578
|
+
current_instruction_budget,
|
|
579
|
+
max(0, adjusted_instruction_budget),
|
|
580
|
+
)
|
|
581
|
+
if adjusted_instruction_budget >= current_instruction_budget:
|
|
582
|
+
adjusted_instruction_budget = max(
|
|
583
|
+
0,
|
|
584
|
+
current_instruction_budget - max(64, overflow_tokens),
|
|
585
|
+
)
|
|
586
|
+
if adjusted_instruction_budget == current_instruction_budget:
|
|
587
|
+
break
|
|
588
|
+
|
|
589
|
+
# Rebuild with a tighter managed-exec-only budget so the context pack
|
|
590
|
+
# shrinks in proportion to the instruction's measured request footprint.
|
|
591
|
+
adjusted_pack_result = build_task_context_pack_result(
|
|
592
|
+
cfg=effective_cfg,
|
|
593
|
+
plan=plan,
|
|
594
|
+
task=task,
|
|
595
|
+
role_model=effective_model,
|
|
596
|
+
model_registry=model_registry,
|
|
597
|
+
instruction_token_budget=adjusted_instruction_budget,
|
|
598
|
+
leading_sections=leading_sections,
|
|
599
|
+
relevant_assets_section=relevant_assets_section,
|
|
600
|
+
prefer_startup_headroom_reduction=True,
|
|
601
|
+
)
|
|
602
|
+
adjusted_request_token_estimate = _estimate_initial_execution_request_tokens(
|
|
603
|
+
root=root,
|
|
604
|
+
prefix_messages=prefix_messages,
|
|
605
|
+
tool_list=tool_list,
|
|
606
|
+
instruction=adjusted_pack_result.content,
|
|
607
|
+
image_paths=image_paths,
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
if adjusted_request_token_estimate > startup_target_tokens:
|
|
611
|
+
fallback_instruction_budget = min(
|
|
612
|
+
adjusted_pack_result.instruction_token_estimate,
|
|
613
|
+
128,
|
|
614
|
+
)
|
|
615
|
+
if fallback_instruction_budget < adjusted_pack_result.instruction_token_estimate:
|
|
616
|
+
adjusted_pack_result = build_task_context_pack_result(
|
|
617
|
+
cfg=effective_cfg,
|
|
618
|
+
plan=plan,
|
|
619
|
+
task=task,
|
|
620
|
+
role_model=effective_model,
|
|
621
|
+
model_registry=model_registry,
|
|
622
|
+
instruction_token_budget=fallback_instruction_budget,
|
|
623
|
+
leading_sections=leading_sections,
|
|
624
|
+
relevant_assets_section=relevant_assets_section,
|
|
625
|
+
prefer_startup_headroom_reduction=True,
|
|
626
|
+
)
|
|
627
|
+
adjusted_request_token_estimate = _estimate_initial_execution_request_tokens(
|
|
628
|
+
root=root,
|
|
629
|
+
prefix_messages=prefix_messages,
|
|
630
|
+
tool_list=tool_list,
|
|
631
|
+
instruction=adjusted_pack_result.content,
|
|
632
|
+
image_paths=image_paths,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
adjustment_reason: str | None
|
|
636
|
+
if adjusted_request_token_estimate <= startup_target_tokens:
|
|
637
|
+
adjustment_reason = (
|
|
638
|
+
"reduced the managed-execution context pack so the first request starts below the "
|
|
639
|
+
"execution compaction trigger"
|
|
640
|
+
)
|
|
641
|
+
else:
|
|
642
|
+
adjustment_reason = (
|
|
643
|
+
"reduced the managed-execution context pack to the minimal startup profile, but "
|
|
644
|
+
"deterministic startup overhead still exceeds the startup target"
|
|
645
|
+
)
|
|
646
|
+
return adjusted_pack_result, StartupHeadroomTelemetry(
|
|
647
|
+
initial_request_token_estimate=adjusted_request_token_estimate,
|
|
648
|
+
initial_request_token_estimate_before_adjustment=initial_request_token_estimate,
|
|
649
|
+
compaction_budget_tokens=compaction_budget_tokens,
|
|
650
|
+
compaction_trigger_tokens=compaction_trigger_tokens,
|
|
651
|
+
startup_target_tokens=startup_target_tokens,
|
|
652
|
+
startup_headroom_tokens=startup_target_tokens - adjusted_request_token_estimate,
|
|
653
|
+
startup_headroom_adjustment_applied=True,
|
|
654
|
+
startup_headroom_adjustment_reason=adjustment_reason,
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def build_task_execution_instruction_bundle(
|
|
659
|
+
*,
|
|
660
|
+
plan: dict[str, Any],
|
|
661
|
+
task: dict[str, Any],
|
|
662
|
+
root: Path,
|
|
663
|
+
cfg: AppConfig | None = None,
|
|
664
|
+
role_model: str | None = None,
|
|
665
|
+
mode: str,
|
|
666
|
+
yes: bool,
|
|
667
|
+
deny_write_prefixes: list[str] | None = None,
|
|
668
|
+
allow_write_globs: list[str] | None = None,
|
|
669
|
+
non_interactive: bool = False,
|
|
670
|
+
verification_enabled: bool = True,
|
|
671
|
+
authoritative_verification_commands: list[str] | None = None,
|
|
672
|
+
trusted_system_prompt_override: str | None = None,
|
|
673
|
+
trusted_system_prompt_append: str | None = None,
|
|
674
|
+
untrusted_prompt_prelude: str | None = None,
|
|
675
|
+
model_registry: ModelRegistry | None = None,
|
|
676
|
+
api_key: str | None = None,
|
|
677
|
+
safety_margin_tokens: int = DEFAULT_EXECUTION_SAFETY_MARGIN_TOKENS,
|
|
678
|
+
execution_response_reserve_tokens: int = DEFAULT_EXECUTION_RESPONSE_RESERVE_TOKENS,
|
|
679
|
+
execution_headroom_reserve_tokens: int = DEFAULT_EXECUTION_HEADROOM_RESERVE_TOKENS,
|
|
680
|
+
minimum_instruction_budget_tokens: int = DEFAULT_MINIMUM_EXECUTION_INSTRUCTION_BUDGET_TOKENS,
|
|
681
|
+
subagents_enabled: bool = False,
|
|
682
|
+
leading_sections: list[str] | None = None,
|
|
683
|
+
relevant_assets_section: str | None = None,
|
|
684
|
+
managed_execution_startup_headroom: bool = False,
|
|
685
|
+
) -> PreparedTaskExecutionInstruction:
|
|
686
|
+
effective_model = (role_model or "").strip()
|
|
687
|
+
effective_cfg = cfg or AppConfig(model=effective_model)
|
|
688
|
+
if not effective_model:
|
|
689
|
+
effective_model = (effective_cfg.model or "").strip() or "unknown-model"
|
|
690
|
+
task_image_paths = select_task_image_paths_for_execution(
|
|
691
|
+
cfg=effective_cfg,
|
|
692
|
+
plan=plan,
|
|
693
|
+
task=task,
|
|
694
|
+
root=root,
|
|
695
|
+
role_model=effective_model,
|
|
696
|
+
model_registry=model_registry,
|
|
697
|
+
)
|
|
698
|
+
budget_inputs = compute_execution_prompt_budget_inputs(
|
|
699
|
+
cfg=effective_cfg,
|
|
700
|
+
root=root,
|
|
701
|
+
mode=mode,
|
|
702
|
+
yes=yes,
|
|
703
|
+
deny_write_prefixes=deny_write_prefixes,
|
|
704
|
+
allow_write_globs=allow_write_globs,
|
|
705
|
+
non_interactive=non_interactive,
|
|
706
|
+
one_shot_execution=False,
|
|
707
|
+
verification_enabled=verification_enabled,
|
|
708
|
+
authoritative_verification_commands=authoritative_verification_commands,
|
|
709
|
+
trusted_system_prompt_override=trusted_system_prompt_override,
|
|
710
|
+
trusted_system_prompt_append=trusted_system_prompt_append,
|
|
711
|
+
untrusted_prompt_prelude=untrusted_prompt_prelude,
|
|
712
|
+
subagents_enabled=subagents_enabled,
|
|
713
|
+
subagent_depth=0,
|
|
714
|
+
subagent_registry=None,
|
|
715
|
+
workspace_binding=None,
|
|
716
|
+
api_key=api_key,
|
|
717
|
+
model_registry=model_registry,
|
|
718
|
+
safety_margin_tokens=safety_margin_tokens,
|
|
719
|
+
execution_response_reserve_tokens=execution_response_reserve_tokens,
|
|
720
|
+
execution_headroom_reserve_tokens=execution_headroom_reserve_tokens,
|
|
721
|
+
minimum_instruction_budget_tokens=minimum_instruction_budget_tokens,
|
|
722
|
+
image_count=len(task_image_paths or []),
|
|
723
|
+
)
|
|
724
|
+
budget = budget_inputs.budget
|
|
725
|
+
effective_leading_sections = list(leading_sections or [])
|
|
726
|
+
authoritative_verification_section = (
|
|
727
|
+
_authoritative_verification_context_section(
|
|
728
|
+
authoritative_verification_commands,
|
|
729
|
+
task=task,
|
|
730
|
+
allow_write_globs=allow_write_globs,
|
|
731
|
+
)
|
|
732
|
+
if verification_enabled
|
|
733
|
+
else ""
|
|
734
|
+
)
|
|
735
|
+
if authoritative_verification_section:
|
|
736
|
+
effective_leading_sections.insert(0, authoritative_verification_section)
|
|
737
|
+
pack_result = build_task_context_pack_result(
|
|
738
|
+
cfg=effective_cfg,
|
|
739
|
+
plan=plan,
|
|
740
|
+
task=task,
|
|
741
|
+
role_model=effective_model,
|
|
742
|
+
model_registry=model_registry,
|
|
743
|
+
instruction_token_budget=budget.final_instruction_budget,
|
|
744
|
+
leading_sections=effective_leading_sections,
|
|
745
|
+
relevant_assets_section=relevant_assets_section,
|
|
746
|
+
)
|
|
747
|
+
startup_headroom: StartupHeadroomTelemetry | None = None
|
|
748
|
+
if managed_execution_startup_headroom:
|
|
749
|
+
pack_result, startup_headroom = _apply_startup_headroom_preflight(
|
|
750
|
+
root=root,
|
|
751
|
+
effective_cfg=effective_cfg,
|
|
752
|
+
effective_model=effective_model,
|
|
753
|
+
plan=plan,
|
|
754
|
+
task=task,
|
|
755
|
+
model_registry=model_registry,
|
|
756
|
+
budget=budget,
|
|
757
|
+
prefix_messages=budget_inputs.prefix_messages,
|
|
758
|
+
tool_list=budget_inputs.tool_list,
|
|
759
|
+
image_paths=tuple(task_image_paths or ()),
|
|
760
|
+
leading_sections=leading_sections,
|
|
761
|
+
relevant_assets_section=relevant_assets_section,
|
|
762
|
+
initial_pack_result=pack_result,
|
|
763
|
+
)
|
|
764
|
+
return PreparedTaskExecutionInstruction(
|
|
765
|
+
instruction=pack_result.content,
|
|
766
|
+
artifact_text=pack_result.artifact_text,
|
|
767
|
+
budget=budget,
|
|
768
|
+
final_instruction_token_estimate=pack_result.instruction_token_estimate,
|
|
769
|
+
truncated=pack_result.truncated,
|
|
770
|
+
truncation_strategy=pack_result.truncation_strategy,
|
|
771
|
+
subagents_enabled=budget.subagents_enabled,
|
|
772
|
+
image_paths=tuple(task_image_paths or ()),
|
|
773
|
+
startup_headroom=startup_headroom,
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _parse_bool_env(value: str | None) -> bool:
|
|
778
|
+
if value is None:
|
|
779
|
+
return False
|
|
780
|
+
raw = value.strip().lower()
|
|
781
|
+
return raw in {"1", "true", "yes", "on"}
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def select_task_image_paths_for_execution(
|
|
785
|
+
*,
|
|
786
|
+
cfg: AppConfig,
|
|
787
|
+
plan: dict[str, Any],
|
|
788
|
+
task: dict[str, Any],
|
|
789
|
+
root: Path,
|
|
790
|
+
role_model: str,
|
|
791
|
+
model_registry: ModelRegistry | None = None,
|
|
792
|
+
) -> list[str] | None:
|
|
793
|
+
if not _parse_bool_env(env_get(_TASK_IMAGES_ENV)):
|
|
794
|
+
return None
|
|
795
|
+
|
|
796
|
+
registry = model_registry or ModelRegistry(cfg=cfg)
|
|
797
|
+
meta = registry.get(role_model)
|
|
798
|
+
if not meta.supports_vision:
|
|
799
|
+
return None
|
|
800
|
+
|
|
801
|
+
paths = select_relevant_image_paths(
|
|
802
|
+
plan=plan,
|
|
803
|
+
task=task,
|
|
804
|
+
root=root,
|
|
805
|
+
max_images=4,
|
|
806
|
+
)
|
|
807
|
+
return paths or None
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def write_execution_context_artifact(
|
|
811
|
+
*,
|
|
812
|
+
run_paths: RunPaths,
|
|
813
|
+
task_id: str,
|
|
814
|
+
context_text: str,
|
|
815
|
+
) -> Path:
|
|
816
|
+
ensure_execution_dirs(run_paths)
|
|
817
|
+
safe_task = safe_task_file_component(task_id)
|
|
818
|
+
context_path = run_paths.execution_context_dir / f"{safe_task}_context.md"
|
|
819
|
+
context_path.write_text(context_text, encoding="utf-8")
|
|
820
|
+
return context_path
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def write_execution_budget_artifact(
|
|
824
|
+
*,
|
|
825
|
+
run_paths: RunPaths,
|
|
826
|
+
task_id: str,
|
|
827
|
+
payload: dict[str, Any],
|
|
828
|
+
) -> Path:
|
|
829
|
+
ensure_execution_dirs(run_paths)
|
|
830
|
+
safe_task = safe_task_file_component(task_id)
|
|
831
|
+
budget_path = run_paths.execution_budgets_dir / f"{safe_task}.json"
|
|
832
|
+
budget_path.write_text(
|
|
833
|
+
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
|
834
|
+
encoding="utf-8",
|
|
835
|
+
)
|
|
836
|
+
return budget_path
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def resolve_managed_task_step_budget(
|
|
840
|
+
*,
|
|
841
|
+
cfg: AppConfig,
|
|
842
|
+
plan: dict[str, Any],
|
|
843
|
+
task: dict[str, Any],
|
|
844
|
+
kind: str = "managed_task",
|
|
845
|
+
mode: str | None = None,
|
|
846
|
+
verification_enabled: bool,
|
|
847
|
+
max_steps_override: int | None = None,
|
|
848
|
+
attempt_count: int | None = None,
|
|
849
|
+
image_count: int = 0,
|
|
850
|
+
conflict_file_count: int = 0,
|
|
851
|
+
) -> StepBudgetResolution:
|
|
852
|
+
acceptance_criteria = task.get("acceptance_criteria") or []
|
|
853
|
+
estimated_files = task.get("estimated_files") or []
|
|
854
|
+
write_scope = task.get("write_scope") or []
|
|
855
|
+
dependencies = task.get("dependencies") or []
|
|
856
|
+
selected_assets = select_relevant_assets(plan, task)
|
|
857
|
+
request = StepBudgetRequest(
|
|
858
|
+
kind=str(kind or "managed_task").strip() or "managed_task",
|
|
859
|
+
policy=str(
|
|
860
|
+
getattr(cfg, "step_budget_policy", AUTONOMOUS_STEP_BUDGET_POLICY)
|
|
861
|
+
or AUTONOMOUS_STEP_BUDGET_POLICY
|
|
862
|
+
),
|
|
863
|
+
hard_cap=int(
|
|
864
|
+
max_steps_override
|
|
865
|
+
if max_steps_override is not None
|
|
866
|
+
else getattr(cfg, "task_max_steps", DEFAULT_TASK_MAX_STEPS)
|
|
867
|
+
),
|
|
868
|
+
fixed_override=max_steps_override,
|
|
869
|
+
mode=mode,
|
|
870
|
+
verification_enabled=bool(verification_enabled),
|
|
871
|
+
attempt_count=int(attempt_count or 1),
|
|
872
|
+
image_count=int(image_count or 0),
|
|
873
|
+
acceptance_criteria_count=len(acceptance_criteria)
|
|
874
|
+
if isinstance(acceptance_criteria, list)
|
|
875
|
+
else 0,
|
|
876
|
+
estimated_files_count=len(estimated_files) if isinstance(estimated_files, list) else 0,
|
|
877
|
+
write_scope_count=len(write_scope) if isinstance(write_scope, list) else 0,
|
|
878
|
+
dependency_count=len(dependencies) if isinstance(dependencies, list) else 0,
|
|
879
|
+
asset_count=len(selected_assets),
|
|
880
|
+
conflict_file_count=int(conflict_file_count or 0),
|
|
881
|
+
)
|
|
882
|
+
return resolve_step_budget(request)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def prepare_task_execution_knowledge(
|
|
886
|
+
*,
|
|
887
|
+
run_paths: RunPaths,
|
|
888
|
+
task: dict[str, Any],
|
|
889
|
+
selection_label: str,
|
|
890
|
+
extra_paths: list[str] | None = None,
|
|
891
|
+
limit: int = 4,
|
|
892
|
+
) -> PreparedExecutionKnowledge:
|
|
893
|
+
materialized = prepare_relevant_knowledge(
|
|
894
|
+
paths=run_paths,
|
|
895
|
+
task=task,
|
|
896
|
+
selection_label=selection_label,
|
|
897
|
+
extra_paths=extra_paths,
|
|
898
|
+
limit=limit,
|
|
899
|
+
)
|
|
900
|
+
return PreparedExecutionKnowledge(
|
|
901
|
+
materialized=materialized,
|
|
902
|
+
prompt_section=materialized.render_prompt_section(workspace_root=run_paths.root),
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def mirror_plan_into_worktree(*, run_paths: RunPaths, worktree_repo_path: Path) -> None:
|
|
907
|
+
src_plan_dir = run_paths.plan_dir.resolve()
|
|
908
|
+
if not src_plan_dir.exists():
|
|
909
|
+
return
|
|
910
|
+
dest_plan_dir = worktree_repo_path.resolve() / ".alysis" / "runs" / run_paths.run_id / "plan"
|
|
911
|
+
dest_plan_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
912
|
+
_sync_tree_lightweight(src=src_plan_dir, dest=dest_plan_dir)
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def mirror_selected_knowledge_into_worktree(
|
|
916
|
+
*,
|
|
917
|
+
materialized: PreparedExecutionKnowledge,
|
|
918
|
+
run_paths: RunPaths,
|
|
919
|
+
worktree_repo_path: Path,
|
|
920
|
+
) -> None:
|
|
921
|
+
src_selected_dir = materialized.selected_dir.resolve()
|
|
922
|
+
if not src_selected_dir.exists():
|
|
923
|
+
return
|
|
924
|
+
rel = src_selected_dir.relative_to(run_paths.root.resolve())
|
|
925
|
+
dest_selected_dir = worktree_repo_path.resolve() / rel
|
|
926
|
+
dest_selected_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
927
|
+
_sync_tree_lightweight(src=src_selected_dir, dest=dest_selected_dir)
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _same_file_by_size_and_mtime(*, src: Path, dest: Path) -> bool:
|
|
931
|
+
if not dest.exists() or not dest.is_file():
|
|
932
|
+
return False
|
|
933
|
+
src_stat = src.stat()
|
|
934
|
+
dest_stat = dest.stat()
|
|
935
|
+
if src_stat.st_size != dest_stat.st_size:
|
|
936
|
+
return False
|
|
937
|
+
if src_stat.st_mtime_ns == dest_stat.st_mtime_ns:
|
|
938
|
+
return True
|
|
939
|
+
if src_stat.st_size > _SNAPSHOT_HASH_MAX_BYTES:
|
|
940
|
+
return False
|
|
941
|
+
return sha256(src.read_bytes()).digest() == sha256(dest.read_bytes()).digest()
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _windows_long_path(path: Path) -> str:
|
|
945
|
+
raw = os.path.abspath(os.fspath(path))
|
|
946
|
+
if os.name != "nt" or raw.startswith("\\\\?\\"):
|
|
947
|
+
return raw
|
|
948
|
+
if raw.startswith("\\\\"):
|
|
949
|
+
return "\\\\?\\UNC\\" + raw.lstrip("\\")
|
|
950
|
+
return "\\\\?\\" + raw
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _mkdir_existing(path: Path) -> None:
|
|
954
|
+
os.makedirs(_windows_long_path(path), exist_ok=True)
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def _sync_tree_lightweight(*, src: Path, dest: Path) -> None:
|
|
958
|
+
_mkdir_existing(dest)
|
|
959
|
+
src_files: dict[Path, Path] = {}
|
|
960
|
+
src_dirs: set[Path] = {Path(".")}
|
|
961
|
+
|
|
962
|
+
for src_path in sorted(src.rglob("*")):
|
|
963
|
+
rel = src_path.relative_to(src)
|
|
964
|
+
if src_path.is_dir():
|
|
965
|
+
src_dirs.add(rel)
|
|
966
|
+
_mkdir_existing(dest / rel)
|
|
967
|
+
continue
|
|
968
|
+
src_files[rel] = src_path
|
|
969
|
+
dest_path = dest / rel
|
|
970
|
+
_mkdir_existing(dest_path.parent)
|
|
971
|
+
if _same_file_by_size_and_mtime(src=src_path, dest=dest_path):
|
|
972
|
+
continue
|
|
973
|
+
shutil.copy2(_windows_long_path(src_path), _windows_long_path(dest_path))
|
|
974
|
+
|
|
975
|
+
for dest_path in sorted(dest.rglob("*"), reverse=True):
|
|
976
|
+
rel = dest_path.relative_to(dest)
|
|
977
|
+
if dest_path.is_file():
|
|
978
|
+
if rel not in src_files:
|
|
979
|
+
dest_path.unlink()
|
|
980
|
+
continue
|
|
981
|
+
if rel not in src_dirs:
|
|
982
|
+
try:
|
|
983
|
+
dest_path.rmdir()
|
|
984
|
+
except OSError:
|
|
985
|
+
shutil.rmtree(dest_path, ignore_errors=True)
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def _normalize_workspace_relpath(rel_path: str) -> str:
|
|
989
|
+
cleaned = rel_path.strip().replace("\\", "/")
|
|
990
|
+
while cleaned.startswith("./"):
|
|
991
|
+
cleaned = cleaned[2:]
|
|
992
|
+
return cleaned.strip("/")
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _path_under_root(*, root: Path, rel_path: str) -> Path:
|
|
996
|
+
candidate = (root / rel_path).resolve()
|
|
997
|
+
try:
|
|
998
|
+
candidate.relative_to(root.resolve())
|
|
999
|
+
except ValueError as e:
|
|
1000
|
+
raise ValueError(f"path escapes workspace root: {rel_path}") from e
|
|
1001
|
+
return candidate
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def _ignored_snapshot_path(*, rel_parts: tuple[str, ...], excluded_names: frozenset[str]) -> bool:
|
|
1005
|
+
if any(part in excluded_names for part in rel_parts):
|
|
1006
|
+
return True
|
|
1007
|
+
return False
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def copy_workspace_snapshot(
|
|
1011
|
+
*,
|
|
1012
|
+
src_root: Path,
|
|
1013
|
+
dest_root: Path,
|
|
1014
|
+
excluded_names: frozenset[str] | None = None,
|
|
1015
|
+
) -> None:
|
|
1016
|
+
src_root = src_root.resolve()
|
|
1017
|
+
dest_root = dest_root.resolve()
|
|
1018
|
+
excluded = excluded_names or _DEFAULT_SNAPSHOT_EXCLUDES
|
|
1019
|
+
|
|
1020
|
+
if dest_root.exists():
|
|
1021
|
+
shutil.rmtree(dest_root, ignore_errors=True)
|
|
1022
|
+
dest_root.mkdir(parents=True, exist_ok=True)
|
|
1023
|
+
|
|
1024
|
+
for src_path in sorted(src_root.rglob("*")):
|
|
1025
|
+
rel = src_path.relative_to(src_root)
|
|
1026
|
+
if _ignored_snapshot_path(
|
|
1027
|
+
rel_parts=rel.parts, excluded_names=excluded
|
|
1028
|
+
) or is_runtime_artifact_path(
|
|
1029
|
+
rel.as_posix(),
|
|
1030
|
+
root=src_root,
|
|
1031
|
+
):
|
|
1032
|
+
continue
|
|
1033
|
+
dest_path = dest_root / rel
|
|
1034
|
+
if src_path.is_dir():
|
|
1035
|
+
dest_path.mkdir(parents=True, exist_ok=True)
|
|
1036
|
+
continue
|
|
1037
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1038
|
+
shutil.copy2(src_path, dest_path)
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def sync_snapshot_changed_files(
|
|
1042
|
+
*,
|
|
1043
|
+
snapshot_root: Path,
|
|
1044
|
+
workspace_root: Path,
|
|
1045
|
+
changed_files: list[str],
|
|
1046
|
+
protected_prefixes: tuple[str, ...] = tuple(sorted(ROOT_RUNTIME_ARTIFACT_DIR_NAMES)),
|
|
1047
|
+
) -> list[str]:
|
|
1048
|
+
snapshot_root = snapshot_root.resolve()
|
|
1049
|
+
workspace_root = workspace_root.resolve()
|
|
1050
|
+
applied: list[str] = []
|
|
1051
|
+
normalized_prefixes = tuple(
|
|
1052
|
+
_normalize_workspace_relpath(prefix) for prefix in protected_prefixes if prefix.strip()
|
|
1053
|
+
)
|
|
1054
|
+
|
|
1055
|
+
for raw_path in changed_files:
|
|
1056
|
+
rel_path = _normalize_workspace_relpath(raw_path)
|
|
1057
|
+
if not rel_path:
|
|
1058
|
+
continue
|
|
1059
|
+
if is_runtime_artifact_path(rel_path, root=snapshot_root):
|
|
1060
|
+
continue
|
|
1061
|
+
if any(
|
|
1062
|
+
rel_path == prefix or rel_path.startswith(prefix + "/")
|
|
1063
|
+
for prefix in normalized_prefixes
|
|
1064
|
+
if prefix
|
|
1065
|
+
):
|
|
1066
|
+
continue
|
|
1067
|
+
|
|
1068
|
+
src_path = _path_under_root(root=snapshot_root, rel_path=rel_path)
|
|
1069
|
+
dest_path = _path_under_root(root=workspace_root, rel_path=rel_path)
|
|
1070
|
+
if src_path.exists():
|
|
1071
|
+
if src_path.is_dir():
|
|
1072
|
+
dest_path.mkdir(parents=True, exist_ok=True)
|
|
1073
|
+
else:
|
|
1074
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1075
|
+
shutil.copy2(src_path, dest_path)
|
|
1076
|
+
elif dest_path.exists():
|
|
1077
|
+
if dest_path.is_dir():
|
|
1078
|
+
shutil.rmtree(dest_path)
|
|
1079
|
+
else:
|
|
1080
|
+
dest_path.unlink()
|
|
1081
|
+
parent = dest_path.parent
|
|
1082
|
+
while parent != workspace_root and parent.exists():
|
|
1083
|
+
try:
|
|
1084
|
+
parent.rmdir()
|
|
1085
|
+
except OSError:
|
|
1086
|
+
break
|
|
1087
|
+
parent = parent.parent
|
|
1088
|
+
applied.append(rel_path)
|
|
1089
|
+
|
|
1090
|
+
return applied
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def snapshot_runtime_tree(root: Path) -> dict[str, str]:
|
|
1094
|
+
runtime_dir = (root / ".alysis").resolve()
|
|
1095
|
+
if not runtime_dir.exists():
|
|
1096
|
+
return {}
|
|
1097
|
+
snapshot: dict[str, str] = {}
|
|
1098
|
+
root_resolved = root.resolve()
|
|
1099
|
+
for p in sorted(runtime_dir.rglob("*")):
|
|
1100
|
+
if not p.is_file():
|
|
1101
|
+
continue
|
|
1102
|
+
rel = os.fspath(p.resolve().relative_to(root_resolved))
|
|
1103
|
+
rel_norm = rel.replace("\\", "/")
|
|
1104
|
+
stat = p.stat()
|
|
1105
|
+
is_plan_asset = "/plan/assets/" in f"/{rel_norm}/"
|
|
1106
|
+
if is_plan_asset or stat.st_size > _SNAPSHOT_HASH_MAX_BYTES:
|
|
1107
|
+
snapshot[rel] = f"meta:{stat.st_size}:{stat.st_mtime_ns}"
|
|
1108
|
+
continue
|
|
1109
|
+
digest = sha256(p.read_bytes()).hexdigest()
|
|
1110
|
+
snapshot[rel] = f"sha256:{digest}"
|
|
1111
|
+
return snapshot
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def _run_git_capture(
|
|
1115
|
+
*,
|
|
1116
|
+
root: Path | None,
|
|
1117
|
+
args: list[str],
|
|
1118
|
+
text: bool = False,
|
|
1119
|
+
) -> subprocess.CompletedProcess[Any] | None:
|
|
1120
|
+
cmd = ["git", *args] if root is None else ["git", "-C", os.fspath(root), *args]
|
|
1121
|
+
try:
|
|
1122
|
+
return subprocess.run(
|
|
1123
|
+
cmd,
|
|
1124
|
+
capture_output=True,
|
|
1125
|
+
text=text,
|
|
1126
|
+
check=False,
|
|
1127
|
+
env=build_git_process_env(),
|
|
1128
|
+
timeout=_READ_ONLY_GIT_TIMEOUT_S,
|
|
1129
|
+
)
|
|
1130
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
1131
|
+
return None
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def git_diff_text(root: Path) -> str | None:
|
|
1135
|
+
if shutil.which("git") is None:
|
|
1136
|
+
return None
|
|
1137
|
+
proc = _run_git_capture(root=root, args=["diff"], text=True)
|
|
1138
|
+
if proc is None:
|
|
1139
|
+
return None
|
|
1140
|
+
if proc.returncode != 0:
|
|
1141
|
+
return None
|
|
1142
|
+
return proc.stdout
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def _git_status_entries(root: Path) -> list[_GitStatusEntry]:
|
|
1146
|
+
if shutil.which("git") is None:
|
|
1147
|
+
return []
|
|
1148
|
+
proc = _run_git_capture(root=root, args=["status", "--porcelain=v1", "-z"])
|
|
1149
|
+
if proc is None:
|
|
1150
|
+
return []
|
|
1151
|
+
if proc.returncode != 0:
|
|
1152
|
+
return []
|
|
1153
|
+
|
|
1154
|
+
entries: list[_GitStatusEntry] = []
|
|
1155
|
+
stdout = proc.stdout
|
|
1156
|
+
raw_stdout = (
|
|
1157
|
+
stdout.encode("utf-8", errors="surrogateescape")
|
|
1158
|
+
if isinstance(stdout, str)
|
|
1159
|
+
else (stdout or b"")
|
|
1160
|
+
)
|
|
1161
|
+
parts = raw_stdout.split(b"\0")
|
|
1162
|
+
index = 0
|
|
1163
|
+
while index < len(parts):
|
|
1164
|
+
raw_entry = parts[index]
|
|
1165
|
+
index += 1
|
|
1166
|
+
if not raw_entry or len(raw_entry) < 3:
|
|
1167
|
+
continue
|
|
1168
|
+
status = raw_entry[:2].decode("utf-8", errors="replace")
|
|
1169
|
+
path = raw_entry[3:].decode("utf-8", errors="surrogateescape")
|
|
1170
|
+
orig_path: str | None = None
|
|
1171
|
+
if any(marker in status for marker in {"R", "C"}) and index < len(parts):
|
|
1172
|
+
second_raw = parts[index]
|
|
1173
|
+
index += 1
|
|
1174
|
+
if second_raw:
|
|
1175
|
+
orig_path = second_raw.decode("utf-8", errors="surrogateescape")
|
|
1176
|
+
entries.append(_GitStatusEntry(status=status, path=path, orig_path=orig_path))
|
|
1177
|
+
return entries
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def _git_has_head_commit(root: Path) -> bool:
|
|
1181
|
+
if shutil.which("git") is None:
|
|
1182
|
+
return False
|
|
1183
|
+
proc = _run_git_capture(root=root, args=["rev-parse", "--verify", "HEAD"], text=True)
|
|
1184
|
+
if proc is None:
|
|
1185
|
+
return False
|
|
1186
|
+
return proc.returncode == 0
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def _git_untracked_files(root: Path) -> list[str]:
|
|
1190
|
+
return _git_ls_files(
|
|
1191
|
+
root,
|
|
1192
|
+
["ls-files", "--others", "--exclude-standard", "-z"],
|
|
1193
|
+
include_non_material=False,
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def _git_cached_files(root: Path) -> list[str]:
|
|
1198
|
+
return _git_ls_files(
|
|
1199
|
+
root,
|
|
1200
|
+
["ls-files", "--cached", "-z"],
|
|
1201
|
+
include_non_material=True,
|
|
1202
|
+
)
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def _git_ls_files(
|
|
1206
|
+
root: Path,
|
|
1207
|
+
args: list[str],
|
|
1208
|
+
*,
|
|
1209
|
+
include_non_material: bool,
|
|
1210
|
+
) -> list[str]:
|
|
1211
|
+
if shutil.which("git") is None:
|
|
1212
|
+
return []
|
|
1213
|
+
proc = _run_git_capture(root=root, args=args)
|
|
1214
|
+
if proc is None:
|
|
1215
|
+
return []
|
|
1216
|
+
if proc.returncode != 0:
|
|
1217
|
+
return []
|
|
1218
|
+
stdout = proc.stdout
|
|
1219
|
+
raw_stdout = (
|
|
1220
|
+
stdout.encode("utf-8", errors="surrogateescape")
|
|
1221
|
+
if isinstance(stdout, str)
|
|
1222
|
+
else (stdout or b"")
|
|
1223
|
+
)
|
|
1224
|
+
paths: list[str] = []
|
|
1225
|
+
for item in raw_stdout.split(b"\0"):
|
|
1226
|
+
if not item:
|
|
1227
|
+
continue
|
|
1228
|
+
decoded = item.decode("utf-8", errors="surrogateescape")
|
|
1229
|
+
if not include_non_material and is_non_material_untracked_path(decoded):
|
|
1230
|
+
continue
|
|
1231
|
+
paths.append(decoded)
|
|
1232
|
+
return paths
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
def _git_text_output(
|
|
1236
|
+
*,
|
|
1237
|
+
root: Path,
|
|
1238
|
+
args: list[str],
|
|
1239
|
+
allowed_returncodes: tuple[int, ...] = (0,),
|
|
1240
|
+
) -> str | None:
|
|
1241
|
+
if shutil.which("git") is None:
|
|
1242
|
+
return None
|
|
1243
|
+
proc = _run_git_capture(root=root, args=args, text=True)
|
|
1244
|
+
if proc is None:
|
|
1245
|
+
return None
|
|
1246
|
+
if proc.returncode not in allowed_returncodes:
|
|
1247
|
+
return None
|
|
1248
|
+
return proc.stdout
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
def _tracked_patch_text(root: Path) -> str | None:
|
|
1252
|
+
if _git_has_head_commit(root):
|
|
1253
|
+
combined = _git_text_output(
|
|
1254
|
+
root=root,
|
|
1255
|
+
args=["diff", "HEAD", "--binary", "-M"],
|
|
1256
|
+
)
|
|
1257
|
+
if combined is not None:
|
|
1258
|
+
return combined
|
|
1259
|
+
|
|
1260
|
+
patch_parts: list[str] = []
|
|
1261
|
+
staged = _git_text_output(
|
|
1262
|
+
root=root,
|
|
1263
|
+
args=["diff", "--cached", "--binary", "-M", "--root"],
|
|
1264
|
+
)
|
|
1265
|
+
unstaged = _git_text_output(
|
|
1266
|
+
root=root,
|
|
1267
|
+
args=["diff", "--binary", "-M"],
|
|
1268
|
+
)
|
|
1269
|
+
if staged is None and unstaged is None:
|
|
1270
|
+
return None
|
|
1271
|
+
if staged:
|
|
1272
|
+
patch_parts.append(staged if staged.endswith("\n") else staged + "\n")
|
|
1273
|
+
if unstaged:
|
|
1274
|
+
patch_parts.append(unstaged if unstaged.endswith("\n") else unstaged + "\n")
|
|
1275
|
+
return "".join(patch_parts)
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def _normalize_reporting_changed_path(*, root: Path, path: str) -> str | None:
|
|
1279
|
+
rel_path = _normalize_workspace_relpath(path.strip('"'))
|
|
1280
|
+
if not rel_path or is_runtime_artifact_path(rel_path, root=root):
|
|
1281
|
+
return None
|
|
1282
|
+
return rel_path
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
def _execution_reporting_changed_files(
|
|
1286
|
+
root: Path,
|
|
1287
|
+
*,
|
|
1288
|
+
status_entries: list[_GitStatusEntry] | None = None,
|
|
1289
|
+
untracked_files: list[str] | None = None,
|
|
1290
|
+
) -> tuple[str, ...]:
|
|
1291
|
+
seen: set[str] = set()
|
|
1292
|
+
changed: list[str] = []
|
|
1293
|
+
entries = status_entries if status_entries is not None else _git_status_entries(root)
|
|
1294
|
+
|
|
1295
|
+
def _append(path: str) -> None:
|
|
1296
|
+
normalized = _normalize_reporting_changed_path(root=root, path=path)
|
|
1297
|
+
if not normalized or normalized in seen:
|
|
1298
|
+
return
|
|
1299
|
+
seen.add(normalized)
|
|
1300
|
+
changed.append(normalized)
|
|
1301
|
+
|
|
1302
|
+
for entry in entries:
|
|
1303
|
+
if entry.status in {"!!", "??"}:
|
|
1304
|
+
continue
|
|
1305
|
+
if entry.orig_path is not None:
|
|
1306
|
+
_append(entry.orig_path)
|
|
1307
|
+
_append(entry.path)
|
|
1308
|
+
|
|
1309
|
+
for path in untracked_files if untracked_files is not None else _git_untracked_files(root):
|
|
1310
|
+
_append(path)
|
|
1311
|
+
|
|
1312
|
+
return tuple(sorted(changed))
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
def _untracked_file_patch_text(*, root: Path, rel_path: str) -> str | None:
|
|
1316
|
+
if shutil.which("git") is None:
|
|
1317
|
+
return None
|
|
1318
|
+
abs_path = _path_under_root(root=root.resolve(), rel_path=rel_path)
|
|
1319
|
+
if not abs_path.exists() or not abs_path.is_file():
|
|
1320
|
+
return None
|
|
1321
|
+
proc = _run_git_capture(
|
|
1322
|
+
root=root,
|
|
1323
|
+
args=["diff", "--no-index", "--", os.devnull, rel_path],
|
|
1324
|
+
text=True,
|
|
1325
|
+
)
|
|
1326
|
+
if proc is None:
|
|
1327
|
+
return None
|
|
1328
|
+
# git diff --no-index returns 1 when differences are found.
|
|
1329
|
+
if proc.returncode not in {0, 1}:
|
|
1330
|
+
return None
|
|
1331
|
+
return proc.stdout
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
def build_execution_reporting_diff(root: Path) -> ExecutionReportingDiff:
|
|
1335
|
+
# Sequential forge execution used plain `git diff` / `git diff --name-only`, which
|
|
1336
|
+
# made reports truthful for one slice of repo state at a time and silently dropped new
|
|
1337
|
+
# untracked files or staged-only changes. Build one deterministic status-based view for the
|
|
1338
|
+
# changed-file list, then keep tracked patch text close to normal git diff behavior.
|
|
1339
|
+
status_entries = _git_status_entries(root)
|
|
1340
|
+
tracked_patch_text = _tracked_patch_text(root)
|
|
1341
|
+
raw_untracked_files = _git_untracked_files(root)
|
|
1342
|
+
changed_files = _execution_reporting_changed_files(
|
|
1343
|
+
root,
|
|
1344
|
+
status_entries=status_entries,
|
|
1345
|
+
untracked_files=raw_untracked_files,
|
|
1346
|
+
)
|
|
1347
|
+
normalized_untracked = {
|
|
1348
|
+
normalized
|
|
1349
|
+
for path in raw_untracked_files
|
|
1350
|
+
if (normalized := _normalize_reporting_changed_path(root=root, path=path)) is not None
|
|
1351
|
+
}
|
|
1352
|
+
untracked_files = tuple(path for path in changed_files if path in normalized_untracked)
|
|
1353
|
+
|
|
1354
|
+
patch_parts: list[str] = []
|
|
1355
|
+
if tracked_patch_text is None:
|
|
1356
|
+
if not changed_files:
|
|
1357
|
+
return ExecutionReportingDiff(
|
|
1358
|
+
changed_files=changed_files,
|
|
1359
|
+
patch_text="(no git diff available)\n",
|
|
1360
|
+
)
|
|
1361
|
+
elif tracked_patch_text:
|
|
1362
|
+
patch_parts.append(
|
|
1363
|
+
tracked_patch_text if tracked_patch_text.endswith("\n") else tracked_patch_text + "\n"
|
|
1364
|
+
)
|
|
1365
|
+
|
|
1366
|
+
for rel_path in untracked_files:
|
|
1367
|
+
untracked_patch = _untracked_file_patch_text(root=root, rel_path=rel_path)
|
|
1368
|
+
if not untracked_patch:
|
|
1369
|
+
continue
|
|
1370
|
+
patch_parts.append(
|
|
1371
|
+
untracked_patch if untracked_patch.endswith("\n") else untracked_patch + "\n"
|
|
1372
|
+
)
|
|
1373
|
+
|
|
1374
|
+
return ExecutionReportingDiff(
|
|
1375
|
+
changed_files=changed_files,
|
|
1376
|
+
patch_text="".join(patch_parts),
|
|
1377
|
+
)
|
|
1378
|
+
|
|
1379
|
+
|
|
1380
|
+
def build_workspace_snapshot_reporting_diff(
|
|
1381
|
+
root: Path,
|
|
1382
|
+
*,
|
|
1383
|
+
before_snapshot: dict[str, str],
|
|
1384
|
+
after_snapshot: dict[str, str],
|
|
1385
|
+
) -> ExecutionReportingDiff:
|
|
1386
|
+
changed_files = _workspace_snapshot_changed_files(
|
|
1387
|
+
before=before_snapshot,
|
|
1388
|
+
after=after_snapshot,
|
|
1389
|
+
)
|
|
1390
|
+
if not changed_files:
|
|
1391
|
+
return ExecutionReportingDiff(changed_files=(), patch_text="")
|
|
1392
|
+
|
|
1393
|
+
summary_lines = ["# Workspace snapshot diff", ""]
|
|
1394
|
+
patch_parts: list[str] = []
|
|
1395
|
+
for rel_path in changed_files:
|
|
1396
|
+
if rel_path not in before_snapshot:
|
|
1397
|
+
summary_lines.append(f"added: {rel_path}")
|
|
1398
|
+
untracked_patch = _untracked_file_patch_text(root=root, rel_path=rel_path)
|
|
1399
|
+
if untracked_patch:
|
|
1400
|
+
patch_parts.append(
|
|
1401
|
+
untracked_patch if untracked_patch.endswith("\n") else untracked_patch + "\n"
|
|
1402
|
+
)
|
|
1403
|
+
elif rel_path not in after_snapshot:
|
|
1404
|
+
summary_lines.append(f"deleted: {rel_path}")
|
|
1405
|
+
else:
|
|
1406
|
+
summary_lines.append(f"modified: {rel_path}")
|
|
1407
|
+
|
|
1408
|
+
summary_text = "\n".join(summary_lines) + "\n"
|
|
1409
|
+
return ExecutionReportingDiff(
|
|
1410
|
+
changed_files=changed_files,
|
|
1411
|
+
patch_text=summary_text + ("\n" + "".join(patch_parts) if patch_parts else ""),
|
|
1412
|
+
)
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
def _git_no_index_display_path(path: Path) -> str:
|
|
1416
|
+
return path.resolve().as_posix().lstrip("/")
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
def _rewrite_no_index_patch_paths(
|
|
1420
|
+
patch_text: str,
|
|
1421
|
+
*,
|
|
1422
|
+
before_root: Path,
|
|
1423
|
+
after_root: Path,
|
|
1424
|
+
) -> str:
|
|
1425
|
+
normalized = patch_text.replace("\\", "/")
|
|
1426
|
+
while "//" in normalized:
|
|
1427
|
+
normalized = normalized.replace("//", "/")
|
|
1428
|
+
normalized = "".join(
|
|
1429
|
+
line.replace('"', "") if line.startswith(("diff --git ", "--- ", "+++ ")) else line
|
|
1430
|
+
for line in normalized.splitlines(keepends=True)
|
|
1431
|
+
)
|
|
1432
|
+
replacements = (
|
|
1433
|
+
(f"a/{_git_no_index_display_path(before_root)}/", "a/"),
|
|
1434
|
+
(f"b/{_git_no_index_display_path(before_root)}/", "b/"),
|
|
1435
|
+
(f"a/{_git_no_index_display_path(after_root)}/", "a/"),
|
|
1436
|
+
(f"b/{_git_no_index_display_path(after_root)}/", "b/"),
|
|
1437
|
+
)
|
|
1438
|
+
for old, new in replacements:
|
|
1439
|
+
normalized = normalized.replace(old, new)
|
|
1440
|
+
return normalized
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
def _task_local_snapshot_file_patch_text(
|
|
1444
|
+
*,
|
|
1445
|
+
before_root: Path,
|
|
1446
|
+
after_root: Path,
|
|
1447
|
+
rel_path: str,
|
|
1448
|
+
exists_before: bool,
|
|
1449
|
+
exists_after: bool,
|
|
1450
|
+
) -> str | None:
|
|
1451
|
+
if shutil.which("git") is None:
|
|
1452
|
+
return None
|
|
1453
|
+
|
|
1454
|
+
before_path = (
|
|
1455
|
+
os.fspath(_path_under_root(root=before_root.resolve(), rel_path=rel_path))
|
|
1456
|
+
if exists_before
|
|
1457
|
+
else os.devnull
|
|
1458
|
+
)
|
|
1459
|
+
after_path = (
|
|
1460
|
+
os.fspath(_path_under_root(root=after_root.resolve(), rel_path=rel_path))
|
|
1461
|
+
if exists_after
|
|
1462
|
+
else os.devnull
|
|
1463
|
+
)
|
|
1464
|
+
proc = _run_git_capture(
|
|
1465
|
+
root=None,
|
|
1466
|
+
args=[
|
|
1467
|
+
"diff",
|
|
1468
|
+
"--no-index",
|
|
1469
|
+
"--binary",
|
|
1470
|
+
"-M",
|
|
1471
|
+
"--",
|
|
1472
|
+
before_path,
|
|
1473
|
+
after_path,
|
|
1474
|
+
],
|
|
1475
|
+
text=True,
|
|
1476
|
+
)
|
|
1477
|
+
if proc is None:
|
|
1478
|
+
return None
|
|
1479
|
+
if proc.returncode not in {0, 1}:
|
|
1480
|
+
return None
|
|
1481
|
+
if not proc.stdout:
|
|
1482
|
+
return ""
|
|
1483
|
+
return _rewrite_no_index_patch_paths(
|
|
1484
|
+
proc.stdout,
|
|
1485
|
+
before_root=before_root,
|
|
1486
|
+
after_root=after_root,
|
|
1487
|
+
)
|
|
1488
|
+
|
|
1489
|
+
|
|
1490
|
+
def _build_task_local_snapshot_reporting_diff(
|
|
1491
|
+
root: Path,
|
|
1492
|
+
*,
|
|
1493
|
+
baseline: TaskLocalWorkspaceBaseline,
|
|
1494
|
+
after_snapshot: dict[str, str],
|
|
1495
|
+
) -> ExecutionReportingDiff:
|
|
1496
|
+
changed_files = _workspace_snapshot_changed_files(
|
|
1497
|
+
before=baseline.before_snapshot,
|
|
1498
|
+
after=after_snapshot,
|
|
1499
|
+
)
|
|
1500
|
+
if not changed_files:
|
|
1501
|
+
return ExecutionReportingDiff(changed_files=(), patch_text="")
|
|
1502
|
+
|
|
1503
|
+
summary_lines = ["# Task-local workspace diff", ""]
|
|
1504
|
+
if baseline.preexisting_changed_files:
|
|
1505
|
+
summary_lines.append(
|
|
1506
|
+
"# Workspace was already dirty at task start; this artifact excludes "
|
|
1507
|
+
f"{len(baseline.preexisting_changed_files)} pre-existing change(s)."
|
|
1508
|
+
)
|
|
1509
|
+
summary_lines.append("")
|
|
1510
|
+
|
|
1511
|
+
patch_parts: list[str] = []
|
|
1512
|
+
for rel_path in changed_files:
|
|
1513
|
+
exists_before = rel_path in baseline.before_snapshot
|
|
1514
|
+
exists_after = rel_path in after_snapshot
|
|
1515
|
+
if not exists_before:
|
|
1516
|
+
summary_lines.append(f"added: {rel_path}")
|
|
1517
|
+
elif not exists_after:
|
|
1518
|
+
summary_lines.append(f"deleted: {rel_path}")
|
|
1519
|
+
else:
|
|
1520
|
+
summary_lines.append(f"modified: {rel_path}")
|
|
1521
|
+
|
|
1522
|
+
if baseline.snapshot_root is None:
|
|
1523
|
+
continue
|
|
1524
|
+
patch_text = _task_local_snapshot_file_patch_text(
|
|
1525
|
+
before_root=baseline.snapshot_root,
|
|
1526
|
+
after_root=root,
|
|
1527
|
+
rel_path=rel_path,
|
|
1528
|
+
exists_before=exists_before,
|
|
1529
|
+
exists_after=exists_after,
|
|
1530
|
+
)
|
|
1531
|
+
if not patch_text:
|
|
1532
|
+
continue
|
|
1533
|
+
patch_parts.append(patch_text if patch_text.endswith("\n") else patch_text + "\n")
|
|
1534
|
+
|
|
1535
|
+
summary_text = "\n".join(summary_lines) + "\n"
|
|
1536
|
+
return ExecutionReportingDiff(
|
|
1537
|
+
changed_files=changed_files,
|
|
1538
|
+
patch_text=summary_text + ("\n" + "".join(patch_parts) if patch_parts else ""),
|
|
1539
|
+
)
|
|
1540
|
+
|
|
1541
|
+
|
|
1542
|
+
def capture_task_local_workspace_baseline(
|
|
1543
|
+
root: Path,
|
|
1544
|
+
*,
|
|
1545
|
+
before_commit: str | None,
|
|
1546
|
+
) -> TaskLocalWorkspaceBaseline:
|
|
1547
|
+
# Always capture a file-state snapshot so changed-file attribution stays task-local even in
|
|
1548
|
+
# normal git repos. Only materialize a full workspace copy when the task starts dirty, because
|
|
1549
|
+
# that is the case where a plain end-of-task git diff would otherwise include unrelated work.
|
|
1550
|
+
before_snapshot = snapshot_workspace_tree(root)
|
|
1551
|
+
if before_commit is None:
|
|
1552
|
+
return TaskLocalWorkspaceBaseline(
|
|
1553
|
+
before_commit=None,
|
|
1554
|
+
before_snapshot=before_snapshot,
|
|
1555
|
+
)
|
|
1556
|
+
|
|
1557
|
+
status_entries = _git_status_entries(root)
|
|
1558
|
+
raw_untracked_files = _git_untracked_files(root)
|
|
1559
|
+
preexisting_changed_files = _execution_reporting_changed_files(
|
|
1560
|
+
root,
|
|
1561
|
+
status_entries=status_entries,
|
|
1562
|
+
untracked_files=raw_untracked_files,
|
|
1563
|
+
)
|
|
1564
|
+
if not preexisting_changed_files:
|
|
1565
|
+
return TaskLocalWorkspaceBaseline(
|
|
1566
|
+
before_commit=before_commit,
|
|
1567
|
+
before_snapshot=before_snapshot,
|
|
1568
|
+
)
|
|
1569
|
+
|
|
1570
|
+
snapshot_root = Path(tempfile.mkdtemp(prefix="alysis-task-baseline-")).resolve()
|
|
1571
|
+
try:
|
|
1572
|
+
copy_workspace_snapshot(src_root=root, dest_root=snapshot_root)
|
|
1573
|
+
except Exception:
|
|
1574
|
+
shutil.rmtree(snapshot_root, ignore_errors=True)
|
|
1575
|
+
raise
|
|
1576
|
+
|
|
1577
|
+
return TaskLocalWorkspaceBaseline(
|
|
1578
|
+
before_commit=before_commit,
|
|
1579
|
+
before_snapshot=before_snapshot,
|
|
1580
|
+
preexisting_changed_files=preexisting_changed_files,
|
|
1581
|
+
snapshot_root=snapshot_root,
|
|
1582
|
+
)
|
|
1583
|
+
|
|
1584
|
+
|
|
1585
|
+
def cleanup_task_local_workspace_baseline(baseline: TaskLocalWorkspaceBaseline) -> None:
|
|
1586
|
+
if baseline.snapshot_root is None:
|
|
1587
|
+
return
|
|
1588
|
+
shutil.rmtree(baseline.snapshot_root, ignore_errors=True)
|
|
1589
|
+
|
|
1590
|
+
|
|
1591
|
+
def build_task_local_workspace_reporting_diff(
|
|
1592
|
+
root: Path,
|
|
1593
|
+
*,
|
|
1594
|
+
baseline: TaskLocalWorkspaceBaseline,
|
|
1595
|
+
after_commit: str | None,
|
|
1596
|
+
) -> ExecutionReportingDiff:
|
|
1597
|
+
after_snapshot = snapshot_workspace_tree(root)
|
|
1598
|
+
task_local_diff = _build_task_local_snapshot_reporting_diff(
|
|
1599
|
+
root,
|
|
1600
|
+
baseline=baseline,
|
|
1601
|
+
after_snapshot=after_snapshot,
|
|
1602
|
+
)
|
|
1603
|
+
if baseline.before_commit is None or baseline.had_preexisting_workspace_dirt:
|
|
1604
|
+
return task_local_diff
|
|
1605
|
+
|
|
1606
|
+
try:
|
|
1607
|
+
live_git_diff = build_execution_reporting_diff_with_commit_range(
|
|
1608
|
+
root,
|
|
1609
|
+
before_commit=baseline.before_commit,
|
|
1610
|
+
after_commit=after_commit,
|
|
1611
|
+
)
|
|
1612
|
+
except GitOpsError as e:
|
|
1613
|
+
return ExecutionReportingDiff(
|
|
1614
|
+
changed_files=task_local_diff.changed_files,
|
|
1615
|
+
patch_text=task_local_diff.patch_text,
|
|
1616
|
+
inspection_error=f"scope inspection failed: {e}",
|
|
1617
|
+
)
|
|
1618
|
+
|
|
1619
|
+
return ExecutionReportingDiff(
|
|
1620
|
+
changed_files=live_git_diff.changed_files,
|
|
1621
|
+
patch_text=live_git_diff.patch_text,
|
|
1622
|
+
)
|
|
1623
|
+
|
|
1624
|
+
|
|
1625
|
+
def _merge_execution_reporting_changed_files(
|
|
1626
|
+
root: Path,
|
|
1627
|
+
*path_groups: list[str],
|
|
1628
|
+
) -> tuple[str, ...]:
|
|
1629
|
+
seen: set[str] = set()
|
|
1630
|
+
merged: list[str] = []
|
|
1631
|
+
for group in path_groups:
|
|
1632
|
+
for path in group:
|
|
1633
|
+
normalized = _normalize_reporting_changed_path(root=root, path=str(path))
|
|
1634
|
+
if not normalized or normalized in seen:
|
|
1635
|
+
continue
|
|
1636
|
+
seen.add(normalized)
|
|
1637
|
+
merged.append(normalized)
|
|
1638
|
+
return tuple(merged)
|
|
1639
|
+
|
|
1640
|
+
|
|
1641
|
+
def build_execution_reporting_diff_with_commit_range(
|
|
1642
|
+
root: Path,
|
|
1643
|
+
*,
|
|
1644
|
+
before_commit: str | None,
|
|
1645
|
+
after_commit: str | None,
|
|
1646
|
+
) -> ExecutionReportingDiff:
|
|
1647
|
+
live = build_execution_reporting_diff(root)
|
|
1648
|
+
if not after_commit or after_commit == before_commit:
|
|
1649
|
+
return live
|
|
1650
|
+
|
|
1651
|
+
committed_files = changed_files_since(
|
|
1652
|
+
root,
|
|
1653
|
+
before_commit=before_commit,
|
|
1654
|
+
after_commit=after_commit,
|
|
1655
|
+
)
|
|
1656
|
+
committed_patch_text = diff_text_since(
|
|
1657
|
+
root,
|
|
1658
|
+
before_commit=before_commit,
|
|
1659
|
+
after_commit=after_commit,
|
|
1660
|
+
)
|
|
1661
|
+
|
|
1662
|
+
patch_parts: list[str] = []
|
|
1663
|
+
if committed_patch_text:
|
|
1664
|
+
patch_parts.append(
|
|
1665
|
+
committed_patch_text
|
|
1666
|
+
if committed_patch_text.endswith("\n")
|
|
1667
|
+
else committed_patch_text + "\n"
|
|
1668
|
+
)
|
|
1669
|
+
if live.patch_text and live.patch_text != "(no git diff available)\n":
|
|
1670
|
+
patch_parts.append(
|
|
1671
|
+
live.patch_text if live.patch_text.endswith("\n") else live.patch_text + "\n"
|
|
1672
|
+
)
|
|
1673
|
+
|
|
1674
|
+
return ExecutionReportingDiff(
|
|
1675
|
+
changed_files=_merge_execution_reporting_changed_files(
|
|
1676
|
+
root,
|
|
1677
|
+
list(live.changed_files),
|
|
1678
|
+
committed_files,
|
|
1679
|
+
),
|
|
1680
|
+
patch_text="".join(patch_parts) or live.patch_text,
|
|
1681
|
+
)
|
|
1682
|
+
|
|
1683
|
+
|
|
1684
|
+
def git_changed_files(root: Path) -> list[str]:
|
|
1685
|
+
if shutil.which("git") is None:
|
|
1686
|
+
return []
|
|
1687
|
+
proc = _run_git_capture(root=root, args=["diff", "--name-only"], text=True)
|
|
1688
|
+
if proc is None:
|
|
1689
|
+
return []
|
|
1690
|
+
if proc.returncode != 0:
|
|
1691
|
+
return []
|
|
1692
|
+
return [line.strip() for line in proc.stdout.splitlines() if line.strip()]
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
def write_patch_from_diff(*, root: Path, patch_path: Path) -> None:
|
|
1696
|
+
diff_text = git_diff_text(root)
|
|
1697
|
+
if diff_text is None:
|
|
1698
|
+
patch_path.write_text("(no git diff available)\n", encoding="utf-8")
|
|
1699
|
+
return
|
|
1700
|
+
patch_path.write_text(diff_text, encoding="utf-8")
|
|
1701
|
+
|
|
1702
|
+
|
|
1703
|
+
def snapshot_session_logs(cfg: AppConfig) -> set[Path]:
|
|
1704
|
+
sessions_dir = resolve_sessions_dir(cfg)
|
|
1705
|
+
if not sessions_dir.exists():
|
|
1706
|
+
return set()
|
|
1707
|
+
return set(sessions_dir.glob("*.jsonl"))
|
|
1708
|
+
|
|
1709
|
+
|
|
1710
|
+
def _path_is_relative_to(path: Path, root: Path) -> bool:
|
|
1711
|
+
try:
|
|
1712
|
+
path.resolve().relative_to(root.resolve())
|
|
1713
|
+
return True
|
|
1714
|
+
except ValueError:
|
|
1715
|
+
return False
|
|
1716
|
+
|
|
1717
|
+
|
|
1718
|
+
def _execution_runtime_base_candidates() -> tuple[Path, ...]:
|
|
1719
|
+
return (
|
|
1720
|
+
resolve_sessions_dir(AppConfig()).resolve().parent / "execution_runtime_sessions",
|
|
1721
|
+
Path(tempfile.gettempdir()).resolve() / "alysis" / "execution_runtime_sessions",
|
|
1722
|
+
)
|
|
1723
|
+
|
|
1724
|
+
|
|
1725
|
+
def _safe_execution_runtime_base(*, workspace_root: Path) -> Path:
|
|
1726
|
+
workspace_root = workspace_root.resolve()
|
|
1727
|
+
protected_workspace_root = (workspace_root / ".alysis").resolve()
|
|
1728
|
+
for candidate in _execution_runtime_base_candidates():
|
|
1729
|
+
candidate_resolved = candidate.resolve()
|
|
1730
|
+
if _path_is_relative_to(candidate_resolved, workspace_root):
|
|
1731
|
+
continue
|
|
1732
|
+
if _path_is_relative_to(candidate_resolved, protected_workspace_root):
|
|
1733
|
+
continue
|
|
1734
|
+
return candidate_resolved
|
|
1735
|
+
raise ValueError(
|
|
1736
|
+
f"unable to resolve safe execution runtime base outside workspace: {workspace_root}"
|
|
1737
|
+
)
|
|
1738
|
+
|
|
1739
|
+
|
|
1740
|
+
def execution_private_sessions_dir(
|
|
1741
|
+
*, cfg: AppConfig, run_id: str, task_id: str, workspace_root: Path
|
|
1742
|
+
) -> Path:
|
|
1743
|
+
del cfg
|
|
1744
|
+
base_dir = _safe_execution_runtime_base(workspace_root=workspace_root)
|
|
1745
|
+
return base_dir / sanitize_session_id(run_id) / safe_task_file_component(task_id)
|
|
1746
|
+
|
|
1747
|
+
|
|
1748
|
+
def cleanup_execution_private_sessions_dir(path: Path) -> None:
|
|
1749
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
1750
|
+
|
|
1751
|
+
|
|
1752
|
+
def task_execution_session_artifact_dir(*, run_paths: RunPaths, task_id: str) -> Path:
|
|
1753
|
+
ensure_execution_dirs(run_paths)
|
|
1754
|
+
safe_task = safe_task_file_component(task_id)
|
|
1755
|
+
return run_paths.execution_sessions_dir / safe_task
|
|
1756
|
+
|
|
1757
|
+
|
|
1758
|
+
@dataclass(frozen=True)
|
|
1759
|
+
class ExecutionLogArtifactsResult:
|
|
1760
|
+
log_copy_path: Path
|
|
1761
|
+
pointer_path: Path
|
|
1762
|
+
logging_enabled: bool
|
|
1763
|
+
log_retained: bool
|
|
1764
|
+
session_artifacts_retained: bool
|
|
1765
|
+
copied_log_path: Path | None
|
|
1766
|
+
source_log_path: Path | None
|
|
1767
|
+
session_id: str | None
|
|
1768
|
+
session_artifact_dir: Path | None
|
|
1769
|
+
note: str | None = None
|
|
1770
|
+
cleanup_note: str | None = None
|
|
1771
|
+
|
|
1772
|
+
def pointer_payload(self, *, workspace_root: Path) -> dict[str, Any]:
|
|
1773
|
+
# Persist repo-relative paths when the artifact lives under the workspace,
|
|
1774
|
+
# and redact external runtime locations so beta-facing logs do not leak
|
|
1775
|
+
# host directory structures.
|
|
1776
|
+
payload: dict[str, Any] = {
|
|
1777
|
+
"task_id": self.pointer_path.stem.removesuffix(".log"),
|
|
1778
|
+
"logging_enabled": self.logging_enabled,
|
|
1779
|
+
"log_retained": self.log_retained,
|
|
1780
|
+
"session_artifacts_retained": self.session_artifacts_retained,
|
|
1781
|
+
"copied_log_path": (
|
|
1782
|
+
safe_serialized_path(self.copied_log_path, workspace_root=workspace_root)
|
|
1783
|
+
if self.copied_log_path is not None
|
|
1784
|
+
else None
|
|
1785
|
+
),
|
|
1786
|
+
"source_log_path": (
|
|
1787
|
+
safe_serialized_path(self.source_log_path, workspace_root=workspace_root)
|
|
1788
|
+
if self.source_log_path is not None
|
|
1789
|
+
else None
|
|
1790
|
+
),
|
|
1791
|
+
"session_id": self.session_id,
|
|
1792
|
+
"session_artifact_dir": (
|
|
1793
|
+
safe_serialized_path(self.session_artifact_dir, workspace_root=workspace_root)
|
|
1794
|
+
if self.session_artifact_dir is not None
|
|
1795
|
+
else None
|
|
1796
|
+
),
|
|
1797
|
+
}
|
|
1798
|
+
if self.note is not None:
|
|
1799
|
+
payload["note"] = self.note
|
|
1800
|
+
if self.cleanup_note is not None:
|
|
1801
|
+
payload["cleanup_note"] = self.cleanup_note
|
|
1802
|
+
return payload
|
|
1803
|
+
|
|
1804
|
+
|
|
1805
|
+
def write_exec_log_artifacts(
|
|
1806
|
+
*,
|
|
1807
|
+
paths: RunPaths,
|
|
1808
|
+
task_id: str,
|
|
1809
|
+
cfg: AppConfig,
|
|
1810
|
+
no_log: bool,
|
|
1811
|
+
before_logs: set[Path] | None,
|
|
1812
|
+
sessions_dir: Path | None = None,
|
|
1813
|
+
expected_session_id: str | None = None,
|
|
1814
|
+
) -> ExecutionLogArtifactsResult:
|
|
1815
|
+
ensure_execution_dirs(paths)
|
|
1816
|
+
safe_task = safe_task_file_component(task_id)
|
|
1817
|
+
log_copy_path = paths.execution_logs_dir / f"{safe_task}.jsonl"
|
|
1818
|
+
pointer_path = paths.execution_logs_dir / f"{safe_task}.log.json"
|
|
1819
|
+
retained_session_artifact_dir = task_execution_session_artifact_dir(
|
|
1820
|
+
run_paths=paths,
|
|
1821
|
+
task_id=task_id,
|
|
1822
|
+
)
|
|
1823
|
+
|
|
1824
|
+
resolved_sessions_dir = sessions_dir or resolve_sessions_dir(cfg)
|
|
1825
|
+
retained_sessions_parent = paths.execution_sessions_dir.resolve()
|
|
1826
|
+
selected: Path | None = None
|
|
1827
|
+
if expected_session_id:
|
|
1828
|
+
candidate = resolved_sessions_dir / f"{expected_session_id}.jsonl"
|
|
1829
|
+
if candidate.exists():
|
|
1830
|
+
selected = candidate
|
|
1831
|
+
|
|
1832
|
+
after_logs = sorted(
|
|
1833
|
+
resolved_sessions_dir.glob("*.jsonl"),
|
|
1834
|
+
key=lambda p: p.stat().st_mtime if p.exists() else 0.0,
|
|
1835
|
+
)
|
|
1836
|
+
if selected is None:
|
|
1837
|
+
previous_logs = before_logs or set()
|
|
1838
|
+
new_logs = [p for p in after_logs if p not in previous_logs]
|
|
1839
|
+
selected = new_logs[-1] if new_logs else (after_logs[-1] if after_logs else None)
|
|
1840
|
+
|
|
1841
|
+
source_session_id = selected.stem if selected is not None else expected_session_id
|
|
1842
|
+
source_session_artifact_dir = (
|
|
1843
|
+
resolved_sessions_dir / sanitize_session_id(source_session_id)
|
|
1844
|
+
if source_session_id
|
|
1845
|
+
else None
|
|
1846
|
+
)
|
|
1847
|
+
source_artifacts_exist = bool(
|
|
1848
|
+
source_session_artifact_dir
|
|
1849
|
+
and source_session_artifact_dir.exists()
|
|
1850
|
+
and source_session_artifact_dir.is_dir()
|
|
1851
|
+
)
|
|
1852
|
+
source_artifacts_are_run_owned = resolved_sessions_dir.resolve() == retained_sessions_parent
|
|
1853
|
+
|
|
1854
|
+
log_retained = False
|
|
1855
|
+
copied_log: Path | None = None
|
|
1856
|
+
source_log: Path | None = None
|
|
1857
|
+
retained_artifacts = False
|
|
1858
|
+
session_artifact_dir: Path | None = None
|
|
1859
|
+
note: str | None = None
|
|
1860
|
+
cleanup_note: str | None = None
|
|
1861
|
+
|
|
1862
|
+
if no_log:
|
|
1863
|
+
note = "session logging disabled (--no-log)"
|
|
1864
|
+
if source_artifacts_exist and source_artifacts_are_run_owned:
|
|
1865
|
+
retained_artifacts = True
|
|
1866
|
+
session_artifact_dir = source_session_artifact_dir
|
|
1867
|
+
elif source_artifacts_exist:
|
|
1868
|
+
cleanup_note = "temporary runtime session artifacts will be cleaned up"
|
|
1869
|
+
elif selected and selected.exists():
|
|
1870
|
+
shutil.copy2(selected, log_copy_path)
|
|
1871
|
+
log_retained = True
|
|
1872
|
+
copied_log = log_copy_path
|
|
1873
|
+
source_log = selected
|
|
1874
|
+
if source_artifacts_exist and source_session_artifact_dir is not None:
|
|
1875
|
+
if source_session_artifact_dir.resolve() != retained_session_artifact_dir.resolve():
|
|
1876
|
+
_sync_tree_lightweight(
|
|
1877
|
+
src=source_session_artifact_dir,
|
|
1878
|
+
dest=retained_session_artifact_dir,
|
|
1879
|
+
)
|
|
1880
|
+
retained_artifacts = True
|
|
1881
|
+
session_artifact_dir = retained_session_artifact_dir
|
|
1882
|
+
else:
|
|
1883
|
+
note = "session log not found"
|
|
1884
|
+
|
|
1885
|
+
result = ExecutionLogArtifactsResult(
|
|
1886
|
+
log_copy_path=log_copy_path,
|
|
1887
|
+
pointer_path=pointer_path,
|
|
1888
|
+
logging_enabled=not no_log,
|
|
1889
|
+
log_retained=log_retained,
|
|
1890
|
+
session_artifacts_retained=retained_artifacts,
|
|
1891
|
+
copied_log_path=copied_log,
|
|
1892
|
+
source_log_path=source_log,
|
|
1893
|
+
session_id=source_session_id,
|
|
1894
|
+
session_artifact_dir=session_artifact_dir,
|
|
1895
|
+
note=note,
|
|
1896
|
+
cleanup_note=cleanup_note,
|
|
1897
|
+
)
|
|
1898
|
+
pointer_payload = dict(result.pointer_payload(workspace_root=paths.root))
|
|
1899
|
+
pointer_payload["task_id"] = task_id
|
|
1900
|
+
pointer_path.write_text(
|
|
1901
|
+
json.dumps(pointer_payload, indent=2, sort_keys=True) + "\n",
|
|
1902
|
+
encoding="utf-8",
|
|
1903
|
+
)
|
|
1904
|
+
return result
|