Skip to main content

bamboo_engine/ticket_worker_plan/
remote.rs

1//! Native Worker projection over the existing bounded HostBridge callback lane.
2//! Host revalidates the live Run and service authority on every read/write.
3use super::*;
4use bamboo_subagent::executor::HostBridge;
5use bamboo_tickets::WorkContextPacket;
6use serde::{Deserialize, Serialize};
7
8pub const TICKET_BOOTSTRAP_POSTURE_PENDING: &str = "ticket_bootstrap_posture_pending";
9
10#[derive(Clone, Debug, Serialize, Deserialize)]
11#[serde(deny_unknown_fields)]
12pub struct PlanProjection {
13    pub assignment_id: String,
14    pub generation: u64,
15    pub contract_revision: u64,
16    pub packet_hash: String,
17    pub binding: bamboo_tickets::ScopeBinding,
18    pub task_list: Option<TaskList>,
19    pub plan_revision: u64,
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub question: Option<bamboo_tickets::PendingRequest>,
22}
23
24pub struct RemoteWorkerPlan {
25    host: HostBridge,
26    packet: WorkContextPacket,
27    session_id: String,
28    run_id: String,
29    initial: PlanProjection,
30}
31
32impl RemoteWorkerPlan {
33    pub async fn from_run(
34        run: &bamboo_subagent::proto::RunSpec,
35        host: Option<HostBridge>,
36    ) -> Result<Option<Self>> {
37        let mut packets = run
38            .messages
39            .iter()
40            .filter(|m| m.get("role").and_then(|v| v.as_str()) == Some("system"))
41            .filter_map(|m| m.get("metadata")?.get(TICKET_PLAN_PACKET_KEY));
42        let Some(raw) = packets.next() else {
43            return Ok(None);
44        };
45        if packets.next().is_some() {
46            return Err(Error::ScopeDenied("multiple LocalPlan packets".into()));
47        }
48        let packet: WorkContextPacket = serde_json::from_value(raw.clone())?;
49        let identity = run
50            .logical_session
51            .as_ref()
52            .filter(|i| i.creation.is_some())
53            .ok_or_else(|| {
54                Error::ScopeDenied("native LocalPlan needs exact Child creation".into())
55            })?;
56        if identity.parent_session_id.as_deref() != Some(&packet.binding.supervisor_session_id) {
57            return Err(Error::ScopeDenied(
58                "native LocalPlan Supervisor differs".into(),
59            ));
60        }
61        let run_id = run
62            .activation_run_id
63            .clone()
64            .filter(|id| !id.is_empty())
65            .ok_or_else(|| Error::ScopeDenied("native LocalPlan needs Host Run".into()))?;
66        let host = host.ok_or_else(|| {
67            Error::AuthorityUnavailable("native LocalPlan HostBridge missing".into())
68        })?;
69        Self::from_host_packet(host, packet, identity.session_id.clone(), run_id)
70            .await
71            .map(Some)
72    }
73
74    pub async fn from_host_packet(
75        host: HostBridge,
76        packet: WorkContextPacket,
77        session_id: String,
78        run_id: String,
79    ) -> Result<Self> {
80        if session_id.is_empty() || run_id.is_empty() {
81            return Err(Error::ScopeDenied(
82                "invalid native LocalPlan binding".into(),
83            ));
84        }
85        bamboo_tickets::validate_native_tool_ceiling(
86            packet.contract.allowed_tools.iter().map(String::as_str),
87        )?;
88        // Events and callback controls use distinct transport lanes. A queued
89        // posture event may arrive after this read. Retry only the Host's exact
90        // pending-posture response, never a mutation or general authority error.
91        let request = serde_json::json!({(TICKET_PLAN_ACTION): {"read":true}});
92        let mut attempts = 0;
93        let data = loop {
94            match host
95                .subagent_call(request.clone(), "ticket-plan-bootstrap")
96                .await
97            {
98                Ok(data) => break data,
99                Err(error) if error == TICKET_BOOTSTRAP_POSTURE_PENDING && attempts < 16 => {
100                    attempts += 1;
101                    tokio::time::sleep(std::time::Duration::from_millis(50)).await;
102                }
103                Err(error) => return Err(Error::AuthorityUnavailable(error)),
104            }
105        };
106        let initial: PlanProjection = serde_json::from_value(data)?;
107        if initial.assignment_id != packet.assignment_id
108            || initial.generation != packet.generation
109            || initial.contract_revision != packet.contract_revision
110            || initial.binding != packet.binding
111            || initial.packet_hash != packet_fingerprint(&packet)?
112        {
113            return Err(Error::ScopeDenied(
114                "LocalPlan packet differs from Host capability".into(),
115            ));
116        }
117        if initial
118            .task_list
119            .as_ref()
120            .is_some_and(|plan| plan.session_id != session_id)
121        {
122            return Err(Error::ScopeDenied("Host returned another plan".into()));
123        }
124        Ok(Self {
125            host,
126            packet,
127            session_id,
128            run_id,
129            initial,
130        })
131    }
132
133    fn validate(&self, session: &Session) -> Result<()> {
134        if session.kind != SessionKind::Child
135            || session.id != self.session_id
136            || session.parent_session_id.as_deref()
137                != Some(&self.packet.binding.supervisor_session_id)
138            || session
139                .agent_runtime_state
140                .as_ref()
141                .is_none_or(|s| s.run_id != self.run_id)
142        {
143            return Err(Error::ScopeDenied(
144                "native LocalPlan identity changed".into(),
145            ));
146        }
147        Ok(())
148    }
149}
150
151#[async_trait::async_trait]
152impl WorkerLocalPlan for RemoteWorkerPlan {
153    fn run_id(&self) -> &str {
154        &self.run_id
155    }
156    fn bind_session(&self, session: &mut Session) -> Result<()> {
157        self.validate(session)?;
158        session.metadata.insert(
159            TICKET_LOCAL_PLAN_KEY.into(),
160            self.packet.assignment_id.clone(),
161        );
162        session.task_list = self.initial.task_list.clone();
163        session.set_task_list_version_meta(self.initial.plan_revision.to_string());
164        Ok(())
165    }
166    async fn apply_task(
167        &self,
168        session: &mut Session,
169        call_id: &str,
170        args: &serde_json::Value,
171    ) -> Result<TaskList> {
172        self.validate(session)?;
173        if session.metadata.get(TICKET_LOCAL_PLAN_KEY) != Some(&self.packet.assignment_id) {
174            return Err(Error::ScopeDenied(
175                "native LocalPlan capability missing".into(),
176            ));
177        }
178        let data = self
179            .host
180            .subagent_call(
181                serde_json::json!({(TICKET_PLAN_ACTION): {"task":args}}),
182                call_id,
183            )
184            .await
185            .map_err(Error::AuthorityUnavailable)?;
186        let projection: PlanProjection = serde_json::from_value(data)?;
187        if projection.assignment_id != self.packet.assignment_id
188            || projection.generation != self.packet.generation
189            || projection.contract_revision != self.packet.contract_revision
190            || projection.binding != self.packet.binding
191            || projection.packet_hash != packet_fingerprint(&self.packet)?
192        {
193            return Err(Error::ScopeDenied(
194                "Host LocalPlan capability changed".into(),
195            ));
196        }
197        let plan = projection
198            .task_list
199            .filter(|p| p.session_id == self.session_id)
200            .ok_or_else(|| Error::ScopeDenied("Host omitted the exact plan".into()))?;
201        if projection.plan_revision
202            < session
203                .task_list_version_meta()
204                .and_then(|v| v.parse().ok())
205                .unwrap_or(0)
206        {
207            return Err(Error::RevisionConflict);
208        }
209        session.task_list = Some(plan.clone());
210        session.set_task_list_version_meta(projection.plan_revision.to_string());
211        if let Some(q) = projection.question {
212            if q.kind != bamboo_tickets::RequestKind::Question
213                || q.work_id != self.packet.contract_ref
214                || q.assignment_id.as_deref() != Some(self.packet.assignment_id.as_str())
215                || q.generation != self.packet.generation
216                || q.contract_revision != self.packet.contract_revision
217                || q.worker_packet_hash.as_deref() != Some(projection.packet_hash.as_str())
218            {
219                return Err(Error::ScopeDenied("Host question binding changed".into()));
220            }
221            session
222                .metadata
223                .insert(TICKET_QUESTION_YIELD_KEY.into(), serde_json::to_string(&q)?);
224        }
225        Ok(plan)
226    }
227}
228
229/// Caller is a Host-loaded, live, creation-fenced Session. JSON only contains a
230/// bounded Task payload; all execution identity comes from Host state.
231pub fn apply_host_plan_request(
232    service: &TicketService,
233    caller: &Session,
234    run_id: &str,
235    args: &serde_json::Value,
236    call_id: &str,
237) -> Result<PlanProjection> {
238    let body = args
239        .as_object()
240        .filter(|o| o.len() == 1)
241        .and_then(|o| o.get(TICKET_PLAN_ACTION))
242        .and_then(|v| v.as_object())
243        .filter(|o| o.len() == 1)
244        .ok_or_else(|| Error::InvalidTransition("invalid private Task request".into()))?;
245    let assignment = caller
246        .metadata
247        .get(TICKET_LOCAL_PLAN_KEY)
248        .ok_or_else(|| Error::ScopeDenied("Child has no LocalPlan capability".into()))?;
249    let plan = TicketWorkerPlan::from_receipt_identity(Arc::new(service.clone()), assignment)?;
250    if plan.run_id() != run_id || plan.session_id != caller.id {
251        return Err(Error::ScopeDenied(
252            "Host Run differs from admission receipt".into(),
253        ));
254    }
255    let mut projection = caller.clone();
256    projection
257        .agent_runtime_state
258        .get_or_insert_with(Default::default)
259        .run_id = run_id.into();
260    plan.project_session(&mut projection)?;
261    let saved_question = service.published()?.1.assignments[assignment]
262        .awaiting_request
263        .clone();
264    let packet_hash = if let Some(request) = saved_question {
265        service.published()?.1.requests[&request]
266            .worker_packet_hash
267            .clone()
268            .ok_or_else(|| Error::AuthorityUnavailable("question packet hash missing".into()))?
269    } else {
270        packet_fingerprint(&service.child_context_packet(&plan.authority, assignment, 65536)?)?
271    };
272    if body.get("read") != Some(&serde_json::Value::Bool(true)) {
273        let task = body
274            .get("task")
275            .ok_or_else(|| Error::InvalidTransition("Task payload missing".into()))?;
276        let prompt = question_prompt(task)?;
277        // Refuse a plan that cannot fit the existing bounded callback reply
278        // before committing it. A transport budget must not become a partial
279        // success in which the Host writes but the Worker cannot read its plan.
280        let mut candidate = TaskTool::task_list_from_args_with_existing(
281            task,
282            &caller.id,
283            projection.task_list.as_ref(),
284            None,
285        )
286        .map_err(|e| Error::InvalidTransition(e.to_string()))?;
287        let snapshot = service.published()?.1;
288        let a = &snapshot.assignments[assignment];
289        candidate.title = format!(
290            "Local plan: {}",
291            snapshot.tickets[&a.work_id].contract.title
292        );
293        let candidate = PlanProjection {
294            assignment_id: assignment.clone(),
295            generation: a.generation,
296            contract_revision: a.contract_revision,
297            packet_hash: packet_hash.clone(),
298            binding: snapshot.binding,
299            task_list: Some(candidate),
300            plan_revision: a.plan.plan_revision.saturating_add(1),
301            question: None,
302        };
303        let question_budget = prompt
304            .as_ref()
305            .map_or(0, |p| p.len().saturating_mul(2).saturating_add(2048));
306        if serde_json::to_vec(&serde_json::json!({"result":candidate}))?
307            .len()
308            .saturating_add(question_budget)
309            > 16 * 1024 - 256
310        {
311            return Err(Error::InvalidTransition(
312                "native LocalPlan exceeds HostBridge reply budget".into(),
313            ));
314        }
315        plan.apply_task(&mut projection, call_id, task)?;
316    } else {
317        service.authorize_tool(&plan.authority, "Task")?;
318    }
319    let plan_revision = projection
320        .task_list_version_meta()
321        .and_then(|v| v.parse().ok())
322        .unwrap_or(0);
323    let snapshot = service.published()?.1;
324    let a = &snapshot.assignments[assignment];
325    Ok(PlanProjection {
326        assignment_id: assignment.clone(),
327        generation: a.generation,
328        contract_revision: a.contract_revision,
329        packet_hash,
330        binding: snapshot.binding,
331        task_list: projection.task_list,
332        plan_revision,
333        question: a
334            .awaiting_request
335            .as_ref()
336            .and_then(|id| snapshot.requests.get(id))
337            .cloned(),
338    })
339}
340
341fn packet_fingerprint(packet: &WorkContextPacket) -> Result<String> {
342    Ok(bamboo_tickets::content_hash(
343        &bamboo_tickets::canonical_bytes(packet)?,
344    ))
345}