Skip to main content

oxicode_agent/tools/
issue.rs

1//! `issue` agent tool — agent-driven local issue management.
2//!
3//! Implements `oxicode_agent::AgentTool` against the `FileIssueStore`. One
4//! tool with an `action` discriminator (matches the pattern used by the
5//! `github` tool, see `oxicode-agent/src/tools/github.rs`).
6//!
7//! Actions: `list`, `read`, `create`, `update`, `start`, `release`, `close`,
8//! `link_session`. See [`AgentTool::parameters_schema`] for the exact JSON
9//! schema each action accepts.
10//!
11//! Design notes:
12//! - The tool holds an `Arc<FileIssueStore>` so it can be cheaply cloned when
13//!   the tool is registered in the live `ToolRegistry` (mirrors how
14//!   `McpTool`/`WasmTool` hold their managers).
15//! - The "current session id" used for assignment / session-linking is taken
16//!   from `ToolContext.session_id`. The agent loop fills this in from the
17//!   active session.
18
19use std::sync::Arc;
20
21use crate::{AgentTool, AgentToolResult, ToolContext};
22use async_trait::async_trait;
23use serde_json::{Value, json};
24
25use crate::issues::{FileIssueStore, Issue, IssueError, IssueFilter, IssuePatch, Priority, Status};
26
27/// The `issue` tool. One registration, multiple actions.
28#[derive(Debug, Clone)]
29pub struct IssueTool {
30    store: Arc<FileIssueStore>,
31}
32
33impl IssueTool {
34    /// Construct a new `issue` tool backed by `store`.
35    pub fn new(store: FileIssueStore) -> Self {
36        Self {
37            store: Arc::new(store),
38        }
39    }
40}
41
42#[async_trait]
43impl AgentTool for IssueTool {
44    fn name(&self) -> &str {
45        "issue"
46    }
47
48    fn label(&self) -> &str {
49        "Issue"
50    }
51
52    fn description(&self) -> &str {
53        "Manage local issues stored as markdown files in `.oxicode/issues/`. \
54         Before editing, call `start` to claim the issue — this prevents other \
55         agents/sessions from concurrently working on the same issue. Always \
56         call `list` first to see existing issues and avoid duplicates. \
57         Use `release` to give up a claim, or `close` to finish the work. \
58         For `update`: every field is optional — omit to keep, provide to replace; \
59         `labels: []` clears all labels (omit to keep). Prefer the dedicated \
60         `close`/`reopen`/`start`/`release` actions over `update { status }`. \
61         To resume a closed issue, call `reopen`, then `start`. Concurrent edits \
62         are auto-reconciled (up to 4 retries), so a stale `content_hash` from \
63         an earlier `read` still succeeds."
64    }
65
66    fn parameters_schema(&self) -> Value {
67        json!({
68            "type": "object",
69            "properties": {
70                "action": {
71                    "type": "string",
72                    "enum": ["list", "read", "create", "update", "reopen", "start", "release", "close", "link_session"],
73                    "description": "Issue operation. For `update`, every field is optional — omit to keep, provide to replace. Concurrent edits are auto-reconciled (up to 4 retries)."
74                },
75                "id": {"type": "integer", "description": "Issue id (for read/update/reopen/start/release/close/link_session)."},
76                "title": {"type": "string", "description": "create: required. update: replaces the title. Max 512 chars."},
77                "body": {"type": "string", "description": "create: optional (defaults empty). update: replaces the body. Max 256 KiB."},
78                "priority": {"type": "string", "enum": ["low", "medium", "high", "critical"], "description": "create/update: new priority. list: filter to this priority."},
79                "labels": {"type": "array", "items": {"type": "string"}, "description": "create/update: REPLACES labels entirely. Omit to keep; pass [] to clear all. Max 32 labels, 64 chars each."},
80                "status": {"type": "string", "enum": ["open", "closed"], "description": "list: filter by status. update: new status (prefer the `close`/`reopen` actions for clarity)."},
81                "label": {"type": "string", "description": "list: filter to issues with this label."},
82                "text": {"type": "string", "description": "list: case-insensitive substring filter on the title."},
83                "content_hash": {"type": "string", "description": "Hash from the last `read`. ADVISORY: the tool auto re-reads and retries on conflict, so a stale hash still succeeds."},
84                "github": {"type": "object", "readOnly": true, "description": "READ-ONLY. Populated by GitHub sync (Phase 6); cannot be set via this tool."}
85            },
86            "required": ["action"]
87        })
88    }
89
90    fn essential(&self) -> bool {
91        false
92    }
93
94    async fn execute(
95        &self,
96        _tool_call_id: &str,
97        params: Value,
98        _signal: Option<tokio::sync::oneshot::Receiver<()>>,
99        ctx: &ToolContext,
100    ) -> Result<AgentToolResult, String> {
101        let action = match params.get("action").and_then(|v| v.as_str()) {
102            Some(a) => a.to_string(),
103            None => return Ok(AgentToolResult::error("missing required field: action")),
104        };
105
106        // Guard the disk before dispatch: reject oversize payloads early (#5).
107        if let Err(e) = validate_size(&params, &action) {
108            return Ok(AgentToolResult::error(e));
109        }
110
111        let session = ctx.session_id.clone().unwrap_or_default();
112        let result: Result<String, String> = match action.as_str() {
113            "list" => self.list(params),
114            "read" => self.read(params).await,
115            "create" => self.create(params, &session).await,
116            "update" => self.update(params, &session).await,
117            "start" => self.start(params, &session).await,
118            "release" => self.release(params, &session).await,
119            "close" => self.close(params, &session).await,
120            "reopen" => self.reopen(params).await,
121            "link_session" => self.link_session(params, &session).await,
122            other => Err(format!("unknown action: {other}")),
123        };
124
125        Ok(match result {
126            Ok(text) => AgentToolResult::success(text),
127            Err(e) => AgentToolResult::error(e),
128        })
129    }
130}
131
132impl IssueTool {
133    fn list(&self, params: Value) -> Result<String, String> {
134        let status = parse_status_opt(params.get("status"))?;
135        let priority = parse_priority_opt(params.get("priority"))?;
136        let label = params
137            .get("label")
138            .and_then(|v| v.as_str())
139            .map(String::from);
140        let text = params
141            .get("text")
142            .and_then(|v| v.as_str())
143            .map(String::from);
144        let filter = IssueFilter {
145            status,
146            priority,
147            label,
148            assigned_to_session: None,
149            text,
150        };
151        let issues = self.store.list(&filter).map_err(|e| e.to_string())?;
152        if issues.is_empty() {
153            return Ok("no issues match the filter".to_string());
154        }
155        Ok(issues
156            .iter()
157            .map(format_issue_line)
158            .collect::<Vec<_>>()
159            .join("\n"))
160    }
161
162    async fn read(&self, params: Value) -> Result<String, String> {
163        let id = require_u32(params.get("id"), "id")?;
164        self.store
165            .read(id)
166            .map(|(issue, hash)| format_issue_full(&issue, &hash, &self.store.issues_dir()))
167            .map_err(|e| e.to_string())
168    }
169
170    async fn create(&self, params: Value, session: &str) -> Result<String, String> {
171        let title = require_string(params.get("title"), "title")?;
172        let body = params
173            .get("body")
174            .and_then(|v| v.as_str())
175            .unwrap_or("")
176            .to_string();
177        let priority = parse_priority_opt(params.get("priority"))?.unwrap_or(Priority::Medium);
178        let labels = parse_labels(params.get("labels"))?;
179        let session_opt = if session.is_empty() {
180            None
181        } else {
182            Some(session)
183        };
184        let issue = self
185            .store
186            .create(title, body, priority, labels, session_opt)
187            .map_err(|e| e.to_string())?;
188        Ok(format!(
189            "created issue #{}: {}",
190            issue.meta.id, issue.meta.title
191        ))
192    }
193
194    async fn update(&self, params: Value, session: &str) -> Result<String, String> {
195        let id = require_u32(params.get("id"), "id")?;
196        let agent_hash = hash_param(params.get("content_hash"));
197        // IssuePatch makes absent vs [] unambiguous: labels absent → None (keep),
198        // labels: [] → Some(vec![]) (clear). Resolves #3.
199        let patch = IssuePatch {
200            title: params
201                .get("title")
202                .and_then(|v| v.as_str())
203                .map(String::from),
204            body: params
205                .get("body")
206                .and_then(|v| v.as_str())
207                .map(String::from),
208            status: parse_status_opt(params.get("status"))?,
209            priority: parse_priority_opt(params.get("priority"))?,
210            labels: params
211                .get("labels")
212                .map(|v| parse_labels(Some(v)))
213                .transpose()?,
214        };
215        let caller = if session.is_empty() {
216            None
217        } else {
218            Some(session.to_string())
219        };
220        let store = self.store.clone();
221        cas_retry(&store, id, agent_hash, |hash| {
222            let store = store.clone();
223            let patch = patch.clone();
224            let caller = caller.clone();
225            async move { store.apply_patch(id, patch, caller, hash).await }
226        })
227        .await
228        .map(|issue| format!("updated issue #{}", issue.meta.id))
229        .map_err(|e| e.to_string())
230    }
231
232    async fn start(&self, params: Value, session: &str) -> Result<String, String> {
233        let id = require_u32(params.get("id"), "id")?;
234        if session.is_empty() {
235            return Err("cannot start: no active session id in context".to_string());
236        }
237        let agent_hash = hash_param(params.get("content_hash"));
238        let store = self.store.clone();
239        let session = session.to_string();
240        cas_retry(&store, id, agent_hash, |hash| {
241            let store = store.clone();
242            let session = session.clone();
243            async move { store.start(id, &session, hash).await }
244        })
245        .await
246        .map(|issue| format!("assigned issue #{} to session {}", issue.meta.id, session))
247        .map_err(|e| e.to_string())
248    }
249
250    async fn release(&self, params: Value, session: &str) -> Result<String, String> {
251        let id = require_u32(params.get("id"), "id")?;
252        if session.is_empty() {
253            return Err("cannot release: no active session id in context".to_string());
254        }
255        let agent_hash = hash_param(params.get("content_hash"));
256        let store = self.store.clone();
257        let session = session.to_string();
258        cas_retry(&store, id, agent_hash, |hash| {
259            let store = store.clone();
260            let session = session.clone();
261            async move { store.release(id, &session, hash).await }
262        })
263        .await
264        .map(|_| format!("released issue #{id}"))
265        .map_err(|e| e.to_string())
266    }
267
268    async fn close(&self, params: Value, session: &str) -> Result<String, String> {
269        let id = require_u32(params.get("id"), "id")?;
270        if session.is_empty() {
271            return Err("cannot close: no active session id in context".to_string());
272        }
273        let agent_hash = hash_param(params.get("content_hash"));
274        let store = self.store.clone();
275        let session = session.to_string();
276        cas_retry(&store, id, agent_hash, |hash| {
277            let store = store.clone();
278            let session = session.clone();
279            async move { store.close(id, &session, hash).await }
280        })
281        .await
282        .map(|issue| format!("closed issue #{}: {}", issue.meta.id, issue.meta.title))
283        .map_err(|e| e.to_string())
284    }
285
286    async fn reopen(&self, params: Value) -> Result<String, String> {
287        let id = require_u32(params.get("id"), "id")?;
288        let agent_hash = hash_param(params.get("content_hash"));
289        let store = self.store.clone();
290        cas_retry(&store, id, agent_hash, |hash| {
291            let store = store.clone();
292            async move { store.reopen(id, hash).await }
293        })
294        .await
295        .map(|issue| format!("reopened issue #{}: {}", issue.meta.id, issue.meta.title))
296        .map_err(|e| e.to_string())
297    }
298
299    async fn link_session(&self, params: Value, session: &str) -> Result<String, String> {
300        let id = require_u32(params.get("id"), "id")?;
301        if session.is_empty() {
302            return Err("cannot link_session: no active session id in context".to_string());
303        }
304        let agent_hash = hash_param(params.get("content_hash"));
305        let store = self.store.clone();
306        let session = session.to_string();
307        cas_retry(&store, id, agent_hash, |hash| {
308            let store = store.clone();
309            let session = session.clone();
310            async move { store.link_session(id, &session, hash).await }
311        })
312        .await
313        .map(|_| format!("linked session to issue #{id}"))
314        .map_err(|e| e.to_string())
315    }
316}
317
318// ── Formatting helpers (shared with the CLI subcommand in Phase 1.5) ─────
319
320/// Render an issue as a single summary line (id, status, priority, lock,
321/// title, labels, assignee + assignment age). Used by both the agent tool
322/// and the CLI.
323pub fn format_issue_line(i: &Issue) -> String {
324    let lock = if i.meta.assigned_to.is_some() {
325        "▣"
326    } else {
327        " "
328    };
329    let assignee = i.meta.assigned_to.as_ref().map(|a| {
330        format!(
331            " (assigned: {} since {})",
332            short_session(&a.session),
333            a.acquired_at.format("%m-%d %H:%M")
334        )
335    });
336    format!(
337        "#{:<4} [{}] {:8} {}{} {}{}",
338        i.meta.id,
339        i.meta.status,
340        i.meta.priority,
341        lock,
342        i.meta.title,
343        i.meta.labels.join(","),
344        assignee.unwrap_or_default(),
345    )
346}
347
348/// Render a full issue view (summary line + meta + body). Used by both the
349/// agent tool and the CLI. `issues_dir` is the store's issues directory —
350/// when the issue is assigned, the flock holder's provenance (pid/host/cwd)
351/// is read from `.alive/` and appended; a `None`/missing payload (older
352/// writer, dead session) simply omits the line.
353pub fn format_issue_full(i: &Issue, hash: &str, issues_dir: &std::path::Path) -> String {
354    let mut s = format_issue_line(i);
355    s.push('\n');
356    s.push_str(&format!("  id: {}\n", i.meta.id));
357    s.push_str(&format!("  created: {}\n", i.meta.created_at));
358    s.push_str(&format!("  updated: {}\n", i.meta.updated_at));
359    if let Some(c) = i.meta.closed_at {
360        s.push_str(&format!("  closed: {}\n", c));
361    }
362    s.push_str(&format!("  sessions: {:?}\n", i.meta.sessions));
363    if let Some(a) = &i.meta.assigned_to {
364        s.push_str(&format!(
365            "  assigned: {} (since {})\n",
366            short_session(&a.session),
367            a.acquired_at.format("%Y-%m-%d %H:%M")
368        ));
369        if let Some(o) = crate::issues::liveness::read_owner_info(issues_dir, &a.session) {
370            let holder = if o.host.is_empty() {
371                format!("pid {}", o.pid)
372            } else {
373                format!("pid {} on {}", o.pid, o.host)
374            };
375            s.push_str(&format!(
376                "  lock: {holder} (cwd: {}, since {})\n",
377                o.cwd,
378                chrono::DateTime::from_timestamp(o.started as i64, 0)
379                    .map(|t| t.format("%Y-%m-%d %H:%M").to_string())
380                    .unwrap_or_else(|| o.started.to_string())
381            ));
382        }
383    }
384    s.push_str(&format!("  content_hash: {}\n", hash));
385    s.push('\n');
386    s.push_str(&i.body);
387    s
388}
389
390fn short_session(s: &str) -> String {
391    if s.len() <= 8 {
392        s.to_string()
393    } else {
394        format!("{}…", &s[..8])
395    }
396}
397
398// ── Param parsers (centralized so each action doesn't repeat) ────────────
399
400/// Maximum CAS attempts before giving up (#2). The first attempt uses the
401/// agent's `content_hash` (fast path); on each [`IssueError::Conflict`] we
402/// re-read a fresh hash and retry. So a stale hash from the agent still
403/// succeeds as long as contention resolves within the bound. See
404/// `docs/designs/2026-06-17-issue-system-hardening.md` §1.4 for why the hash
405/// gate is *required* for safe automatic recovery.
406const MAX_CAS_ATTEMPTS: u32 = 4;
407
408/// Run an issue store mutation under bounded compare-and-set retry.
409///
410/// The store is deliberately strict: it returns raw [`IssueError::Conflict`]
411/// and never retries on its own (principle: strictness in the store, recovery
412/// in the tool). This helper owns the recovery — re-reading a fresh hash after
413/// each conflict and retrying up to `MAX_CAS_ATTEMPTS` times.
414pub async fn cas_retry<T, F, Fut>(
415    store: &FileIssueStore,
416    id: u32,
417    agent_hash: Option<String>,
418    mut op: F,
419) -> Result<T, IssueError>
420where
421    F: FnMut(Option<String>) -> Fut,
422    Fut: Future<Output = Result<T, IssueError>> + Send,
423    T: Send,
424{
425    let mut hash = agent_hash;
426    for attempt in 0..MAX_CAS_ATTEMPTS {
427        match op(hash.clone()).await {
428            Ok(v) => return Ok(v),
429            Err(IssueError::Conflict { .. }) if attempt + 1 < MAX_CAS_ATTEMPTS => {
430                tracing::debug!(
431                    id,
432                    attempt = attempt + 1,
433                    "issue CAS conflict, re-reading fresh hash"
434                );
435                hash = store.read(id).ok().map(|(_, h)| h);
436                continue;
437            }
438            Err(e) => return Err(e),
439        }
440    }
441    Err(IssueError::Conflict { id })
442}
443
444/// Extract a non-empty `content_hash` from params (absent/empty → `None`).
445fn hash_param(v: Option<&Value>) -> Option<String> {
446    v.and_then(|x| x.as_str())
447        .filter(|s| !s.is_empty())
448        .map(String::from)
449}
450
451// ── Size limits (#5) — early rejection to prevent disk fill / oversized docs ──
452
453/// Maximum title length, in characters.
454const MAX_TITLE_LEN: usize = 512;
455/// Maximum body length, in bytes (256 KiB).
456const MAX_BODY_LEN: usize = 256 * 1024;
457/// Maximum number of labels per issue.
458const MAX_LABELS: usize = 32;
459/// Maximum length of a single label, in characters.
460const MAX_LABEL_LEN: usize = 64;
461
462/// Reject oversized `create`/`update` payloads before they touch the store.
463///
464/// Size is enforced only for the actions that accept free-form text
465/// (`create`, `update`); read-only actions (`list`/`read`) are unaffected.
466/// `title`/`label` length is measured in `char`s (grapheme-safe enough for a
467/// bound); `body` in bytes (the on-disk cost).
468fn validate_size(params: &Value, action: &str) -> Result<(), String> {
469    if !matches!(action, "create" | "update") {
470        return Ok(());
471    }
472    if let Some(t) = params.get("title").and_then(|v| v.as_str())
473        && t.chars().count() > MAX_TITLE_LEN
474    {
475        return Err(format!("title too long (max {MAX_TITLE_LEN} chars)"));
476    }
477    if let Some(b) = params.get("body").and_then(|v| v.as_str())
478        && b.len() > MAX_BODY_LEN
479    {
480        return Err(format!("body too large (max {MAX_BODY_LEN} bytes)"));
481    }
482    if let Some(l) = params.get("labels").and_then(|v| v.as_array()) {
483        if l.len() > MAX_LABELS {
484            return Err(format!("too many labels (max {MAX_LABELS})"));
485        }
486        for item in l {
487            if item.as_str().map(|s| s.chars().count()).unwrap_or(0) > MAX_LABEL_LEN {
488                return Err(format!("label too long (max {MAX_LABEL_LEN} chars)"));
489            }
490        }
491    }
492    Ok(())
493}
494
495fn require_string(v: Option<&Value>, name: &str) -> Result<String, String> {
496    v.and_then(|x| x.as_str())
497        .map(String::from)
498        .ok_or_else(|| format!("missing required field: {name}"))
499}
500
501fn require_u32(v: Option<&Value>, name: &str) -> Result<u32, String> {
502    v.and_then(|x| x.as_u64())
503        .and_then(|n| u32::try_from(n).ok())
504        .ok_or_else(|| format!("missing or invalid field: {name}"))
505}
506
507fn parse_status_opt(v: Option<&Value>) -> Result<Option<Status>, String> {
508    let Some(v) = v else { return Ok(None) };
509    let s = v
510        .as_str()
511        .ok_or_else(|| "status must be a string".to_string())?;
512    match s {
513        "open" => Ok(Some(Status::Open)),
514        "closed" => Ok(Some(Status::Closed)),
515        other => Err(format!("invalid status: {other}")),
516    }
517}
518
519fn parse_priority_opt(v: Option<&Value>) -> Result<Option<Priority>, String> {
520    let Some(v) = v else { return Ok(None) };
521    let s = v
522        .as_str()
523        .ok_or_else(|| "priority must be a string".to_string())?;
524    match s {
525        "low" => Ok(Some(Priority::Low)),
526        "medium" => Ok(Some(Priority::Medium)),
527        "high" => Ok(Some(Priority::High)),
528        "critical" => Ok(Some(Priority::Critical)),
529        other => Err(format!("invalid priority: {other}")),
530    }
531}
532
533fn parse_labels(v: Option<&Value>) -> Result<Vec<String>, String> {
534    let Some(v) = v else { return Ok(vec![]) };
535    let arr = v
536        .as_array()
537        .ok_or_else(|| "labels must be an array of strings".to_string())?;
538    let mut out = Vec::with_capacity(arr.len());
539    for item in arr {
540        let s = item
541            .as_str()
542            .ok_or_else(|| "labels must be an array of strings".to_string())?;
543        out.push(s.to_string());
544    }
545    Ok(out)
546}
547
548#[cfg(test)]
549mod tests {
550    //! Phase 2 coverage for the tool-layer CAS retry helper (#2). The store
551    //! is strict (raw Conflict, no retry); `cas_retry` owns recovery.
552    use super::*;
553
554    fn tmp_store() -> (tempfile::TempDir, FileIssueStore) {
555        let tmp = tempfile::tempdir().unwrap();
556        let dir = tmp.path().join(".oxicode").join("issues");
557        std::fs::create_dir_all(&dir).unwrap();
558        (tmp, FileIssueStore::open(dir).unwrap())
559    }
560
561    #[tokio::test]
562    async fn cas_retry_recovers_from_stale_hash() {
563        // The agent passes a stale/wrong content_hash. First attempt conflicts;
564        // cas_retry re-reads a fresh hash and retries → succeeds. This is the
565        // whole point of #2: a stale hash is advisory, not fatal.
566        let (_tmp, store) = tmp_store();
567        store
568            .create("T".into(), "b".into(), Priority::Low, vec![], None)
569            .unwrap();
570        let id = 1;
571
572        let result: Result<Issue, _> = cas_retry(
573            &store,
574            id,
575            Some("deadbeefdeadbeef".to_string()), // deliberately wrong
576            |hash| {
577                let store = store.clone();
578                async move {
579                    store
580                        .apply_patch(
581                            id,
582                            IssuePatch {
583                                title: Some("Patched".into()),
584                                ..Default::default()
585                            },
586                            None,
587                            hash,
588                        )
589                        .await
590                }
591            },
592        )
593        .await;
594        let issue = result.expect("cas_retry should recover from a stale hash");
595        assert_eq!(issue.meta.title, "Patched");
596    }
597
598    #[tokio::test]
599    async fn cas_retry_gives_up_after_bound() {
600        // If contention never resolves, cas_retry surfaces Conflict after
601        // MAX_CAS_ATTEMPTS — it never loops forever.
602        let (_tmp, store) = tmp_store();
603        store
604            .create("T".into(), "b".into(), Priority::Low, vec![], None)
605            .unwrap();
606        let id = 1;
607
608        let result: Result<Issue, _> = cas_retry(&store, id, None, |_hash| async move {
609            Err(IssueError::Conflict { id })
610        })
611        .await;
612        assert!(
613            matches!(result, Err(IssueError::Conflict { id: 1 })),
614            "must give up with Conflict after the bound, got: {result:?}"
615        );
616    }
617
618    // ── Phase 3 coverage: size limits (#5) ──
619
620    #[test]
621    fn validate_size_passes_small_payload() {
622        let p = json!({"title": "ok", "body": "short", "labels": ["a", "b"]});
623        assert!(validate_size(&p, "create").is_ok());
624        assert!(validate_size(&p, "update").is_ok());
625    }
626
627    #[test]
628    fn validate_size_skips_non_text_actions() {
629        // list/read/start/etc. never hit the size gate even with huge values.
630        let p = json!({"body": "x".repeat(300_000)});
631        assert!(validate_size(&p, "list").is_ok());
632        assert!(validate_size(&p, "start").is_ok());
633    }
634
635    #[test]
636    fn validate_size_rejects_oversize_body() {
637        let p = json!({"body": "x".repeat(MAX_BODY_LEN + 1)});
638        let err = validate_size(&p, "create").unwrap_err();
639        assert!(err.contains("body too large"), "got: {err}");
640    }
641    #[tokio::test]
642    async fn format_issue_line_shows_assignee_since() {
643        let (_tmp, store) = tmp_store();
644        store
645            .create("T".into(), "b".into(), Priority::Low, vec![], None)
646            .unwrap();
647        store.start(1, "proc-1-abc", None).await.unwrap();
648        let (issue, _) = store.read(1).unwrap();
649        let s = format_issue_line(&issue);
650        assert!(s.contains("since"), "line must show assignment age: {s}");
651    }
652
653    #[tokio::test]
654    async fn format_issue_full_shows_flock_holder_provenance() {
655        let (_tmp, store) = tmp_store();
656        store
657            .create("T".into(), "b".into(), Priority::Low, vec![], None)
658            .unwrap();
659        store.start(1, "tui-1-abcdef", None).await.unwrap();
660        let _guard = crate::issues::liveness::acquire(&store.issues_dir(), "tui-1-abcdef").unwrap();
661        let (issue, hash) = store.read(1).unwrap();
662        let s = format_issue_full(&issue, &hash, &store.issues_dir());
663        assert!(
664            s.contains("since"),
665            "full view must show assignment age: {s}"
666        );
667        assert!(
668            s.contains(&format!("pid {}", std::process::id())),
669            "full view must show the flock holder pid: {s}"
670        );
671    }
672}