diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx
index d30f3ea61a..e4890536a5 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx
@@ -272,6 +272,8 @@ const en = {
"drawer.subagentConfirmEnable": "Confirm turning on sub-agent execution",
"drawer.subagentCurrentBoundary": "Current task-domain restriction",
"drawer.subagentDescription": "Allows the runtime to create temporary child agents for independent tasks only after Todo, quota, capability, and write-scope gates pass. It does not force parallel work or grant durable authority.",
+ "drawer.subagentReportTitle": "Child activity",
+ "drawer.subagentReportedActivity": "Latest coordinator report: {started} starts, {skipped} skips, {rejected} capacity rejections, {failed} host failures, {accepted} parent-accepted results. Host verification is unavailable.",
"drawer.subagentDisable": "Preview turning off sub-agent execution",
"drawer.subagentDisableSummary": "New child-agent execution will be disabled for this Goal. Existing Todo ownership and execution records stay unchanged.",
"drawer.subagentDomainInvalid": "The selected task-domain restriction is invalid.",
@@ -1386,6 +1388,8 @@ const zhCN: Record = {
"drawer.subagentConfirmEnable": "确认开启子代理执行",
"drawer.subagentCurrentBoundary": "当前任务领域限制",
"drawer.subagentDescription": "仅在 Todo、配额、能力和写入范围门禁全部通过后,允许运行时为相互独立的任务临时创建子代理;不会强制并行,也不会授予持久权限。",
+ "drawer.subagentReportTitle": "子代理活动",
+ "drawer.subagentReportedActivity": "最近一轮主 Agent 回报:启动 {started} 次、跳过 {skipped} 次、容量拒绝 {rejected} 次、宿主失败 {failed} 次、主 Agent 验收 {accepted} 项;目前没有宿主核验。",
"drawer.subagentDisable": "预览关闭子代理执行",
"drawer.subagentDisableSummary": "这个 Goal 将不再创建新的子代理;现有 Todo 归属和执行记录不受影响。",
"drawer.subagentDomainInvalid": "所选任务领域限制无效。",
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts
index 1f006cfbcc..9c92342da0 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts
+++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts
@@ -110,6 +110,16 @@ export type WorkspaceGoal = {
repository?: WorkspaceRepositoryContext;
state: WorkspaceGoalState;
subagentExecution?: WorkspaceGoalSubagentConfiguration;
+ nativeChildActivity?: {
+ turn_instance_id: string;
+ observation: "unknown" | "coordinator_reported";
+ host_attested: false;
+ launched_count: number;
+ skipped_count: number;
+ capacity_rejected_count: number;
+ host_failed_count: number;
+ parent_accepted_count: number;
+ } | null;
title: string;
usage?: WorkspaceGoalUsage | null;
};
diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx
index 79876e845f..c5c5680554 100644
--- a/apps/presentation/dashboard/src/views/dashboard-page.tsx
+++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx
@@ -459,6 +459,16 @@ type PersonalGoalItem = {
needsYouTodoId?: string | null;
nextSentence: string;
hasRunObservation: boolean;
+ nativeChildActivity?: {
+ turn_instance_id: string;
+ observation: "unknown" | "coordinator_reported";
+ host_attested: false;
+ launched_count: number;
+ skipped_count: number;
+ capacity_rejected_count: number;
+ host_failed_count: number;
+ parent_accepted_count: number;
+ } | null;
state: PersonalGoalState;
subagentExecution?: {
allowedDomains: string[];
@@ -1208,6 +1218,7 @@ function buildPersonalHomeModel(
hasRunObservation: Boolean(row.queueItem?.project_asset?.latest_validation
|| row.latestRun
|| payload.event_ledger_summary?.goals.some((item) => item.goal_id === goal.id)),
+ nativeChildActivity: row.queueItem?.project_asset?.native_child_activity,
state,
...(goalSubagentConfigurationEnabled ? {
subagentExecution: {
diff --git a/docs/integrations/host-native-child-receipts.md b/docs/integrations/host-native-child-receipts.md
new file mode 100644
index 0000000000..7ccc04b921
--- /dev/null
+++ b/docs/integrations/host-native-child-receipts.md
@@ -0,0 +1,88 @@
+# Host-native child receipts / 宿主原生子代理回执
+
+## Contract / 契约
+
+When an enabled `multi_subagent` coordinator works in an admitted Turn, it can
+write a bounded `decision → result → parent review` record for a child created
+through any host's native tools. The record uses the existing Goal rollout event
+log, the Turn instance ID, a stable operation ID, and an opaque `entrypoint_id`.
+It does not launch children, schedule another Turn, grant write authority, or
+spend quota. The configured `max_children` is an upper bound, never an observed
+live capacity or a required launch count.
+
+启用 `multi_subagent` 的主 Agent 在已准入的 Turn 内,可以为任意宿主原生工具
+创建的子代理记录有界的“决策 → 结果 → 主 Agent 验收”回执。回执复用现有
+Goal 事件流,以 Turn ID、稳定操作 ID 和不限定宿主的 `entrypoint_id` 关联。
+记录动作不会启动子代理、调度新 Turn、授予写入权限或消耗配额。
+`max_children` 只是配置上限,不代表当前可用槽位,也不是必须启动的数量。
+
+`native-child record` currently accepts the coordinator's typed report. Its
+`observation` is `coordinator_reported` and `host_attested` is always `false`.
+LoopX cannot intercept an arbitrary external host's native tool call. A future
+host adapter must observe that call at its own boundary and extend this event
+and read-model contract with a separately verified provenance variant; this
+v0 recorder cannot claim host attestation. Missing records remain
+`unknown`; neither a missing record nor `max_children > 0` proves that a child
+was created or deliberately skipped.
+
+目前 `native-child record` 接受主 Agent 的类型化上报,因此 `observation` 为
+`coordinator_reported`,`host_attested` 始终为 `false`。LoopX 无法拦截任意
+外部宿主的原生工具调用。后续宿主适配器须在自己的边界观察调用,给事件与
+读模型扩展单独核验的来源类型;当前 v0 上报器不能声称宿主核验。缺少回执
+就是 `unknown`;没有回执或配置上限大于零,都不能证明已启动或主动跳过。
+
+## Lifecycle / 生命周期
+
+1. The admitted Turn guard must already have a settlement binding. `record`
+ rejects unregistered coordinators and disabled policy before writing.
+2. Use `stage=decision` with a stable `operation-id` for `spawn`, `followup`,
+ or a bounded `skip` reason. A host capacity rejection maps to the generic
+ `host_capacity_exhausted` reason. Capacity rejection and typed host failure
+ stop same-Turn spawn/followup retries while parent work may continue.
+3. A started operation may get a typed `result`. Only a completed result may
+ receive `parent review`; `accepted` requires public-safe evidence and
+ validation references. Raw prompts, host errors, transcripts, local paths,
+ and credentials are excluded.
+4. Replay with the same identity and payload is idempotent; a conflicting
+ payload is rejected. `read` and `agent-context --phase after_delegate_result
+ --turn-instance-id ...` expose the same Turn read model. Goal status (JSON and
+ Markdown) exposes
+ the latest reported Turn only when the capability is enabled and a decision
+ exists. The dashboard uses that status projection, and omits the activity
+ line for unconfigured or unrelated Goals.
+
+1. Turn 须先有已提交的结算绑定;未注册主 Agent 或未启用策略不能写入。
+2. 用稳定 `operation-id` 写 `decision`,区分 `spawn`、`followup` 和有界理由的
+ `skip`。宿主容量拒绝映射为通用 `host_capacity_exhausted`;容量拒绝和
+ 类型化宿主失败都停止同一 Turn 的启动或跟进重试,主 Agent 仍可继续工作。
+3. 已启动操作可记录类型化 `result`;只有完成结果才能进入主 Agent `review`。
+ `accepted` 必须有公开安全的证据与验证引用。原始提示、宿主错误、对话、
+ 本地路径和凭据不进入回执。
+4. 同一身份和内容重放幂等,内容冲突会被拒绝。`read` 与带 Turn ID 的
+ `agent-context` 读取同一模型。Goal 状态的 JSON 与 Markdown 只在能力启用且确有决策时投影
+ 最近一轮;仪表板读取该投影,未配置或无关 Goal 不显示活动行。
+
+Example / 示例:
+
+```sh
+loopx --format json --registry REGISTRY native-child record \
+ --goal-id GOAL --agent-id COORDINATOR --turn-instance-id TURN \
+ --operation-id OPERATION --stage decision --operation spawn \
+ --outcome started --entrypoint-id HOST_ENTRYPOINT --execute
+loopx --format json --registry REGISTRY native-child record \
+ --goal-id GOAL --agent-id COORDINATOR --turn-instance-id TURN \
+ --operation-id OPERATION --stage result --outcome completed --execute
+loopx --format json --registry REGISTRY native-child record \
+ --goal-id GOAL --agent-id COORDINATOR --turn-instance-id TURN \
+ --operation-id OPERATION --stage review --outcome accepted \
+ --evidence-ref EVIDENCE_ID --validation-ref VALIDATION_ID --execute
+loopx --format json --registry REGISTRY native-child read \
+ --goal-id GOAL --agent-id COORDINATOR --turn-instance-id TURN
+```
+
+The source of truth for a bound LoopX delegation remains its delegation
+operation receipt. A native child report never substitutes for that receipt or
+for the parent validation of the underlying work.
+
+已绑定的 LoopX delegation 仍以自身操作回执为权威。原生子代理上报不能代替
+delegation 回执,也不能代替主 Agent 对工作结果的实际核验。
diff --git a/examples/dashboard-home-browser-smoke.mjs b/examples/dashboard-home-browser-smoke.mjs
index 676d1496f0..9dc369d430 100644
--- a/examples/dashboard-home-browser-smoke.mjs
+++ b/examples/dashboard-home-browser-smoke.mjs
@@ -201,6 +201,18 @@ const goalSpecs = [
max_children: 2,
allowed_domains: ["docs", "validation"],
},
+ nativeChildActivity: {
+ schema_version: "native_subagent_activity_v0",
+ turn_instance_id: "fixture-turn-1",
+ observation: "coordinator_reported",
+ host_attested: false,
+ configured_limit: 2,
+ launched_count: 1,
+ skipped_count: 0,
+ capacity_rejected_count: 1,
+ host_failed_count: 0,
+ parent_accepted_count: 0,
+ },
latest: {
generated_at: "2026-01-01T00:03:00+00:00",
classification: "dashboard_home_chinese_operator_copy_contract",
@@ -224,6 +236,7 @@ function projectAssetFor(spec) {
quota: spec.quota,
control_plane: spec.controlPlane,
orchestration: spec.orchestration,
+ native_child_activity: spec.nativeChildActivity,
latest_validation: {
generated_at: spec.latest.generated_at,
classification: spec.latest.classification,
@@ -1293,6 +1306,42 @@ async function main() {
throw new Error(`Failed waiting for personal-goal-home: ${error.message}; body=${diagnostic.slice(0, 1000)}; pageErrors=${pageErrors.join(" | ")}`);
}
+ if (process.env.LOOPX_NATIVE_CHILD_SMOKE_ONLY === "1") {
+ for (const [label, target] of [["desktop", page],
+ ["mobile", await browser.newPage({ isMobile: true, viewport: { width: 390, height: 900 } })]]) {
+ try {
+ await target.goto(`${baseUrl}/?goalId=loopx-meta&statusUrl=/${fixtureName}`, { waitUntil: "networkidle" });
+ await target.waitForSelector('[data-testid="personal-goal-home"]', { timeout: 10_000 });
+ await target.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: "概览", exact: true }).click();
+ await target.locator(".goal-overview-heading button").click();
+ const drawerText = await target.locator(".personal-context-drawer").innerText();
+ if (!drawerText.includes("主 Agent 回报:启动 1 次、跳过 0 次、容量拒绝 1 次")
+ || !drawerText.includes("目前没有宿主核验")) {
+ throw new Error(`${label} native child report lost provenance: ${drawerText}`);
+ }
+ await target.screenshot({
+ path: resolve(visualOutputDir, `${label}-native-child-activity.png`),
+ animations: "disabled",
+ });
+ await target.locator(".personal-native-child-activity").screenshot({
+ path: resolve(visualOutputDir, `${label}-native-child-card.png`),
+ animations: "disabled",
+ });
+ } finally {
+ if (target !== page) await target.close();
+ }
+ }
+ await page.goto(`${baseUrl}/?goalId=showcase-user-gate-safe-side-path&statusUrl=/${fixtureName}`, { waitUntil: "networkidle" });
+ await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: "概览", exact: true }).click();
+ await page.locator(".goal-overview-heading button").click();
+ const unconfiguredDrawer = await page.locator(".personal-context-drawer").innerText();
+ if (unconfiguredDrawer.includes("主 Agent 回报")) {
+ throw new Error("Unconfigured Goal displayed unrelated native child activity.");
+ }
+ console.log("dashboard-native-child-activity-smoke ok");
+ return;
+ }
+
await page.locator(".personal-composer-tools > summary").click();
const body = await page.locator("body").innerText();
const required = [
@@ -1410,6 +1459,9 @@ async function main() {
if (!drawerText.includes("Goal 详情") && !drawerText.includes("Repository")) {
throw new Error(`Context drawer did not render expected details: ${drawerText}`);
}
+ if (!drawerText.includes("主 Agent 回报:启动 1 次、跳过 0 次、容量拒绝 1 次")) {
+ throw new Error(`Native child report was not shown with its provenance: ${drawerText}`);
+ }
}
// Missing status URL error state & fallback
diff --git a/loopx/capabilities/multi_subagent/cli.py b/loopx/capabilities/multi_subagent/cli.py
new file mode 100644
index 0000000000..d411ac8f6d
--- /dev/null
+++ b/loopx/capabilities/multi_subagent/cli.py
@@ -0,0 +1,82 @@
+"""Provider-neutral, Turn-scoped native child receipt commands."""
+
+from __future__ import annotations
+
+from ...agent_registry import load_goal_from_registry, registered_agent_ids_for_goal
+from ...orchestration import compact_orchestration_policy
+from .native_child_receipts import load_native_child_activity, record_native_child
+
+
+def register_native_child_commands(subparsers, add_format):
+ parser = subparsers.add_parser(
+ "native-child", help="Record or read typed host-native child activity for an admitted Turn."
+ )
+ add_format(parser)
+ parser.add_argument("native_child_action", choices=("record", "read"))
+ parser.add_argument("--goal-id", required=True)
+ parser.add_argument("--agent-id", required=True)
+ parser.add_argument("--turn-instance-id", required=True)
+ parser.add_argument("--operation-id", help="Stable identity for one host-native child operation.")
+ parser.add_argument("--stage", choices=("decision", "result", "review"))
+ parser.add_argument("--operation", choices=("spawn", "followup", "skip"))
+ parser.add_argument("--outcome")
+ parser.add_argument("--entrypoint-id", help="Opaque host entrypoint identity, not a host-specific enum.")
+ parser.add_argument("--reason-code")
+ parser.add_argument("--evidence-ref", help="Public-safe opaque reference for accepted evidence.")
+ parser.add_argument("--validation-ref", help="Public-safe opaque reference for parent validation.")
+ parser.add_argument("--execute", action="store_true", help="Append the receipt; otherwise preview only.")
+
+
+def handle_native_child_command(args, registry_path, runtime_root, print_payload, output_format):
+ if args.command != "native-child":
+ return None
+ try:
+ goal = load_goal_from_registry(registry_path, args.goal_id)
+ if goal is None or args.agent_id not in registered_agent_ids_for_goal(goal):
+ raise ValueError("coordinator is not registered for this Goal")
+ orchestration = compact_orchestration_policy(goal.get("spawn_policy"))
+ if not (
+ orchestration.get("mode") == "multi_subagent"
+ and orchestration.get("spawn_allowed") is True
+ and int(orchestration.get("max_children") or 0) > 0
+ ):
+ raise ValueError("native child receipts require enabled multi_subagent policy")
+ configured_limit = int(orchestration["max_children"])
+ if args.native_child_action == "read":
+ if any((args.operation_id, args.stage, args.operation, args.outcome,
+ args.entrypoint_id, args.reason_code, args.evidence_ref,
+ args.validation_ref, args.execute)):
+ raise ValueError("read accepts only goal, agent and Turn identity")
+ payload = {"ok": True, "native_child_activity": load_native_child_activity(
+ runtime_root, goal_id=args.goal_id, agent_id=args.agent_id,
+ turn_instance_id=args.turn_instance_id, configured_limit=configured_limit,
+ )}
+ else:
+ if not args.operation_id or not args.stage or not args.outcome:
+ raise ValueError("record requires operation-id, stage and outcome")
+ payload = record_native_child(
+ runtime_root=runtime_root, goal_id=args.goal_id, agent_id=args.agent_id,
+ turn_instance_id=args.turn_instance_id, operation_id=args.operation_id,
+ configured_limit=configured_limit, stage=args.stage,
+ operation=args.operation, outcome=args.outcome,
+ entrypoint_id=args.entrypoint_id, reason_code=args.reason_code,
+ evidence_ref=args.evidence_ref, validation_ref=args.validation_ref,
+ execute=args.execute,
+ )
+ except (OSError, ValueError, KeyError) as exc:
+ payload = {"ok": False, "error": str(exc)}
+ print_payload(payload, output_format(args), render_native_child)
+ return 0 if payload["ok"] else 1
+
+
+def render_native_child(payload):
+ if not payload["ok"]:
+ return str(payload["error"])
+ activity = payload["native_child_activity"]
+ return (
+ f"Native child activity for {activity['turn_instance_id']}: "
+ f"{activity['observation']}; {activity['launched_count']} reported starts, "
+ f"{activity['skipped_count']} skips, "
+ f"{activity['capacity_rejected_count']} capacity rejections, "
+ f"{activity['host_failed_count']} host failures."
+ )
diff --git a/loopx/capabilities/multi_subagent/native_child_receipts.py b/loopx/capabilities/multi_subagent/native_child_receipts.py
new file mode 100644
index 0000000000..e6b23fcdc4
--- /dev/null
+++ b/loopx/capabilities/multi_subagent/native_child_receipts.py
@@ -0,0 +1,299 @@
+"""Turn-bound reports of host-native child-tool decisions.
+
+The native tool belongs to the host. LoopX can durably reconcile the
+coordinator's typed report of its result, but cannot attest that a host call
+occurred unless the host itself supplies an integration. This distinction is
+part of the projection, not an implicit promise of configured capacity.
+"""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Mapping, Sequence
+from pathlib import Path
+from typing import Any
+
+from ...control_plane.quota.heartbeat_receipt import find_heartbeat_receipt
+from ...control_plane.runtime.public_safety import validate_public_safe_value
+from ...rollout_event_log import (
+ append_rollout_event_once,
+ build_rollout_event,
+ iter_rollout_events,
+ load_rollout_events,
+ rollout_event_log_path,
+)
+
+
+SCHEMA_VERSION = "native_subagent_activity_v0"
+EVENT_KINDS = {
+ "decision": "native_child_decision",
+ "result": "native_child_result",
+ "review": "native_child_review",
+}
+OPERATIONS = frozenset({"spawn", "followup", "skip"})
+DECISION_OUTCOMES = frozenset({"started", "capacity_rejected", "host_failed", "skipped"})
+RESULT_OUTCOMES = frozenset({"completed", "failed", "cancelled"})
+REVIEW_OUTCOMES = frozenset({"accepted", "deferred", "rejected"})
+SKIP_REASONS = frozenset({
+ "no_independent_work", "duplicate_evidence", "brief_incomplete",
+ "parent_work_priority", "scope_not_admitted", "capacity_deferred",
+})
+FAILURE_REASONS = frozenset({"host_unavailable", "host_rejected", "host_failed"})
+REVIEW_REASONS = frozenset({"evidence_incomplete", "source_unverified", "contradicted", "not_needed"})
+_OPAQUE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$")
+_MAX_VISIBLE = 8
+
+
+def _id(value: Any, *, field: str) -> str:
+ if not isinstance(value, str) or not _OPAQUE_ID.fullmatch(value):
+ raise ValueError(f"{field} must be a compact opaque id")
+ validate_public_safe_value(value, path=field)
+ return value
+
+
+def _choice(value: Any, *, field: str, choices: frozenset[str]) -> str:
+ if value not in choices:
+ raise ValueError(f"{field} must be one of: {', '.join(sorted(choices))}")
+ return str(value)
+
+
+def _details(event: Mapping[str, Any]) -> dict[str, Any]:
+ value = event.get("details")
+ return dict(value) if isinstance(value, Mapping) else {}
+
+
+def _events_for_turn(
+ events: Sequence[Mapping[str, Any]], *, agent_id: str, turn_instance_id: str,
+) -> list[dict[str, Any]]:
+ return [dict(event) for event in events
+ if event.get("event_kind") in EVENT_KINDS.values()
+ and event.get("agent_id") == agent_id
+ and event.get("run_id") == turn_instance_id]
+
+
+def native_child_activity(
+ events: Sequence[Mapping[str, Any]], *, goal_id: str, agent_id: str,
+ turn_instance_id: str, configured_limit: int,
+) -> dict[str, Any]:
+ """One read model for CLI, agent-context and product projections."""
+ rows = _events_for_turn(events, agent_id=agent_id, turn_instance_id=turn_instance_id)
+ operations: dict[str, dict[str, Any]] = {}
+ for event in rows:
+ details = _details(event)
+ operation_id = str(event.get("case_id") or "")
+ if not operation_id:
+ continue
+ row = operations.setdefault(operation_id, {"operation_id": operation_id})
+ kind = event.get("event_kind")
+ if kind == EVENT_KINDS["decision"]:
+ row.update({key: details[key] for key in
+ ("entrypoint_id", "observation_source", "operation", "outcome", "reason_code")
+ if key in details})
+ row["recorded_at"] = event.get("recorded_at")
+ elif kind == EVENT_KINDS["result"]:
+ row["result"] = details.get("outcome")
+ elif kind == EVENT_KINDS["review"]:
+ row["parent_review"] = details.get("outcome")
+ if details.get("outcome") == "accepted":
+ row["evidence_ref"] = details.get("evidence_ref")
+ row["validation_ref"] = details.get("validation_ref")
+ ordered = [row for row in operations.values() if "operation" in row]
+ ordered.sort(key=lambda row: (str(row.get("recorded_at") or ""), row["operation_id"]))
+ launched = sum(row.get("outcome") == "started" and row.get("operation") == "spawn"
+ for row in ordered)
+ attempted = sum(row.get("operation") in {"spawn", "followup"} for row in ordered)
+ rejected = sum(row.get("outcome") == "capacity_rejected" for row in ordered)
+ host_failed = sum(row.get("outcome") == "host_failed" for row in ordered)
+ return {
+ "schema_version": SCHEMA_VERSION,
+ "goal_id": goal_id, "agent_id": agent_id,
+ "turn_instance_id": turn_instance_id,
+ "entrypoint_scope": "host_native_child_tools",
+ "observation": "coordinator_reported" if ordered else "unknown",
+ "host_attested": False,
+ "configured_limit_kind": "upper_bound",
+ "configured_limit": configured_limit,
+ "observed_capacity": "capacity_rejection_reported" if rejected else
+ "host_failure_reported" if host_failed else
+ "attempt_reported" if attempted else "not_observed",
+ "attempted_count": attempted,
+ "launched_count": launched,
+ "skipped_count": sum(row.get("outcome") == "skipped" for row in ordered),
+ "capacity_rejected_count": rejected,
+ "host_failed_count": host_failed,
+ "parent_accepted_count": sum(row.get("parent_review") == "accepted" for row in ordered),
+ "retry_same_turn": False if rejected or host_failed else None,
+ "operation_count": len(ordered),
+ "visible_operation_count": min(len(ordered), _MAX_VISIBLE),
+ "operations": ordered[-_MAX_VISIBLE:],
+ "quota_spend_slots": 0,
+ }
+
+
+def load_native_child_activity(
+ runtime_root: Path, *, goal_id: str, agent_id: str,
+ turn_instance_id: str, configured_limit: int,
+) -> dict[str, Any]:
+ source = iter_rollout_events(rollout_event_log_path(runtime_root, goal_id))
+ events = [event for event in source
+ if event.get("event_kind") in EVENT_KINDS.values()
+ and event.get("agent_id") == agent_id
+ and event.get("run_id") == turn_instance_id]
+ return native_child_activity(
+ events, goal_id=goal_id, agent_id=agent_id,
+ turn_instance_id=turn_instance_id, configured_limit=configured_limit,
+ )
+
+
+def latest_native_child_activity(
+ events: Sequence[Mapping[str, Any]], *, goal_id: str, configured_limit: int,
+) -> dict[str, Any] | None:
+ """Expose only the latest reported Turn in existing Goal status surfaces."""
+ observations = [event for event in events
+ if event.get("goal_id") == goal_id
+ and event.get("event_kind") in EVENT_KINDS.values()
+ and event.get("agent_id") and event.get("run_id")]
+ if not observations:
+ return None
+ latest = max(observations, key=lambda event: str(event.get("recorded_at") or ""))
+ return native_child_activity(
+ events, goal_id=goal_id, agent_id=str(latest["agent_id"]),
+ turn_instance_id=str(latest["run_id"]), configured_limit=configured_limit,
+ )
+
+
+def _normalized_fields(
+ *, stage: str, operation: str | None, outcome: str,
+ entrypoint_id: str | None, reason_code: str | None,
+ evidence_ref: str | None, validation_ref: str | None,
+) -> dict[str, str]:
+ if stage == "decision":
+ op = _choice(operation, field="operation", choices=OPERATIONS)
+ result = _choice(outcome, field="outcome", choices=DECISION_OUTCOMES)
+ host = _id(entrypoint_id, field="entrypoint_id")
+ if (op == "skip") != (result == "skipped"):
+ raise ValueError("skip operation and skipped outcome must occur together")
+ if result == "skipped":
+ reason = _choice(reason_code, field="reason_code", choices=SKIP_REASONS)
+ elif result == "capacity_rejected":
+ if reason_code not in {None, "host_capacity_exhausted"}:
+ raise ValueError("capacity rejection reason must be host_capacity_exhausted")
+ reason = "host_capacity_exhausted"
+ elif result == "host_failed":
+ reason = _choice(reason_code, field="reason_code", choices=FAILURE_REASONS)
+ else:
+ if reason_code is not None:
+ raise ValueError("started outcome must not include a reason_code")
+ reason = None
+ if evidence_ref or validation_ref:
+ raise ValueError("evidence refs belong to parent review")
+ return {"entrypoint_id": host, "observation_source": "coordinator_reported",
+ "operation": op, "outcome": result,
+ **({"reason_code": reason} if reason else {})}
+ if operation or entrypoint_id:
+ raise ValueError("result/review reuse the decision's operation and entrypoint_id")
+ if stage == "result":
+ result = _choice(outcome, field="outcome", choices=RESULT_OUTCOMES)
+ if reason_code or evidence_ref or validation_ref:
+ raise ValueError("result accepts only a typed outcome")
+ return {"outcome": result}
+ if stage == "review":
+ result = _choice(outcome, field="outcome", choices=REVIEW_OUTCOMES)
+ if result == "accepted":
+ if reason_code:
+ raise ValueError("accepted review must not include a reason_code")
+ return {"outcome": result, "evidence_ref": _id(evidence_ref, field="evidence_ref"),
+ "validation_ref": _id(validation_ref, field="validation_ref")}
+ if evidence_ref or validation_ref:
+ raise ValueError("deferred/rejected review must not adopt evidence")
+ return {"outcome": result, "reason_code":
+ _choice(reason_code, field="reason_code", choices=REVIEW_REASONS)}
+ raise ValueError("stage must be decision, result or review")
+
+
+def record_native_child(
+ *, runtime_root: Path, goal_id: str, agent_id: str,
+ turn_instance_id: str, operation_id: str, configured_limit: int,
+ stage: str, outcome: str, operation: str | None = None,
+ entrypoint_id: str | None = None, reason_code: str | None = None,
+ evidence_ref: str | None = None, validation_ref: str | None = None,
+ execute: bool = False,
+) -> dict[str, Any]:
+ """Preview or append a typed report; never launch a child or spend quota."""
+ goal_id = _id(goal_id, field="goal_id")
+ agent_id = _id(agent_id, field="agent_id")
+ turn_instance_id = _id(turn_instance_id, field="turn_instance_id")
+ operation_id = _id(operation_id, field="operation_id")
+ if isinstance(configured_limit, bool) or not isinstance(configured_limit, int) or configured_limit < 1:
+ raise ValueError("enabled multi_subagent configured_limit must be positive")
+ fields = _normalized_fields(
+ stage=stage, operation=operation, outcome=outcome, entrypoint_id=entrypoint_id,
+ reason_code=reason_code, evidence_ref=evidence_ref, validation_ref=validation_ref,
+ )
+ guard = find_heartbeat_receipt(
+ runtime_root, goal_id=goal_id, agent_id=agent_id,
+ turn_instance_id=turn_instance_id,
+ )
+ if not guard or not _details(guard).get("settlement_effect_id"):
+ raise ValueError("native child report requires an admitted, settlement-bound Turn guard")
+ log_path = rollout_event_log_path(runtime_root, goal_id)
+ events = load_rollout_events(log_path)
+ prior = _events_for_turn(events, agent_id=agent_id, turn_instance_id=turn_instance_id)
+ existing = next((event for event in prior
+ if event.get("case_id") == operation_id
+ and event.get("event_kind") == EVENT_KINDS[stage]), None)
+ if existing is not None and _details(existing) != fields:
+ raise ValueError("operation identity already has a conflicting native child report")
+
+ def validate_transition(observed: Sequence[Mapping[str, Any]]) -> None:
+ current = _events_for_turn(observed, agent_id=agent_id,
+ turn_instance_id=turn_instance_id)
+ decisions = {str(item.get("case_id")): item for item in current
+ if item.get("event_kind") == EVENT_KINDS["decision"]}
+ if stage == "decision":
+ if str(guard.get("status") or "") not in {"normal_run", "turn_run_once"}:
+ raise ValueError("native child decision requires a runnable Turn guard")
+ if fields["operation"] in {"spawn", "followup"} and any(
+ _details(item).get("outcome") in {"capacity_rejected", "host_failed"}
+ for item in decisions.values()
+ ):
+ raise ValueError("host failure forbids same-Turn spawn/followup retry")
+ return
+ decision = decisions.get(operation_id)
+ if decision is None or _details(decision).get("outcome") != "started":
+ raise ValueError("result/review requires a started native child decision")
+ if stage == "review":
+ result = next((item for item in current if item.get("case_id") == operation_id
+ and item.get("event_kind") == EVENT_KINDS["result"]), None)
+ if result is None or _details(result).get("outcome") != "completed":
+ raise ValueError("parent review requires a completed native child result")
+
+ if existing is None:
+ validate_transition(prior)
+ event = build_rollout_event(
+ goal_id=goal_id, event_kind=EVENT_KINDS[stage], agent_id=agent_id,
+ run_id=turn_instance_id, case_id=operation_id, status=fields["outcome"],
+ details=fields, recorded_at=(existing or {}).get("recorded_at"),
+ )
+ appended = False
+ if execute:
+ stored, appended = append_rollout_event_once(
+ log_path, event,
+ identity_fields=("goal_id", "event_kind", "agent_id", "run_id", "case_id"),
+ precondition=lambda: validate_transition(load_rollout_events(log_path)),
+ )
+ if _details(stored) != fields:
+ raise ValueError("operation identity already has a conflicting native child report")
+ events = load_rollout_events(log_path)
+ else:
+ stored = existing or event
+ return {
+ "ok": True, "dry_run": not execute, "appended": appended,
+ "receipt": {key: stored[key] for key in
+ ("event_id", "event_kind", "recorded_at", "run_id", "case_id", "status")},
+ "native_child_activity": native_child_activity(
+ events if execute or existing else [*events, event], goal_id=goal_id,
+ agent_id=agent_id, turn_instance_id=turn_instance_id,
+ configured_limit=configured_limit,
+ ),
+ }
diff --git a/loopx/cli.py b/loopx/cli.py
index e1bc9700a4..f9ed9a709d 100644
--- a/loopx/cli.py
+++ b/loopx/cli.py
@@ -10,6 +10,9 @@
from .cli_commands.agent_capabilities import register_agent_capabilities, handle_agent_capabilities
from .cli_commands.agent_directory import register_agent_directory, handle_agent_directory
from .cli_commands.agent_context import register_agent_context, handle_agent_context
+from .capabilities.multi_subagent.cli import (
+ register_native_child_commands, handle_native_child_command,
+)
from .cli_commands.todo_continuation import register_todo_continuation, handle_todo_continuation
from .cli_commands.manager_inbox import register_manager_inbox, handle_manager_inbox
from .cli_commands.delegation import register_delegation, handle_delegation
@@ -348,6 +351,7 @@ def build_parser() -> LoopXArgumentParser:
register_delegation(sub, add_subcommand_format)
register_agent_capabilities(sub, add_subcommand_format)
register_agent_context(sub, add_subcommand_format)
+ register_native_child_commands(sub, add_subcommand_format)
register_agent_directory(sub, add_subcommand_format)
register_lark_inbox_commands(sub, add_subcommand_format)
register_lark_kanban_commands(sub, add_subcommand_format)
@@ -803,6 +807,13 @@ def main(argv: list[str] | None = None) -> int:
output_format,
)
+ native_child_result = handle_native_child_command(
+ args, registry_path, effective_runtime_root(registry_path, args.runtime_root),
+ print_payload, output_format,
+ )
+ if native_child_result is not None:
+ return native_child_result
+
if args.command == "agent-directory":
return handle_agent_directory(
args, registry_path, effective_runtime_root(registry_path, args.runtime_root),
diff --git a/loopx/cli_commands/agent_context.py b/loopx/cli_commands/agent_context.py
index 9ce1658ff4..c93a394874 100644
--- a/loopx/cli_commands/agent_context.py
+++ b/loopx/cli_commands/agent_context.py
@@ -1,7 +1,9 @@
"""Read-only lifecycle context for hosts whose native tools bypass LoopX Turn."""
from ..agent_registry import load_goal_from_registry, registered_agent_ids_for_goal
+from ..capabilities.multi_subagent.native_child_receipts import load_native_child_activity
from ..control_plane.agent_context import project_goal_agent_context
+from ..orchestration import compact_orchestration_policy
def register_agent_context(subparsers, add_format):
@@ -31,6 +33,10 @@ def register_agent_context(subparsers, add_format):
type=int,
help="Optional non-negative native child count observed by the host.",
)
+ parser.add_argument(
+ "--turn-instance-id",
+ help="Read durable native child activity for this exact admitted Turn.",
+ )
def handle_agent_context(args, registry_path, runtime_root, print_payload, output_format):
@@ -81,6 +87,12 @@ def handle_agent_context(args, registry_path, runtime_root, print_payload, outpu
render_agent_context,
)
return 1
+ if args.turn_instance_id and args.phase != "after_delegate_result":
+ print_payload(
+ {"ok": False, "error": "Turn child receipts require --phase after_delegate_result"},
+ output_format(args), render_agent_context,
+ )
+ return 1
observations = {}
if operation:
native_capacity = {
@@ -91,6 +103,23 @@ def handle_agent_context(args, registry_path, runtime_root, print_payload, outpu
if child_count is not None:
native_capacity["child_count"] = child_count
observations["native_host_capacity"] = native_capacity
+ native_activity = None
+ if args.turn_instance_id:
+ orchestration = compact_orchestration_policy(goal.get("spawn_policy"))
+ if (orchestration.get("mode") != "multi_subagent"
+ or orchestration.get("spawn_allowed") is not True
+ or int(orchestration.get("max_children") or 0) < 1):
+ print_payload(
+ {"ok": False, "error": "Turn child receipts require enabled multi_subagent policy"},
+ output_format(args), render_agent_context,
+ )
+ return 1
+ native_activity = load_native_child_activity(
+ runtime_root, goal_id=args.goal_id, agent_id=args.agent_id,
+ turn_instance_id=args.turn_instance_id,
+ configured_limit=int(orchestration["max_children"]),
+ )
+ observations["native_child_activity"] = native_activity
context = project_goal_agent_context(
phase=args.phase,
scope={"goal_id": args.goal_id, "agent_id": args.agent_id, "todo_id": None},
@@ -124,6 +153,9 @@ def handle_agent_context(args, registry_path, runtime_root, print_payload, outpu
"host_capacity_scope": "native_tool_input",
}
)
+ if native_activity is not None:
+ payload["native_child_activity"] = native_activity
+ payload["host_receipts_scope"] = "turn_bound_coordinator_report"
print_payload(payload, output_format(args), render_agent_context)
return 0
diff --git a/loopx/control_plane/subagent_context.ts b/loopx/control_plane/subagent_context.ts
index 31cc76b35e..c9587b8d98 100644
--- a/loopx/control_plane/subagent_context.ts
+++ b/loopx/control_plane/subagent_context.ts
@@ -4,21 +4,21 @@ import type { JsonObject } from "./effect_program.ts";
import { jsonObject, requireJsonObject } from "./runtime_decode.ts";
export const subagentContextProvider: AgentContextProvider = {
- hookId: "multi_subagent.coordinator", capabilityId: "multi_subagent", revision: "v5",
+ hookId: "multi_subagent.coordinator", capabilityId: "multi_subagent", revision: "v6",
phases: AGENT_CONTEXT_PHASES,
produce(input, config) {
const guidance = {
before_plan: [
"Prefer bounded independent delegation; max_children is a configured ceiling, not live availability. Admit native children incrementally, avoid duplicate reads, and keep one parent question.",
- "Native child tools can read loopx agent-context at before_delegate and after_delegate_result; these calls do not start Turns or spend quota.",
+ "Host-native child work uses loopx native-child record/read for bounded Turn receipts; these calls do not start Turns or spend quota.",
"Choose independent work from current routes and user preferences. Runtime availability is not entrypoint admission; inspect bound delegation. Peer-activation blocks do not assess native children or delegation. Do not relaunch every route on every heartbeat.",
],
before_delegate: [
"Give each child a bounded question, sources, read/write limits, expected evidence and stopping condition; identify dependencies and the coordinator's concurrent question.",
- "For an authorized route, use its binding entrypoint and recheck the chosen runtime, execution profile and budget. Never silently substitute a runtime/model; record the selection reason and stable operation id. Preferences and readiness observations are not execution receipts.",
+ "For an authorized route, use its binding entrypoint and recheck the chosen runtime, execution profile and budget. Never silently substitute a runtime/model; record the selection reason and stable operation id. Report each native decision or bounded skip; missing reports stay unknown.",
],
after_delegate_result: [
- "Check returned sources, omissions and contradictions against the question. Reconcile native child receipts and any freshly read bound delegation operation receipts; missing, unavailable or rejected receipts do not establish completed work.",
+ "Check returned sources, omissions and contradictions against the question. Record native child result and parent review separately, then reconcile fresh Turn receipts and bound delegation receipts; missing or rejected receipts do not establish completed work.",
"On typed agent_thread_limit_reached, stop same-Turn spawn/followup retries, mark unlaunched work incomplete, and continue useful parent work.",
"Verify decisive sources and record accept/defer/reject with reasons. Link accepted evidence to the deliverable and run parent validation before writeback; opinions are not independent evidence.",
],
@@ -48,6 +48,11 @@ export const subagentContextProvider: AgentContextProvider = {
contract.live_availability = nativeCapacity.outcome === "agent_thread_limit_reached"
? "capacity_exhausted" : "attempt_observed";
}
+ const nativeActivity = boundedNativeChildActivity(input.observations.native_child_activity);
+ if (nativeActivity) {
+ facts.native_child_activity = nativeActivity;
+ facts.native_receipt_observation = nativeActivity.observation;
+ }
const counts = jsonObject(input.observations.reconciliation_counts);
facts.receipt_observation = counts ? "host_reconciled" : "not_supplied";
if (counts) facts.reconciliation_counts = Object.fromEntries(
@@ -61,7 +66,7 @@ export const subagentContextProvider: AgentContextProvider = {
};
}
return { guidance, facts, source_refs: [
- "goal_boundary.orchestration", "docs/integrations/codex-subagent-orchestration.md",
+ "goal_boundary.orchestration", "docs/integrations/host-native-child-receipts.md",
] };
},
};
@@ -98,6 +103,32 @@ function boundedNativeCapacityObservation(value: unknown): JsonObject | null {
return result;
}
+function boundedNativeChildActivity(value: unknown): JsonObject | null {
+ const source = jsonObject(value);
+ if (!source || source.schema_version !== "native_subagent_activity_v0"
+ || source.entrypoint_scope !== "host_native_child_tools") return null;
+ const observation = String(source.observation ?? "");
+ if (!["unknown", "coordinator_reported"].includes(observation)) return null;
+ const count = (key: string) => Number.isInteger(source[key]) && Number(source[key]) >= 0
+ ? Math.min(Number(source[key]), 10_000) : 0;
+ const result: JsonObject = {
+ schema_version: "native_subagent_activity_v0",
+ entrypoint_scope: "host_native_child_tools",
+ observation,
+ host_attested: false,
+ configured_limit_kind: "upper_bound",
+ configured_limit: count("configured_limit"),
+ attempted_count: count("attempted_count"),
+ launched_count: count("launched_count"),
+ skipped_count: count("skipped_count"),
+ capacity_rejected_count: count("capacity_rejected_count"),
+ host_failed_count: count("host_failed_count"),
+ parent_accepted_count: count("parent_accepted_count"),
+ };
+ if (source.retry_same_turn === false) result.retry_same_turn = false;
+ return result;
+}
+
function boundedDelegationContext(value: unknown): JsonObject | null {
const source = jsonObject(value);
if (!source || source.schema_version !== "loopx_delegation_context_v0") return null;
diff --git a/loopx/presentation/renderers/status_markdown.py b/loopx/presentation/renderers/status_markdown.py
index c9f1395165..c715bd741e 100644
--- a/loopx/presentation/renderers/status_markdown.py
+++ b/loopx/presentation/renderers/status_markdown.py
@@ -1268,6 +1268,24 @@ def _append_project_asset_runtime_policy_markdown(
f"{markdown_scalar(orchestration_policy_summary(asset_orchestration))}"
)
+ native_child_activity = (
+ project_asset.get("native_child_activity")
+ if isinstance(project_asset.get("native_child_activity"), dict)
+ else {}
+ )
+ if native_child_activity.get("observation") == "coordinator_reported":
+ lines.append(
+ " - native_child_activity: "
+ f"turn={markdown_scalar(native_child_activity.get('turn_instance_id'))} "
+ "source=coordinator_reported host_attested=false "
+ f"configured_max={native_child_activity.get('configured_limit')} "
+ f"starts={native_child_activity.get('launched_count')} "
+ f"skips={native_child_activity.get('skipped_count')} "
+ f"capacity_rejections={native_child_activity.get('capacity_rejected_count')} "
+ f"host_failures={native_child_activity.get('host_failed_count')} "
+ f"parent_accepted={native_child_activity.get('parent_accepted_count')}"
+ )
+
subagent_activity = (
project_asset.get("subagent_activity")
if isinstance(project_asset.get("subagent_activity"), dict)
diff --git a/loopx/rollout_event_log.py b/loopx/rollout_event_log.py
index 0e81dadf24..a0e4a6c33c 100644
--- a/loopx/rollout_event_log.py
+++ b/loopx/rollout_event_log.py
@@ -5,7 +5,7 @@
from collections import Counter, deque
from datetime import datetime, timezone
from pathlib import Path
-from typing import Any, Iterable, Iterator, Mapping, Sequence
+from typing import Any, Callable, Iterable, Iterator, Mapping, Sequence
from .file_lock import exclusive_file_lock
@@ -21,6 +21,9 @@
"compact_case_result",
"evidence_log_read",
"failure_attribution",
+ "native_child_decision",
+ "native_child_result",
+ "native_child_review",
"pr_merge",
"pr_review_ack",
"quota_monitor_poll",
@@ -441,8 +444,9 @@ def append_rollout_event_once(
event: Mapping[str, Any],
*,
identity_fields: Sequence[str],
+ precondition: Callable[[], None] | None = None,
) -> tuple[dict[str, Any], bool]:
- """Append once by a stable public identity, returning whether it was new."""
+ """Append once by a stable identity; check a transition under the same lock."""
payload = dict(event)
if payload.get("schema_version") != ROLLOUT_EVENT_SCHEMA_VERSION:
@@ -473,6 +477,8 @@ def append_rollout_event_once(
if _idempotency_body(existing) == _idempotency_body(payload):
return existing, False
raise ValueError(f"conflicting rollout event_id: {event_id}")
+ if precondition is not None:
+ precondition()
_append_rollout_event_line(log_path, payload)
return payload, True
diff --git a/loopx/status.py b/loopx/status.py
index 26ee24cf16..ca15548960 100644
--- a/loopx/status.py
+++ b/loopx/status.py
@@ -60,6 +60,10 @@
from .interface_budget import interface_budget_cadence_for_runs
from .long_task_cadence import build_long_task_cadence_hint
from .orchestration import compact_orchestration_policy
+from .capabilities.multi_subagent.native_child_receipts import (
+ latest_native_child_activity,
+ load_native_child_activity,
+)
from .paths import resolve_runtime_root
from .control_plane.work_items.task_graph import (
build_task_graph_projection as _build_task_graph_projection_read_model,
@@ -1176,6 +1180,31 @@ def request_active_state_todo_fields(
)
if receipts:
item["evidence_log_read_receipts"] = receipts
+ project_asset = item.get("project_asset")
+ if not isinstance(project_asset, dict):
+ continue
+ orchestration = project_asset.get("orchestration")
+ if not isinstance(orchestration, dict) or not (
+ orchestration.get("mode") == "multi_subagent"
+ and orchestration.get("spawn_allowed") is True
+ and int(orchestration.get("max_children") or 0) > 0
+ ):
+ continue
+ native_activity = latest_native_child_activity(
+ events, goal_id=goal_id,
+ configured_limit=int(orchestration["max_children"]),
+ )
+ if native_activity:
+ # The shared status snapshot is bounded for Todo work. Once it
+ # reveals a native event, read its exact Turn before showing
+ # counts, so older stages falling outside that window cannot
+ # silently undercount the activity.
+ project_asset["native_child_activity"] = load_native_child_activity(
+ runtime_root, goal_id=goal_id,
+ agent_id=native_activity["agent_id"],
+ turn_instance_id=native_activity["turn_instance_id"],
+ configured_limit=int(orchestration["max_children"]),
+ )
return queue
diff --git a/tests/capabilities/test_native_child_receipts.py b/tests/capabilities/test_native_child_receipts.py
new file mode 100644
index 0000000000..59ce71799b
--- /dev/null
+++ b/tests/capabilities/test_native_child_receipts.py
@@ -0,0 +1,260 @@
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+from loopx.capabilities.multi_subagent.native_child_receipts import (
+ latest_native_child_activity,
+ load_native_child_activity,
+ record_native_child,
+)
+from loopx.rollout_event_log import (
+ append_rollout_event,
+ build_rollout_event,
+ load_rollout_events,
+ rollout_event_log_path,
+)
+
+
+GOAL = "native-child-fixture"
+AGENT = "generic-coordinator"
+TURN = "turn-native-1"
+
+
+def _admit(runtime_root: Path) -> None:
+ append_rollout_event(
+ rollout_event_log_path(runtime_root, GOAL),
+ build_rollout_event(
+ goal_id=GOAL, event_kind="quota_should_run", agent_id=AGENT,
+ run_id=TURN, todo_id="todo-1", status="normal_run",
+ details={"todo_id": "todo-1",
+ "settlement_effect_id": f"{GOAL}:{AGENT}:todo-1:{TURN}"},
+ ),
+ )
+
+
+def _record(runtime_root: Path, operation_id: str, *, stage: str, outcome: str,
+ **kwargs):
+ return record_native_child(
+ runtime_root=runtime_root, goal_id=GOAL, agent_id=AGENT,
+ turn_instance_id=TURN, operation_id=operation_id, configured_limit=6,
+ stage=stage, outcome=outcome, execute=True, **kwargs,
+ )
+
+
+def test_generic_report_adoption_is_idempotent_and_survives_restart(tmp_path: Path):
+ _admit(tmp_path)
+ unknown = load_native_child_activity(
+ tmp_path, goal_id=GOAL, agent_id=AGENT, turn_instance_id=TURN,
+ configured_limit=6,
+ )
+ assert unknown["observation"] == "unknown"
+ assert unknown["configured_limit"] == 6
+ assert unknown["launched_count"] == 0
+ assert unknown["host_attested"] is False
+
+ first = _record(
+ tmp_path, "op-1", stage="decision", operation="spawn",
+ outcome="started", entrypoint_id="generic_host",
+ )
+ replay = _record(
+ tmp_path, "op-1", stage="decision", operation="spawn",
+ outcome="started", entrypoint_id="generic_host",
+ )
+ assert first["appended"] is True
+ assert replay["appended"] is False
+ assert first["receipt"]["event_id"] == replay["receipt"]["event_id"]
+ assert first["native_child_activity"]["parent_accepted_count"] == 0
+
+ _record(tmp_path, "op-1", stage="result", outcome="completed")
+ reviewed = _record(
+ tmp_path, "op-1", stage="review", outcome="accepted",
+ evidence_ref="evidence-1", validation_ref="validation-1",
+ )
+ activity = load_native_child_activity(
+ tmp_path, goal_id=GOAL, agent_id=AGENT, turn_instance_id=TURN,
+ configured_limit=6,
+ )
+ assert activity == reviewed["native_child_activity"]
+ assert activity["observation"] == "coordinator_reported"
+ assert activity["launched_count"] == 1
+ assert activity["parent_accepted_count"] == 1
+ assert activity["operations"][0]["entrypoint_id"] == "generic_host"
+ assert latest_native_child_activity(
+ load_rollout_events(rollout_event_log_path(tmp_path, GOAL)),
+ goal_id=GOAL, configured_limit=6,
+ ) == activity
+ assert len(load_rollout_events(rollout_event_log_path(tmp_path, GOAL))) == 4
+
+
+def test_skip_capacity_rejection_and_no_same_turn_retry(tmp_path: Path):
+ _admit(tmp_path)
+ skipped = _record(
+ tmp_path, "skip-1", stage="decision", operation="skip",
+ outcome="skipped", entrypoint_id="another_host",
+ reason_code="no_independent_work",
+ )["native_child_activity"]
+ assert skipped["skipped_count"] == 1
+ assert skipped["observed_capacity"] == "not_observed"
+ rejected = _record(
+ tmp_path, "op-1", stage="decision", operation="spawn",
+ outcome="capacity_rejected", entrypoint_id="another_host",
+ reason_code="host_capacity_exhausted",
+ )["native_child_activity"]
+ assert rejected["capacity_rejected_count"] == 1
+ assert rejected["retry_same_turn"] is False
+ with pytest.raises(ValueError, match="same-Turn"):
+ _record(
+ tmp_path, "op-2", stage="decision", operation="followup",
+ outcome="started", entrypoint_id="another_host",
+ )
+ assert len(load_rollout_events(rollout_event_log_path(tmp_path, GOAL))) == 3
+
+
+def test_typed_host_failure_stops_same_turn_retry_but_keeps_parent_reporting(
+ tmp_path: Path,
+):
+ _admit(tmp_path)
+ failed = _record(
+ tmp_path, "op-1", stage="decision", operation="spawn",
+ outcome="host_failed", entrypoint_id="generic_host",
+ reason_code="host_unavailable",
+ )["native_child_activity"]
+ assert failed["host_failed_count"] == 1
+ assert failed["retry_same_turn"] is False
+ with pytest.raises(ValueError, match="same-Turn"):
+ _record(
+ tmp_path, "op-2", stage="decision", operation="spawn",
+ outcome="started", entrypoint_id="generic_host",
+ )
+ skipped = _record(
+ tmp_path, "skip-1", stage="decision", operation="skip",
+ outcome="skipped", entrypoint_id="generic_host",
+ reason_code="parent_work_priority",
+ )["native_child_activity"]
+ assert skipped["skipped_count"] == 1
+
+
+def test_no_unadmitted_or_conflicting_receipts(tmp_path: Path):
+ with pytest.raises(ValueError, match="admitted"):
+ _record(
+ tmp_path, "op-1", stage="decision", operation="spawn",
+ outcome="started", entrypoint_id="generic_host",
+ )
+ _admit(tmp_path)
+ with pytest.raises(ValueError, match="started"):
+ _record(tmp_path, "op-1", stage="review", outcome="accepted",
+ evidence_ref="evidence-1", validation_ref="validation-1")
+ _record(
+ tmp_path, "op-1", stage="decision", operation="spawn",
+ outcome="started", entrypoint_id="generic_host",
+ )
+ with pytest.raises(ValueError, match="conflicting"):
+ _record(
+ tmp_path, "op-1", stage="decision", operation="spawn",
+ outcome="started", entrypoint_id="other_host",
+ )
+ with pytest.raises(ValueError, match="completed"):
+ _record(tmp_path, "op-1", stage="review", outcome="accepted",
+ evidence_ref="evidence-1", validation_ref="validation-1")
+ with pytest.raises(ValueError, match="compact opaque id"):
+ _record(
+ tmp_path, "op-2", stage="decision", operation="spawn",
+ outcome="started", entrypoint_id="/private/source",
+ )
+
+
+def test_cli_readback_and_disabled_policy_do_not_mutate(tmp_path: Path):
+ registry = tmp_path / "registry.json"
+ registry.write_text(json.dumps({"schema_version": 1, "goals": [{
+ "id": GOAL, "repo": str(tmp_path), "status": "active",
+ "registered_agents": [AGENT],
+ "spawn_policy": {"mode": "multi_subagent", "allowed": True, "max_children": 6},
+ }]}))
+ runtime_root = tmp_path / "runtime"
+ _admit(runtime_root)
+ base = [sys.executable, "-m", "loopx.cli", "--registry", str(registry),
+ "--runtime-root", str(runtime_root), "--format", "json",
+ "native-child", "--goal-id", GOAL, "--agent-id", AGENT,
+ "--turn-instance-id", TURN]
+ command = [*base, "record", "--operation-id", "op-1", "--stage", "decision",
+ "--operation", "spawn", "--outcome", "started",
+ "--entrypoint-id", "generic_host"]
+ preview = subprocess.run(command, text=True, capture_output=True)
+ assert preview.returncode == 0, preview.stderr
+ assert json.loads(preview.stdout)["dry_run"] is True
+ assert len(load_rollout_events(rollout_event_log_path(runtime_root, GOAL))) == 1
+ recorded = subprocess.run([*command, "--execute"], text=True, capture_output=True)
+ assert recorded.returncode == 0, recorded.stderr
+ read = subprocess.run([*base, "read"], text=True, capture_output=True)
+ assert read.returncode == 0, read.stderr
+ assert json.loads(read.stdout)["native_child_activity"]["launched_count"] == 1
+ context = subprocess.run(
+ [sys.executable, "-m", "loopx.cli", "--registry", str(registry),
+ "--runtime-root", str(runtime_root), "agent-context", "--format", "json",
+ "--goal-id", GOAL, "--agent-id", AGENT, "--phase", "after_delegate_result",
+ "--turn-instance-id", TURN],
+ text=True, capture_output=True,
+ )
+ assert context.returncode == 0, context.stderr
+ context_payload = json.loads(context.stdout)
+ assert context_payload["host_receipts_observed"] is False
+ assert context_payload["native_child_activity"]["launched_count"] == 1
+ [contribution] = context_payload["agent_context"]["contributions"]
+ assert contribution["facts"]["native_child_activity"]["launched_count"] == 1
+
+ disabled = json.loads(registry.read_text())
+ disabled["goals"][0]["spawn_policy"]["allowed"] = False
+ registry.write_text(json.dumps(disabled))
+ rejected = subprocess.run([*base, "read"], text=True, capture_output=True)
+ assert rejected.returncode == 1
+ assert "enabled multi_subagent" in json.loads(rejected.stdout)["error"]
+
+
+def test_goal_status_only_attaches_reported_activity_to_enabled_goal(
+ tmp_path: Path, monkeypatch,
+):
+ import loopx.status as status
+
+ _admit(tmp_path)
+ _record(
+ tmp_path, "op-1", stage="decision", operation="skip",
+ outcome="skipped", entrypoint_id="generic_host",
+ reason_code="no_independent_work",
+ )
+ policy = {"mode": "multi_subagent", "spawn_allowed": True, "max_children": 6}
+ queue = {"items": [{"goal_id": GOAL, "project_asset": {"orchestration": policy}}]}
+ monkeypatch.setattr(status, "_build_attention_queue_read_model", lambda **_kwargs: queue)
+ projected = status.build_attention_queue(
+ contract={}, history={}, global_registry={}, runtime_root=tmp_path,
+ )
+ activity = projected["items"][0]["project_asset"]["native_child_activity"]
+ assert activity["skipped_count"] == 1
+ assert activity["observation"] == "coordinator_reported"
+
+ # A bounded status snapshot may retain a later result but not its decision.
+ # The status projection must recover the complete Turn from the event log.
+ _record(tmp_path, "op-2", stage="decision", operation="spawn",
+ outcome="started", entrypoint_id="generic_host")
+ _record(tmp_path, "op-2", stage="result", outcome="completed")
+ projected = status.build_attention_queue(
+ contract={}, history={}, global_registry={}, runtime_root=tmp_path,
+ events_for_goal=lambda _goal_id, *, limit: load_rollout_events(
+ rollout_event_log_path(tmp_path, GOAL), limit=limit,
+ )[-1:],
+ )
+ activity = projected["items"][0]["project_asset"]["native_child_activity"]
+ assert activity["launched_count"] == 1
+ assert activity["skipped_count"] == 1
+
+ queue["items"][0]["project_asset"] = {
+ "orchestration": {**policy, "spawn_allowed": False},
+ }
+ disabled = status.build_attention_queue(
+ contract={}, history={}, global_registry={}, runtime_root=tmp_path,
+ )
+ assert "native_child_activity" not in disabled["items"][0]["project_asset"]
diff --git a/tests/control_plane_ts/agent_context.test.ts b/tests/control_plane_ts/agent_context.test.ts
index cf7147244d..f9bbadf339 100644
--- a/tests/control_plane_ts/agent_context.test.ts
+++ b/tests/control_plane_ts/agent_context.test.ts
@@ -192,7 +192,7 @@ test("coordinator participation guidance survives all bounded lifecycle projecti
} })!;
assert.deepEqual(packet.failures, []);
const [contribution] = packet.contributions as Record[];
- assert.equal(contribution.revision, "v5");
+ assert.equal(contribution.revision, "v6");
assert.equal(packet.authority, "guidance_only");
assert.ok(Buffer.byteLength(JSON.stringify(contribution)) <= 2048);
assert.ok(Buffer.byteLength(JSON.stringify(packet)) <= 3072);
@@ -249,3 +249,30 @@ test("configured child limit stays distinct from typed native host capacity", ()
assert.equal(succeededFacts.capacity_contract.live_availability, "attempt_observed");
assert.equal(succeededFacts.native_host_capacity.retry_same_turn, undefined);
});
+
+test("durable native child report is bounded and does not claim host attestation", () => {
+ const packet = evaluateSubagentContext({ phase: "after_delegate_result", scope,
+ orchestration: { ...policy, max_children: 6 }, observations: {
+ native_child_activity: {
+ schema_version: "native_subagent_activity_v0",
+ entrypoint_scope: "host_native_child_tools",
+ observation: "coordinator_reported",
+ host_attested: true,
+ configured_limit: 6,
+ attempted_count: 2,
+ launched_count: 1,
+ skipped_count: 0,
+ capacity_rejected_count: 1,
+ host_failed_count: 0,
+ parent_accepted_count: 0,
+ retry_same_turn: false,
+ raw_host_result: "private result",
+ },
+ } })!;
+ const facts = (packet.contributions as Record[])[0].facts;
+ assert.equal(facts.native_child_activity.host_attested, false);
+ assert.equal(facts.native_child_activity.launched_count, 1);
+ assert.equal(facts.native_child_activity.retry_same_turn, false);
+ assert.equal(facts.native_receipt_observation, "coordinator_reported");
+ assert.ok(!JSON.stringify(packet).includes("private result"));
+});