Skip to content

Commit 21a07ca

Browse files
feat(sessions): index workflow runs and agents (layer 2 of session intelligence) (#284)
* feat(sessions): index workflow runs and their agents Layer 2 of session intelligence (builds on #281's git spans). Indexes Claude Code workflow runs + agents into the session store, attached to the parent thread and to branches/worktrees/commits. Adds the tracedecay_workflows tool (list by thread or git ref, show a run, drill an agent), workflow_run/agent filters on message_search, and a 'sessions unfinished' CLI. Ingest is idempotent + incremental. Tool count 100 -> 101. Verified against ~/.claude: 11 runs / 79 agents. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: set directory mtime cross-platform in workflow_ingest tests The set_mtime helper opened files read-only and called set_times, which works on Unix but panics on Windows (adjusting a directory's timestamps needs backup-semantics access). Switch to filetime::set_file_mtime (already a transitive dep; added as an explicit dev-dependency). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: deslop workflow indexing comments * refactor(workflow): dedup helpers and hoist loop-invariant resolution /simplify cleanup (behavior-preserving), converged on by 4 reviewers: - delete WorkflowStatus::from_db (redundant with the infallible from_disk given the status column's CHECK constraint) - reuse crate::sessions::claude::transcript_cwd instead of a byte-copy - promote string_arg/argument_error/tool_json_with_md to handlers/support and share them between session.rs and workflow_query.rs - one shared shared::one_line_truncated (single ellipsis convention) replacing three divergent copies - ProjectRootMatcher resolves the fixed project side's git worktree/common dir once, so the ingest loop stops re-resolving it per run Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: split workflow session tests * refactor(sessions): simplify workflow indexing reuse and dedup Reuse git_scope_exists_clauses for workflow git-scope queries, avoid per-status string allocations, HashSet dedup in roster assembly, and colocate one_line_truncated tests with shared helpers. * refactor(sessions): dedupe workflow args * refactor(sessions): extract workflow scope EXISTS predicate * fix(workflow): satisfy branch quality gates * fix(test): normalize CI path assertions * fix(test): normalize Windows command paths * fix(test): normalize Cursor hook paths --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent b6a5cd9 commit 21a07ca

27 files changed

Lines changed: 3961 additions & 89 deletions

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ cc = "1"
162162
tempfile = "3"
163163
sha2 = "0.11"
164164
hex = "0.4"
165+
filetime = "0.2"
165166
criterion = { version = "0.5", features = ["async_tokio", "html_reports"] }
166167
# test-util enables tokio::test(start_paused = true) so timer-driven unit
167168
# tests (daemon restart-grace windows) run on virtual time instead of real

plugin/README-cursor.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,8 @@ per-call review, add the snippet below to `~/.cursor/permissions.json`
151151
"tracedecay:tracedecay_todos",
152152
"tracedecay:tracedecay_type_hierarchy",
153153
"tracedecay:tracedecay_unsafe_patterns",
154-
"tracedecay:tracedecay_unused_imports"
154+
"tracedecay:tracedecay_unused_imports",
155+
"tracedecay:tracedecay_workflows"
155156
]
156157
}
157158
```

plugin/skills/managing-session-context/SKILL.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ Climb cheapest-first; stop as soon as the question is answered.
4545
`branch`|`worktree`|`commit`, `value`, optional `since`/`until`, `limit`):
4646
find sessions active on a branch or worktree, or sessions that produced a
4747
commit; feed returned session ids back into grep/replay/drill-down above.
48+
7. **Workflow-run recovery → `tracedecay_workflows`**: recover multi-agent
49+
workflow (`wf_*`) runs and their per-phase agents. List runs for a thread
50+
with `session_id`, or every run on a branch/worktree/commit with
51+
`branch`/`worktree`/`commit` (a run inherits its parent session's git
52+
spans). Show one run's result summary + phases + agent roster with
53+
`run_id`, then drill into a single agent with `run_id` + `agent_label`.
54+
To read that agent's messages, scope `tracedecay_message_search` with
55+
`workflow_run` (+ optional `workflow_agent`), or replay via rungs 3–4.
4856

4957
After a compaction, if prior-session context seems missing, run this ladder
5058
before assuming the compacted summary is complete.

src/cli.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,21 @@ pub enum SessionsAction {
669669
#[arg(long)]
670670
dry_run: bool,
671671
},
672+
/// List unfinished workflow/task evidence from ingested session messages
673+
Unfinished {
674+
/// Maximum evidence rows
675+
#[arg(long, default_value_t = 25)]
676+
limit: usize,
677+
/// Output as JSON
678+
#[arg(long)]
679+
json: bool,
680+
/// Registered project id whose session store should be searched
681+
#[arg(long)]
682+
project_id: Option<String>,
683+
/// Registered project root path or alias whose session store should be searched
684+
#[arg(long, conflicts_with = "project_id")]
685+
project_path: Option<String>,
686+
},
672687
}
673688

674689
#[derive(Subcommand)]

src/global_db.rs

Lines changed: 164 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,21 @@ use crate::sessions::{
2121

2222
const UNIX_TIMESTAMP_MILLIS_THRESHOLD: i64 = 1_000_000_000_000;
2323

24+
/// Scopes a `tracedecay_message_search` to the agent transcripts of one
25+
/// workflow run, mirroring `GitScopeFilter` as a search-only concern. The run's
26+
/// messages are the messages of its agents (rows in `workflow_agents`); see
27+
/// [`GlobalDb::search_session_messages_workflow_scoped`] for the EXISTS
28+
/// pushdown. Serializes so the applied filter echoes cleanly into the payload.
29+
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
30+
pub struct WorkflowScopeFilter {
31+
/// The `wf_*` run whose agents' messages to keep.
32+
pub run_id: String,
33+
/// When set, narrows the scope to just this one agent of the run
34+
/// (matched on `workflow_agents.agent_label`).
35+
#[serde(skip_serializing_if = "Option::is_none")]
36+
pub agent_label: Option<String>,
37+
}
38+
2439
/// Total savings + call count for a project (or all projects when `project` is None).
2540
#[derive(Debug, Clone, serde::Serialize)]
2641
pub struct SavingsTotal {
@@ -1023,6 +1038,9 @@ impl GlobalDb {
10231038
crate::sessions::git_correlation::ensure_git_correlation_schema(&db.conn)
10241039
.await
10251040
.ok()?;
1041+
crate::sessions::workflow_index::ensure_workflow_index_schema(&db.conn)
1042+
.await
1043+
.ok()?;
10261044
// One-off self-heal: re-derive timestamps and token-usage counters
10271045
// for legacy messages ingested before extraction existed.
10281046
// Marker-guarded (runs once per store) and fail-open, like the LCM
@@ -3271,6 +3289,80 @@ impl GlobalDb {
32713289
crate::sessions::git_correlation::session_ids_for_scope(&self.conn, filter).await
32723290
}
32733291

3292+
// ── Workflow-run index ───────────────────────────────────────────
3293+
3294+
/// Inserts or updates one indexed workflow run (idempotent on `run_id`).
3295+
/// See [`crate::sessions::workflow_index::upsert_run`].
3296+
pub async fn workflow_upsert_run(
3297+
&self,
3298+
run: &crate::sessions::workflow_index::WorkflowRun,
3299+
) -> Result<(), crate::sessions::workflow_index::WorkflowIndexError> {
3300+
crate::sessions::workflow_index::upsert_run(&self.conn, run).await
3301+
}
3302+
3303+
/// Inserts or updates one workflow agent (idempotent on
3304+
/// `(run_id, agent_label, agent_id)`).
3305+
/// See [`crate::sessions::workflow_index::upsert_agent`].
3306+
pub async fn workflow_upsert_agent(
3307+
&self,
3308+
agent: &crate::sessions::workflow_index::WorkflowAgent,
3309+
) -> Result<(), crate::sessions::workflow_index::WorkflowIndexError> {
3310+
crate::sessions::workflow_index::upsert_agent(&self.conn, agent).await
3311+
}
3312+
3313+
/// Lists workflow runs spawned by one parent session, newest-first.
3314+
/// See [`crate::sessions::workflow_index::runs_for_session`].
3315+
pub async fn workflow_runs_for_session(
3316+
&self,
3317+
parent_session_id: &str,
3318+
limit: usize,
3319+
) -> Result<
3320+
Vec<crate::sessions::workflow_index::WorkflowRun>,
3321+
crate::sessions::workflow_index::WorkflowIndexError,
3322+
> {
3323+
crate::sessions::workflow_index::runs_for_session(&self.conn, parent_session_id, limit)
3324+
.await
3325+
}
3326+
3327+
/// Fetches one workflow run by its `wf_*` id.
3328+
/// See [`crate::sessions::workflow_index::run_for_id`].
3329+
pub async fn workflow_run_for_id(
3330+
&self,
3331+
run_id: &str,
3332+
) -> Result<
3333+
Option<crate::sessions::workflow_index::WorkflowRun>,
3334+
crate::sessions::workflow_index::WorkflowIndexError,
3335+
> {
3336+
crate::sessions::workflow_index::run_for_id(&self.conn, run_id).await
3337+
}
3338+
3339+
/// Lists the agents of one workflow run in phase order.
3340+
/// See [`crate::sessions::workflow_index::agents_for_run`].
3341+
pub async fn workflow_agents_for_run(
3342+
&self,
3343+
run_id: &str,
3344+
limit: usize,
3345+
) -> Result<
3346+
Vec<crate::sessions::workflow_index::WorkflowAgent>,
3347+
crate::sessions::workflow_index::WorkflowIndexError,
3348+
> {
3349+
crate::sessions::workflow_index::agents_for_run(&self.conn, run_id, limit).await
3350+
}
3351+
3352+
/// Lists workflow runs that ran on a git branch/worktree/commit, joined
3353+
/// through their parent session's git spans.
3354+
/// See [`crate::sessions::workflow_index::runs_for_git_scope`].
3355+
pub async fn workflow_runs_for_git_scope(
3356+
&self,
3357+
filter: &crate::sessions::git_correlation::GitScopeFilter,
3358+
limit: usize,
3359+
) -> Result<
3360+
Vec<crate::sessions::workflow_index::WorkflowRun>,
3361+
crate::sessions::workflow_index::WorkflowIndexError,
3362+
> {
3363+
crate::sessions::workflow_index::runs_for_git_scope(&self.conn, filter, limit).await
3364+
}
3365+
32743366
/// Lists per-session activity windows for the historical git-correlation
32753367
/// backfill: each row carries the session's declared `started_at`/`ended_at`
32763368
/// plus the min/max `session_messages.timestamp`, so the caller can derive
@@ -3317,6 +3409,7 @@ impl GlobalDb {
33173409
limit,
33183410
filters,
33193411
None,
3412+
None,
33203413
)
33213414
.await
33223415
}
@@ -3342,6 +3435,37 @@ impl GlobalDb {
33423435
limit,
33433436
filters,
33443437
Some(git_filter),
3438+
None,
3439+
)
3440+
.await
3441+
}
3442+
3443+
/// Like [`Self::search_session_messages_filtered`], additionally scoping
3444+
/// hits to the agent transcripts of one workflow run via EXISTS pushdown
3445+
/// against `workflow_agents`. A run's agents are matched either by the
3446+
/// transcript file the message came from (`workflow_agents.transcript_path
3447+
/// = session_messages.source_path`) or, as a fallback, by the agent's own
3448+
/// session id (`workflow_agents.agent_session_id = session_messages.session_id`),
3449+
/// so the scope holds whichever key the ingest recorded. When
3450+
/// `filter.agent_label` is set the scope narrows to that one agent. A call
3451+
/// against a store predating the workflow-index schema returns no hits.
3452+
pub async fn search_session_messages_workflow_scoped(
3453+
&self,
3454+
provider: Option<&str>,
3455+
project_key: Option<&str>,
3456+
query: &str,
3457+
limit: usize,
3458+
filters: SessionSearchFilters<'_>,
3459+
workflow_filter: &WorkflowScopeFilter,
3460+
) -> Vec<SessionMessageSearchResult> {
3461+
self.search_session_messages_filtered_inner(
3462+
provider,
3463+
project_key,
3464+
query,
3465+
limit,
3466+
filters,
3467+
None,
3468+
Some(workflow_filter),
33453469
)
33463470
.await
33473471
}
@@ -3354,10 +3478,19 @@ impl GlobalDb {
33543478
limit: usize,
33553479
filters: SessionSearchFilters<'_>,
33563480
) -> Vec<SessionMessageSearchResult> {
3357-
self.search_session_messages_filtered_inner(None, project_key, query, limit, filters, None)
3358-
.await
3481+
self.search_session_messages_filtered_inner(
3482+
None,
3483+
project_key,
3484+
query,
3485+
limit,
3486+
filters,
3487+
None,
3488+
None,
3489+
)
3490+
.await
33593491
}
33603492

