Skip to content

fix(governance): delegation + org-model hardening (#1661/#1662 retro, #174) - #1712

Merged
jaylfc merged 2 commits into
devfrom
fix/delegation-org-hardening
Jul 7, 2026
Merged

fix(governance): delegation + org-model hardening (#1661/#1662 retro, #174)#1712
jaylfc merged 2 commits into
devfrom
fix/delegation-org-hardening

Conversation

@jaylfc

@jaylfc jaylfc commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Retrospective audit fixes for the merged agent org model (#1661) and heartbeat (#1662), all verified against current dev. Each fix ships with a test. Relates to #174.

Fixes

1. CRITICAL - deny-event audit rows were unfindable. On a policy deny, delegation.py recorded the board-audit event with a synthetic task_id=f"agent:{from_agent}" and no project_id (defaults to ""). Since recent_for_project filters on project_id, deny events never appeared in any project feed. Now the deny is keyed on the real task_id when a task is present (so it also shows in the task's history) and always carries the real project_id; with no task it records a policy:delegate event rather than a fabricated agent-as-task id.

2. WARNING - raw task id leaked to the human approval inbox. When delegating an existing task with no task_title, the blocking Decision question read "wants to delegate <raw task_id>". The task's title (already fetched in delegate_task) is now threaded into _check_delegation_policy and used, falling back to task_title then task_id.

3. MEDIUM - assignee identity mismatch (from #1662). complete_delegation wrote assignee_id=to_agent, but that is the agent NAME (slug), while beads, project members, and the heartbeat sweep (list_ready_tasks_for_assignee) all key on the config HEX id (config.py mints uuid4().hex[:12]). Delegated tasks were therefore invisible to the heartbeat and id-keyed member lookups. The name is now resolved to the hex id for assignee_id (name kept for display/notify), with a graceful fallback when the agent has no distinct id (id == name).

4. WARNING - set_reporting TOCTOU cycle race. The cycle guard walked the reporting chain via multiple await self.get(...) reads and then issued a separate UPDATE with no serialization. On the shared aiosqlite connection the awaits yield, so two concurrent edits (A->B and B->A) could both pass the check then both write, persisting a cycle. The check-then-write is now held under an asyncio.Lock.

5. WARNING - PUT /org empty-body no-op + no "" normalization. A body of {} (all fields None) returned 200 while changing nothing, and role/title were stored verbatim including whitespace, whereas the codebase convention (applied to reports_to) is that "" clears a nullable field. An all-empty body is now rejected with 400, and empty/whitespace-only role/title clear the field (NULL).

Tests

  • tests/test_routes_delegation.py: fixtures given distinct id vs name (which masked Authentication system for web GUI #3); new tests for the findable deny audit event, the title-not-raw-id approval question, and the id == name fallback.
  • tests/test_agent_registry_store.py: empty-string/whitespace clear for set_role_title; concurrent A->B / B->A edits cannot persist a cycle.
  • tests/test_routes_agent_org.py: empty body 400, clear role via "", whitespace-only title cleared.

All affected and adjacent suites pass (test_routes_delegation, test_routes_agent_org, test_agent_registry_store, test_agent_registry, test_board_audit, test_routes_decisions, test_agent_heartbeat).

Summary by CodeRabbit

  • Bug Fixes
    • Updating agent org fields now rejects {} (HTTP 400) and properly clears role/title when empty or whitespace is provided, returning null in responses.
    • Concurrent reporting-line updates are now protected against persisting circular reporting relationships.
    • Delegation handling now uses stable agent identifiers for task assignment and produces more discoverable denial/audit events; approval prompts use the existing task’s title when available.
  • Tests
    • Added coverage for role/title clearing and concurrent reporting-cycle prevention, plus updated route/delegation scenarios and audit/approval behaviors.

…174)

Retrospective audit fixes for the agent org model (#1661) and heartbeat
(#1662), all verified against current dev.

1. Deny-event audit rows were unfindable. A policy deny recorded the board
   audit event with a synthetic agent:{from_agent} task_id and a blank
   project_id, so recent_for_project never surfaced it. Now the deny is keyed
   on the real task (when present) and always carries the real project_id, so
   it shows in both the project feed and the task history.

2. Raw task id leaked to the human approval inbox. The blocking Decision
   question showed the raw task_id when no task_title was given. The existing
   task's title is now threaded through and used, falling back to task_title
   then task_id.

3. Assignee identity mismatch. complete_delegation wrote assignee_id as the
   agent NAME, but beads, project members, and the heartbeat sweep all key on
   the config hex id, so delegated tasks were invisible to id-keyed lookups.
   The name is now resolved to the hex id for assignee_id (name kept for
   display/notify), with a graceful fallback when id == name.

4. set_reporting cycle-check TOCTOU race. The check-then-write walked the
   reporting chain across multiple awaits on the shared aiosqlite connection
   with no serialization, so two concurrent edits (A->B and B->A) could both
   pass and both write a persisted cycle. The sequence is now held under an
   asyncio.Lock.

5. PUT /org empty-body no-op and missing "" normalization. An all-None body
   returned 200 while changing nothing, and role/title were stored verbatim
   including whitespace. An all-empty body is now rejected with 400, and
   empty/whitespace-only role/title clear the field (NULL), matching the
   reports_to convention.
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3c9d358d-1a9a-4222-86ad-fae6084f307d

📥 Commits

Reviewing files that changed from the base of the PR and between ca384fc and 8b9a6a2.

📒 Files selected for processing (1)
  • tinyagentos/agent_registry_store.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tinyagentos/agent_registry_store.py

📝 Walkthrough

Walkthrough

The PR changes org-field updates to reject empty payloads and normalize clearing semantics, adds locking and targeted updates in the agent registry store, and updates delegation to resolve stable assignee ids, adjust denial audit payloads, and refine approval-question text. Tests cover the new behavior.

Changes

Role/title clearing and reporting-cycle locking

Layer / File(s) Summary
Store-level role/title update and reporting lock
tinyagentos/agent_registry_store.py
Adds a reporting lock, rewrites set_role_title to update only provided fields with empty strings stored as NULL, and wraps set_reporting in the lock around validation and persistence.
Org fields route validation and normalization
tinyagentos/routes/agent_registry.py
Rejects empty org updates with HTTP 400 and normalizes role/title inputs before calling the store.
Tests for role/title clearing and cycle prevention
tests/test_agent_registry_store.py, tests/test_routes_agent_org.py
Adds tests for empty-string and whitespace handling, concurrent reporting-cycle prevention, and empty-body rejection.

Delegation assignee resolution and audit fixes

Layer / File(s) Summary
Agent id resolution helper
tinyagentos/routes/delegation.py
Adds a helper that resolves a config-agent name to a stable hex id, with fallback to the original name.
Delegation policy check, denial audit, and completion assignment
tinyagentos/routes/delegation.py
Threads existing task titles into policy checks, changes denial audit fields to real ids, and assigns delegated tasks using resolved assignee ids.
Delegation route tests for assignee id and audit findability
tests/test_routes_delegation.py
Updates delegation assertions to expect resolved ids and adds coverage for deny-audit visibility, title-based approval questions, and legacy id fallback.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

  • jaylfc/taOS#1661: Touches the same AgentRegistryStore/update_org_fields and delegation gating flow that this PR refines.
  • jaylfc/taOS#1662: Covers the same delegation approval and task-assignment path that this PR updates for stable assignee ids and audit events.
  • jaylfc/taOS#1674: Shares the delegation governance path that feeds into the completion and audit behavior changed here.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the main scope: governance delegation fixes and org-model hardening.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/delegation-org-hardening

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gitar-bot

gitar-bot Bot commented Jul 7, 2026

Copy link
Copy Markdown

Gitar is working

Gitar

if title is not None:
cols.append("title = ?")
vals.append(title or None)
if not cols:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The new set_role_title has a dead-code branch that returns the pre-update record.

The if not cols: return record branch only fires when both role and title are None, but the public route (update_org_fields) already rejects the all-None body with 400. Inside the store it's safer to treat role=None, title=None as a no-op that still reflects disk truth — either drop the early return and let the execute run harmlessly, or replace it with a single return await self.get(canonical_id) so the returned row is consistent with any concurrent write.

Suggested change
if not cols:
if not cols:
return await self.get(canonical_id)

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread tinyagentos/agent_registry_store.py Outdated
# edits (A->B and B->A) can't both pass the cycle guard and then both write
# a persisted cycle. The shared aiosqlite connection yields on every await
# inside the check, so the read chain and the write must be held together.
_reporting_lock: asyncio.Lock

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: The class-level _reporting_lock: asyncio.Lock annotation declares the attribute without a value, so instances do NOT actually have _reporting_lock until init() runs.

The runtime RuntimeError("AgentRegistryStore not initialised") guard on self._db is None happens to cover this in practice because self._db is also assigned in init() on the same line order, but the lock is not a defensive default (a class-level asyncio.Lock() default would be). Any future caller that bypasses init() (e.g. a test stub that sets _db directly) will get AttributeError: 'AgentRegistryStore' object has no attribute '_reporting_lock' instead of a clear initialization error.

Recommend either initializing at class scope (_reporting_lock: asyncio.Lock = asyncio.Lock() — note asyncio.Lock instances must be created in a running loop, so keep the init() assignment) or asserting in set_reporting before the lock acquisition.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

# Normalize whitespace-only values to "" so set_role_title clears the
# field (NULL), matching the reports_to ""-clears convention below;
# a None field is left untouched.
role = body.role.strip() if body.role is not None else None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: body.role.strip() and body.title.strip() will raise AttributeError if the JSON body sends a non-string (e.g. null is fine but 42 or an array slips past pydantic only if the type is str).

Pydantic's Optional[str] does coerce/validate, but consider explicitly documenting in OrgUpdateRequest that role and title MUST be strings when present, and use a field_validator to reject non-string types with a 422 rather than a 500. Today a body like {"role": 42} would be accepted by pydantic (coerced to "42") and silently stripped to "42" — surprising.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

agent = find_agent(config, name)
if agent is None:
return name
return agent.get("id") or name

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: The docstring claims fallback behavior is for agents where id == name, but the implementation just does agent.get("id") or name.

For an agent whose config entry has "id": "" (empty string), the code returns the name (correct) — but for "id": null, agent.get("id") is None, also returning name (correct). However, for an agent that has id set to a value that does not match the slug (which is the intended shape post-fix), the code uses the id — which is the desired behavior. The doc is slightly misleading about WHY the fallback fires; consider rewording to "falls back to name when the agent has no resolvable id (missing, empty, or null)" so the comment matches the actual semantics.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

# to_agent is the config slug (name); task assignee_id must be the agent's
# stable hex id so the heartbeat sweep and id-keyed member lookups can see
# the delegated task. The name is still used below for the notify text.
assignee_id = _resolve_agent_id(request, to_agent)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: _resolve_agent_id silently returns name unchanged when find_agent(config, name) returns None.

In delegate_task the slug is already validated by the preceding _resolve_agent_name call (which 404s on unknown agents), so for the direct-allow path this is safe. But complete_delegation is also called from routes/decisions.py:_apply_delegation_grant (line 330), where to_agent comes from decision.metadata.to_agent — a value persisted at decision-creation time. If the config agent is renamed or removed between the gate being raised and the gate being answered, _resolve_agent_id will silently fall back to the now-stale slug, and the task will be assigned to an assignee that no longer exists in config.agents. The PR claims this fixes the assignee mismatch — the heartbeat sweep and id-keyed member lookups still won't find the task.

Recommend: log a warning when the fallback fires, or re-validate the slug against current config and return a clear error to the decision-approval path.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

return await self.update(canonical_id, role=role, title=title)
if self._db is None:
raise RuntimeError("AgentRegistryStore not initialised")
record = await self.get(canonical_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The new set_role_title re-implements update logic instead of delegating to self.update() and is not protected by the new _reporting_lock.

The new function does its own SELECT → UPDATE → SELECT rather than calling the existing update() method (which had the same semantics). More importantly, set_role_title and set_reporting are not held under a common lock, so a concurrent call pair like set_role_title("X", title="lead") interleaved with set_reporting("X", "Y") could see a stale get() and overwrite a concurrent change. The PR's stated race is real for set_reporting, but the same TOCTOU exists across the new set_role_title and set_reporting boundary. Consider widening the lock to cover both org-mutating methods, or add a brief comment noting the intentional scope.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@kilo-code-bot

kilo-code-bot Bot commented Jul 7, 2026

Copy link
Copy Markdown

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 0

The incremental commit (8b9a6a2) addresses the previously reported WARNING on _reporting_lock by moving its creation from init() into init. The lock now exists at construction time, independent of init ordering, so any caller will see a real asyncio.Lock instance and the AttributeError regression vector from the prior review is closed.

Files Reviewed (1 file)
  • tinyagentos/agent_registry_store.py - 0 new issues (previous WARNING resolved)
Previous Review Summary (commit ca384fc)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit ca384fc)

Status: 6 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 2
SUGGESTION 4
Issue Details (click to expand)

WARNING

File Line Issue
tinyagentos/agent_registry_store.py 335 _reporting_lock class-level annotation does not actually create the attribute; instances depend on init() having run, with no defensive default.
tinyagentos/routes/delegation.py 286 _resolve_agent_id silently falls back to a stale slug when the config agent was removed/renamed between gate creation and gate answer — delegated task still invisible to id-keyed lookups.

SUGGESTION

File Line Issue
tinyagentos/agent_registry_store.py 668 set_role_title re-implements update logic and is not covered by the new _reporting_lock — TOCTOU vs concurrent set_reporting.
tinyagentos/agent_registry_store.py 680 Dead-code if not cols: return record returns pre-update row that may be stale vs disk.
tinyagentos/routes/agent_registry.py 762 body.role.strip() / body.title.strip() should reject non-string inputs explicitly via pydantic field validator to avoid silent coercion.
tinyagentos/routes/delegation.py 95 Docstring says fallback is for agents where id == name; actual code is agent.get("id") or name — comment misstates the trigger condition.
Files Reviewed (6 files)
  • tinyagentos/agent_registry_store.py - 3 issues
  • tinyagentos/routes/agent_registry.py - 1 issue
  • tinyagentos/routes/delegation.py - 2 issues
  • tests/test_agent_registry_store.py - 0 issues
  • tests/test_routes_agent_org.py - 0 issues
  • tests/test_routes_delegation.py - 0 issues

Fix these issues in Kilo Cloud


Reviewed by minimax-m3 · Input: 37.8K · Output: 3.7K · Cached: 485.6K

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tinyagentos/routes/agent_registry.py (1)

758-771: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Role/title writes commit before reports_to validation, enabling partial writes on failure.

If a caller sends role/title together with an invalid reports_to (self-report, cycle, missing manager), set_role_title (line 764) commits immediately, then set_reporting fails and the route returns 400. The client sees a failure but role/title were silently persisted — an inconsistent partial update from a single logical request.

Validating reports_to first (it already fails fast via exception, without any side effect on role/title) avoids applying role/title when the overall request will be rejected.

♻️ Proposed reordering
-    if body.role is not None or body.title is not None:
-        # Normalize whitespace-only values to "" so set_role_title clears the
-        # field (NULL), matching the reports_to ""-clears convention below;
-        # a None field is left untouched.
-        role = body.role.strip() if body.role is not None else None
-        title = body.title.strip() if body.title is not None else None
-        await store.set_role_title(canonical_id, role=role, title=title)
-
-    if body.reports_to is not None:
-        manager_id = body.reports_to or None  # "" clears the manager
-        try:
-            await store.set_reporting(canonical_id, manager_id)
-        except (ValueError, KeyError) as exc:
-            return JSONResponse({"error": str(exc)}, status_code=400)
+    if body.reports_to is not None:
+        manager_id = body.reports_to or None  # "" clears the manager
+        try:
+            await store.set_reporting(canonical_id, manager_id)
+        except (ValueError, KeyError) as exc:
+            return JSONResponse({"error": str(exc)}, status_code=400)
+
+    if body.role is not None or body.title is not None:
+        # Normalize whitespace-only values to "" so set_role_title clears the
+        # field (NULL), matching the reports_to ""-clears convention below;
+        # a None field is left untouched.
+        role = body.role.strip() if body.role is not None else None
+        title = body.title.strip() if body.title is not None else None
+        await store.set_role_title(canonical_id, role=role, title=title)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/routes/agent_registry.py` around lines 758 - 771, The route
handler in agent_registry currently applies role/title updates before validating
reports_to, which can leave a partial commit when set_reporting later rejects
the request. Reorder the logic in the update path so reports_to is validated and
applied first via store.set_reporting (handling ValueError/KeyError as it
already does), and only call store.set_role_title after that succeeds; keep the
existing normalization of body.role/body.title and body.reports_to behavior
intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@tinyagentos/routes/agent_registry.py`:
- Around line 758-771: The route handler in agent_registry currently applies
role/title updates before validating reports_to, which can leave a partial
commit when set_reporting later rejects the request. Reorder the logic in the
update path so reports_to is validated and applied first via store.set_reporting
(handling ValueError/KeyError as it already does), and only call
store.set_role_title after that succeeds; keep the existing normalization of
body.role/body.title and body.reports_to behavior intact.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6c2446e7-7ca3-420c-816e-c3ce8b9f098f

📥 Commits

Reviewing files that changed from the base of the PR and between 02e296a and ca384fc.

📒 Files selected for processing (6)
  • tests/test_agent_registry_store.py
  • tests/test_routes_agent_org.py
  • tests/test_routes_delegation.py
  • tinyagentos/agent_registry_store.py
  • tinyagentos/routes/agent_registry.py
  • tinyagentos/routes/delegation.py

Fold Kilo review WARNING: the class-level _reporting_lock annotation created
no attribute, so set_reporting relied on init() having run. Move creation into
__init__ so the lock always exists before any caller, independent of init
ordering. asyncio.Lock() binds lazily on first use, so constructing it without
a running loop is safe.
@jaylfc

jaylfc commented Jul 7, 2026

Copy link
Copy Markdown
Owner Author

Folded the _reporting_lock WARNING: the lock is now created in init so it always exists before any caller, independent of init ordering.

Tracking the remaining Kilo WARNING as a follow-up: _resolve_agent_id falls back to the stale slug if a config agent is renamed or removed between a delegation gate's creation and its approval, which would leave that one delegated task invisible to id-keyed lookups. Low impact (needs an agent to vanish mid-approval) and resolving it cleanly means capturing the id at gate-creation time, which is a larger change than this PR's scope.

@jaylfc

jaylfc commented Jul 7, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@jaylfc
jaylfc merged commit 1212915 into dev Jul 7, 2026
9 checks passed
@github-project-automation github-project-automation Bot moved this from Todo to Done in TinyAgentOS Roadmap Jul 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

1 participant