mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* fix(notifications): release re-escalation row locks per row; stop swallowing DB errors in notify_get The re-escalation sweep ran one tick-wide transaction, so each CAS claim's row lock was held across every remaining delivery until the single commit — a concurrent mark-read UPDATE on a claimed row starved into the 60s lock_timeout. The sweep now commits per row (claim commit releases the lock before delivery and makes the burned slot durable), re-fetches each row by snapshotted id so one row's rollback can't expire the rest of the tick, and savepoints each recipient's delivery. notify_get's bare except swallowed the resulting LockNotAvailableError into a false "notification not found" and returned a poisoned session to the commit-at-send middleware, which blew up with PendingRollbackError; it now catches only the two domain outcomes. defer_after_commit's listeners fire on SAVEPOINT release too, which would have drained deferred telegram/bus work before real durability — they now skip savepoint boundaries via get_nested_transaction() (the root get_transaction() is non-None inside the listener even at a real commit). acknowledge_for_recipient's Redis dedup-clear moved before the flush so the row lock never spans a Redis round-trip. The five best-effort CEO-notify swallows that persist notification rows are savepointed. * fix(services): contain swallowed best-effort DB write failures instead of poisoning the session Sweep of the same class as the notify_get incident: broad except-Exception handlers that swallow a failure whose try-body writes through the shared session leave the session rollback-pending, and the verb/request then dies later with PendingRollbackError at commit-at-send. Confirmed-dangerous sites now run the write inside a savepoint (safe since defer_after_commit skips savepoint boundaries): ceo_approve's verified-stamp, completion/pitch/postmortem-style CEO notifies, _inherit_upstream_base, _link_commit_to_task (covers every commit route), board-program LEARN records, the QA/PR-gate/PM-merge verified-stamps, and the documenter->PM handoff. _ack_pending_wake_notifications gets the same treatment so a wake-ack failure can't fail the A2A read. telegram_inbound's per-update loop and intake confirm roll back explicitly instead (their success paths commit mid-flow, so a savepoint doesn't fit). A swallowed savepoint rollback fully expires any ORM object mutated inside the block, and the next attribute read raises MissingGreenlet — strictly worse than the original bug. The two paths that keep using the object after the swallow (doc handoff's envelope build, base inheritance's claim continuation) refresh it in the except path; regression tests run against a real session and were verified to fail with the refresh reverted. * test: shape mocked session.execute results so sync accessors stop leaking unawaited coroutines An AsyncMock's auto-created children are themselves AsyncMock, so production code that correctly awaits session.execute() and then calls sync accessors (.scalars().all(), .scalar_one_or_none()) on the result was silently collecting unawaited coroutines in 22 test files — 80 RuntimeWarnings per unit run, and in test_flow_soup_guard one mock raised a real TypeError that a coincidentally-matching invalid_state envelope masked. Each affected fixture now returns a plain MagicMock shaped like a real Result. Zero AsyncMock warnings remain. * docs: document per-row sweep commits and the savepoint/refresh containment pattern --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
543 lines
18 KiB
Python
543 lines
18 KiB
Python
"""Gate Set D: content-tool ownership guards in ContentActions.
|
|
|
|
When a caller passes a ``task_id`` to commit / dm / note / evidence,
|
|
the gateway must verify ``task.assigned_to == caller_agent_id`` before
|
|
allowing the side effect.
|
|
|
|
Pre-gateway equivalent: agents could only act on tasks they owned because
|
|
the MCP handlers resolved task from session context. With the gateway
|
|
exposing the task_id parameter, the ownership check must be explicit.
|
|
|
|
Exception: ``dm`` without a task_id (channel-only / off-task
|
|
A2A) are exempt — used for channel announcements.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
|
|
|
|
|
|
def _make_deps(**overrides: AsyncMock) -> ContentActionsDeps:
|
|
if "task" in overrides:
|
|
task = overrides["task"]
|
|
else:
|
|
task = AsyncMock()
|
|
task.get_active_task_for_agent.return_value = None
|
|
|
|
# commit() now checks caller role; default to developer.
|
|
|
|
task.agent_for.return_value = MagicMock(role="developer")
|
|
|
|
if "git" in overrides:
|
|
git = overrides["git"]
|
|
else:
|
|
git = AsyncMock()
|
|
git.commit.return_value = {"sha": "abc12345"}
|
|
git.diff.return_value = ""
|
|
|
|
a2a = overrides.get("a2a", AsyncMock())
|
|
journal = overrides.get("journal", AsyncMock())
|
|
workspace = overrides.get("workspace", AsyncMock())
|
|
notifications = overrides.get("notifications", AsyncMock())
|
|
if "evidence_repo" in overrides:
|
|
evidence_repo = overrides["evidence_repo"]
|
|
else:
|
|
evidence_repo = AsyncMock()
|
|
evidence_repo.journal_highlights_for_task.return_value = []
|
|
return ContentActionsDeps(
|
|
task=task,
|
|
git=git,
|
|
a2a=a2a,
|
|
journal=journal,
|
|
workspace=workspace,
|
|
notifications=notifications,
|
|
evidence_repo=evidence_repo,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# commit — uses get_active_task_for_agent which is already a self-scoped query.
|
|
# But if a caller had explicit task_id semantics, we'd guard them. The current
|
|
# commit verb uses active task; tests below verify it cannot reach a task that
|
|
# isn't theirs (active task query handles this naturally).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_commit_active_task_returned_must_be_caller_owned() -> None:
|
|
"""get_active_task_for_agent already self-scopes; verify call signature."""
|
|
agent_id = uuid4()
|
|
task_id = uuid4()
|
|
task_obj = MagicMock(
|
|
id=task_id,
|
|
status="in_progress",
|
|
assigned_to=agent_id,
|
|
active_claimant_id=agent_id,
|
|
branch_name="feature/backend/abc",
|
|
)
|
|
task_svc = AsyncMock()
|
|
task_svc.get_active_task_for_agent.return_value = task_obj
|
|
git_svc = AsyncMock()
|
|
git_svc.commit.return_value = {"sha": "deadbeef1234"}
|
|
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.commit(
|
|
agent_id=agent_id,
|
|
message="feat(api): add /healthz endpoint",
|
|
)
|
|
assert env.error is None
|
|
task_svc.get_active_task_for_agent.assert_awaited_once_with(agent_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# note — explicit task_id ownership
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_note_with_task_id_blocks_when_not_assignee() -> None:
|
|
"""note(task_id=X) where X is owned by someone else is rejected."""
|
|
agent_id = uuid4()
|
|
other_id = uuid4()
|
|
task_id = uuid4()
|
|
task_obj = MagicMock(id=task_id, status="in_progress", assigned_to=other_id)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = task_obj
|
|
journal_svc = AsyncMock()
|
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.note(
|
|
agent_id=agent_id,
|
|
text="I am putting a note on someone else's task",
|
|
scope="reflect",
|
|
task_id=task_id,
|
|
)
|
|
body = env.as_dict()
|
|
assert body["error"] == "not_authorized"
|
|
assert str(task_id) in body["message"] or "assignee" in body["message"]
|
|
journal_svc.write_entry.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_note_with_task_id_allows_when_assignee() -> None:
|
|
"""note(task_id=X) where X is owned by caller succeeds."""
|
|
agent_id = uuid4()
|
|
task_id = uuid4()
|
|
task_obj = MagicMock(
|
|
id=task_id,
|
|
status="in_progress",
|
|
assigned_to=agent_id,
|
|
active_claimant_id=agent_id,
|
|
)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = task_obj
|
|
task_svc.get_active_task_for_agent.return_value = None
|
|
journal_svc = AsyncMock()
|
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
# Uses scope='note' (no structured-field requirement) — the test
|
|
# exercises the ownership gate, not the journal-shape gate (which is
|
|
# covered separately in test_content_actions.py).
|
|
env = await ca.note(
|
|
agent_id=agent_id,
|
|
text="Working on my own task",
|
|
scope="note",
|
|
task_id=task_id,
|
|
)
|
|
assert env.error is None
|
|
journal_svc.write_entry.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_note_without_task_id_skips_ownership_check() -> None:
|
|
"""note without task_id is fine (might be a general entry)."""
|
|
agent_id = uuid4()
|
|
task_svc = AsyncMock()
|
|
task_svc.get_active_task_for_agent.return_value = None
|
|
journal_svc = AsyncMock()
|
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.note(
|
|
agent_id=agent_id,
|
|
text="A general note with enough length",
|
|
scope="note",
|
|
)
|
|
assert env.error is None
|
|
journal_svc.write_entry.assert_awaited_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# say — explicit task_id ownership (only when task_id provided)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# dm — explicit task_id ownership
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dm_with_task_id_blocks_when_not_assignee() -> None:
|
|
"""dm(task_id=X) where X is owned by someone else is rejected."""
|
|
agent_id = uuid4()
|
|
other_id = uuid4()
|
|
task_id = uuid4()
|
|
task_obj = MagicMock(id=task_id, status="in_progress", assigned_to=other_id)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = task_obj
|
|
a2a_svc = AsyncMock()
|
|
deps = _make_deps(task=task_svc, a2a=a2a_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.dm(
|
|
agent_id=agent_id,
|
|
recipient="be-qa-1",
|
|
text="DM about someone else's task",
|
|
task_id=task_id,
|
|
)
|
|
body = env.as_dict()
|
|
assert body["error"] == "not_authorized"
|
|
a2a_svc.send.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dm_with_task_id_owned_by_caller_succeeds() -> None:
|
|
agent_id = uuid4()
|
|
task_id = uuid4()
|
|
task_obj = MagicMock(
|
|
id=task_id,
|
|
status="in_progress",
|
|
assigned_to=agent_id,
|
|
active_claimant_id=agent_id,
|
|
)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = task_obj
|
|
a2a_svc = AsyncMock()
|
|
deps = _make_deps(task=task_svc, a2a=a2a_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.dm(
|
|
agent_id=agent_id,
|
|
recipient="be-qa-1",
|
|
text="DM about my own task",
|
|
task_id=task_id,
|
|
)
|
|
assert env.error is None
|
|
a2a_svc.send.assert_awaited_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# evidence — explicit task_id ownership
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_evidence_blocks_when_not_assignee() -> None:
|
|
"""evidence(task_id=X) where X is owned by someone else is rejected.
|
|
|
|
Exception: QA/documenter agents legitimately review tasks they don't
|
|
own. We allow the read when assigned_to is None (post-handoff state)
|
|
or matches the caller. The strict gate is for posting/writing only.
|
|
|
|
For this test we use a task with another developer as assignee — the
|
|
inspect call is denied because there's no role-based exemption logic
|
|
yet at the gateway layer for content-tools.
|
|
"""
|
|
agent_id = uuid4()
|
|
other_id = uuid4()
|
|
task_id = uuid4()
|
|
task_obj = MagicMock(
|
|
id=task_id,
|
|
status="in_progress",
|
|
assigned_to=other_id,
|
|
branch_name="feature/backend/abc",
|
|
work_session_id=uuid4(),
|
|
commits=["sha1"],
|
|
pr_number=8,
|
|
pr_url="x",
|
|
dev_notes="",
|
|
acceptance_criteria_status=[],
|
|
)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = task_obj
|
|
git_svc = AsyncMock()
|
|
git_svc.diff.return_value = ""
|
|
workspace_svc = AsyncMock()
|
|
deps = _make_deps(task=task_svc, git=git_svc, workspace=workspace_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.evidence(agent_id=agent_id, task_id=task_id)
|
|
body = env.as_dict()
|
|
assert body["error"] == "not_authorized"
|
|
workspace_svc.fetch_branch_for_inspection.assert_not_awaited()
|
|
git_svc.diff.assert_not_awaited()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Board co-review exemption (cluster C5): a board role may record its review
|
|
# note/say on a board/coordination task held by the OTHER board member.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_board_role_may_note_coordination_task_held_by_other_board() -> None:
|
|
"""A board/coordination task (project_id=None, product_id set) is reviewed
|
|
by BOTH board members; the non-assignee board reviewer may still note it."""
|
|
agent_id = uuid4()
|
|
other_board_id = uuid4()
|
|
task_id = uuid4()
|
|
coord_task = MagicMock(
|
|
id=task_id,
|
|
status="pending",
|
|
assigned_to=other_board_id,
|
|
project_id=None,
|
|
product_id=uuid4(),
|
|
)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = coord_task
|
|
task_svc.get_active_task_for_agent.return_value = None
|
|
journal_svc = AsyncMock()
|
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
|
# _make_deps defaults agent_for to a developer; override AFTER so the
|
|
# board co-review exemption sees a board role.
|
|
task_svc.agent_for.return_value = MagicMock(role="head_marketing")
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.note(
|
|
agent_id=agent_id,
|
|
text="UX + positioning review of the board task",
|
|
scope="note",
|
|
task_id=task_id,
|
|
)
|
|
assert env.error is None
|
|
journal_svc.write_entry.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_board_role_blocked_on_project_task_held_by_other() -> None:
|
|
"""The exemption is narrow: a board role still cannot post to a normal
|
|
project-backed task assigned to someone else."""
|
|
agent_id = uuid4()
|
|
other_id = uuid4()
|
|
task_id = uuid4()
|
|
project_task = MagicMock(
|
|
id=task_id,
|
|
status="in_progress",
|
|
assigned_to=other_id,
|
|
project_id=uuid4(),
|
|
product_id=None,
|
|
)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = project_task
|
|
journal_svc = AsyncMock()
|
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
|
# Board role, but a project-backed task — exemption must NOT apply.
|
|
task_svc.agent_for.return_value = MagicMock(role="product_owner")
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.note(
|
|
agent_id=agent_id,
|
|
text="trying to note a code task I don't own",
|
|
scope="note",
|
|
task_id=task_id,
|
|
)
|
|
body = env.as_dict()
|
|
assert body["error"] == "not_authorized"
|
|
journal_svc.write_entry.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_board_role_blocked_on_coordination_task_held_by_other() -> None:
|
|
"""The exemption is board-only: a developer cannot piggyback on it."""
|
|
agent_id = uuid4()
|
|
other_id = uuid4()
|
|
task_id = uuid4()
|
|
coord_task = MagicMock(
|
|
id=task_id,
|
|
status="pending",
|
|
assigned_to=other_id,
|
|
project_id=None,
|
|
product_id=uuid4(),
|
|
)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = coord_task
|
|
task_svc.agent_for.return_value = MagicMock(role="developer")
|
|
journal_svc = AsyncMock()
|
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.note(
|
|
agent_id=agent_id,
|
|
text="dev trying to note a coordination task",
|
|
scope="note",
|
|
task_id=task_id,
|
|
)
|
|
body = env.as_dict()
|
|
assert body["error"] == "not_authorized"
|
|
journal_svc.write_entry.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_evidence_unassigned_task_allows_inspection() -> None:
|
|
"""A task with assigned_to=None (between handoffs) may be inspected.
|
|
|
|
This handles the QA-after-dev-submit case where assigned_to is briefly
|
|
None until reassigned. Strict ownership only applies to write-side.
|
|
"""
|
|
agent_id = uuid4()
|
|
task_id = uuid4()
|
|
task_obj = MagicMock(
|
|
id=task_id,
|
|
status="awaiting_qa",
|
|
assigned_to=None,
|
|
branch_name="feature/backend/abc",
|
|
work_session_id=uuid4(),
|
|
commits=["sha1"],
|
|
pr_number=8,
|
|
pr_url="x",
|
|
dev_notes="",
|
|
acceptance_criteria_status=[],
|
|
)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = task_obj
|
|
# Findings-ledger reads (ReviewFindingsRepository.list_for_task) go
|
|
# through session.execute — an unconfigured AsyncMock's awaited result
|
|
# is itself an AsyncMock, so a plain sync `.scalars()` call on it leaks
|
|
# an unawaited coroutine. Empty scalars result (no findings).
|
|
task_svc.session.execute = AsyncMock(
|
|
return_value=MagicMock(
|
|
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[])))
|
|
)
|
|
)
|
|
git_svc = AsyncMock()
|
|
git_svc.diff.return_value = "diff content"
|
|
workspace_svc = AsyncMock()
|
|
deps = _make_deps(task=task_svc, git=git_svc, workspace=workspace_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.evidence(agent_id=agent_id, task_id=task_id)
|
|
assert env.error is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_evidence_allows_dependency_inspection() -> None:
|
|
"""A caller whose own assigned task depends on the target may read it.
|
|
|
|
A frontend cell waiting on a UX design task must be able to inspect the
|
|
dependency it is blocked on, even though another agent owns it.
|
|
"""
|
|
agent_id = uuid4()
|
|
other_id = uuid4()
|
|
dep_task_id = uuid4()
|
|
target = MagicMock(
|
|
id=dep_task_id,
|
|
status="in_progress",
|
|
assigned_to=other_id,
|
|
branch_name="feature/ux_ui/abc",
|
|
work_session_id=uuid4(),
|
|
commits=[],
|
|
pr_number=None,
|
|
pr_url="",
|
|
dev_notes="",
|
|
acceptance_criteria_status=[],
|
|
)
|
|
callers_task = MagicMock(id=uuid4(), dependency_ids=[dep_task_id])
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = target
|
|
task_svc.list_assigned_for_agent.return_value = [callers_task]
|
|
# Findings-ledger reads (ReviewFindingsRepository.list_for_task) go
|
|
# through session.execute — an unconfigured AsyncMock's awaited result
|
|
# is itself an AsyncMock, so a plain sync `.scalars()` call on it leaks
|
|
# an unawaited coroutine. Empty scalars result (no findings).
|
|
task_svc.session.execute = AsyncMock(
|
|
return_value=MagicMock(
|
|
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[])))
|
|
)
|
|
)
|
|
git_svc = AsyncMock()
|
|
git_svc.diff.return_value = ""
|
|
git_svc.list_changed_files.return_value = []
|
|
workspace_svc = AsyncMock()
|
|
deps = _make_deps(task=task_svc, git=git_svc, workspace=workspace_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.evidence(agent_id=agent_id, task_id=dep_task_id)
|
|
assert env.error is None
|
|
task_svc.list_assigned_for_agent.assert_awaited()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Reaped/handoff window: assigned_to persists, active_claimant_id is cleared
|
|
# on release. A reaped agent must not keep posting to its former task.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_note_reaped_assignee_cannot_post_to_former_task() -> None:
|
|
"""A reaped agent (assigned_to=caller, active_claimant_id=None) is rejected
|
|
on note(task_id=X) — its claim was released, so it must not journal on the
|
|
task it no longer actively holds."""
|
|
agent_id = uuid4()
|
|
task_id = uuid4()
|
|
task_obj = MagicMock(
|
|
id=task_id,
|
|
status="in_progress",
|
|
assigned_to=agent_id, # persists across the reap
|
|
active_claimant_id=None, # cleared on release — the reaped window
|
|
)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = task_obj
|
|
journal_svc = AsyncMock()
|
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.note(
|
|
agent_id=agent_id,
|
|
text="Posting after my claim was reaped",
|
|
scope="note",
|
|
task_id=task_id,
|
|
)
|
|
body = env.as_dict()
|
|
assert body["error"] == "not_authorized", body
|
|
assert "active claim" in body["message"], body
|
|
journal_svc.write_entry.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_note_active_owner_can_still_post() -> None:
|
|
"""No-regression: an active owner (assigned_to=caller AND
|
|
active_claimant_id=caller) still posts. The reaped-window rejection must
|
|
not weaken the legitimate active-owner path."""
|
|
agent_id = uuid4()
|
|
task_id = uuid4()
|
|
task_obj = MagicMock(
|
|
id=task_id,
|
|
status="in_progress",
|
|
assigned_to=agent_id,
|
|
active_claimant_id=agent_id, # active claim — the normal case
|
|
)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = task_obj
|
|
task_svc.get_active_task_for_agent.return_value = None
|
|
journal_svc = AsyncMock()
|
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.note(
|
|
agent_id=agent_id,
|
|
text="Working on my actively-claimed task",
|
|
scope="note",
|
|
task_id=task_id,
|
|
)
|
|
assert env.error is None, env.as_dict()
|
|
journal_svc.write_entry.assert_awaited_once()
|