3493+
#[allow(clippy::too_many_arguments)] // internal fan-in of independent scope/time/git/workflow filters
33613494
async fn search_session_messages_filtered_inner(
33623495
&self,
33633496
provider: Option<&str>,
@@ -3366,6 +3499,7 @@ impl GlobalDb {
33663499
limit: usize,
33673500
filters: SessionSearchFilters<'_>,
33683501
git_filter: Option<&crate::sessions::git_correlation::GitScopeFilter>,
3502+
workflow_filter: Option<&WorkflowScopeFilter>,
33693503
) -> Vec<SessionMessageSearchResult> {
33703504
// A git-scoped search against a store written before the correlation
33713505
// schema existed can never match; report empty rather than issuing a
@@ -3379,6 +3513,16 @@ impl GlobalDb {
33793513
return Vec::new();
33803514
}
33813515
}
3516+
// Likewise a workflow-scoped search against a store predating the
3517+
// workflow-index schema can never match: short-circuit to empty rather
3518+
// than hitting `no such table: workflow_agents`.
3519+
if workflow_filter.is_some()
3520+
&& !crate::sessions::workflow_index::tables_present(&self.conn)
3521+
.await
3522+
.unwrap_or(false)
3523+
{
3524+
return Vec::new();
3525+
}
33823526
let fts_query = session_fts_query(query);
33833527
if fts_query.is_empty() || limit == 0 {
33843528
return Vec::new();
@@ -3455,6 +3599,24 @@ impl GlobalDb {
34553599
query_params.extend(predicate_values);
34563600
}
34573601
}
3602+
// Workflow-run scoping: reuse the shared EXISTS predicate (also used
3603+
// by future lcm/grep paths) so run/agent correlation semantics stay in
3604+
// one place. Renumber its `?1`, `?2`, … slots to follow the query's
3605+
// existing numbered placeholders, then append the bind values in order.
3606+
if let Some(filter) = workflow_filter {
3607+
let (mut predicate, predicate_values) =
3608+
crate::sessions::workflow_index::workflow_scope_exists_predicate(
3609+
filter,
3610+
"m.source_path",
3611+
"m.session_id",
3612+
);
3613+
let base = query_params.len();
3614+
for slot in (1..=predicate_values.len()).rev() {
3615+
predicate = predicate.replace(&format!("?{slot}"), &format!("?{}", base + slot));
3616+
}
3617+
let _ = write!(sql, " AND {predicate}");
3618+
query_params.extend(predicate_values);
3619+
}
34583620
for term in &literal_terms {
34593621
query_params.push(Value::Text(term.clone()));
34603622
let _ = write!(

src/main.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,9 @@ fn should_skip_startup_maintenance(command: &Commands) -> bool {
731731
| Commands::Lsp { .. }
732732
| Commands::Doctor { .. }
733733
| Commands::Analytics { .. }
734+
| Commands::Sessions {
735+
action: SessionsAction::Unfinished { .. },
736+
}
734737
| Commands::Migrate { .. }
735738
| Commands::Projects { .. }
736739
| Commands::HookPreToolUse
@@ -806,6 +809,9 @@ fn should_skip_agent_install_maintenance(command: &Commands) -> bool {
806809
| Commands::Migrate { .. }
807810
| Commands::Projects { .. }
808811
| Commands::Tool { .. }
812+
| Commands::Sessions {
813+
action: SessionsAction::Unfinished { .. },
814+
}
809815
| Commands::Daemon { .. }
810816
)
811817
}

src/mcp/tools/definitions.rs

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,7 @@ pub fn get_tool_definitions() -> Vec<ToolDefinition> {
309309
def_dashboard(),
310310
def_message_search(),
311311
def_sessions_for(),
312+
def_workflows(),
312313
def_lcm_status(),
313314
def_lcm_doctor(),
314315
def_lcm_load_session(),
@@ -2463,7 +2464,9 @@ fn def_message_search() -> ToolDefinition {
24632464
},
24642465
"branch": git_scope_branch_schema(),
24652466
"worktree": git_scope_worktree_schema(),
2466-
"commit": git_scope_commit_schema()
2467+
"commit": git_scope_commit_schema(),
2468+
"workflow_run": workflow_run_scope_schema(),
2469+
"workflow_agent": workflow_agent_scope_schema()
24672470
},
24682471
"required": ["query"]
24692472
}),
@@ -2534,6 +2537,65 @@ fn def_sessions_for() -> ToolDefinition {
25342537
)
25352538
}
25362539

