From 61b2c2b702e3277024c9ef8b0f05aacc9b91ac16 Mon Sep 17 00:00:00 2001 From: Olivier Cervello Date: Sun, 5 Jul 2026 16:37:08 +0200 Subject: [PATCH] fix(ai): give spawned sub-runners a clean identity so they persist their own runner doc _get_result_context now strips the parent's runner-identity keys (task_id/workflow_id/scan_id/task_chunk_id) from the child context and marks it has_parent=True, while still keeping session_id/drivers/ workspace_id/workspace_name. Previously a child inheriting the parent's task_id would make update_runner target the PARENT's doc instead of minting its own, clobbering the parent runner. The child is now linked to the conversation only via session_id. --- secator/ai/actions.py | 20 +++++++++++--------- tests/unit/test_ai_actions.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/secator/ai/actions.py b/secator/ai/actions.py index 0cac0563b..b3a4154d8 100644 --- a/secator/ai/actions.py +++ b/secator/ai/actions.py @@ -647,19 +647,21 @@ def _run_runner(action: Dict, ctx: ActionContext, runner_type: str) -> Generator def _get_result_context(action, ctx): - """Get result context from action. - - Always stamps the ai task's ``session_id`` (the conversation id) onto the - derived context. The ai task's ``self.session_id`` may be derived (from - ``session_name`` / the runner id) and is therefore not guaranteed to already - live in ``ctx.context``. Stamping it here means every sub-runner (task / - workflow / scan) dispatched by the ai task persists a runner doc whose - ``context.session_id`` matches the conversation — so the runners spawned by a - conversation are queryable by that conversation's session_id. + """Build the CHILD runner's context. + + Stamps the conversation ``session_id`` (parenting link — see the runner-parenting + design) and marks the child ``has_parent``. Critically, it STRIPS the parent's + runner-identity keys (`task_id`/`workflow_id`/`scan_id`): a child that inherited + them would make `update_runner`/`runner_id` target the PARENT's doc instead of + minting its own. The child keeps drivers/workspace so it persists into the same + workspace, linked to the conversation by ``session_id``. """ new_ctx = ctx.context.copy() + for identity_key in ("task_id", "workflow_id", "scan_id", "task_chunk_id"): + new_ctx.pop(identity_key, None) if ctx.session_id and not new_ctx.get("session_id"): new_ctx["session_id"] = ctx.session_id + new_ctx["has_parent"] = True action_context = {} tool_call_id = action.get("tool_call_id") tool_call_name = action.get("tool_call_name") diff --git a/tests/unit/test_ai_actions.py b/tests/unit/test_ai_actions.py index 21bc7e6bf..b8c955f52 100644 --- a/tests/unit/test_ai_actions.py +++ b/tests/unit/test_ai_actions.py @@ -1280,5 +1280,36 @@ def test_normal_depth1_spawn_succeeds(self, mock_build_hooks, mock_task_cls, _mo self.assertEqual(kwargs.get('context', {}).get('ai_subagent_depth'), 1) +@unittest.skipUnless(ADDONS_ENABLED['ai'], 'ai addon not installed') +class TestChildContextParenting(unittest.TestCase): + """A spawned sub-runner must get a CLEAN identity: it inherits the conversation + session_id + drivers but NOT the parent's runner-doc id, so it mints its own doc + (linked to the conversation by session_id), instead of clobbering the parent.""" + + def test_child_context_strips_parent_identity_keeps_session(self): + from secator.ai.actions import _get_result_context, ActionContext + ctx = ActionContext( + targets=['t.com'], model='m', + context={ + 'workspace_id': 'ws1', 'workspace_name': 'w', 'drivers': ['mongodb'], + 'task_id': 'PARENT_AI_ID', # the parent ai task's own doc id + 'session_id': 'conv-1', + }, + session_id='conv-1', + ) + action = {'action': 'task', 'name': 'nmap', 'tool_call_id': 'tc1', 'tool_call_name': 'run_task'} + child = _get_result_context(action, ctx) + # keeps the conversation link + drivers/workspace + self.assertEqual(child['session_id'], 'conv-1') + self.assertEqual(child['drivers'], ['mongodb']) + self.assertEqual(child['workspace_id'], 'ws1') + # marks it a child + self.assertTrue(child.get('has_parent')) + # does NOT inherit the parent's runner-doc identity (would clobber / suppress its own doc) + self.assertNotIn('task_id', child) + self.assertNotIn('workflow_id', child) + self.assertNotIn('scan_id', child) + + if __name__ == '__main__': unittest.main()