fix(company): stop Report #N storm and spill large Cursor prompts to files

Cursor-agent puts prompts on argv, so oversized company/report handoffs hit
OS ARG_MAX, crash the card as FAILED, and reconcile kept minting new Report
attempts forever. Spill large Cursor prompts to a workspace file and hold the
report chain after consecutive failures.
This commit is contained in:
Mao Weiming
2026-07-29 15:42:07 +08:00
committed by LZH-YS1998
parent 3a053e3af8
commit 1268b67223
4 changed files with 382 additions and 8 deletions
+156
View File
@@ -235,6 +235,12 @@ DEFAULT_MAX_PRE_DELIVERY_REWORKS = 3
# honest-but-rejected work. # honest-but-rejected work.
MAX_VERDICT_PARSE_RETRIES = 2 MAX_VERDICT_PARSE_RETRIES = 2
# Cap consecutive FAILED report cards for one parent while it stays in
# AWAITING_MANAGER_REVIEW. Reconcile treats FAILED as "no active report" and
# would otherwise mint Report #N forever (seen when cursor-agent spawn dies
# with ARG_MAX before the process starts).
DEFAULT_MAX_CONSECUTIVE_REPORT_FAILURES = 3
_REVIEW_VERDICT_PARSE_RETRY_HINT = ( _REVIEW_VERDICT_PARSE_RETRY_HINT = (
"\n\n[REVIEW RETRY — Your previous verdict could not be parsed. The " "\n\n[REVIEW RETRY — Your previous verdict could not be parsed. The "
"runtime needs an explicit approve/reject decision to drive the next " "runtime needs an explicit approve/reject decision to drive the next "
@@ -3881,6 +3887,13 @@ class CompanyWorkItemExecutor:
else: else:
# No unconsumed durable report exists. This is either the # No unconsumed durable report exists. This is either the
# first handoff for the phase or a later rework cycle. # first handoff for the phase or a later rework cycle.
# Brake: consecutive FAILED report cards must not mint a
# new Report #N on every reconcile tick.
if await self._should_hold_report_chain(
parent,
work_items=work_items,
):
continue
spawned = await self._ensure_report_work_item_for_work_item( spawned = await self._ensure_report_work_item_for_work_item(
target_id, target_id,
run_items=work_items, run_items=work_items,
@@ -6278,6 +6291,37 @@ class CompanyWorkItemExecutor:
review_evidence["manager_dispatch"] = dict(manager_turn_context) review_evidence["manager_dispatch"] = dict(manager_turn_context)
if review_evidence: if review_evidence:
metadata_updates["review_evidence"] = review_evidence metadata_updates["review_evidence"] = review_evidence
if target_phase == Phase.AWAITING_MANAGER_REVIEW:
# A fresh execute→review handoff must clear any prior report
# storm hold so rework cycles can spawn Report #1 again.
# Baseline the failure streak on already-minted report attempts
# so historical FAILED cards from a previous cycle do not
# immediately re-trigger the hold.
prior_attempts = 0
if linked_work_item is not None:
try:
prior_items = await self._run_items_for_parent(linked_work_item)
prior_attempts = max(
(
self._auxiliary_attempt_number(item, kind="report")
for item in self._targeting_auxiliary_items(
prior_items,
work_item_id,
kind="report",
)
),
default=0,
)
except Exception:
prior_attempts = int(
(linked_work_item_metadata or {}).get("report_attempt_count", 0)
or 0
)
metadata_updates["report_chain_hold"] = ""
metadata_updates["report_chain_hold_reason"] = ""
metadata_updates["report_chain_hold_at"] = ""
metadata_updates["report_chain_failed_attempts"] = 0
metadata_updates["report_failure_baseline_attempt"] = prior_attempts
# Phase write + local status sync via the canonical helper. Returns # Phase write + local status sync via the canonical helper. Returns
# False only if wid disappeared between our lookup and the call — # False only if wid disappeared between our lookup and the call —
@@ -6792,6 +6836,111 @@ class CompanyWorkItemExecutor:
] ]
return active[-1] if active else None return active[-1] if active else None
@classmethod
def _consecutive_failed_auxiliary_attempts(
cls,
run_items: list[DelegationWorkItem],
target_work_item_id: str,
*,
kind: str,
baseline_attempt: int = 0,
) -> int:
"""Count trailing FAILED auxiliary cards since the last non-failed one.
Why this exists: each crashed report card settles as Phase.FAILED
(a DONE_PHASE), so ``_active_auxiliary_item`` returns None and
reconcile mints a fresh attempt. Counting the trailing failure
streak lets the runtime stop before Report #295.
``baseline_attempt`` ignores older cards from a previous execute
review cycle so a rework handoff can spawn again after the hold
was cleared.
"""
floor = max(0, int(baseline_attempt or 0))
streak = 0
for item in reversed(
cls._targeting_auxiliary_items(
run_items,
target_work_item_id,
kind=kind,
)
):
attempt_no = cls._auxiliary_attempt_number(item, kind=kind)
if attempt_no <= floor:
break
if getattr(item, "phase", None) == Phase.FAILED:
streak += 1
continue
break
return streak
@classmethod
def _report_failure_limit(cls, parent_item: DelegationWorkItem) -> int:
metadata = dict(getattr(parent_item, "metadata", {}) or {})
raw = metadata.get("max_consecutive_report_failures")
try:
value = int(raw) if raw is not None else DEFAULT_MAX_CONSECUTIVE_REPORT_FAILURES
except (TypeError, ValueError):
value = DEFAULT_MAX_CONSECUTIVE_REPORT_FAILURES
return max(1, value)
async def _should_hold_report_chain(
self,
parent_item: DelegationWorkItem,
*,
work_items: list[DelegationWorkItem] | None = None,
) -> bool:
"""True when reconcile must stop minting new report cards for parent."""
parent_metadata = dict(getattr(parent_item, "metadata", {}) or {})
if str(parent_metadata.get("report_chain_hold", "") or "").strip():
return True
all_run_items = await self._run_items_for_parent(parent_item, work_items)
try:
baseline = int(parent_metadata.get("report_failure_baseline_attempt", 0) or 0)
except (TypeError, ValueError):
baseline = 0
consecutive_failures = self._consecutive_failed_auxiliary_attempts(
all_run_items,
parent_item.work_item_id,
kind="report",
baseline_attempt=baseline,
)
limit = self._report_failure_limit(parent_item)
if consecutive_failures < limit:
return False
hold_reason = (
f"{consecutive_failures} consecutive report cards failed "
f"(limit {limit}); refusing to spawn another Report #N until "
"the parent leaves AWAITING_MANAGER_REVIEW or the hold is cleared"
)
try:
await self.store.update_delegation_work_item(
parent_item.work_item_id,
metadata_updates={
"report_chain_hold": "consecutive_report_failures",
"report_chain_hold_reason": hold_reason,
"report_chain_hold_at": datetime.now().isoformat(),
"report_chain_failed_attempts": consecutive_failures,
},
)
except Exception:
logger.opt(exception=True).warning(
"Failed to stamp report_chain_hold on "
f"work_item_id={parent_item.work_item_id}"
)
await self._record_work_item_runtime_diagnostic(
code="report_chain_held_after_failures",
severity="error",
work_item=parent_item,
message=hold_reason,
details={
"consecutive_failures": consecutive_failures,
"limit": limit,
"baseline_attempt": baseline,
},
)
return True
@classmethod @classmethod
def _next_auxiliary_attempt( def _next_auxiliary_attempt(
cls, cls,
@@ -7511,6 +7660,13 @@ class CompanyWorkItemExecutor:
kind="review", kind="review",
) is not None: ) is not None:
return None return None
# Defense in depth: even callers outside reconcile (e.g. DONE
# transition) must not mint Report #N after a failure storm.
if await self._should_hold_report_chain(
worker_item,
work_items=all_run_items,
):
return None
existing_card = self._active_auxiliary_item( existing_card = self._active_auxiliary_item(
all_run_items, all_run_items,
target_work_item_id, target_work_item_id,
+92 -8
View File
@@ -3,8 +3,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import hashlib
import re import re
import shutil import shutil
from pathlib import Path
from typing import Any from typing import Any
from loguru import logger from loguru import logger
@@ -18,6 +20,12 @@ class CursorAdapter(ExternalAgentAdapter):
agent_type = "cursor" agent_type = "cursor"
default_command = "cursor-agent" default_command = "cursor-agent"
# cursor-agent takes the prompt as a positional argv token (not stdin in
# --print mode). Large company/report prompts routinely exceed OS ARG_MAX
# and crash spawn with ``OSError: [Errno 7] Argument list too long``. Keep
# small prompts on argv; spill larger ones to a workspace file and pass a
# short pointer prompt instead.
_ARGV_PROMPT_MAX_BYTES = 16 * 1024
def __init__(self, config=None) -> None: def __init__(self, config=None) -> None:
super().__init__(config=config) super().__init__(config=config)
@@ -90,13 +98,71 @@ class CursorAdapter(ExternalAgentAdapter):
def agent_isolation_home_slug(self) -> str: def agent_isolation_home_slug(self) -> str:
return "cursor" return "cursor"
def _prompt_arg_for_invocation(
self,
prompt: str,
*,
workspace_path: str | None = None,
task: Task | None = None,
) -> tuple[str, dict[str, object]]:
"""Return argv-safe prompt text plus transport metadata.
Why file spill exists: ``cursor-agent -p`` consumes the prompt as a
positional CLI argument. Putting a 100k+ company handoff prompt on
argv trips Linux/macOS ``Argument list too long`` before the process
starts, which previously crashed report cards and triggered an
infinite Report #N reconcile storm.
"""
prompt_text = str(prompt or "")
prompt_bytes = len(prompt_text.encode("utf-8"))
if prompt_bytes <= self._ARGV_PROMPT_MAX_BYTES:
return prompt_text, {
"prompt_transport": "argv",
"prompt_bytes": prompt_bytes,
}
root = Path(str(workspace_path or "").strip() or ".").expanduser()
try:
root = root.resolve()
except OSError:
root = Path(".").resolve()
prompt_dir = root / ".opc" / "external_prompts"
prompt_dir.mkdir(parents=True, exist_ok=True)
task_id = str(getattr(task, "id", "") or "").strip() or "task"
digest = hashlib.sha256(prompt_text.encode("utf-8")).hexdigest()[:16]
prompt_path = prompt_dir / f"cursor_{task_id}_{digest}.md"
prompt_path.write_text(prompt_text, encoding="utf-8")
pointer = (
"Open and follow the complete task instructions in this file exactly:\n"
f"{prompt_path}\n\n"
"Treat the file contents as your full prompt. Do not ask for confirmation "
"before starting; do not recreate the file."
)
return pointer, {
"prompt_transport": "file",
"prompt_bytes": prompt_bytes,
"prompt_file": str(prompt_path),
"prompt_transport_reason": "prompt_too_large_for_argv",
}
@staticmethod
def _redact_prompt_arg(cmd: list[str], prompt: str) -> list[str]:
redacted = list(cmd)
if redacted:
redacted[-1] = f"<prompt:{len(prompt.encode('utf-8'))}-bytes>"
return redacted
def build_invocation( def build_invocation(
self, self,
task: Task, task: Task,
workspace_path: str | None = None, workspace_path: str | None = None,
) -> tuple[list[str], dict[str, object]]: ) -> tuple[list[str], dict[str, object]]:
_ = workspace_path full_prompt = self.build_task_prompt(task)
prompt = self.build_task_prompt(task) prompt_arg, transport_meta = self._prompt_arg_for_invocation(
full_prompt,
workspace_path=workspace_path,
task=task,
)
command = self._runtime_command() or self.configured_command() command = self._runtime_command() or self.configured_command()
cmd = [ cmd = [
command, command,
@@ -108,10 +174,18 @@ class CursorAdapter(ExternalAgentAdapter):
*self._build_model_args(), *self._build_model_args(),
*self._build_session_args(), *self._build_session_args(),
*list(self.config.extra_args), *list(self.config.extra_args),
prompt, prompt_arg,
] ]
metadata = self.build_invocation_metadata(cmd) # Redact large/file-backed prompts from audit command strings so logs
# stay small and never re-inflate ARG_MAX-sized text into metadata.
display_cmd = (
self._redact_prompt_arg(cmd, full_prompt)
if transport_meta.get("prompt_transport") == "file" or len(full_prompt) > 160
else cmd
)
metadata = self.build_invocation_metadata(display_cmd)
metadata["binary"] = command metadata["binary"] = command
metadata.update(transport_meta)
return cmd, metadata return cmd, metadata
def build_interactive_invocation( def build_interactive_invocation(
@@ -119,8 +193,12 @@ class CursorAdapter(ExternalAgentAdapter):
task: Task, task: Task,
workspace_path: str | None = None, workspace_path: str | None = None,
) -> tuple[list[str], dict[str, object]]: ) -> tuple[list[str], dict[str, object]]:
_ = workspace_path full_prompt = self.build_task_prompt(task)
prompt = self.build_task_prompt(task) prompt_arg, transport_meta = self._prompt_arg_for_invocation(
full_prompt,
workspace_path=workspace_path,
task=task,
)
command = self._runtime_command() or self.configured_command() command = self._runtime_command() or self.configured_command()
cmd = [ cmd = [
command, command,
@@ -132,10 +210,16 @@ class CursorAdapter(ExternalAgentAdapter):
*self._build_model_args(), *self._build_model_args(),
*self._build_session_args(), *self._build_session_args(),
*list(self.config.extra_args), *list(self.config.extra_args),
prompt, prompt_arg,
] ]
metadata = self.build_invocation_metadata(cmd) display_cmd = (
self._redact_prompt_arg(cmd, full_prompt)
if transport_meta.get("prompt_transport") == "file" or len(full_prompt) > 160
else cmd
)
metadata = self.build_invocation_metadata(display_cmd)
metadata["binary"] = command metadata["binary"] = command
metadata.update(transport_meta)
return cmd, metadata return cmd, metadata
def extract_resume_session_id(self, output: str) -> str: def extract_resume_session_id(self, output: str) -> str:
+32
View File
@@ -3503,6 +3503,38 @@ class ExternalAgentMonitoringTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(result.status, TaskStatus.DONE) self.assertEqual(result.status, TaskStatus.DONE)
self.assertEqual(spawn_mock.await_args.kwargs["stdin"], asyncio.subprocess.DEVNULL) self.assertEqual(spawn_mock.await_args.kwargs["stdin"], asyncio.subprocess.DEVNULL)
def test_cursor_adapter_large_prompt_spills_to_workspace_file(self) -> None:
adapter = CursorAdapter(config=ExternalAgentConfig(command="cursor-agent"))
prompt = "x" * (CursorAdapter._ARGV_PROMPT_MAX_BYTES + 1)
task = Task(id="task-large", title="large", description=prompt)
full_prompt = adapter.build_task_prompt(task)
tmpdir = _make_test_dir("cursor-large-prompt-file")
try:
cmd, metadata = adapter.build_interactive_invocation(
task, workspace_path=tmpdir
)
self.assertEqual(metadata["prompt_transport"], "file")
self.assertEqual(
metadata["prompt_transport_reason"],
"prompt_too_large_for_argv",
)
prompt_file = Path(str(metadata["prompt_file"]))
self.assertTrue(prompt_file.is_file())
self.assertEqual(prompt_file.read_text(encoding="utf-8"), full_prompt)
self.assertNotIn(full_prompt, cmd)
self.assertIn(str(prompt_file), str(cmd[-1]))
self.assertNotIn(prompt[:64], metadata["command"])
self.assertLess(len(cmd[-1].encode("utf-8")), CursorAdapter._ARGV_PROMPT_MAX_BYTES)
finally:
_cleanup_test_dir(tmpdir)
def test_cursor_adapter_small_prompt_stays_on_argv(self) -> None:
adapter = CursorAdapter(config=ExternalAgentConfig(command="cursor-agent"))
task = Task(title="demo", description="short body")
cmd, metadata = adapter.build_invocation(task, workspace_path="/tmp/opc-ws")
self.assertEqual(metadata["prompt_transport"], "argv")
self.assertEqual(cmd[-1], adapter.build_task_prompt(task))
def test_codex_adapter_mirrors_user_auth_and_config_with_copy_fallback(self) -> None: def test_codex_adapter_mirrors_user_auth_and_config_with_copy_fallback(self) -> None:
adapter = CodexAdapter() adapter = CodexAdapter()
tmpdir = _make_test_dir("codex-mirror-user-config") tmpdir = _make_test_dir("codex-mirror-user-config")
+102
View File
@@ -1433,5 +1433,107 @@ class ReportCardRunnableFilterTests(unittest.TestCase):
) )
class ReportFailureStormBrakeTests(unittest.IsolatedAsyncioTestCase):
"""FAILED report cards must not mint Report #N forever on reconcile."""
async def asyncSetUp(self) -> None:
self._tmpdir = tempfile.TemporaryDirectory()
self.root = Path(self._tmpdir.name)
self.store = OPCStore(self.root / "tasks.db")
await self.store.initialize()
self.executor = _build_executor(self.store, _make_org_engine(self.root))
async def asyncTearDown(self) -> None:
await self.store.close()
self._tmpdir.cleanup()
def _failed_report(self, attempt: int) -> DelegationWorkItem:
report_id = report_work_item_id_for_attempt("wi-child", attempt)
return DelegationWorkItem(
work_item_id=report_id,
run_id="run-1",
cell_id="team::cto",
team_id="team::cto",
role_id="engineer",
seat_id="seat::team::cto::engineer",
parent_work_item_id="wi-child",
kind="report",
projection_id=report_id,
phase=Phase.FAILED,
batch_index=attempt,
metadata={
"runtime_model": "multi_team_org",
"report_execution_work_item": True,
"report_attempt": attempt,
"report_target_work_item_id": "wi-child",
"hidden_from_company_kanban": True,
},
)
async def test_reconcile_stops_after_consecutive_report_failures(self) -> None:
parent = _build_child_work_item()
parent.phase = Phase.AWAITING_MANAGER_REVIEW
parent.metadata = {
**dict(parent.metadata or {}),
"review_owner_role_id": "cto",
"review_owner_seat_id": "seat::team::cto::cto",
"max_consecutive_report_failures": 3,
}
await self.store.save_delegation_work_item(parent)
for attempt in (1, 2, 3):
await self.store.save_delegation_work_item(self._failed_report(attempt))
before = [
item.work_item_id
for item in await self.store.list_delegation_work_items("run-1")
if item.kind == "report"
]
await self.executor._reconcile_missing_review_chain(
await self.store.list_delegation_work_items("run-1")
)
after = [
item.work_item_id
for item in await self.store.list_delegation_work_items("run-1")
if item.kind == "report"
]
self.assertEqual(before, after)
self.assertIsNone(
await self.store.get_delegation_work_item(
report_work_item_id_for_attempt("wi-child", 4)
)
)
refreshed = await self.store.get_delegation_work_item("wi-child")
self.assertEqual(
refreshed.metadata.get("report_chain_hold"),
"consecutive_report_failures",
)
async def test_reconcile_allows_retry_below_failure_limit(self) -> None:
parent = _build_child_work_item()
parent.phase = Phase.AWAITING_MANAGER_REVIEW
parent.metadata = {
**dict(parent.metadata or {}),
"review_owner_role_id": "cto",
"review_owner_seat_id": "seat::team::cto::cto",
"max_consecutive_report_failures": 3,
}
await self.store.save_delegation_work_item(parent)
await self.store.save_delegation_work_item(self._failed_report(1))
await self.store.save_delegation_work_item(self._failed_report(2))
await self.executor._reconcile_missing_review_chain(
await self.store.list_delegation_work_items("run-1")
)
next_report = await self.store.get_delegation_work_item(
report_work_item_id_for_attempt("wi-child", 3)
)
self.assertIsNotNone(next_report)
self.assertEqual(next_report.phase, Phase.READY)
refreshed = await self.store.get_delegation_work_item("wi-child")
self.assertFalse(
str((refreshed.metadata or {}).get("report_chain_hold", "") or "").strip()
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()