2540+
fn def_workflows() -> ToolDefinition {
2541+
def(
2542+
"tracedecay_workflows",
2543+
"Workflow Runs",
2544+
"Recover Claude Code workflow runs (multi-agent `wf_*` orchestrations) and their per-phase agents from the active project. Three modes, chosen by which argument is set: (1) list runs for a parent thread via session_id, or every run on a branch/worktree/commit via branch/worktree/commit (a run inherits its parent session's git spans); (2) show one run's result summary, phases, and agent roster via run_id; (3) drill into one agent's transcript via run_id + agent_label. Read-only; runs that never ran leave no rows.",
2545+
json!({
2546+
"type": "object",
2547+
"properties": {
2548+
"session_id": {
2549+
"type": "string",
2550+
"description": "Parent thread/session id: list the workflow runs it spawned (newest first). Mutually exclusive with run_id and the git filters."
2551+
},
2552+
"run_id": {
2553+
"type": "string",
2554+
"description": "A `wf_*` run id: show that run's summary, phases, and agents. Combine with agent_label to drill into one agent."
2555+
},
2556+
"agent_label": {
2557+
"type": "string",
2558+
"description": "With run_id, drill into a single agent of that run by its label (e.g. 'mine:claude-transcripts')."
2559+
},
2560+
"branch": {
2561+
"type": "string",
2562+
"description": "List workflow runs whose parent session was active on this git branch (via the session-git correlation index)."
2563+
},
2564+
"worktree": {
2565+
"type": "string",
2566+
"description": "List workflow runs whose parent session was active in this git worktree root path."
2567+
},
2568+
"commit": {
2569+
"type": "string",
2570+
"description": "List workflow runs whose parent session was attributed to this commit sha (full or >=6-char hex prefix)."
2571+
},
2572+
"limit": {
2573+
"type": "integer",
2574+
"minimum": 1,
2575+
"maximum": 100,
2576+
"description": "Maximum runs or agents to return (default: 20)."
2577+
}
2578+
}
2579+
}),
2580+
)
2581+
}
2582+
2583+
/// Optional `workflow_run` narrowing filter shared by `tracedecay_message_search`:
2584+
/// scopes hits to the transcripts of one workflow run's agents.
2585+
fn workflow_run_scope_schema() -> Value {
2586+
json!({
2587+
"type": "string",
2588+
"description": "Optional workflow run id (`wf_*`) filter: only messages from sessions that spawned this workflow run (via the workflow-run index). Pair with agent_label to scope to one agent."
2589+
})
2590+
}
2591+
2592+
fn workflow_agent_scope_schema() -> Value {
2593+
json!({
2594+
"type": "string",
2595+
"description": "Optional workflow agent label filter, used with workflow_run to scope to a single agent of that run."
2596+
})
2597+
}
2598+
25372599
fn lcm_storage_scope_schema() -> Value {
25382600
json!({
25392601
"type": "string",

0 commit comments

Comments
 (0)