feat(R04): run every Cowork turn through ConversationApplicationService

R04-T03 — the turn lifecycle, extracted from `core/chat_agent.py::run_cowork`
into `application/conversations/`. The 260-line body mixed the lifecycle (step
budget, cancel checks, guard -> preview -> gate -> execute ordering, sandbox
tidy-up) with the machinery doing each step, and reaching any of it meant
standing up a Qt widget and a worker thread. It is now a plain object driven
through two Protocols and six callables (`turn_runtime.py`), with the concrete
`core/*` wiring confined to `core_runtime_adapter.py` — the same shape R03 used
for routing. Faithful port, not an improvement pass: where the original had a
quirk (the step-ceiling note only merges into the answer when the last message
is the assistant's) the quirk is preserved and commented.

R04-T04 — `ui/cowork_tab.py::build_job` no longer calls run_cowork. It captures
the widget's state at submit time, builds the request via the new
`cowork_turn_request.py` and executes it. `execute(..., messages=...)` hands the
widget's own list over because `_reattach_running_turn` replays from it WHILE
the worker appends and `_finalize_turn` slices it afterwards — a private list
would break both silently.

R04-T05 — `core/task_executors.py`'s cowork branch shares the same engine. All
five unattended-run behaviours stay put (plan reminder, history_ready, History
autosave per assistant message, timeout notice, plan_incomplete_reason), and
`_unattended_prompt` now expresses the load-bearing prefix order in one
readable call instead of three successive rebindings.

Verification: 74 new tests (364 passed, 1 skipped overall; check_imports PASS).
The two that matter most:
- `test_conversation_service_parity.py` runs the same scripted turn through
  run_cowork AND the service and compares the event stream, the resulting
  conversation and the advertised tool list across 7 scenarios;
- `test_task_executor_turn.py` was written BEFORE the migration and passed 8/8
  against the old code, then unchanged against the new.

Known: `ui/cowork_tab.py` (416 -> 455) and `core/task_executors.py` (476 -> 524)
stay above the 400-LOC limit. Both were already over it before this change;
bringing them under needs the R08 / R07 decompositions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 13:14:15 +09:00
co-authored by Claude Opus 5
parent 19e6b4deb2
commit 3665135c38
16 changed files with 2452 additions and 48 deletions
+69 -19
View File
@@ -2,7 +2,9 @@
``execute_task`` dispatches by ``task_type`` to the app's existing engines:
- ``cowork`` → ``chat_agent.run_cowork`` (documents/answers, real files)
- ``cowork`` → ``ConversationApplicationService`` (documents/answers, real
files) — the same turn engine the interactive Cowork chat
runs on since R04-T05
- ``co4e_code`` → ``code_agent.run_code`` (code agent with file/command tools)
- ``script`` → local subprocess with a timeout
- ``flow`` → the task's own simple step list, run sequentially, each
@@ -162,6 +164,30 @@ _TIMEOUT_NOTICE_TMPL = (
)
_UNATTENDED_PREFIX = (
"This runs unattended (Schedule Task) — no one is watching live. Use "
"update_plan to track your steps and keep it accurate: mark a step "
"'error' (not silently skip it) if it genuinely can't be completed."
)
def _unattended_prompt(prompt: str, *, skill_text: str = "",
agent_instructions: str = "") -> str:
"""Assemble the user message an unattended run sends.
The order is load-bearing and used to be encoded as three successive
rebindings of ``prompt``, each prepending its own block: the plan reminder
must lead (it is the instruction that keeps a run without a human watching
honest), then the chosen skill's rules, then the Admin agent's persona, and
the task's own words last. Routing it through ``combine_instructions`` keeps
that order in one readable expression and drops the absent blocks instead of
leaving blank lines behind.
"""
from ..application.conversations.turn_runtime import combine_instructions
return combine_instructions(_UNATTENDED_PREFIX, skill_text, agent_instructions, prompt)
def _cancel_with_timeout(cancel: CancelFn, timeout_sec: Optional[int]) -> Tuple[CancelFn, Callable[[], bool]]:
"""Wrap ``cancel`` so it also fires once ``timeout_sec`` of wall-clock time
elapses. ``timed_out()`` tells the caller whether THAT is why it stopped
@@ -218,36 +244,29 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
# default, see state.build_provider_for). A legacy Admin-agent preset
# (task.admin_agent_id), if still set on an older task, keeps working and
# takes precedence — it pins the provider/model AND prepends instructions.
agent_instructions = ""
if admin_agent is not None:
from .admin_agents import build_agent_provider
provider = build_agent_provider(ctx, admin_agent)
agent_instructions = admin_agent.effective_prompt()
if agent_instructions:
prompt = f"{agent_instructions}\n\n{prompt}"
elif provider_name or model:
# An explicit per-task provider/model override.
provider = ctx.build_provider_for(provider_name or None, model or None)
else:
# Neither overridden → the machine's own Settings default, exactly as before.
provider = ctx.build_active_provider()
# A chosen skill's instructions are prepended so this unattended run follows
# A chosen skill's instructions are applied so this unattended run follows
# them, mirroring how the interactive chat applies /skill.
skill_text = ""
if skill_slug:
from .skills import skill_prefix_for
skill_text = skill_prefix_for(skill_slug)
if skill_text:
prompt = f"{skill_text}\n\n{prompt}"
# This is an UNATTENDED run (no human watching to catch a half-finished
# job) — push the agent to actually use the Plan checklist so completion
# can be verified afterward, instead of just trusting "no exception".
prompt = (
"This runs unattended (Schedule Task) — no one is watching live. Use "
"update_plan to track your steps and keep it accurate: mark a step "
"'error' (not silently skip it) if it genuinely can't be completed.\n\n"
f"{prompt}"
)
# Assemble reminder + skill + persona + the task's own words in one place
# (see _unattended_prompt for why that order matters).
prompt = _unattended_prompt(prompt, skill_text=skill_text,
agent_instructions=agent_instructions)
messages = [{"role": "user", "content": prompt}]
session_id = new_session_id()
project_id = project.project_id if project is not None else ""
@@ -273,10 +292,41 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
watched_cancel, timed_out = _cancel_with_timeout(cancel, timeout_sec)
try:
if task_type == "cowork":
from .chat_agent import run_cowork
run_cowork(provider, messages, out_dir, emit_and_autosave, watched_cancel,
security_config=ctx.config, agent_role=agent_roles.TASK,
project_context=project_context)
# R04-T05: the unattended run shares the interactive turn engine
# instead of calling run_cowork itself, so there is exactly one place
# where a turn's lifecycle is defined. Everything unattended-specific
# stays here (the plan reminder above, the History autosave in
# emit_and_autosave, the timeout notice below).
from ..application.conversations.core_runtime_adapter import (
build_cowork_conversation_service,
legacy_event_sink,
)
from ..domain.agents.conversation_execution_request import (
ConversationExecutionRequest,
)
# No extra_tools/extra_executor and no permission gate: a scheduled
# run gets no MCP connectors and nobody is there to approve a
# command, which is exactly what run_cowork was called with.
service = build_cowork_conversation_service(
provider, out_dir, emit_and_autosave, title=title,
project_context=project_context, security_config=ctx.config,
agent_role=agent_roles.TASK,
)
request = ConversationExecutionRequest(
# The artifact folder is named by the run id, which identifies
# this attempt in the audit log.
turn_id=out_dir.name or session_id, session_id=session_id,
surface="task", title=title, project_id=project_id,
prompt=prompt, output_dir=out_dir,
agent_role=agent_roles.TASK, unattended=True,
timeout_sec=timeout_sec,
)
# ``messages`` is handed over so the History autosave in
# emit_and_autosave (and the final save in the finally block below)
# keep reading the live conversation as it grows.
service.execute(request, legacy_event_sink(emit_and_autosave),
cancel=watched_cancel, messages=messages)
else:
from .code_agent import run_code
limits, block_network = agent_security.sandbox_settings(ctx.config)