merge: integrate orchestration branch

This commit is contained in:
Keisuke Hirata 2026-06-15 02:24:46 +09:00
commit 169f29e960
No known key found for this signature in database
29 changed files with 3260 additions and 146 deletions

View File

@ -0,0 +1,2 @@
{"id":"orch-plan-20260614-154052-1","ticket_id":"00001KT0Z4BK8","kind":"waiting_capacity_note","note":"Ticket 自体は implementation_ready で blocking relation なし。現在 `00001KTFY8V80` と `00001KV09WYC6` の Coder Pod が running で、review/integration follow-up capacity も必要なため、追加 spawn は一時待機する。","author":"yoi-orchestrator","at":"2026-06-14T15:40:52Z"}
{"id":"orch-plan-20260614-154934-2","ticket_id":"00001KT0Z4BK8","kind":"accepted_plan","accepted_plan":{"summary":"Accept queued Plugin package/discovery design Ticket now that one active Coder has moved to review stage. Implement as design proposal and minimal safe references, preserving Plugin/MCP/feature authority boundaries.","branch":"impl/00001KT0Z4BK8-plugin-package-discovery","worktree":"/home/hare/Projects/yoi/.worktree/00001KT0Z4BK8-plugin-package-discovery","role_plan":"Orchestrator creates a dedicated implementation worktree and spawns a Coder with write scope limited to that worktree. Reviewer will run read-only after implementation report. This work is documentation/design-focused and source-disjoint from active Panel/TUI implementation."},"author":"yoi-orchestrator","at":"2026-06-14T15:49:34Z"}

View File

@ -1,8 +1,8 @@
---
title: 'Plugin distribution package format and discovery'
state: 'queued'
state: 'done'
created_at: '2026-06-01T06:49:53Z'
updated_at: '2026-06-14T15:40:15Z'
updated_at: '2026-06-14T15:56:45Z'
queued_by: 'workspace-panel'
queued_at: '2026-06-14T15:40:15Z'
---

View File

@ -134,4 +134,245 @@ Marked ready by `yoi ticket state`.
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-14T15:40:52Z -->
## Decision
Routing decision: implementation_ready_but_waiting_capacity
Reason:
- Ticket body / thread / artifacts、relation、OrchestrationPlan、Orchestrator workspace state を確認した。Plugin package / discovery / enablement boundary の design work item として要件・受け入れ条件・non-goals・invariants は十分に具体化されている。
- blocking relation / OrchestrationPlan blocker はない。
- Plugin package work は現在 active な Panel/TUI implementation と source surface が大きく重ならないため、設計上の conflict blocker ではない。
- ただし現在 `00001KTFY8V80``00001KV09WYC6` の2件が inprogress で Coder Pod running。Reviewer follow-up と integration capacity も未使用ではなく、さらに queued Panel/TUI work 2件を待機させている。
- 現時点では追加 Coder Pod を spawn せず、active Coder のいずれかが implementation report を返して review/integration 見通しが立ってから acceptance する。
Evidence checked:
- Ticket body/thread: Plugin package design requirements、過去の Plugin/MCP/feature-layer decision、`planning -> ready`、Panel `ready -> queued` を確認。
- Ticket relations: blocker なし。
- OrchestrationPlan: 既存 record なし。
- Orchestrator workspace: `/home/hare/Projects/yoi/.worktree/orchestration` は clean、queue commit `4be6c966` 上。
- Visible Pods: `yoi-coder-00001KTFY8V80``yoi-coder-00001KV09WYC6` が running。
Next action:
- 先行 inprogress Ticket の少なくとも1件が implementation report / review stage に進み、Coder capacity が空いた時点で再確認し、unblocked なら `queued -> inprogress` acceptance と dedicated worktree 作成へ進む。
- planning return ではなく queued のまま waiting とする。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-14T15:49:34Z -->
## Decision
Routing decision: implementation_ready
Reason:
- 先行 Coder のうち `00001KV09WYC6` が implementation report を返し review stage に入ったため、Plugin work 用の Coder capacity を再評価した。
- Ticket body / thread / relations / orchestration plan / Orchestrator workspace state を再確認した。blocking relation はなく、既存 waiting note は capacity 起因であり、現在は1件分の Coder capacity を空けられる。
- 本 Ticket は Plugin package / discovery / enablement boundary の design/documentation work が主で、active Panel/TUI implementation と source surface が大きく重ならない。
- Plugin/MCP/feature-layer authority boundary に関する prior decisions は Ticket thread に記録済みで、残る不確実性は proposal の構成・記述・必要最小限の config shape 調査に閉じている。
Evidence checked:
- Ticket body / thread: package format、store/source mapping、discovery vs enablement、manifest semantics、runtime-specific notes、cache/pinning、diagnostics、prior Plugin/MCP/feature-layer decisions を確認。
- Ticket relations: blocker なし。
- OrchestrationPlan: capacity waiting note 1件のみ。blocking/conflict record なし。
- Orchestrator workspace: `/home/hare/Projects/yoi/.worktree/orchestration` は clean、`80a9e40d` 上。
- Active Pods: `00001KTFY8V80` coder running、`00001KV09WYC6` reviewer running。
- Bounded code/doc map: Plugin docs は未作成。関連 candidate は `docs/design/*`, `crates/manifest/src/{config,profile}.rs`, `crates/pod/src/feature.rs`, `crates/pod/src/hook.rs`
IntentPacket:
Intent:
- `.yoi-plugin` package distribution/discovery/enablement boundary の durable design proposal を repository に追加し、後続 implementation Ticket を独立して切れる状態にする。
Binding decisions / invariants:
- Package presence in user/workspace plugin stores is discovery only; registration, WASM init, Hooks/Tools contribution, process/server startup, and MCP server launch require explicit enablement and grants.
- Source-qualified identity is required: `user:<id>`, `project:<id>`, `builtin:<id>` are distinct; ambiguous unqualified IDs fail closed.
- Plugin permission declarations are requests, not grants. Effective grants are Plugin-layer policy plus existing manifest/profile/scope/tool/web/secret/runtime allowlists.
- Do not model Plugin permissions with `pod::feature` HostAuthority/grant concepts.
- MCP remains a separate feature-backed integration and is out of initial Plugin packaging/runtime unless future Ticket explicitly approves a bridge.
- Archive handling must reject path traversal and unsafe layout, use bounded extraction, compute deterministic digest, and materialize into digest-keyed cache before runtime initialization.
- Restore should use resolved manifest/session metadata for enabled Plugin plan; fresh discovery must not silently upgrade a restored Pod.
Requirements / acceptance criteria:
- Repository contains a documented Plugin distribution/package proposal covering `.yoi-plugin` archive structure, root `plugin.toml`, assets, user/workspace/builtin stores, source/trust mapping, identity collision rules, discovery vs enablement, manifest fields, archive safety, cache/digest/pinning, diagnostics, and runtime-specific notes for declarative hooks and WASM.
- Proposal explicitly states store placement is discovery only, not execution or registration.
- Proposal distinguishes Plugin permission request/grant model from `pod::feature` authority concepts.
- Proposal calls out MCP as separate and out of initial Plugin packaging.
- Follow-up implementation cuts are clear for manifest/profile enablement, package discovery, archive validation/cache, Plugin permission policy, WASM packaging, and any future MCP/plugin bridge.
Implementation latitude:
- Primary deliverable may be a design doc plus minimal cross-references; code changes are optional and should stay within safe internal boundaries.
- Coder may choose exact doc path/name consistent with existing docs organization.
- If proposing config shape, prefer illustrative schemas over broad runtime implementation unless obviously small and safe.
Escalate if:
- A real runtime implementation becomes necessary to satisfy the Ticket.
- Plugin package design would require changing Profile/manifest authority semantics, Pod restore semantics, secret handling, or MCP enablement model.
- The proposal would imply workspace package execution or silent restore upgrades.
Validation:
- `cargo fmt --check` if code or Rust doc tests are touched.
- `git diff --check` always.
- If only Markdown docs are touched, focused validation may be `git diff --check` plus link/path sanity review.
Current code/doc map:
- Likely doc destination: `docs/design/`.
- Related architecture candidates: `crates/manifest/src/config.rs`, `crates/manifest/src/profile.rs`, `crates/pod/src/feature.rs`, `crates/pod/src/hook.rs`.
Critical risks / reviewer focus:
- Discovery vs enablement separation.
- Plugin permission requests vs grants.
- MCP separation.
- Source identity collision/fail-closed behavior.
- Archive safety and digest/cache semantics.
- Restore/fresh discovery no silent upgrade invariant。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-14T15:49:39Z from: queued to: inprogress reason: orchestrator_acceptance_after_capacity_release field: state -->
## State changed
Routing decision と accepted implementation plan を記録済み。blocking relation / orchestration-plan blocker はなく、capacity waiting reason は解消した。implementation side effects の前に `queued -> inprogress` acceptance を記録する。
---
<!-- event: implementation_report author: yoi-coder-00001KT0Z4BK8 at: 2026-06-14T15:53:15Z -->
## Implementation report
Implementation report from Coder Pod `yoi-coder-00001KT0Z4BK8`:
Commit:
- `3c674a70 docs: propose plugin package distribution`
Changed files:
- `docs/design/plugin-packages.md`
- Added the `.yoi-plugin` package/discovery/enablement design proposal.
- `docs/README.md`
- Added the new design doc to the maintained documentation reading order.
Design choices covered:
- `.yoi-plugin` as a constrained single-file archive with required root `plugin.toml`.
- Suggested package layout for `module.wasm`, declarative hooks, schemas, README/license, and assets.
- User/workspace/builtin plugin stores and source-qualified identity: `user:<id>`, `project:<id>`, `builtin:<id>`.
- Explicit discovery-vs-enablement separation.
- Store presence under `${XDG_DATA_HOME:-~/.local/share}/yoi/plugins/` or `<workspace>/.yoi/plugins/` is discovery only, never registration/execution.
- Plugin permission declarations are requests, not grants.
- Plugin permission policy is explicitly separate from `pod::feature` HostAuthority/grant concepts.
- Archive validation, traversal rejection, bounded extraction, deterministic digest, digest-keyed cache materialization, and optional pin/lock behavior.
- Restore uses resolved enabled-plugin plan/digest metadata; fresh discovery must not silently upgrade a restored Pod.
- Diagnostics guidance without secret leakage.
- Runtime notes for declarative hooks and WASM.
- MCP is explicitly out of initial Plugin packaging/runtime and requires a future separate bridge Ticket.
- Follow-up implementation cuts are listed for enablement schema, discovery, validation/cache, permission policy, declarative hooks, WASM, locks, and future MCP bridge.
Validation reported by coder:
- Passed: `git diff --check`.
- Link/path sanity review:
- Verified `docs/README.md` references existing `docs/design/plugin-packages.md`.
- Verified touched Markdown files have trailing newlines and no tab characters.
- Post-commit check:
- `git status --short --branch` showed clean branch `impl/00001KT0Z4BK8-plugin-package-discovery`.
- `git diff --check HEAD~1 HEAD` passed with no whitespace errors.
- No Rust/code changes were made, so `cargo fmt` / `cargo test` were not run.
Repository status:
- Child implementation worktree clean after commit.
Residual risks / blockers:
- This is intentionally a design proposal only. Exact manifest/Profile schema, lock-file format, archive limits, cache path, and WASM ABI details remain for follow-up implementation Tickets.
---
<!-- event: review author: yoi-reviewer-00001KT0Z4BK8 at: 2026-06-14T15:56:22Z status: approve -->
## Review: approve
Review result: approve
Evidence checked:
- Child worktree/branch:
- `/home/hare/Projects/yoi/.worktree/00001KT0Z4BK8-plugin-package-discovery`
- `impl/00001KT0Z4BK8-plugin-package-discovery`
- HEAD: `3c674a70512ca31b5745d901959c04442c1695d0`
- Base merge point: `d73f748ee8d2e25217cafe3754eb9fa8870ddbed`
- Diff `d73f748e..HEAD` inspected:
- added `docs/design/plugin-packages.md`
- updated `docs/README.md`
- Ticket intent/acceptance context reviewed from the child worktree Ticket record.
Acceptance criteria review:
- `.yoi-plugin` archive structure and required root `plugin.toml` are documented.
- Packaged assets/layout are covered, including optional WASM module, hooks, schemas, README/license, and `assets/**`.
- Stores and source/trust mapping are covered for `builtin:<id>`, `user:<id>`, and `project:<id>`.
- Package presence in user/workspace stores is clearly discovery only, not execution/registration.
- Source-qualified identity, ambiguous-id fail-closed behavior, and collision handling are covered.
- Discovery vs enablement and restore/no-silent-upgrade behavior are explicit.
- Manifest/Profile enablement shape is illustrative and appropriately deferred.
- Plugin permission declarations are requests, not grants; effective grants are tied to Plugin-layer policy plus existing manifest/profile/tool/scope/web/secret/runtime authority layers.
- The document avoids using `pod::feature` HostAuthority/grant concepts as Plugin permission/security model.
- Archive safety covers traversal rejection, unsafe file types, bounded extraction, deterministic digest, digest-keyed cache, and manifest path validation.
- Diagnostics guidance covers attribution, bounded output, and no secret leakage.
- Runtime notes cover declarative hooks, WASM initialization from digest cache, host limits, and ToolRegistry/permission checks.
- MCP is explicitly separate and out of the initial Plugin package runtime.
- Follow-up implementation cuts are clear and separable.
- `docs/README.md` cross-reference is appropriate and remains Why/design-oriented.
Validation performed:
- Passed: `git diff --check d73f748e..HEAD`
- Passed: `git diff --check HEAD~1 HEAD`
- README-listed relative doc target existence checked with shell commands.
- Manual Markdown/design boundary review completed.
Validation not run:
- No cargo commands because the change is documentation-only.
- A Python-based link check could not run because `python3` is unavailable; shell existence checks were used instead.
Conclusion:
- Approved. No blocking concern remains.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-14T15:56:45Z -->
## Implementation report
Integration report:
Merged implementation branch into orchestration branch:
- branch: `impl/00001KT0Z4BK8-plugin-package-discovery`
- implementation commit: `3c674a70 docs: propose plugin package distribution`
- merge commit: `2b9dae48 merge: plugin package design`
Reviewer result:
- `approve` recorded from `yoi-reviewer-00001KT0Z4BK8`.
Validation in Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration`:
- Passed: `git diff --check`
- Passed: `test -f docs/design/plugin-packages.md`
- Passed: `grep -n 'plugin-packages.md' docs/README.md`
Cargo validation:
- Not run because the merged change is documentation-only.
Notes:
- The proposal is intentionally design-only. Exact manifest/Profile schema, lock-file format, archive limits, cache path, and WASM ABI remain follow-up implementation work.
- Orchestrator worktree is clean after validation.
Cleanup planned:
- Stop related coder/reviewer Pods.
- Remove only child implementation worktree/branch for this Ticket.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-14T15:56:45Z from: inprogress to: done reason: merged_validated field: state -->
## State changed
Reviewer approved, documentation/design implementation branch merged into the orchestration branch, and documentation-focused validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch.
---

View File

@ -1,8 +1,8 @@
---
title: "Preserve active workflows across compaction"
state: 'inprogress'
state: 'done'
created_at: "2026-06-07T02:23:28Z"
updated_at: '2026-06-14T15:24:58Z'
updated_at: '2026-06-14T16:26:01Z'
queued_by: 'workspace-panel'
queued_at: '2026-06-14T15:23:07Z'
---

View File

@ -145,3 +145,273 @@ Critical risks / reviewer focus:
Routing decision と accepted implementation plan を記録済み。blocking relation / orchestration-plan blocker はなく、Orchestrator workspace は clean。implementation side effects の前に `queued -> inprogress` acceptance を記録する。
---
<!-- event: implementation_report author: yoi-coder-00001KTFY8V80 at: 2026-06-14T15:50:38Z -->
## Implementation report
Implementation report from Coder Pod `yoi-coder-00001KTFY8V80`:
Commit:
- `362fedfb fix: preserve active workflows across compaction`
Changed files:
- `crates/pod/src/active_workflow.rs`
- `crates/pod/src/lib.rs`
- `crates/pod/src/pod.rs`
- `crates/pod/src/segment_log_sink.rs`
- `resources/prompts/internal/compact_system.md`
Implemented behavior:
- Added durable typed active workflow state as session-log extension domain `pod.active_workflows`.
- State records include:
- workflow slug
- invocation source/time
- task scope
- active/completed/cancelled status
- snapshotted workflow guidance
- extracted obligations/checkpoints
- completion/cancellation reason/time
- Workflow bodies are snapshotted at invocation time rather than resolved to latest resource/builtin version during rehydration. Rationale: active workflow authority remains traceable to the original governed task and does not silently change when resource files change later.
- Compaction now:
- feeds active workflow state into compact worker input
- writes active workflow state into the replacement segment as typed extension state
- injects post-compaction workflow guidance into `SegmentStart.history` from durable state, not transient turn-local data
- Added `ActiveWorkflowList`, `ActiveWorkflowComplete`, and `ActiveWorkflowCancel` tools so active workflow state can be inspected, completed, or explicitly cancelled.
- Missing/corrupt/unsupported active workflow extension state fails closed with bounded diagnostics rather than reusing stale prior state.
Validation reported by coder:
- Passed: `cargo fmt --check`
- Passed: `git diff --check`
- Passed: `cargo test -p pod active_workflow --lib`
- includes focused coverage for review/merge/close-style obligations crossing compaction/rehydration
- Passed: `cargo test -p pod includes_active_workflow_snapshot_section --lib`
- Ran: `cargo test -p pod --lib`
- Failed on 2 prompt text assertions reported as unrelated/pre-existing:
- `prompt::catalog::tests::pod_orchestration_guidance_section_renders_resource_body`
- `prompt::system::tests::pod_orchestration_guidance_is_included_for_pod_management_tools`
- both assert the unrelated string `worktree status, diff, and test results`
Repository status:
- Child implementation worktree clean after commit.
Residual risks / notes:
- Active workflow obligation extraction is intentionally conservative: it stores full snapshotted guidance as authority and derives bounded checkpoint labels from obligation-like lines.
- Completion/cancellation tool calls persist through normal history; compaction additionally writes updated typed snapshot into the compacted segment.
---
<!-- event: review author: yoi-reviewer-00001KTFY8V80 at: 2026-06-14T15:58:49Z status: request_changes -->
## Review: request changes
Review result: request_changes
Evidence checked:
- Child worktree/branch/head:
- `/home/hare/Projects/yoi/.worktree/00001KTFY8V80-active-workflows-compaction`
- `impl/00001KTFY8V80-active-workflows-compaction`
- HEAD `362fedfbe6689886f1e2e7c29da61e39b0ce1e38`
- merge base with requested base: `73d0a6a4`
- `git status --short` was clean.
- Diff `73d0a6a4..362fedfb` inspected.
- Read-only validation:
- Passed: `git diff --check 73d0a6a4..362fedfb`
- Cargo/fmt not rerun because review scope was read-only.
What looks good:
- A typed active workflow snapshot was added with slug, status, invocation source/time, task scope, snapshot policy, snapshotted guidance, obligations/checkpoints, and completion metadata.
- Active workflow state is separated from advertised workflows; activation comes from invoked `SystemItem::Workflow` rather than resident workflow catalog.
- Snapshot-vs-latest behavior is explicit via `WorkflowBodySnapshotPolicy::SnapshottedAtInvocation`.
- Compaction passes active workflow state into compactor input and writes typed `LogEntry::Extension` into the compacted segment.
- Clear/cancel tools are exposed as `ActiveWorkflowComplete` / `ActiveWorkflowCancel`.
Required changes:
1. Stale active workflow guidance can remain in prompt history after typed state is invalid, completed, or cancelled.
- The implementation writes active workflow rehydration guidance as an ordinary system message in compacted history (`pod.rs` around the compaction replacement history construction).
- Restore later uses `SegmentStart.history` as worker history.
- Corrupt/obsolete extension handling drops/diagnoses the typed state but does not remove the old `[Active workflow snapshot]` system message from compacted history.
- Therefore the model can still see stale workflow obligations even when the durable active-workflow extension is missing/corrupt/obsolete.
- The same leakage risk applies after completion/cancellation: old compacted system messages can remain until another compaction.
Required fix:
- Ensure active workflow guidance shown to the model is gated by currently valid active workflow state, not immutable old compacted history.
- For example, regenerate guidance from validated typed state at context/compaction time, or sanitize/supersede old active workflow system messages when typed state is invalid/inactive.
- Add focused coverage for compacted history containing an active workflow message plus missing/corrupt/obsolete extension, and for completion/cancellation after compaction.
2. Completion/cancellation durability is inferred from generic tool-call history and trusts bare `ToolCall`s.
- `ActiveWorkflowComplete` / `ActiveWorkflowCancel` mutate only the in-memory store in the tool implementation.
- They do not append a new typed `LogEntry::Extension` on successful status change.
- Restore reconstructs completion/cancellation by scanning history.
- Replay marks workflows completed/cancelled based solely on `Item::ToolCall` names, without requiring a matching successful `ToolResult`.
Required fix:
- Persist status changes as typed durable active workflow state when the status tool succeeds, or make replay validate a matching non-error tool result/event proving the tool executed successfully.
- Add focused tests for interrupted/unmatched/error status tool calls.
Prompt-test failure assessment:
- The reported `cargo test -p pod --lib` failures about `worktree status, diff, and test results` appear unrelated. The diff changes `resources/prompts/internal/compact_system.md`, while the failing assertions are in pod orchestration prompt tests and reference a different prompt resource.
Conclusion:
- Changes requested. Do not integrate until stale-guidance gating and completion/cancellation durability are fixed and covered.
---
<!-- event: implementation_report author: yoi-coder-00001KTFY8V80 at: 2026-06-14T16:17:48Z -->
## Implementation report
Follow-up implementation report after reviewer request_changes:
New commit:
- `ff446052 fix: gate active workflow rehydration state`
Changed files in follow-up:
- `crates/pod/src/active_workflow.rs`
- `crates/pod/src/ipc/interceptor.rs`
- `crates/pod/src/pod.rs`
Review blocker 1 resolved: stale active workflow guidance in prompt history
- Active workflow rehydration guidance is no longer persisted into compacted `SegmentStart.history` as an ordinary system message.
- Compaction still carries active workflow state forward via typed `pod.active_workflows` extension state.
- Request context is sanitized before model calls:
- stale `[Active workflow snapshot]` system messages are stripped,
- current active workflow guidance is regenerated request-locally from validated typed state,
- when typed state is missing/corrupt/obsolete/inactive and stale workflow hints are present, a bounded inactive-state message supersedes old compacted references.
- Restore paths strip stale active workflow rehydration messages before installing worker history.
- Compaction source/retained histories strip old active workflow rehydration messages so old snapshots are not re-summarized as active authority.
Review blocker 2 resolved: completion/cancellation durability
- `ActiveWorkflowComplete` / `ActiveWorkflowCancel` now persist successful status changes by committing a new typed `LogEntry::Extension` snapshot through the session log writer.
- Restore no longer infers completion/cancellation from bare `Item::ToolCall` history.
- Typed extension state is the authority, avoiding trust in unmatched/interrupted/error tool calls.
Focused coverage added/updated:
- Compacted history containing active workflow rehydration message plus:
- missing extension,
- corrupt extension,
- obsolete extension.
- Completion/cancellation after compaction suppressing old compacted guidance.
- Successful status tool writes a typed durable extension.
- Unmatched/error status tool calls do not mutate restored active workflow state.
- Existing review/merge/close workflow obligation coverage still passes.
Validation reported by coder:
- Passed: `cargo fmt --check`
- Passed: `git diff --check`
- Passed: `cargo test -p pod active_workflow --lib`
- 7 passed
- Passed: `cargo test -p pod interceptor --lib`
- 19 passed
- Ran: `cargo test -p pod --lib`
- Still fails only on the previously reported unrelated prompt assertion failures:
- `prompt::catalog::tests::pod_orchestration_guidance_section_renders_resource_body`
- `prompt::system::tests::pod_orchestration_guidance_is_included_for_pod_management_tools`
- both assert missing text: `worktree status, diff, and test results`
- result: `332 passed; 2 failed`
Repository status:
- Child implementation worktree clean after follow-up commit.
---
<!-- event: review author: yoi-reviewer-00001KTFY8V80-r2 at: 2026-06-14T16:25:00Z status: approve -->
## Review: approve
Review result: approve
Evidence checked:
- Child worktree:
- `/home/hare/Projects/yoi/.worktree/00001KTFY8V80-active-workflows-compaction`
- HEAD: `ff446052 fix: gate active workflow rehydration state`
- Base checked against: `73d0a6a4`
- Original implementation checked where relevant: `362fedfb`
Prior blocker 1: stale active workflow guidance after compaction
- Resolved.
- Active workflow rehydration is now derived from typed `LogEntry::Extension` state via `ActiveWorkflowStore`, not from ordinary prompt/tool-call history.
- Restore strips prior `[Active workflow snapshot]` system messages before installing history.
- Rehydration guidance is regenerated request-time from validated active typed state.
- Missing/corrupt/unsupported extension state fails closed: no active workflow restored, stale rehydration messages stripped, and bounded inactive diagnostic text tells the model not to treat older compacted history/summaries as active workflow authority.
- Completed/cancelled typed state does not regenerate active guidance.
- Compaction no longer stores active workflow guidance directly in `SegmentStart.history` as ordinary durable prompt authority; it carries typed extension entries.
- Focused coverage exists for stale active workflow message plus missing/corrupt/unsupported state and completion/cancellation after compaction.
Prior blocker 2: completion/cancellation durability
- Resolved.
- `ActiveWorkflowComplete` / `ActiveWorkflowCancel` mutate store status and commit a fresh typed `LogEntry::Extension` snapshot through the active workflow log committer.
- Production controller wiring attaches the log writer before feature/tool registration, so status tools have durable commit plumbing.
- Restore no longer trusts bare unmatched `Item::ToolCall` entries or failed/error calls to infer completed/cancelled state.
- Focused tests cover unmatched/error status tool calls and explicit completed/cancelled typed extension suppression of active guidance.
Overall acceptance:
- Durable typed active workflow representation exists.
- Compaction carries active workflow state forward through typed extension state.
- Rehydration restores guidance from durable validated state.
- Snapshot-vs-latest policy is explicit and fail-closed on missing/corrupt/unsupported latest state.
- No hidden context injection from non-durable transient data was found.
- Active vs advertised workflow separation is preserved.
- Clear/cancel/complete behavior is durable typed-state transition.
Validation performed by reviewer:
- Passed: `git diff --check 73d0a6a4..HEAD`
- Passed: `cargo fmt --check`
- `git status --short` remained clean.
Validation not rerun by reviewer:
- Cargo tests were not rerun because review scope was read-only and tests write build artifacts. Coder-reported focused test results were inspected as evidence.
Full-suite prompt failure assessment:
- The remaining reported `cargo test -p pod --lib` prompt assertion failures involving `worktree status, diff, and test results` appear unrelated to the active workflow typed-state/compaction changes.
Conclusion:
- Approved. No remaining blocker found.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-14T16:26:01Z -->
## Implementation report
Integration report:
Merged implementation branch into orchestration branch:
- branch: `impl/00001KTFY8V80-active-workflows-compaction`
- implementation commits:
- `362fedfb fix: preserve active workflows across compaction`
- `ff446052 fix: gate active workflow rehydration state`
- merge commit: `64d26f84 merge: preserve active workflows across compaction`
Reviewer result:
- First review requested changes for stale prompt-history guidance and completion/cancellation durability.
- Second-pass review `approve` recorded from `yoi-reviewer-00001KTFY8V80-r2`.
Validation in Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration`:
- Passed: `cargo test -p pod active_workflow --lib`
- 7 passed
- Passed: `cargo test -p pod interceptor --lib`
- 19 passed
- Passed: `cargo fmt --check`
- Passed: `git diff --check`
Notes:
- Full `cargo test -p pod --lib` was not rerun at integration because coder already reported only the known unrelated prompt assertion failures. Focused tests and reviewer inspection covered the changed active workflow/compaction/interceptor paths.
- Orchestrator worktree is clean after validation.
Cleanup planned:
- Stop related coder/reviewer Pods.
- Remove only child implementation worktree/branch for this Ticket.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-14T16:26:01Z from: inprogress to: done reason: merged_validated field: state -->
## State changed
Reviewer approved after requested fixes, implementation branch merged into the orchestration branch, and focused validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch.
---

View File

@ -1,8 +1,8 @@
---
title: 'Workspace panel: show Ticket-associated Intake Pods adjacent to Ticket rows'
state: 'inprogress'
state: 'done'
created_at: '2026-06-13T10:54:31Z'
updated_at: '2026-06-14T15:24:58Z'
updated_at: '2026-06-14T15:55:36Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['panel-ux', 'local-role-session-registry', 'pod-session-state']

View File

@ -109,3 +109,139 @@ Critical risks / reviewer focus:
Routing decision と accepted implementation plan を記録済み。blocking relation / orchestration-plan blocker はなく、Orchestrator workspace は clean。00001KTFY8V80 とは主対象が workflow/compaction と TUI Panel で分かれており、独立 worktree/branch で並行開始可能と判断したため、implementation side effects の前に `queued -> inprogress` acceptance を記録する。
---
<!-- event: implementation_report author: yoi-coder-00001KV09WYC6 at: 2026-06-14T15:48:12Z -->
## Implementation report
Implementation report from Coder Pod `yoi-coder-00001KV09WYC6`:
Commit:
- `2664cdd9 feat: show ticket intake pods in panel`
Changed files:
- `crates/tui/src/workspace_panel.rs`
- `crates/tui/src/multi_pod.rs`
Implemented behavior:
- Workspace Panel derives Ticket-associated Intake Pods from the local role/session registry:
- active local Intake claim for a Ticket
- related Intake sessions whose `related_tickets` explicitly contain that Ticket id
- Pre-Ticket Intake sessions without an explicit Ticket relation are not associated, even if the Pod name contains the Ticket id.
- Ticket rows now get adjacent child rows:
- `↳ Intake Pod: <pod-name>`
- status column shows `live`, `restorable`, or `stale`
- non-stale associated Intake rows expose `Open/attach` behavior via the existing Pod open path
- stale rows show unavailable/stale reason instead of trying to open
- Added typed row key/kind for Ticket Intake Pod rows:
- `PanelRowKey::TicketIntakePod`
- `PanelRowKind::TicketIntakePod`
- Kept related display bounded with `MAX_ASSOCIATED_INTAKE_ROWS_PER_TICKET = 3`.
- Preserved existing local claim handling for generic `related_pods`, while using the new typed `intake_pods` field for visible Ticket Intake child rows.
- Updated panel selection/open plumbing so associated Intake rows resolve to existing Pod open/attach behavior.
Focused coverage:
- Added `workspace_panel_shows_ticket_associated_intake_pods_adjacent_to_ticket`, covering:
- local Intake claim adjacent row
- related Intake session adjacent row
- live status/action contract
- Pre-Ticket Intake session not falsely associated
Validation reported by coder:
- Passed: `cargo test -p tui workspace_panel --lib`
- Passed: `cargo test -p tui role_session_registry --lib`
- Passed: `cargo fmt --check`
- Passed: `git diff --check`
- Ran relevant: `cargo test -p tui multi_pod --lib`
- Failed in one existing environment/path-sensitive test:
- `tests::orchestrator_launch_uses_runtime_workspace_not_process_cwd`
- Failure expected `/tmp/.../runtime-workspace` but observed the worktree path.
- The suite compiled; coder assessed this as unrelated to the Intake-row changes.
Repository status:
- Child implementation worktree clean after commit.
Residual risks / blockers:
- No known implementation blocker.
- Validation caveat: unrelated `multi_pod` filtered-suite failure noted above remains for reviewer/orchestrator assessment.
---
<!-- event: review author: yoi-reviewer-00001KV09WYC6 at: 2026-06-14T15:54:30Z status: approve -->
## Review: approve
Review result: approve
Evidence checked:
- Worktree clean and HEAD at implementation commit `2664cdd9`.
- Ticket intent/body/thread reviewed from the child worktree.
- Diff `73d0a6a4..2664cdd9` reviewed.
- Changed files limited to:
- `crates/tui/src/workspace_panel.rs`
- `crates/tui/src/multi_pod.rs`
- Read-only whitespace validation:
- `git diff --check 73d0a6a4..2664cdd9` passed.
Acceptance / invariant review:
- Ticket-associated Intake Pods are modeled as explicit `PanelRowKind::TicketIntakePod` rows with `PanelRowKey::TicketIntakePod { ticket_id, pod_name }`.
- Rows are inserted immediately after the owning Ticket row, satisfying adjacent/clearly-related display.
- Association comes from local role/session registry evidence: active local claim and Intake sessions with explicit `related_tickets`.
- Pod-name-contains-ticket-id heuristic was removed, reducing false association risk and preserving the pre-Ticket Intake invariant.
- Pre-Ticket Intake Pods without related Ticket evidence are not attached to Ticket rows; focused test covers this.
- Multiple Intake entries per Ticket are supported and bounded by `MAX_ASSOCIATED_INTAKE_ROWS_PER_TICKET`; no 1:1 assumption.
- one-active-claim-per-Ticket remains enforced by existing registry claim path; this change only reads/displays claims/sessions.
- No git-tracked Ticket metadata/frontmatter/thread storage was added for Pod assignment/status.
- No automatic polling or automatic Intake spawn was added.
- Arbitrary selected Pod direct-send UX was not revived; associated Intake row routes through existing open/attach behavior.
- live/restorable/stale status is surfaced via `TicketLocalClaimStatus::label()`.
- Focused coverage exists for adjacent Intake rows and multi-pod selection/open behavior.
Validation not rerun:
- Cargo/fmt validation was not rerun because the review scope was read-only and those commands may write build artifacts.
Reported `multi_pod` failure assessment:
- `tests::orchestrator_launch_uses_runtime_workspace_not_process_cwd` appears unrelated. The implementation changes Workspace Panel row modeling/rendering/selection/open routing only and does not modify orchestrator launch workspace/cwd construction.
Conclusion:
- Approved. No blocker remains for integration.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-14T15:55:36Z -->
## Implementation report
Integration report:
Merged implementation branch into orchestration branch:
- branch: `impl/00001KV09WYC6-panel-intake-pod-rows`
- implementation commit: `2664cdd9 feat: show ticket intake pods in panel`
- merge commit: `2fcbd6ae merge: panel intake pod rows`
Reviewer result:
- `approve` recorded from `yoi-reviewer-00001KV09WYC6`.
Validation in Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration`:
- Passed: `cargo test -p tui workspace_panel --lib`
- Passed: `cargo test -p tui role_session_registry --lib`
- Passed: `cargo fmt --check`
- Passed: `git diff --check`
Notes:
- The coder-reported `cargo test -p tui multi_pod --lib` failure was reviewed and assessed unrelated to this Ticket. It was not used as a blocker.
- Orchestrator worktree is clean after validation.
Cleanup planned:
- Stop related coder/reviewer Pods.
- Remove only child implementation worktree/branch for this Ticket.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-14T15:55:36Z from: inprogress to: done reason: merged_validated field: state -->
## State changed
Reviewer approved, implementation branch merged into the orchestration branch, focused validation passed in the Orchestrator worktree, and cleanup is ready. Marking Ticket done in the orchestration branch.
---

View File

@ -1,2 +1,3 @@
{"id":"orch-plan-20260614-153704-1","ticket_id":"00001KV3A5CNH","kind":"conflicts_with","related_ticket":"00001KV09WYC6","note":"同じ Workspace Panel Ticket row/model/action/diagnostic surface を変更する可能性が高いため、`00001KV09WYC6` の実装・review・integration 後に再 routing する。","author":"yoi-orchestrator","at":"2026-06-14T15:37:04Z"}
{"id":"orch-plan-20260614-153704-2","ticket_id":"00001KV3A5CNH","kind":"waiting_capacity_note","note":"現在 `00001KTFY8V80` と `00001KV09WYC6` の2件が inprogress で Coder Pod running。`00001KV09WYC6` と source surface が重なるため、追加 spawn せず queued のまま待機。","author":"yoi-orchestrator","at":"2026-06-14T15:37:04Z"}
{"id":"orch-plan-20260614-155739-3","ticket_id":"00001KV3A5CNH","kind":"accepted_plan","accepted_plan":{"summary":"Accept invalid-Ticket partial failure Panel bugfix now that prior Panel Intake row work is integrated. Implement partial failure handling and focused tests without changing Ticket lifecycle authority.","branch":"impl/00001KV3A5CNH-panel-invalid-ticket-tolerance","worktree":"/home/hare/Projects/yoi/.worktree/00001KV3A5CNH-panel-invalid-ticket-tolerance","role_plan":"Orchestrator creates a dedicated implementation worktree and spawns a Coder with write scope limited to that worktree. Reviewer will run read-only after implementation report. Prior conflict with `00001KV09WYC6` is resolved because that Ticket is merged/validated/done in orchestration branch."},"author":"yoi-orchestrator","at":"2026-06-14T15:57:39Z"}

View File

@ -1,8 +1,8 @@
---
title: 'Panel: invalid Ticket があっても Ticket 機能全体を無効化しない'
state: 'queued'
state: 'done'
created_at: '2026-06-14T14:56:51Z'
updated_at: '2026-06-14T15:37:04Z'
updated_at: '2026-06-14T16:38:01Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['tui-panel', 'ticket-backend', 'partial-failure', 'diagnostics']

View File

@ -39,3 +39,316 @@ Next action:
- planning return ではなく queued のまま waiting とする。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-14T15:57:39Z -->
## Decision
Routing decision: implementation_ready
Reason:
- 以前の waiting reason は `00001KV09WYC6` と同じ Panel row/action surface の conflict risk だったが、`00001KV09WYC6` は reviewer approve、orchestration branch への merge、focused validation、Ticket `done` まで完了した。
- Ticket body / thread / relations / orchestration plan / current Orchestrator workspace を再確認した。blocking relation はなく、planning に戻す concrete missing information はない。
- 本 Ticket は invalid/corrupt/unreadable individual Ticket record に対する Panel partial failure handling として concrete であり、残る不確実性は backend/list/show error handling と Panel row/diagnostic 表現の実装戦術に閉じている。
Evidence checked:
- Ticket body/thread: Background, requirements, acceptance criteria, invariants, implementation latitude, escalation conditions, validation を確認。
- Ticket relations: blocker なし。
- OrchestrationPlan: `00001KV09WYC6` との prior conflict/waiting note を確認。先行 Ticket 完了により blocker は解消。
- Orchestrator workspace: `/home/hare/Projects/yoi/.worktree/orchestration` は clean、`81667a9a` 上。
- Active Pods: `00001KTFY8V80` reviewer running、coder idle。Panel implementation worker/reviewer for `00001KV09WYC6` は停止済み。
- Current code map after prior Panel merge: `crates/tui/src/workspace_panel.rs`, `crates/tui/src/multi_pod.rs`, `crates/ticket/src/lib.rs`
IntentPacket:
Intent:
- Workspace Panel で個別 invalid/corrupt/unreadable Ticket record があっても、正常な Ticket rows と actions を表示・維持し、invalid record は bounded diagnostic/placeholder として見せる。
Binding decisions / invariants:
- invalid Ticket を理由に正常 Ticket の Panel 操作を巻き添えで止めない。
- invalid Ticket record を Panel が自動修復・自動削除しない。
- invalid Ticket には Queue / Close / planning return など lifecycle mutation action を出さない。
- Ticket lifecycle authority / state schema は変更しない。
- Ticket backend config 全体が unusable な場合と、個別 record の partial failure を区別する。
- 正常 Ticket の lifecycle mutation は既存 typed Ticket backend / Panel action path を通す。
- invalid record の content や secret-like content を UI/diagnostic に漏らさない。
Requirements / acceptance criteria:
- valid + invalid Ticket が混在しても valid rows は残る。
- 正常 ready Ticket の Queue action、正常 planning Ticket の clarification/Intake 導線を維持する。
- invalid Ticket は bounded diagnostic または disabled placeholder row として見える。
- invalid Ticket に lifecycle mutation action を提示しない。
- Panel header/diagnostics は全体 unavailable ではなく一部読み込み失敗を表す。
- backend root/config unusable の既存 degraded behavior は壊さない。
- Focused tests で partial failure、bounded invalid indication、valid action preservation、config unusable case を確認する。
Implementation latitude:
- 表示形式は header diagnostic / placeholder row / detail route のどれでもよい。
- backend `list` を lossy にするか、Panel 側 per-Ticket load recovery にするかは実装判断。ただし typed boundary を保ち、Panel 専用 ad hoc parsing で schema authority を迂回しない。
- `TicketDoctor` logic を再利用してよいが、Panel 起動ごとに重い full doctor を必須にしない。
Escalate if:
- `TicketBackend::list` public semantics の大幅変更が必要。
- invalid path/id を安全に特定できない。
- Panel action dispatch が valid Ticket と invalid placeholder を安全に分けられない。
- TicketDoctor と Panel diagnostics の severity/wording が矛盾する。
- invalid content を読まないと UI 表示できない設計になる。
Validation:
- `cargo test -p tui workspace_panel --lib`
- 必要に応じて `cargo test -p ticket`
- `cargo fmt --check`
- `git diff --check`
Critical risks / reviewer focus:
- partial failure が全体 Ticket UI unavailable に戻らないこと。
- invalid placeholder/action key が lifecycle mutation path に入らないこと。
- Ticket backend config failure との区別。
- diagnostics の boundedness と secret-like content 非露出。
- prior `00001KV09WYC6` の Ticket-associated Intake row behavior との整合。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-14T15:57:47Z from: queued to: inprogress reason: orchestrator_acceptance_after_conflict_resolution field: state -->
## State changed
Routing decision と accepted implementation plan を記録済み。先行 `00001KV09WYC6` は merge/validation/done 済みで、prior conflict/waiting reason は解消。blocking relation / unresolved orchestration-plan blocker はないため、implementation side effects の前に `queued -> inprogress` acceptance を記録する。
---
<!-- event: implementation_report author: yoi-coder-00001KV3A5CNH at: 2026-06-14T16:21:50Z -->
## Implementation report
Implementation report from Coder Pod `yoi-coder-00001KV3A5CNH`:
Commit:
- `b83b9e4e fix: tolerate invalid ticket rows in panel`
Changed files:
- `crates/ticket/src/lib.rs`
- Added tolerant `LocalTicketBackend::list_partial(...)` and `show_partial(...)`.
- Added bounded/sanitized `TicketInvalidRecord` reporting.
- Preserved strict existing `TicketBackend::list/show` semantics.
- Added ticket backend test for valid records surviving peer invalid record failures.
- `crates/tui/src/workspace_panel.rs`
- Panel now uses partial ticket loading.
- Valid Ticket rows remain visible/actionable when sibling Ticket records are invalid.
- Invalid records render as disabled diagnostic placeholder rows.
- Invalid placeholder rows have no lifecycle actions and no `ticket` action identity.
- Header diagnostics indicate partial Ticket load failure with bounded placeholder count.
- Added focused Panel tests covering valid ready Queue action, valid planning Clarify/Intake path, associated Intake row adjacency, invalid row bounded/non-actionable behavior, secret-like content non-exposure, and backend config unusable behavior.
- `crates/tui/src/multi_pod.rs`
- Added rendering/selection support for invalid Ticket placeholder rows.
- Invalid placeholder rows are shown as ticket-section diagnostics but remain action-disabled.
Validation reported by coder:
- Passed: `cargo test -p tui workspace_panel --lib`
- Passed: `cargo test -p ticket partial_list_and_show --lib`
- Passed: `cargo fmt --check`
- Passed: `git diff --check`
Repository status:
- Child implementation worktree clean after commit.
Residual risks / blockers:
- None known.
- Strict backend list/show behavior was preserved; new partial methods are opt-in for Panel tolerance.
---
<!-- event: review author: yoi-reviewer-00001KV3A5CNH at: 2026-06-14T16:27:19Z status: request_changes -->
## Review: request changes
Review result: request_changes
Blocker:
- A corrupt current-ticket relation artifact can still produce an actionable normal Ticket row.
Details:
- New tolerant relation loading records relation-artifact failures as `TicketInvalidRecord`, but still returns an `Ok(Ticket)` with lossy/omitted relations.
- `LocalTicketBackend::show_partial` returns `ticket_from_dir_tolerant(...)` as successful partial details.
- `relation_view_for_meta_tolerant` builds a relation view from remaining records after `all_ticket_relation_records_tolerant` records relation read errors.
- Panel unconditionally emits a normal actionable `ticket_row(...)` whenever `show_partial` returns `Ok`, and only extends `invalid_records` for diagnostics.
- A ready row can therefore still receive `NextUserAction::Queue`.
Consequence:
- If a ready Ticket's own `artifacts/relations.json` is corrupt/unreadable, Panel can show both:
1. a normal ready Ticket row with `Queue`, and
2. an invalid placeholder for the same Ticket id.
- This violates the invariant that invalid/corrupt Ticket records must not expose Queue/Close/planning-return/lifecycle mutation actions.
- It is also risky because malformed relations may hide dependency/blocker authority; Queue must not be exposed for that same corrupt Ticket record.
Required changes:
- If partial detail loading reports an invalid record for the same Ticket id being rendered, do not also render that Ticket as an actionable normal row.
- Replace it with a disabled invalid placeholder, or otherwise guarantee no lifecycle mutation action is available for that id.
- Add focused coverage for a valid `item.md` with corrupt/unreadable current Ticket detail/artifact, preferably malformed `artifacts/relations.json`, asserting:
- Ticket id is visible as invalid diagnostic/placeholder.
- no Queue/Close/lifecycle action is available for that Ticket id.
- other valid Ticket rows/actions remain available.
Evidence checked:
- Reviewed implementation diff `a1c8264b..b83b9e4e`.
- Inspected:
- `crates/ticket/src/lib.rs`
- `crates/tui/src/workspace_panel.rs`
- `crates/tui/src/multi_pod.rs`
- Ticket `00001KV3A5CNH` item.
Positive findings:
- Strict `TicketBackend::list/show` behavior remains unchanged; partial behavior is opt-in.
- Mixed valid/invalid item frontmatter is covered by focused tests.
- Panel creates disabled invalid placeholder rows with no `next_action`.
- Backend config unusable remains a whole-ticket degraded case in tests.
- Existing associated Intake row behavior is covered for a valid ready Ticket.
Validation performed by reviewer:
- Passed: `git diff --check a1c8264b..HEAD`
- Passed: `cargo fmt --check`
Validation not run:
- `cargo test` was not rerun because review scope was read-only and Cargo writes build artifacts.
Conclusion:
- Changes requested. Do not integrate until current-ticket detail/artifact corruption cannot expose lifecycle actions and focused coverage is added.
---
<!-- event: implementation_report author: yoi-coder-00001KV3A5CNH at: 2026-06-14T16:30:55Z -->
## Implementation report
Follow-up implementation report after reviewer request_changes:
New commit:
- `456722c3 fix: disable tickets with invalid detail records`
Changed file in follow-up:
- `crates/tui/src/workspace_panel.rs`
Blocker resolved:
- `build_ticket_rows(...)` now checks `show_partial(...)` diagnostics for an invalid record whose label matches the Ticket id currently being rendered.
- If the current Ticket id is reported invalid during partial detail loading, Panel no longer emits the normal actionable `ticket_row(...)`.
- The invalid record flows through the existing bounded invalid placeholder/diagnostic path, so the Ticket id remains visible but has no lifecycle action.
Behavior after fix:
- A ready Ticket with valid `item.md` but malformed `artifacts/relations.json` is shown only as an invalid diagnostic placeholder.
- It cannot receive Queue, Close, clarification/planning-return, or other lifecycle mutation actions.
- Other valid Ticket rows continue to render normally and keep their actions.
Coverage added:
- `workspace_panel_disables_current_ticket_when_detail_artifact_is_invalid`
- Test scenario:
- one ready Ticket with malformed `artifacts/relations.json`
- one separate valid ready Ticket
- Assertions:
- corrupt Ticket id is visible as `PanelRowKey::InvalidTicket(...)`
- no normal `PanelRowKey::Ticket(...)` row exists for that corrupt id
- placeholder has no action and no Ticket action identity
- other valid ready Ticket still has `NextUserAction::Queue`
- header reports partial Ticket load failure
Validation reported by coder:
- Passed: `cargo test -p tui workspace_panel_disables_current_ticket_when_detail_artifact_is_invalid --lib`
- Passed: `cargo test -p tui workspace_panel --lib`
- Passed: `cargo fmt --check`
- Passed: `git diff --check`
Repository status:
- Child implementation worktree clean after follow-up commit.
---
<!-- event: review author: yoi-reviewer-00001KV3A5CNH-r2 at: 2026-06-14T16:37:15Z status: approve -->
## Review: approve
Review result: approve
Evidence checked:
- Target worktree clean at `456722c3`.
- Reviewed diff `a1c8264b..456722c3` and second-pass fix diff `b83b9e4e..456722c3`.
- Confirmed backend partial/tolerant read paths:
- `LocalTicketBackend::list_partial`
- `LocalTicketBackend::show_partial`
- tolerant relation/state loading records invalid Ticket ids via `TicketInvalidRecord` instead of failing entire list/detail load.
- Confirmed Panel row construction:
- uses `list_partial` and `show_partial`;
- collects invalid records into bounded diagnostics/placeholders;
- detects when `show_partial` reports an invalid record whose label matches the summary id being rendered;
- skips normal actionable `PanelRowKey::Ticket(id)` for that id;
- emits `InvalidTicket(id)` placeholder instead.
- Confirmed invalid placeholder rows are diagnostics-only:
- `kind == PanelRowKind::InvalidTicket`
- `ticket == None`
- `next_action == None`
- `is_ticket_action()` excludes invalid rows
- lifecycle dispatch preparation only accepts `row.is_ticket_action()`.
- Confirmed regression test for prior blocker:
- `workspace_panel_disables_current_ticket_when_detail_artifact_is_invalid`
- malformed `artifacts/relations.json` ready Ticket has no normal actionable row;
- disabled `PanelRowKey::InvalidTicket(corrupt.id)` placeholder exists;
- another valid ready Ticket keeps `Queue`.
- Confirmed broader acceptance coverage remains:
- mixed valid/invalid records preserve valid ready/planning rows;
- invalid rows are capped and do not leak malformed state strings;
- backend config unusable remains separate whole-ticket degradation;
- valid Ticket-associated Intake rows remain adjacent to valid Ticket rows.
Validation performed by reviewer:
- Passed: `git diff --check a1c8264b..HEAD`
- Passed: `cargo fmt --check`
Validation not run:
- `cargo test` was not run by reviewer because read-only review scope prevents writing build artifacts. Coder-reported passing tests were inspected as evidence.
Conclusion:
- Approved. No remaining blocker found.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-14T16:38:01Z -->
## Implementation report
Integration report:
Merged implementation branch into orchestration branch:
- branch: `impl/00001KV3A5CNH-panel-invalid-ticket-tolerance`
- implementation commits:
- `b83b9e4e fix: tolerate invalid ticket rows in panel`
- `456722c3 fix: disable tickets with invalid detail records`
- merge commit: `863b13b6 merge: tolerate invalid panel tickets`
Reviewer result:
- First review requested changes for corrupt current-ticket relation/detail artifacts exposing lifecycle actions.
- Second-pass review `approve` recorded from `yoi-reviewer-00001KV3A5CNH-r2`.
Validation in Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration`:
- Passed: `cargo test -p tui workspace_panel --lib`
- 16 passed
- Passed: `cargo test -p ticket partial_list_and_show --lib`
- 1 passed
- Passed: `cargo fmt --check`
- Passed: `git diff --check`
Notes:
- Valid Ticket rows/actions remain available when peer records are invalid.
- If a current Ticket's detail/artifact load reports invalidity, Panel renders only a disabled invalid placeholder for that id and no lifecycle action.
- Orchestrator worktree is clean after validation.
Cleanup planned:
- Stop related coder/reviewer Pods.
- Remove only child implementation worktree/branch for this Ticket.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-14T16:38:01Z from: inprogress to: done reason: merged_validated field: state -->
## State changed
Reviewer approved after requested fixes, implementation branch merged into the orchestration branch, and focused validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch.
---

View File

@ -0,0 +1,27 @@
# E2E evidence for Ticket 00001KV3BQ7Q3
Validation date: 2026-06-14
Worktree: `/home/hare/Projects/yoi/.worktree/00001KV3BQ7Q3-panel-e2e-evidence`
Branch: `impl/00001KV3BQ7Q3-panel-e2e-evidence`
## Summary
| Target merge behavior | Status | E2E scenario / assertion |
| --- | --- | --- |
| `802fa1f00f8725fe35336e083cd05652fee1409e` / `merge: rewind live refresh` | Pass for current fixture PTY E2E | `single_pod_rewind_picker_applies_without_escape_and_suppresses_duplicate_enter` spawns the real `yoi` binary under PTY, opens the rewind picker, applies the target without Esc/restart/restore, observes `rewind_applied` with restored composer text, and now also waits for the post-apply PTY stream to contain the unique live composer marker `rewind-live-refresh`. |
| `02311883f7cda116676d8e179a14ad0be9e7a244` / `merge: panel mouse selection` | Pass for current fixture PTY E2E | `panel_mouse_click_selects_row_without_dispatching_action` spawns the real `yoi panel` PTY path, injects SGR mouse click input, observes `selection_changed` for the clicked row, and asserts no `action_requested` event was emitted by click alone. |
| `db7bad7a64766c2039a4c10781801cb571027955` / `merge: panel quit latency` | Pass for bounded current fixture PTY E2E; original live-terminal latency remains outside this fixture | `panel_ctrl_c_exits_promptly_after_background_barrier` spawns the real `yoi panel` PTY path with a held `reload` background task, confirms that task is pending, sends Ctrl-C, and asserts clean process exit within `PanelHarness::default_exit_wait()` (1500 ms) plus `quit_requested` and `background_task_aborted { task: "reload" }` events. This guarantees that pending fixture background reload work is aborted and does not block quit past the threshold; it does not prove arbitrary live-terminal latency outside this fixture. |
## Commands and results
- `cargo fmt --check` — passed.
- `cargo test -p yoi-e2e --features e2e --no-run` — passed; built `yoi-e2e` unit/integration test executables.
- `cargo test -p yoi-e2e --features e2e` — passed: `yoi_e2e` unit test 1/1, `panel` integration tests 3/3, `rewind` integration test 1/1, doc-tests 0.
- `cargo check -p yoi-e2e -p yoi -p tui` — passed.
- `git diff --check` — passed.
## Residual gaps / non-claims
- These are automated fixture PTY confirmations only. They are not manual/live-terminal validation.
- The mouse path uses the harness's SGR mouse injection through a PTY. It confirms the real `yoi panel` process path receives and handles the encoded click as intended, but it is not a hardware/terminal-emulator compatibility matrix.
- The quit-latency assertion is bounded to the fixture's held `reload` task and 1500 ms threshold. It confirms pending fixture background work does not user-visibly block quit beyond that bound, but does not independently reproduce every historical live latency observation.

View File

@ -1,2 +1,3 @@
{"id":"orch-plan-20260614-153704-1","ticket_id":"00001KV3BQ7Q3","kind":"waiting_capacity_note","note":"現在2件の Coder Pod が running。さらに本 Ticket は現行 HEAD の Panel/TUI E2E evidence を扱うため、先行 Panel/TUI implementation branch の integration 後に、検証対象 HEAD を明確化してから acceptance する。","author":"yoi-orchestrator","at":"2026-06-14T15:37:04Z"}
{"id":"orch-plan-20260614-153704-2","ticket_id":"00001KV3BQ7Q3","kind":"after","related_ticket":"00001KV09WYC6","note":"E2E evidence 対象の現行 HEAD を曖昧にしないため、少なくとも active な Panel display implementation (`00001KV09WYC6`) の outcome 確認後に開始する。","author":"yoi-orchestrator","at":"2026-06-14T15:37:04Z"}
{"id":"orch-plan-20260614-163914-3","ticket_id":"00001KV3BQ7Q3","kind":"accepted_plan","accepted_plan":{"summary":"Accept TUI/Panel E2E evidence Ticket after prior Panel implementation Tickets are integrated and done. Validate current orchestration HEAD behavior using existing/updated yoi-e2e scenarios and record pass/fail/gap evidence.","branch":"impl/00001KV3BQ7Q3-panel-e2e-evidence","worktree":"/home/hare/Projects/yoi/.worktree/00001KV3BQ7Q3-panel-e2e-evidence","role_plan":"Orchestrator creates a dedicated implementation/validation worktree and spawns a Coder with write scope limited to that worktree. Coder should run/add minimal E2E evidence and commit test/doc/evidence changes as needed. Reviewer will run read-only after implementation report."},"author":"yoi-orchestrator","at":"2026-06-14T16:39:14Z"}

View File

@ -1,8 +1,8 @@
---
title: '対象 TUI/Panel merge commit の挙動を現行 E2E で確認する'
state: 'queued'
state: 'done'
created_at: '2026-06-14T15:24:05Z'
updated_at: '2026-06-14T15:37:04Z'
updated_at: '2026-06-14T16:54:05Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['e2e', 'tui', 'panel', 'regression-evidence']

View File

@ -39,3 +39,200 @@ Next action:
- planning return ではなく queued のまま waiting とする。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-14T16:39:14Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Prior waiting reason は Panel/TUI surface の先行 implementation branch により E2E evidence 対象 HEAD が曖昧になることだった。
- `00001KV09WYC6``00001KV3A5CNH` は reviewer approve、orchestration branch merge、focused validation、Ticket `done` まで完了した。
- Ticket body / thread / relations / orchestration plan / current Orchestrator workspace を再確認した。blocking relation はなく、planning に戻す concrete missing information はない。
- 本 Ticket は validation/evidence work item であり、現行 orchestration HEAD で対象 TUI/Panel behavior を E2E で pass/fail/coverage-gap として記録する作業に閉じている。
Evidence checked:
- Ticket body/thread: 対象 commit 3件、確認すべき user-visible behavior、acceptance criteria、binding decisions、escalation conditions、validation command を確認。
- Ticket relations: blocker なし。
- OrchestrationPlan: prior waiting note と `after 00001KV09WYC6` を確認。先行 Panel work は完了済み。
- Orchestrator workspace: `/home/hare/Projects/yoi/.worktree/orchestration` は clean、`765e6e8e` 上。
- Visible Pods: child Pod なし。
IntentPacket:
Intent:
- 現行 orchestration HEAD / 現行 E2E infrastructure で、対象 TUI/Panel merge commit が意図した user-visible behavior を実プロセス PTY 経路で確認し、pass/fail/coverage-gap を明示する。
Binding decisions / invariants:
- focused unit test / code review だけで user-visible TUI/Panel behavior を確認済み扱いにしない。
- E2E pass と manual/live user confirmation を混同しない。
- 現行 E2E が確認していない behavior を pass と書かない。
- historical merge decision を書き換えず、現行状態の evidence を追加する。
- 主目的は validation/evidence 整理であり、対象 behavior の大きな再設計や unrelated fix はしない。
- E2E 追加・更新時は fixture-local HOME/XDG/runtime/workspace isolation と no-provider/no-network 前提を維持する。
Requirements / acceptance criteria:
- `802fa1f0`, `02311883`, `db7bad7a` の3件それぞれについて、現行 E2E での確認結果を pass / fail / coverage gap として記録する。
- pass の場合は E2E test 名、assertion、command、結果を記録する。
- coverage gap の場合は何が確認できないか、追加 E2E か manual/live validation が必要かを記録する。
- mouse selection は実 `yoi` binary + PTY 経路で user-visible observer を確認する。
- quit latency は process exit だけでなく pending work / threshold / latency 観点で何を保証したか明示する。
- rewind live refresh は restart/restore なしの live 表示更新が E2E で確認されるか、不足を明示する。
- `cargo test -p yoi-e2e --features e2e` または同等の現行 E2E command を実行し結果を記録する。
Implementation latitude:
- 既存 `yoi-e2e` scenario の再利用、test name/assertion の明確化、最小限の scenario 追加・更新は Coder 判断。
- 不足する observer/helper は production behavior に影響しない `e2e-test` feature gate 配下で追加可。
- latency threshold は既存 E2E 基準を優先し、変更が必要なら理由を報告する。
- flake の hardening と behavior fix を混同しない。
Escalate if:
- 現行 E2E infrastructure では原理的に確認不能で、実端末 manual validation や新 harness 設計が必要。
- latency の測定が既存 observer / threshold では意味を持たない。
- PTY SGR injection と実端末 mouse 操作に乖離疑いが残る。
- actual regression が見つかり、validation Ticket の範囲を超える修正が必要。
Validation:
- `cargo test -p yoi-e2e --features e2e` または必要な narrow E2E command。
- E2E 追加・更新時は `cargo test -p yoi-e2e --no-run`, `cargo fmt --check`, `git diff --check`
- 変更範囲に応じて `cargo check -p yoi-e2e -p yoi -p tui`
Critical risks / reviewer focus:
- PTY / real binary 経路であること。
- unit/focused test と E2E evidence の混同防止。
- coverage gap を pass と偽らないこと。
- E2E fixture isolation / no-provider / no-network 維持。
- recently merged Panel changes (`00001KV09WYC6`, `00001KV3A5CNH`) 後の現行 HEAD に対する evidence であること。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-14T16:39:19Z from: queued to: inprogress reason: orchestrator_acceptance_after_panel_head_stabilized field: state -->
## State changed
Routing decision と accepted implementation/evidence plan を記録済み。先行 Panel/TUI implementation Tickets は merge/validation/done 済みで、prior waiting reason は解消。blocking relation / unresolved orchestration-plan blocker はないため、E2E validation side effects の前に `queued -> inprogress` acceptance を記録する。
---
<!-- event: implementation_report author: hare at: 2026-06-14T16:45:57Z -->
## Implementation report
# E2E evidence for Ticket 00001KV3BQ7Q3
Validation date: 2026-06-14
Worktree: `/home/hare/Projects/yoi/.worktree/00001KV3BQ7Q3-panel-e2e-evidence`
Branch: `impl/00001KV3BQ7Q3-panel-e2e-evidence`
## Summary
| Target merge behavior | Status | E2E scenario / assertion |
| --- | --- | --- |
| `802fa1f00f8725fe35336e083cd05652fee1409e` / `merge: rewind live refresh` | Pass for current fixture PTY E2E | `single_pod_rewind_picker_applies_without_escape_and_suppresses_duplicate_enter` spawns the real `yoi` binary under PTY, opens the rewind picker, applies the target without Esc/restart/restore, observes `rewind_applied` with restored composer text, and now also waits for the post-apply PTY stream to contain the unique live composer marker `rewind-live-refresh`. |
| `02311883f7cda116676d8e179a14ad0be9e7a244` / `merge: panel mouse selection` | Pass for current fixture PTY E2E | `panel_mouse_click_selects_row_without_dispatching_action` spawns the real `yoi panel` PTY path, injects SGR mouse click input, observes `selection_changed` for the clicked row, and asserts no `action_requested` event was emitted by click alone. |
| `db7bad7a64766c2039a4c10781801cb571027955` / `merge: panel quit latency` | Pass for bounded current fixture PTY E2E; original live-terminal latency remains outside this fixture | `panel_ctrl_c_exits_promptly_after_background_barrier` spawns the real `yoi panel` PTY path with a held `reload` background task, confirms that task is pending, sends Ctrl-C, and asserts clean process exit within `PanelHarness::default_exit_wait()` (1500 ms) plus `quit_requested` and `background_task_aborted { task: "reload" }` events. This guarantees that pending fixture background reload work is aborted and does not block quit past the threshold; it does not prove arbitrary live-terminal latency outside this fixture. |
## Commands and results
- `cargo fmt --check` — passed.
- `cargo test -p yoi-e2e --features e2e --no-run` — passed; built `yoi-e2e` unit/integration test executables.
- `cargo test -p yoi-e2e --features e2e` — passed: `yoi_e2e` unit test 1/1, `panel` integration tests 3/3, `rewind` integration test 1/1, doc-tests 0.
- `cargo check -p yoi-e2e -p yoi -p tui` — passed.
- `git diff --check` — passed.
## Residual gaps / non-claims
- These are automated fixture PTY confirmations only. They are not manual/live-terminal validation.
- The mouse path uses the harness's SGR mouse injection through a PTY. It confirms the real `yoi panel` process path receives and handles the encoded click as intended, but it is not a hardware/terminal-emulator compatibility matrix.
- The quit-latency assertion is bounded to the fixture's held `reload` task and 1500 ms threshold. It confirms pending fixture background work does not user-visibly block quit beyond that bound, but does not independently reproduce every historical live latency observation.
---
<!-- event: review author: yoi-reviewer-00001KV3BQ7Q3 at: 2026-06-14T16:53:51Z status: approve -->
## Review: approve
Review result: approve
Evidence checked:
- Worktree/branch/commit:
- `/home/hare/Projects/yoi/.worktree/00001KV3BQ7Q3-panel-e2e-evidence`
- `impl/00001KV3BQ7Q3-panel-e2e-evidence`
- HEAD `1f07e57a test: record panel tui e2e evidence`
- base/merge-base `5e81bc38`
- Ticket intent and acceptance criteria in `.yoi/tickets/00001KV3BQ7Q3/item.md`.
- Implementation/evidence changes in:
- `.yoi/tickets/00001KV3BQ7Q3/artifacts/e2e-evidence.md`
- `.yoi/tickets/00001KV3BQ7Q3/thread.md`
- `crates/tui/src/single_pod.rs`
- `tests/e2e/src/lib.rs`
- `tests/e2e/tests/panel.rs`
- `tests/e2e/tests/rewind.rs`
Findings:
- `802fa1f0` / rewind live refresh: strengthened `single_pod_rewind_picker_applies_without_escape_and_suppresses_duplicate_enter` uses real `yoi` PTY fixture, applies rewind, observes `rewind_applied`, verifies restored composer text `rewind-live-refresh`, and waits for raw PTY output after the pre-rewind offset to contain that marker. This satisfies fixture PTY E2E evidence for restart/restore-free live display update.
- `02311883` / panel mouse selection: existing `panel_mouse_click_selects_row_without_dispatching_action` uses real `yoi panel` PTY, SGR click injection, observes selection change, and asserts no `action_requested` from click alone. This is not just unit/code review coverage.
- `db7bad7a` / panel quit latency: strengthened `panel_ctrl_c_exits_promptly_after_background_barrier` confirms pending `reload`, sends Ctrl-C, requires clean exit within `PanelHarness::default_exit_wait()` / 1500 ms, and asserts `background_task_aborted { task: "reload" }`. The evidence states the bounded guarantee precisely and does not overclaim arbitrary live-terminal latency.
Residual gap representation:
- Correctly represented. The evidence distinguishes automated fixture PTY E2E from manual/live-terminal validation, describes mouse coverage as SGR PTY injection rather than terminal-emulator matrix, and keeps the original live-terminal latency gap outside the fixture guarantee.
Validation performed by reviewer:
- Passed: `git diff --check 5e81bc38..HEAD`
- Passed: `cargo fmt --check`
- `git status --porcelain=v1` was clean.
Validation not rerun by reviewer:
- E2E tests/checks were not rerun in reviewer because they write to build/artifact locations. Coder-reported passing results were reviewed as evidence.
Conclusion:
- Approved. No blocker remains.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-14T16:54:05Z -->
## Implementation report
Integration report:
Merged implementation/evidence branch into orchestration branch:
- branch: `impl/00001KV3BQ7Q3-panel-e2e-evidence`
- implementation commit: `1f07e57a test: record panel tui e2e evidence`
- merge commit: `b5e7ca98 merge: panel e2e evidence`
Reviewer result:
- `approve` recorded from `yoi-reviewer-00001KV3BQ7Q3`.
Validation in Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration`:
- Passed: `cargo fmt --check`
- Passed: `cargo test -p yoi-e2e --features e2e --no-run`
- Passed: `cargo test -p yoi-e2e --features e2e`
- Passed: `cargo check -p yoi-e2e -p yoi -p tui`
- Passed: `git diff --check`
Per-target evidence outcome:
- `802fa1f0` / rewind live refresh: pass for current fixture PTY E2E. Strengthened rewind E2E verifies post-apply PTY output contains the unique restored composer marker without restart/restore.
- `02311883` / panel mouse selection: pass for current fixture PTY E2E. Existing Panel mouse E2E covers real `yoi panel` PTY, SGR click selection change, and no action dispatch on click alone.
- `db7bad7a` / panel quit latency: pass for bounded fixture PTY E2E with residual live-terminal gap. Strengthened Panel quit E2E confirms pending reload background task is aborted and quit completes within the 1500 ms fixture threshold.
Residual gaps:
- Evidence is automated fixture PTY E2E, not manual/live-terminal validation.
- Mouse coverage is SGR PTY injection through real process path, not a terminal-emulator compatibility matrix.
- Quit latency evidence is bounded to fixture-held reload work and threshold, not every historical live latency scenario.
Cleanup planned:
- Stop related coder/reviewer Pods.
- Remove only child implementation worktree/branch for this Ticket.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-14T16:54:05Z from: inprogress to: done reason: merged_validated field: state -->
## State changed
Reviewer approved, implementation/evidence branch merged into the orchestration branch, and E2E-focused validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch.
---

View File

@ -0,0 +1,736 @@
//! Durable active workflow invocation state.
//!
//! Workflow bodies are resolved at invocation time and snapshotted here. The
//! snapshot, not whatever resource version is installed later, is the procedural
//! authority that survives compaction for the currently governed task.
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use llm_worker::Item;
use llm_worker::tool::{
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use session_store::{LogEntry, SystemItem, segment_log};
pub const DOMAIN: &str = "pod.active_workflows";
pub const REHYDRATION_MESSAGE_PREFIX: &str = "[Active workflow snapshot]";
pub const INACTIVE_MESSAGE_PREFIX: &str = "[Active workflow state]";
const SCHEMA_VERSION: u32 = 1;
pub type LogEntryCommitter = Arc<dyn Fn(LogEntry) + Send + Sync>;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActiveWorkflowSnapshot {
pub schema_version: u32,
pub workflows: Vec<ActiveWorkflowRecord>,
}
impl Default for ActiveWorkflowSnapshot {
fn default() -> Self {
Self {
schema_version: SCHEMA_VERSION,
workflows: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActiveWorkflowRecord {
pub slug: String,
pub status: ActiveWorkflowStatus,
pub invocation: WorkflowInvocationInfo,
pub task_scope: String,
pub body_snapshot_policy: WorkflowBodySnapshotPolicy,
pub guidance_snapshot: String,
pub obligations: Vec<String>,
pub checkpoints: Vec<WorkflowCheckpoint>,
pub updated_at_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completion: Option<WorkflowCompletionInfo>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ActiveWorkflowStatus {
Active,
Completed,
Cancelled,
}
impl std::fmt::Display for ActiveWorkflowStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::Active => "active",
Self::Completed => "completed",
Self::Cancelled => "cancelled",
};
f.write_str(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkflowInvocationInfo {
pub source: WorkflowInvocationSource,
pub invoked_at_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkflowInvocationSource {
UserWorkflowInvokeSegment,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkflowBodySnapshotPolicy {
SnapshottedAtInvocation,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkflowCheckpoint {
pub label: String,
pub status: WorkflowCheckpointStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkflowCheckpointStatus {
Open,
Done,
Cancelled,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkflowCompletionInfo {
pub completed_at_ms: u64,
pub reason: String,
}
#[derive(Debug, Clone, Default)]
pub struct ActiveWorkflowStore {
inner: Arc<Mutex<ActiveWorkflowSnapshot>>,
}
impl ActiveWorkflowStore {
pub fn new() -> Self {
Self::default()
}
pub fn snapshot(&self) -> ActiveWorkflowSnapshot {
self.inner.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
pub fn replace_with(&self, snapshot: ActiveWorkflowSnapshot) {
*self.inner.lock().unwrap_or_else(|e| e.into_inner()) = snapshot;
}
pub fn active_records(&self) -> Vec<ActiveWorkflowRecord> {
self.snapshot()
.workflows
.into_iter()
.filter(|record| record.status == ActiveWorkflowStatus::Active)
.collect()
}
pub fn activate_from_system_items(
&self,
items: &[SystemItem],
task_scope: String,
invoked_at_ms: u64,
) -> bool {
let mut grouped: BTreeMap<String, Vec<String>> = BTreeMap::new();
for item in items {
if let SystemItem::Workflow { slug, body } = item {
grouped.entry(slug.clone()).or_default().push(body.clone());
}
}
if grouped.is_empty() {
return false;
}
let mut snapshot = self.snapshot();
snapshot.schema_version = SCHEMA_VERSION;
for (slug, bodies) in grouped {
let guidance_snapshot = bodies.join("\n\n---\n\n");
let obligations = extract_obligations(&guidance_snapshot);
let checkpoints = obligations
.iter()
.take(32)
.map(|label| WorkflowCheckpoint {
label: label.clone(),
status: WorkflowCheckpointStatus::Open,
})
.collect();
let record = ActiveWorkflowRecord {
slug: slug.clone(),
status: ActiveWorkflowStatus::Active,
invocation: WorkflowInvocationInfo {
source: WorkflowInvocationSource::UserWorkflowInvokeSegment,
invoked_at_ms,
},
task_scope: truncate_chars(&task_scope, 2_000),
body_snapshot_policy: WorkflowBodySnapshotPolicy::SnapshottedAtInvocation,
guidance_snapshot,
obligations,
checkpoints,
updated_at_ms: invoked_at_ms,
completion: None,
};
upsert_record(&mut snapshot.workflows, record);
}
self.replace_with(snapshot);
true
}
pub fn set_status(
&self,
slug: &str,
status: ActiveWorkflowStatus,
reason: String,
now_ms: u64,
) -> Result<ActiveWorkflowRecord, String> {
let mut snapshot = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let record = snapshot
.workflows
.iter_mut()
.find(|record| record.slug == slug)
.ok_or_else(|| format!("active workflow `{slug}` not found"))?;
record.status = status;
record.updated_at_ms = now_ms;
record.completion = Some(WorkflowCompletionInfo {
completed_at_ms: now_ms,
reason,
});
for checkpoint in &mut record.checkpoints {
checkpoint.status = match status {
ActiveWorkflowStatus::Active => WorkflowCheckpointStatus::Open,
ActiveWorkflowStatus::Completed => WorkflowCheckpointStatus::Done,
ActiveWorkflowStatus::Cancelled => WorkflowCheckpointStatus::Cancelled,
};
}
Ok(record.clone())
}
pub fn snapshot_text(&self) -> Option<String> {
let active = self.active_records();
(!active.is_empty()).then(|| render_snapshot_text(&active))
}
pub fn rehydration_message(&self) -> Option<String> {
let active = self.active_records();
(!active.is_empty()).then(|| render_rehydration_message(&active))
}
pub fn sanitize_context(&self, context: &mut Vec<Item>) -> usize {
let removed = strip_rehydration_messages(context);
if let Some(message) = self.rehydration_message() {
context.push(Item::system_message(message));
} else if removed > 0 || context.iter().any(has_active_workflow_hint) {
context.push(Item::system_message(inactive_workflow_message()));
}
removed
}
pub fn extension_entry(&self) -> LogEntry {
LogEntry::Extension {
ts: segment_log::now_millis(),
domain: DOMAIN.into(),
payload: serde_json::to_value(self.snapshot())
.expect("ActiveWorkflowSnapshot is always JSON-serializable"),
}
}
pub fn restore_from_history_and_extensions(
&self,
_history: &[Item],
extensions: &[(String, serde_json::Value)],
) {
let (snapshot, diagnostics) = fold_extensions(extensions);
for diagnostic in diagnostics {
tracing::warn!(diagnostic, "failed to restore active workflow state");
}
self.replace_with(snapshot);
}
}
pub fn fold_extensions(
extensions: &[(String, serde_json::Value)],
) -> (ActiveWorkflowSnapshot, Vec<String>) {
let mut latest = None;
let mut diagnostics = Vec::new();
for (domain, payload) in extensions {
if domain != DOMAIN {
continue;
}
match serde_json::from_value::<ActiveWorkflowSnapshot>(payload.clone()) {
Ok(snapshot) if snapshot.schema_version == SCHEMA_VERSION => latest = Some(snapshot),
Ok(snapshot) => {
latest = None;
diagnostics.push(format!(
"unsupported active workflow schema_version {}",
snapshot.schema_version
));
}
Err(err) => {
latest = None;
diagnostics.push(format!("corrupt active workflow payload: {err}"));
}
}
}
(latest.unwrap_or_default(), diagnostics)
}
pub fn strip_rehydration_messages(items: &mut Vec<Item>) -> usize {
let before = items.len();
items.retain(|item| !is_rehydration_message(item));
before - items.len()
}
pub fn is_rehydration_message(item: &Item) -> bool {
item_system_text(item)
.map(|text| text.trim_start().starts_with(REHYDRATION_MESSAGE_PREFIX))
.unwrap_or(false)
}
fn has_active_workflow_hint(item: &Item) -> bool {
item_system_text(item)
.map(|text| {
text.contains("Active Workflow Invocation State")
|| text.contains("ActiveWorkflowStore:")
|| text.contains(REHYDRATION_MESSAGE_PREFIX)
})
.unwrap_or(false)
}
fn item_system_text(item: &Item) -> Option<String> {
match item {
Item::Message { role, content, .. } if *role == llm_worker::Role::System => Some(
content
.iter()
.map(|part| part.as_text())
.collect::<String>(),
),
_ => None,
}
}
fn inactive_workflow_message() -> String {
format!(
"{INACTIVE_MESSAGE_PREFIX}\n\n\
No currently valid active workflow invocation state is active. Ignore older compacted \
history or summaries that appear to describe active workflow obligations; only validated \
typed `{DOMAIN}` records with status `active` establish active workflow guidance."
)
}
pub fn active_workflow_tools(
store: ActiveWorkflowStore,
committer: Option<LogEntryCommitter>,
) -> Vec<ToolDefinition> {
vec![
list_tool(store.clone()),
status_tool(
store.clone(),
ActiveWorkflowStatus::Completed,
committer.clone(),
),
status_tool(store, ActiveWorkflowStatus::Cancelled, committer),
]
}
fn list_tool(store: ActiveWorkflowStore) -> ToolDefinition {
Arc::new(move || {
(
ToolMeta::new("ActiveWorkflowList")
.description("List durable active workflow invocations and their status")
.input_schema(
json!({"type":"object","properties":{},"additionalProperties":false}),
),
Arc::new(ActiveWorkflowListTool {
store: store.clone(),
}) as Arc<dyn Tool>,
)
})
}
fn status_tool(
store: ActiveWorkflowStore,
status: ActiveWorkflowStatus,
committer: Option<LogEntryCommitter>,
) -> ToolDefinition {
let name = match status {
ActiveWorkflowStatus::Completed => "ActiveWorkflowComplete",
ActiveWorkflowStatus::Cancelled => "ActiveWorkflowCancel",
ActiveWorkflowStatus::Active => unreachable!("active status tool is not exposed"),
};
let description = match status {
ActiveWorkflowStatus::Completed => {
"Mark an active workflow as completed when its governed task is finished"
}
ActiveWorkflowStatus::Cancelled => {
"Cancel an active workflow when the governed task is explicitly abandoned"
}
ActiveWorkflowStatus::Active => unreachable!("active status tool is not exposed"),
};
let store_for_tool = store.clone();
let committer_for_tool = committer.clone();
Arc::new(move || {
(
ToolMeta::new(name)
.description(description)
.input_schema(json!({
"type":"object",
"properties":{
"slug":{"type":"string","description":"Workflow slug to update"},
"reason":{"type":"string","description":"Brief completion/cancellation reason"}
},
"required":["slug"],
"additionalProperties":false
})),
Arc::new(ActiveWorkflowStatusTool {
store: store_for_tool.clone(),
status,
committer: committer_for_tool.clone(),
}) as Arc<dyn Tool>,
)
})
}
struct ActiveWorkflowListTool {
store: ActiveWorkflowStore,
}
#[async_trait]
impl Tool for ActiveWorkflowListTool {
async fn execute(
&self,
_input_json: &str,
_ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let snapshot = self.store.snapshot();
let content = serde_json::to_string_pretty(&snapshot)
.map_err(|err| ToolError::Internal(err.to_string()))?;
let active = snapshot
.workflows
.iter()
.filter(|record| record.status == ActiveWorkflowStatus::Active)
.count();
Ok(ToolOutput {
summary: format!(
"ActiveWorkflowStore: {} workflow(s), {active} active",
snapshot.workflows.len()
),
content: Some(content),
})
}
}
struct ActiveWorkflowStatusTool {
store: ActiveWorkflowStore,
status: ActiveWorkflowStatus,
committer: Option<LogEntryCommitter>,
}
#[async_trait]
impl Tool for ActiveWorkflowStatusTool {
async fn execute(
&self,
input_json: &str,
_ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: WorkflowStatusParams = serde_json::from_str(input_json)
.map_err(|err| ToolError::InvalidArgument(err.to_string()))?;
let reason = params.reason.unwrap_or_else(|| self.status.to_string());
let record = self
.store
.set_status(&params.slug, self.status, reason, segment_log::now_millis())
.map_err(ToolError::InvalidArgument)?;
if let Some(committer) = &self.committer {
committer(self.store.extension_entry());
}
let content = serde_json::to_string_pretty(&record)
.map_err(|err| ToolError::Internal(err.to_string()))?;
Ok(ToolOutput {
summary: format!("workflow {} marked {}", record.slug, record.status),
content: Some(content),
})
}
}
#[derive(Debug, Deserialize)]
struct WorkflowStatusParams {
slug: String,
#[serde(default)]
reason: Option<String>,
}
fn upsert_record(records: &mut Vec<ActiveWorkflowRecord>, record: ActiveWorkflowRecord) {
if let Some(existing) = records
.iter_mut()
.find(|existing| existing.slug == record.slug)
{
*existing = record;
} else {
records.push(record);
}
}
fn extract_obligations(body: &str) -> Vec<String> {
let mut obligations = Vec::new();
for line in body.lines() {
let trimmed = line.trim();
let candidate = trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
.or_else(|| trimmed.strip_prefix(""))
.unwrap_or(trimmed);
let lower = candidate.to_ascii_lowercase();
let looks_obligating = lower.contains("must")
|| lower.contains("require")
|| lower.contains("obligation")
|| lower.contains("review")
|| lower.contains("merge")
|| lower.contains("close")
|| lower.contains("report")
|| lower.contains("handoff");
if looks_obligating && !candidate.is_empty() {
obligations.push(truncate_chars(candidate, 240));
}
if obligations.len() >= 32 {
break;
}
}
if obligations.is_empty() {
obligations
.push("Follow the snapshotted workflow body until completion or cancellation".into());
}
obligations
}
fn render_snapshot_text(records: &[ActiveWorkflowRecord]) -> String {
let json = serde_json::to_string_pretty(&ActiveWorkflowSnapshot {
schema_version: SCHEMA_VERSION,
workflows: records.to_vec(),
})
.unwrap_or_else(|_| String::from("{\"schema_version\":1,\"workflows\":[]}"));
format!(
"ActiveWorkflowStore: {} active workflow(s)\n\n```json\n{}\n```",
records.len(),
json
)
}
fn render_rehydration_message(records: &[ActiveWorkflowRecord]) -> String {
let mut out = format!(
"{REHYDRATION_MESSAGE_PREFIX}\n\n\
The following workflow invocation state is durable state carried across compaction. \
Continue to follow each active workflow's snapshotted guidance until the governed task \
is completed with ActiveWorkflowComplete or explicitly cancelled with ActiveWorkflowCancel. \
Missing or obsolete workflow resources must not replace these invocation snapshots.\n"
);
for record in records {
out.push_str(&format!(
"\n## /{} ({})\n- invoked_at_ms: {}\n- invocation_source: {:?}\n- body_snapshot_policy: {:?}\n- task_scope: {}\n\n### Current obligations/checkpoints\n",
record.slug,
record.status,
record.invocation.invoked_at_ms,
record.invocation.source,
record.body_snapshot_policy,
record.task_scope.replace('\n', " "),
));
for checkpoint in &record.checkpoints {
out.push_str(&format!(
"- [{}] {}\n",
checkpoint.status_label(),
checkpoint.label
));
}
out.push_str("\n### Snapshotted workflow guidance\n");
out.push_str(record.guidance_snapshot.trim_end());
out.push_str("\n");
}
out
}
impl WorkflowCheckpoint {
fn status_label(&self) -> &'static str {
match self.status {
WorkflowCheckpointStatus::Open => "open",
WorkflowCheckpointStatus::Done => "done",
WorkflowCheckpointStatus::Cancelled => "cancelled",
}
}
}
fn truncate_chars(text: &str, max_chars: usize) -> String {
let mut out = String::new();
for (idx, ch) in text.chars().enumerate() {
if idx >= max_chars {
out.push('…');
return out;
}
out.push(ch);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn store_with_active_workflow() -> ActiveWorkflowStore {
let store = ActiveWorkflowStore::new();
assert!(store.activate_from_system_items(
&[SystemItem::Workflow {
slug: "multi-agent-workflow".into(),
body: "# Multi-agent workflow\n- Delegate implementation to coder.\n- Require external review before merge.\n- Close the Ticket after merge and report evidence.\n".into(),
}],
"/multi-agent-workflow implement ticket".into(),
42,
));
store
}
fn active_extension(store: &ActiveWorkflowStore) -> (String, serde_json::Value) {
(
DOMAIN.to_string(),
serde_json::to_value(store.snapshot()).expect("snapshot json"),
)
}
#[test]
fn active_workflow_guidance_carries_merge_close_obligations() {
let store = store_with_active_workflow();
let msg = store.rehydration_message().unwrap();
assert!(msg.contains("multi-agent-workflow"));
assert!(msg.contains("external review before merge"));
assert!(msg.contains("Close the Ticket after merge"));
assert!(msg.contains("Snapshotted workflow guidance"));
}
#[test]
fn compacted_rehydration_message_is_removed_when_typed_state_missing_or_invalid() {
for extensions in [
Vec::new(),
vec![(DOMAIN.to_string(), json!({"schema_version":"bad"}))],
vec![(
DOMAIN.to_string(),
json!({"schema_version":999,"workflows":[]}),
)],
] {
let original = store_with_active_workflow();
let stale_message = original.rehydration_message().unwrap();
let mut context = vec![
Item::system_message(stale_message),
Item::user_message("continue"),
];
let restored = ActiveWorkflowStore::new();
restored.restore_from_history_and_extensions(&context, &extensions);
let removed = restored.sanitize_context(&mut context);
assert_eq!(removed, 1);
assert!(restored.active_records().is_empty());
assert!(!context.iter().any(is_rehydration_message));
}
}
#[test]
fn completion_or_cancellation_suppresses_old_compacted_guidance() {
for status in [
ActiveWorkflowStatus::Completed,
ActiveWorkflowStatus::Cancelled,
] {
let store = store_with_active_workflow();
let stale_message = store.rehydration_message().unwrap();
let mut context = vec![
Item::system_message(stale_message),
Item::user_message("continue"),
];
store
.set_status("multi-agent-workflow", status, status.to_string(), 84)
.expect("workflow exists");
let removed = store.sanitize_context(&mut context);
assert_eq!(removed, 1);
assert!(!context.iter().any(is_rehydration_message));
}
}
#[test]
fn unmatched_status_tool_calls_do_not_mutate_restored_state() {
let store = store_with_active_workflow();
let extensions = vec![active_extension(&store)];
let history = vec![
Item::tool_call(
"call-1",
"ActiveWorkflowCancel",
json!({"slug":"multi-agent-workflow","reason":"not durable"}).to_string(),
),
Item::tool_result_error("call-1", "error: failed"),
];
let restored = ActiveWorkflowStore::new();
restored.restore_from_history_and_extensions(&history, &extensions);
assert_eq!(restored.active_records().len(), 1);
assert_eq!(
restored.snapshot().workflows[0].status,
ActiveWorkflowStatus::Active
);
}
#[tokio::test]
async fn status_tool_persists_typed_extension_on_success() {
let store = store_with_active_workflow();
let committed = Arc::new(Mutex::new(Vec::<LogEntry>::new()));
let committed_for_tool = committed.clone();
let tools = active_workflow_tools(
store.clone(),
Some(Arc::new(move |entry| {
committed_for_tool
.lock()
.expect("committed entries mutex poisoned")
.push(entry);
})),
);
let (_, tool) = tools[1]();
tool.execute(
&json!({"slug":"multi-agent-workflow","reason":"review complete"}).to_string(),
ToolExecutionContext::default(),
)
.await
.expect("status tool succeeds");
let committed = committed.lock().expect("committed entries mutex poisoned");
let LogEntry::Extension {
domain, payload, ..
} = committed.last().expect("extension committed")
else {
panic!("expected typed active workflow extension");
};
assert_eq!(domain, DOMAIN);
let snapshot: ActiveWorkflowSnapshot = serde_json::from_value(payload.clone()).unwrap();
assert_eq!(
snapshot.workflows[0].status,
ActiveWorkflowStatus::Completed
);
}
#[test]
fn corrupt_extension_fails_closed_with_diagnostic() {
let entries = vec![(DOMAIN.to_string(), json!({"schema_version":"bad"}))];
let (snapshot, diagnostics) = fold_extensions(&entries);
assert!(snapshot.workflows.is_empty());
assert_eq!(diagnostics.len(), 1);
}
}

View File

@ -22,6 +22,7 @@ use llm_worker::tool::ToolOutput;
use tracing::info;
use tracing::warn;
use crate::active_workflow::ActiveWorkflowStore;
use crate::compact::state::CompactState;
use crate::compact::usage_tracker::UsageTracker;
use session_store::SystemItem;
@ -71,6 +72,10 @@ pub(crate) struct PodInterceptor {
/// worker. `None` in tests / `Pod::new` paths where no writer is
/// attached.
log_writer: Option<Arc<dyn SystemItemCommitter>>,
/// Active workflow state is durable typed Pod state. The interceptor
/// regenerates request-local workflow guidance from this store and strips
/// any stale compacted-history copies before each model request.
active_workflows: ActiveWorkflowStore,
/// Next turn index assigned by `on_prompt_submit`.
next_turn_index: AtomicUsize,
/// Tool calls observed in the current turn (reset on each new prompt).
@ -86,6 +91,7 @@ impl PodInterceptor {
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
prompts: Arc<PromptCatalog>,
log_writer: Option<Arc<dyn SystemItemCommitter>>,
active_workflows: ActiveWorkflowStore,
) -> Self {
Self {
registry,
@ -96,6 +102,7 @@ impl PodInterceptor {
pending_attachments,
prompts,
log_writer,
active_workflows,
next_turn_index: AtomicUsize::new(0),
tool_calls_this_turn: AtomicUsize::new(0),
}
@ -234,6 +241,8 @@ impl Interceptor for PodInterceptor {
}
async fn pre_llm_request(&self, context: &mut Vec<Item>) -> PreRequestAction {
self.active_workflows.sanitize_context(context);
let initial_tokens = self.estimated_tokens(context);
if self.request_threshold_exceeded(initial_tokens, context) {
return PreRequestAction::Yield;
@ -449,11 +458,13 @@ mod tests {
}
impl SystemItemCommitter for RecordingSystemItemCommitter {
fn commit_system_item(&self, item: SystemItem) {
self.committed
.lock()
.expect("committed system-item list poisoned")
.push(item);
fn commit_log_entry(&self, entry: session_store::LogEntry) {
if let session_store::LogEntry::SystemItem { item, .. } = entry {
self.committed
.lock()
.expect("committed system-item list poisoned")
.push(item);
}
}
}
@ -525,6 +536,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
@ -557,6 +569,7 @@ mod tests {
Some(Arc::new(RecordingSystemItemCommitter {
committed: Arc::clone(&committed),
})),
ActiveWorkflowStore::new(),
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
@ -593,6 +606,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
)
.with_usage_tracker(usage_tracker);
let mut ctx = ctx_items;
@ -618,6 +632,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
@ -659,6 +674,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
@ -686,6 +702,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
@ -707,6 +724,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let mut ctx: Vec<Item> = Vec::new();
let action = interceptor.pre_llm_request(&mut ctx).await;
@ -735,6 +753,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
Some(committer),
ActiveWorkflowStore::new(),
);
let mut ctx: Vec<Item> = Vec::new();
@ -782,6 +801,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let mut ctx: Vec<Item> = Vec::new();
@ -839,6 +859,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let mut info = task_tool_call_info("TaskList", serde_json::json!({"scope": "all"}));
@ -886,6 +907,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let info = task_tool_call_info("TaskList", serde_json::json!({}));
let mut result_info = ToolResultInfo {
@ -935,6 +957,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let history = vec![Item::user_message("hi"), Item::assistant_message("done")];
@ -969,6 +992,7 @@ mod tests {
Some(Arc::new(RecordingSystemItemCommitter {
committed: Arc::clone(&committed),
})),
ActiveWorkflowStore::new(),
)
.with_usage_tracker(Arc::clone(&usage_tracker));
@ -1028,6 +1052,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let items = interceptor.pending_history_appends().await;
@ -1065,6 +1090,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let mut ctx: Vec<Item> = vec![Item::user_message("hi")];
let action = interceptor.pre_llm_request(&mut ctx).await;
@ -1095,6 +1121,7 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let mut ctx: Vec<Item> = Vec::new();
let action = interceptor.pre_llm_request(&mut ctx).await;

View File

@ -1,3 +1,4 @@
pub mod active_workflow;
pub mod compact;
pub mod controller;
pub mod discovery;

View File

@ -26,6 +26,7 @@ use manifest::{
ScopeError, ScopeRule, SharedScope, WorkerManifest,
};
use crate::active_workflow::{self, ActiveWorkflowStore};
use crate::compact::state::CompactState;
use crate::compact::usage_tracker::UsageTracker;
use crate::feature::builtin::TaskFeature;
@ -145,6 +146,7 @@ struct EmptyTurnRollbackSnapshot {
usage_history_len: usize,
ai_activity_count: usize,
last_run_interrupted: bool,
active_workflows: active_workflow::ActiveWorkflowSnapshot,
}
fn is_ai_materialized_item(item: &Item) -> bool {
@ -196,20 +198,23 @@ where
/// interceptor commit `SystemItem`s without being generic over the
/// concrete `Store` type.
pub trait SystemItemCommitter: Send + Sync {
fn commit_system_item(&self, item: SystemItem);
fn commit_log_entry(&self, entry: LogEntry);
fn commit_system_item(&self, item: SystemItem) {
self.commit_log_entry(LogEntry::SystemItem {
ts: segment_log::now_millis(),
item,
});
}
}
impl<St> SystemItemCommitter for LogWriterHandle<St>
where
St: Store + Clone + Send + Sync + 'static,
{
fn commit_system_item(&self, item: SystemItem) {
let entry = LogEntry::SystemItem {
ts: segment_log::now_millis(),
item,
};
fn commit_log_entry(&self, entry: LogEntry) {
if let Err(err) = self.append_entry(entry) {
warn!(error = %err, "system item commit failed; dropping");
warn!(error = %err, "session log entry commit failed; dropping");
}
}
}
@ -274,6 +279,10 @@ pub struct Pod<C: LlmClient, St: Store> {
/// the narrow snapshot/restore surface Pod needs for compaction and rewind.
/// Store/reminder ownership stays inside the Task feature module.
task_feature: TaskFeature,
/// Durable state for workflow invocations that are active for the current task.
/// The store is persisted as typed session-log extensions and rehydrated into
/// prompt context during compaction.
active_workflows: ActiveWorkflowStore,
/// Parsed system-prompt template awaiting first-turn materialisation.
/// `Some` until `ensure_system_prompt_materialized` renders it once,
/// then `None` forever — including after compaction.
@ -435,6 +444,7 @@ impl<C: LlmClient + Clone + 'static, St: Store + Clone + 'static> Pod<C, St> {
usage_history: self.usage_history.clone(),
tracker: None,
task_feature: self.task_feature.clone(),
active_workflows: self.active_workflows.clone(),
system_prompt_template: None,
alerter: self.alerter.clone(),
event_tx: self.event_tx.clone(),
@ -618,6 +628,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
usage_history: Arc::new(Mutex::new(Vec::<UsageRecord>::new())),
tracker: None,
task_feature: TaskFeature::new(),
active_workflows: ActiveWorkflowStore::new(),
system_prompt_template: None,
alerter: None,
event_tx: None,
@ -813,7 +824,16 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
registry: FeatureRegistryBuilder,
) -> FeatureRegistryInstallReport {
let worker = self.worker.as_mut().expect("worker taken during run");
registry.install_into_worker(worker, &mut self.hook_builder)
let report = registry.install_into_worker(worker, &mut self.hook_builder);
let active_workflow_committer = self.log_writer.clone().map(|writer| {
Arc::new(move |entry| writer.commit_log_entry(entry))
as active_workflow::LogEntryCommitter
});
worker.register_tools(active_workflow::active_workflow_tools(
self.active_workflows.clone(),
active_workflow_committer,
));
report
}
/// Reference to the store.
@ -876,7 +896,11 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
self.sink.truncate_silent(truncate_entries);
self.task_feature.restore_from_history(&state.history);
self.worker_mut().set_history(state.history);
self.active_workflows
.restore_from_history_and_extensions(&state.history, &state.extensions);
let mut history = state.history;
active_workflow::strip_rehydration_messages(&mut history);
self.worker_mut().set_history(history);
self.worker_mut().set_request_config(state.config);
self.worker_mut().set_turn_count(state.turn_count);
self.worker_mut()
@ -1242,6 +1266,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
self.pending_attachments.clone(),
self.prompts.clone(),
self.log_writer.clone(),
self.active_workflows.clone(),
)
.with_usage_tracker(self.usage_tracker.clone());
self.worker_mut().set_interceptor(interceptor);
@ -1428,6 +1453,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
usage_history_len,
ai_activity_count: self.ai_activity_counter.load(Ordering::SeqCst),
last_run_interrupted: self.worker().last_run_interrupted(),
active_workflows: self.active_workflows.snapshot(),
}
}
@ -1465,6 +1491,8 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
.truncate(snapshot.usage_history_len);
let _ = self.usage_tracker.drain();
let _ = self.metrics_tracker.drain();
self.active_workflows
.replace_with(snapshot.active_workflows);
let loc = self.segment_state.location();
self.store
@ -1535,6 +1563,14 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
let mut attachments = self.resolve_file_refs(&input);
attachments.extend(self.resolve_knowledge_refs(&input));
attachments.extend(self.resolve_workflow_invocations(&input)?);
let flattened = self.flatten_segments(&input);
if self.active_workflows.activate_from_system_items(
&attachments,
flattened.clone(),
segment_log::now_millis(),
) {
self.commit_entry(self.active_workflows.extension_entry())?;
}
if !attachments.is_empty() {
*self
.pending_attachments
@ -1542,8 +1578,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
.expect("pending_attachments poisoned") = attachments;
}
let flattened = self.flatten_segments(&input);
let history_before = self.worker.as_ref().unwrap().history().len();
// lock → run → unlock
@ -2368,8 +2402,10 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
let worker = self.worker.as_ref().expect("worker taken during run");
let history = worker.history();
let retain_from = cut.index.min(history.len());
let retained_items = history[retain_from..].to_vec();
let items_to_summarise = history[..retain_from].to_vec();
let mut retained_items = history[retain_from..].to_vec();
let mut items_to_summarise = history[..retain_from].to_vec();
active_workflow::strip_rehydration_messages(&mut retained_items);
active_workflow::strip_rehydration_messages(&mut items_to_summarise);
// Compaction-related knobs. Fall through to manifest defaults when
// `[compaction]` is omitted entirely.
@ -2428,13 +2464,15 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
.unwrap_or_default();
// Input text fed to the compact worker. Includes the default
// references, current TaskStore snapshot, and the (pruned)
// conversation text.
// references, current TaskStore snapshot, active workflow invocation
// state, and the (pruned) conversation text.
let task_snapshot_text = self.task_feature.snapshot_text();
let active_workflow_snapshot_text = self.active_workflows.snapshot_text();
let summary_input = build_summary_input(
&items_to_summarise,
&default_refs,
Some(task_snapshot_text.as_str()),
active_workflow_snapshot_text.as_deref(),
SummaryInputOptions {
overview_target_tokens,
overview_warning_tokens,
@ -2610,6 +2648,10 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
.count();
// Build new history: [summary, ...auto-read, references, ...retained, task snapshot, TaskList synthetic call/result].
// Active workflow guidance is intentionally not persisted as an ordinary
// compacted-history system message. It is regenerated request-locally
// from typed `pod.active_workflows` extension state so completed,
// cancelled, corrupt, or missing state cannot leak stale obligations.
// The TaskStore snapshot trails the retained items so that, on resume,
// `replay_history` walks any pre-compact Task* calls preserved verbatim
// in retained_items first and the trailing snapshot's `replace_with`
@ -2680,18 +2722,23 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
at_turn_index: source_turn_count,
}),
};
let active_workflow_extension = self.active_workflows.extension_entry();
let initial_entries = vec![entry.clone(), active_workflow_extension.clone()];
self.store
.create_segment(old_loc.session_id, new_segment_id, &[entry.clone()])?;
.create_segment(old_loc.session_id, new_segment_id, &initial_entries)?;
self.segment_state.set_location(SegmentLocation {
session_id: old_loc.session_id,
segment_id: new_segment_id,
});
self.segment_state.set_entries_written(1);
self.segment_state
.set_entries_written(initial_entries.len());
let session_start = entry;
// Broadcast the SegmentStart through the sink. This atomically
// resets the mirror to `[SegmentStart]` so any subscriber
// querying after this point sees the post-compaction prefix.
self.sink.reset_with_initial(session_start);
// resets the mirror to the replacement segment prefix so any subscriber
// querying after this point sees the post-compaction prefix, including
// durable extension state.
self.sink
.reset_with_initial_entries(vec![session_start, active_workflow_extension]);
// Keep pods.json pointing at the live segment_id. Without this
// a concurrent `restore_from_manifest(new_segment_id)` would
// see no live writer and grab the session this Pod just moved
@ -3794,6 +3841,7 @@ where
usage_history: Arc::new(Mutex::new(Vec::new())),
tracker: None,
task_feature: TaskFeature::new(),
active_workflows: ActiveWorkflowStore::new(),
system_prompt_template: common.system_prompt_template,
alerter: None,
event_tx: None,
@ -3902,6 +3950,7 @@ where
usage_history: Arc::new(Mutex::new(Vec::new())),
tracker: None,
task_feature: TaskFeature::new(),
active_workflows: ActiveWorkflowStore::new(),
system_prompt_template: common.system_prompt_template,
alerter: None,
event_tx: None,
@ -4101,7 +4150,9 @@ where
..
})
);
worker.set_history(state.history.clone());
let mut restored_history = state.history.clone();
active_workflow::strip_rehydration_messages(&mut restored_history);
worker.set_history(restored_history);
worker.set_request_config(state.config.clone());
worker.set_turn_count(state.turn_count);
worker.set_last_run_interrupted(state.last_run_interrupted);
@ -4111,6 +4162,8 @@ where
let extract_pointer = memory::extract::fold_pointer(&state.extensions);
let task_feature = TaskFeature::from_history(&state.history);
let active_workflows = ActiveWorkflowStore::new();
active_workflows.restore_from_history_and_extensions(&state.history, &state.extensions);
let pod_metadata_writer = Some(pod_metadata_writer_for_store(&store));
let mut pod = Self {
@ -4131,6 +4184,7 @@ where
usage_history: Arc::new(Mutex::new(state.usage_history)),
tracker: None,
task_feature,
active_workflows,
// Restore replays the saved system_prompt verbatim — no
// template re-render on resume.
system_prompt_template: None,
@ -4335,12 +4389,13 @@ struct SummaryInputBuild {
}
/// Build the compact worker's input: default-reference instructions,
/// the list of recently-touched files, task snapshot, and a bounded overview
/// rather than a prefix-wide transcript.
/// the list of recently-touched files, task snapshot, active workflow snapshot,
/// and a bounded overview rather than a prefix-wide transcript.
fn build_summary_input(
items: &[Item],
default_refs: &[PathBuf],
task_snapshot: Option<&str>,
active_workflow_snapshot: Option<&str>,
options: SummaryInputOptions,
) -> SummaryInputBuild {
let overview = build_summary_overview(
@ -4392,6 +4447,17 @@ fn build_summary_input(
out.push_str(task_snapshot);
out.push_str("\n\n");
}
if let Some(active_workflow_snapshot) = active_workflow_snapshot {
out.push_str(
"## Active Workflow Invocation State\n\
This is durable typed workflow state for workflow-governed tasks. Preserve active \
slugs, invocation scope, status, obligations/checkpoints, and the snapshotted \
workflow guidance in the summary; do not substitute advertised/latest workflow \
resources for this invocation state.\n",
);
out.push_str(active_workflow_snapshot);
out.push_str("\n\n");
}
out.push_str("## Conversation overview/index\n");
out.push_str(&overview);
out.push_str("\n\nWhen you are done, call `write_summary` with the final 5-section text.");
@ -5278,6 +5344,7 @@ mod build_summary_prompt_tests {
items,
&[],
None,
None,
SummaryInputOptions {
overview_target_tokens: 512,
overview_warning_tokens: 1024,
@ -5326,6 +5393,27 @@ mod build_summary_prompt_tests {
assert!(!prompt.contains("deliberation"));
}
#[test]
fn includes_active_workflow_snapshot_section() {
let prompt = build_summary_input(
&[Item::user_message("continue after review")],
&[],
None,
Some("ActiveWorkflowStore: 1 active workflow\n- review before merge\n- close ticket"),
SummaryInputOptions {
overview_target_tokens: 512,
overview_warning_tokens: 1024,
overview_deadline_tokens: 2048,
summary_target_tokens: 256,
},
)
.text;
assert!(prompt.contains("## Active Workflow Invocation State"));
assert!(prompt.contains("review before merge"));
assert!(prompt.contains("close ticket"));
}
#[test]
fn overview_warning_does_not_drop_input() {
let items = vec![Item::user_message("x".repeat(4_000))];
@ -5333,6 +5421,7 @@ mod build_summary_prompt_tests {
&items,
&[],
None,
None,
SummaryInputOptions {
overview_target_tokens: 10,
overview_warning_tokens: 100,
@ -5352,6 +5441,7 @@ mod build_summary_prompt_tests {
&items,
&[],
None,
None,
SummaryInputOptions {
overview_target_tokens: 10,
overview_warning_tokens: 10,

View File

@ -147,6 +147,24 @@ impl SegmentLogSink {
let _ = self.inner.broadcast_tx.send(initial);
}
/// Atomically swap the mirror to the supplied replacement-session prefix
/// and broadcast the first entry as the live rotation signal. Entries after
/// the first are already reflected in reconnect snapshots but are not
/// broadcast live; this is intended for non-live extension state that must
/// share the new segment prefix with SegmentStart.
pub fn reset_with_initial_entries(&self, entries: Vec<LogEntry>) {
let first = entries.first().cloned();
let mut mirror = self
.inner
.mirror
.lock()
.expect("session log mirror mutex poisoned");
*mirror = entries;
if let Some(initial) = first {
let _ = self.inner.broadcast_tx.send(initial);
}
}
/// Replace the mirror with the supplied prefix without broadcasting.
///
/// Used by restore paths that load a session's complete log into

View File

@ -765,6 +765,24 @@ pub struct TicketSummary {
pub updated_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketInvalidRecord {
pub label: String,
pub reason: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TicketPartialList {
pub tickets: Vec<TicketSummary>,
pub invalid_records: Vec<TicketInvalidRecord>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketPartial {
pub ticket: Ticket,
pub invalid_records: Vec<TicketInvalidRecord>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketDocument {
pub body: MarkdownText,
@ -932,6 +950,49 @@ impl LocalTicketBackend {
}
}
pub fn list_partial(&self, filter: TicketFilter) -> Result<TicketPartialList> {
let mut output = TicketPartialList::default();
let mut invalid_seen = BTreeSet::new();
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
let item = dir.join("item.md");
if !item.exists() {
continue;
}
match read_item_file(&item)
.and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter))
{
Ok(meta) => {
if filter
.state
.is_some_and(|state| meta.workflow_state != state)
{
continue;
}
output.tickets.push(ticket_summary_from_meta(meta));
}
Err(error) => push_invalid_ticket_record(
&mut output.invalid_records,
&mut invalid_seen,
&dir,
&error,
),
}
}
Ok(output)
}
pub fn show_partial(&self, id: TicketIdOrSlug) -> Result<TicketPartial> {
let dir = self.find_ticket_dir(&id)?;
let mut invalid_records = Vec::new();
let mut invalid_seen = BTreeSet::new();
let ticket =
self.ticket_from_dir_tolerant(&dir, &mut invalid_records, &mut invalid_seen)?;
Ok(TicketPartial {
ticket,
invalid_records,
})
}
fn generated_heading(&self, default: &'static str, japanese: &'static str) -> &'static str {
if is_japanese_record_language(self.record_language()) {
japanese
@ -1045,6 +1106,27 @@ impl LocalTicketBackend {
}
fn ticket_from_dir(&self, dir: &Path) -> Result<Ticket> {
self.ticket_from_dir_with_relations(dir, |backend, meta| {
backend.relation_view_for_meta(meta)
})
}
fn ticket_from_dir_tolerant(
&self,
dir: &Path,
invalid_records: &mut Vec<TicketInvalidRecord>,
invalid_seen: &mut BTreeSet<String>,
) -> Result<Ticket> {
self.ticket_from_dir_with_relations(dir, |backend, meta| {
backend.relation_view_for_meta_tolerant(meta, invalid_records, invalid_seen)
})
}
fn ticket_from_dir_with_relations(
&self,
dir: &Path,
relation_view: impl FnOnce(&Self, &TicketMeta) -> Result<TicketRelationView>,
) -> Result<Ticket> {
let item_path = dir.join("item.md");
let parsed = read_item_file(&item_path)?;
let meta = ticket_meta_for_dir(dir, parsed.frontmatter.clone())?;
@ -1059,7 +1141,7 @@ impl LocalTicketBackend {
Vec::new()
};
let artifacts = collect_artifacts(&dir.join("artifacts"))?;
let relations = self.relation_view_for_meta(&meta)?;
let relations = relation_view(self, &meta)?;
let resolution_path = dir.join("resolution.md");
let resolution = if resolution_path.exists() {
Some(MarkdownText::new(
@ -1223,13 +1305,25 @@ impl LocalTicketBackend {
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
relations.extend(self.read_ticket_relations_for_dir(&dir)?);
}
relations.sort_by(|a, b| {
a.ticket_id
.cmp(&b.ticket_id)
.then_with(|| a.kind.cmp(&b.kind))
.then_with(|| a.target.cmp(&b.target))
.then_with(|| a.at.cmp(&b.at))
});
sort_ticket_relations(&mut relations);
Ok(relations)
}
fn all_ticket_relation_records_tolerant(
&self,
invalid_records: &mut Vec<TicketInvalidRecord>,
invalid_seen: &mut BTreeSet<String>,
) -> Result<Vec<TicketRelation>> {
let mut relations = Vec::new();
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
match self.read_ticket_relations_for_dir(&dir) {
Ok(records) => relations.extend(records),
Err(error) => {
push_invalid_ticket_record(invalid_records, invalid_seen, &dir, &error)
}
}
}
sort_ticket_relations(&mut relations);
Ok(relations)
}
@ -1239,6 +1333,17 @@ impl LocalTicketBackend {
Ok(relation_view_from_records(meta, &all, &states))
}
fn relation_view_for_meta_tolerant(
&self,
meta: &TicketMeta,
invalid_records: &mut Vec<TicketInvalidRecord>,
invalid_seen: &mut BTreeSet<String>,
) -> Result<TicketRelationView> {
let states = self.ticket_state_index_tolerant(invalid_records, invalid_seen)?;
let all = self.all_ticket_relation_records_tolerant(invalid_records, invalid_seen)?;
Ok(relation_view_from_records(meta, &all, &states))
}
fn ticket_state_index(&self) -> Result<HashMap<String, TicketWorkflowState>> {
let mut states = HashMap::new();
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
@ -1249,6 +1354,28 @@ impl LocalTicketBackend {
Ok(states)
}
fn ticket_state_index_tolerant(
&self,
invalid_records: &mut Vec<TicketInvalidRecord>,
invalid_seen: &mut BTreeSet<String>,
) -> Result<HashMap<String, TicketWorkflowState>> {
let mut states = HashMap::new();
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
let item = dir.join("item.md");
match read_item_file(&item)
.and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter))
{
Ok(meta) => {
states.insert(meta.id, meta.workflow_state);
}
Err(error) => {
push_invalid_ticket_record(invalid_records, invalid_seen, &dir, &error)
}
}
}
Ok(states)
}
fn relation_blockers_for_meta(&self, meta: &TicketMeta) -> Result<Vec<TicketRelationBlocker>> {
Ok(self.relation_view_for_meta(meta)?.blockers)
}
@ -1274,21 +1401,7 @@ impl TicketBackend for LocalTicketBackend {
}
let parsed = read_item_file(&item)?;
let meta = ticket_meta_for_dir(&dir, parsed.frontmatter)?;
tickets.push(TicketSummary {
id: meta.id,
slug: meta.slug,
title: meta.title,
status: meta.status,
kind: meta.kind,
priority: meta.priority,
labels: meta.labels,
readiness: meta.readiness,
workflow_state: meta.workflow_state,
workflow_state_explicit: meta.workflow_state_explicit,
queued_by: meta.queued_by,
queued_at: meta.queued_at,
updated_at: meta.updated_at,
});
tickets.push(ticket_summary_from_meta(meta));
}
Ok(tickets)
}
@ -2224,6 +2337,72 @@ fn ticket_meta(frontmatter: TicketItemFrontmatter, id: String) -> TicketMeta {
}
}
fn ticket_summary_from_meta(meta: TicketMeta) -> TicketSummary {
TicketSummary {
id: meta.id,
slug: meta.slug,
title: meta.title,
status: meta.status,
kind: meta.kind,
priority: meta.priority,
labels: meta.labels,
readiness: meta.readiness,
workflow_state: meta.workflow_state,
workflow_state_explicit: meta.workflow_state_explicit,
queued_by: meta.queued_by,
queued_at: meta.queued_at,
updated_at: meta.updated_at,
}
}
fn sort_ticket_relations(relations: &mut [TicketRelation]) {
relations.sort_by(|a, b| {
a.ticket_id
.cmp(&b.ticket_id)
.then_with(|| a.kind.cmp(&b.kind))
.then_with(|| a.target.cmp(&b.target))
.then_with(|| a.at.cmp(&b.at))
});
}
fn invalid_ticket_record_label(dir: &Path) -> String {
dir.file_name()
.and_then(|name| name.to_str())
.filter(|name| validate_record_id(name).is_ok())
.map(ToOwned::to_owned)
.unwrap_or_else(|| "invalid ticket record".to_string())
}
fn invalid_ticket_record_reason(error: &TicketError) -> &'static str {
match error {
TicketError::Io { .. } => "could not read ticket record",
TicketError::Parse { .. } => "invalid ticket record schema",
TicketError::InvalidPathComponent(_) | TicketError::PathEscapesRoot { .. } => {
"invalid ticket record identity"
}
TicketError::Locked { .. } => "ticket backend is locked",
TicketError::NotFound(_) => "ticket record is missing",
TicketError::Ambiguous { .. } | TicketError::Conflict(_) => {
"invalid ticket record metadata"
}
}
}
fn push_invalid_ticket_record(
invalid_records: &mut Vec<TicketInvalidRecord>,
invalid_seen: &mut BTreeSet<String>,
dir: &Path,
error: &TicketError,
) {
let label = invalid_ticket_record_label(dir);
if invalid_seen.insert(label.clone()) {
invalid_records.push(TicketInvalidRecord {
label,
reason: invalid_ticket_record_reason(error).to_string(),
});
}
}
fn trim_owned(value: String) -> String {
value.trim().to_string()
}
@ -3633,6 +3812,47 @@ state: planning
assert!(report.is_ok(), "{:?}", report.diagnostics);
}
#[test]
fn partial_list_and_show_keep_valid_tickets_when_peer_record_is_invalid() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let mut ready = NewTicket::new("Ready Valid");
ready.workflow_state = Some(TicketWorkflowState::Ready);
let valid = backend.create(ready).unwrap();
let invalid = backend
.create(NewTicket::new("Invalid Secret Title"))
.unwrap();
fs::write(
backend.root().join(&invalid.id).join("item.md"),
"---\ntitle: Invalid Secret Title\nstate: super-secret-invalid\n---\nbody\n",
)
.unwrap();
assert!(backend.list(TicketFilter::all()).is_err());
let partial = backend.list_partial(TicketFilter::all()).unwrap();
assert_eq!(partial.tickets.len(), 1);
assert_eq!(partial.tickets[0].id, valid.id);
assert_eq!(partial.invalid_records.len(), 1);
assert_eq!(partial.invalid_records[0].label, invalid.id);
assert_eq!(
partial.invalid_records[0].reason,
"invalid ticket record schema"
);
assert!(
!partial.invalid_records[0]
.reason
.contains("super-secret-invalid")
);
let detail = backend
.show_partial(TicketIdOrSlug::Id(valid.id.clone()))
.unwrap();
assert_eq!(detail.ticket.meta.title, "Ready Valid");
assert_eq!(detail.invalid_records.len(), 1);
assert_eq!(detail.invalid_records[0].label, invalid.id);
}
#[test]
fn create_uses_configured_japanese_record_language_for_generated_defaults() {
let tmp = TempDir::new().unwrap();

View File

@ -48,11 +48,11 @@ use crate::workspace_panel::{
ActionPriority, CompanionLifecyclePlan, CompanionPanelState, CompanionPanelStatus,
CompanionPodPresence, ComposerTarget, NextUserAction, OrchestratorLifecyclePlan,
OrchestratorPanelState, OrchestratorPanelStatus, OrchestratorPodPresence, PanelRow,
PanelRowKey, TicketConfigAvailability, TicketLocalClaimStatus, WorkspacePanelViewModel,
bounded_panel_diagnostic, build_current_ticket_row, build_workspace_panel,
companion_pod_presence, decide_companion_lifecycle, decide_orchestrator_lifecycle,
local_claim_status_for_pod, orchestrator_pod_presence, ticket_config_availability,
workspace_companion_pod_name, workspace_orchestrator_pod_name,
PanelRowKey, PanelRowKind, TicketConfigAvailability, TicketLocalClaimStatus,
WorkspacePanelViewModel, bounded_panel_diagnostic, build_current_ticket_row,
build_workspace_panel, companion_pod_presence, decide_companion_lifecycle,
decide_orchestrator_lifecycle, local_claim_status_for_pod, orchestrator_pod_presence,
ticket_config_availability, workspace_companion_pod_name, workspace_orchestrator_pod_name,
};
const MAX_ENTRIES: usize = 50;
@ -958,6 +958,17 @@ fn panel_e2e_row_key(key: &PanelRowKey) -> PanelE2eRowKey {
kind: "ticket",
id: id.clone(),
},
PanelRowKey::InvalidTicket(label) => PanelE2eRowKey {
kind: "invalid_ticket",
id: label.clone(),
},
PanelRowKey::TicketIntakePod {
ticket_id,
pod_name,
} => PanelE2eRowKey {
kind: "ticket_intake_pod",
id: format!("{ticket_id}:{pod_name}"),
},
PanelRowKey::Pod(name) => PanelE2eRowKey {
kind: "pod",
id: name.clone(),
@ -1207,12 +1218,8 @@ impl MultiPodApp {
}
fn selected_pod_entry(&self) -> Option<&PodListEntry> {
match self.selected_row.as_ref() {
Some(PanelRowKey::Pod(name)) => {
self.list.entries.iter().find(|entry| &entry.name == name)
}
_ => None,
}
let name = self.selected_row.as_ref().and_then(PanelRowKey::pod_name)?;
self.list.entries.iter().find(|entry| entry.name == name)
}
#[cfg(test)]
@ -1238,11 +1245,14 @@ impl MultiPodApp {
}),
);
}
let entry = self.selected_pod_entry()?;
if entry.actions.can_open {
return None;
if let Some(entry) = self.selected_pod_entry() {
if entry.actions.can_open {
return None;
}
return Some(open_disabled_reason(entry));
}
Some(open_disabled_reason(entry))
self.selected_panel_row()
.and_then(|row| row.disabled_reason.clone().or_else(|| row.key_hint.clone()))
}
pub(crate) fn select_next(&mut self) {
@ -1353,7 +1363,12 @@ impl MultiPodApp {
),
None => match &hit.key {
PanelRowKey::Pod(name) => (name.clone(), None, None),
PanelRowKey::Ticket(id) => (id.clone(), None, None),
PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => {
(id.clone(), None, None)
}
PanelRowKey::TicketIntakePod { pod_name, .. } => {
(pod_name.clone(), None, None)
}
},
};
PanelE2eRenderedRow {
@ -1406,7 +1421,9 @@ impl MultiPodApp {
}
if let Some(key) = visible.iter().find(|key| match key {
PanelRowKey::Pod(name) => Some(name.as_str()) != orchestrator_pod_name,
PanelRowKey::Ticket(_) => true,
PanelRowKey::Ticket(_)
| PanelRowKey::InvalidTicket(_)
| PanelRowKey::TicketIntakePod { .. } => true,
}) {
self.select_panel_key(key.clone());
return;
@ -4677,6 +4694,18 @@ fn selected_ticket_notice(row: Option<&PanelRow>) -> String {
row.title
)
}
Some(row) if row.kind == PanelRowKind::TicketIntakePod => row
.disabled_reason
.clone()
.or_else(|| row.key_hint.clone())
.unwrap_or_else(|| {
"Open/attach this Ticket's Intake Pod from the associated row.".to_string()
}),
Some(row) if row.kind == PanelRowKind::InvalidTicket => row
.disabled_reason
.clone()
.or_else(|| row.key_hint.clone())
.unwrap_or_else(|| "Invalid Ticket record placeholder has no actions.".to_string()),
_ => "No Pod is selected.".to_string(),
}
}
@ -4793,7 +4822,7 @@ fn visible_panel_keys(panel: &WorkspacePanelViewModel, list: &PodList) -> Vec<Pa
let mut keys = panel
.rows
.iter()
.filter(|row| row.is_ticket_action())
.filter(|row| row.is_ticket_section_row())
.map(|row| row.key.clone())
.collect::<Vec<_>>();
keys.extend(
@ -5145,7 +5174,7 @@ fn panel_action_rows(
let rows = panel
.rows
.iter()
.filter(|row| row.is_ticket_action())
.filter(|row| row.is_ticket_section_row())
.collect::<Vec<_>>();
if rows.is_empty() {
return Vec::new();
@ -5181,11 +5210,15 @@ fn panel_action_header_line(total: usize, width: u16) -> Line<'static> {
const TICKET_STATE_COLUMN_WIDTH: usize = 10;
const POD_STATUS_COLUMN_WIDTH: usize = 18;
fn panel_row_lines(row: &PanelRow, selected: bool, width: u16) -> [Line<'static>; 2] {
[
panel_row_title_line(row, selected, width),
panel_row_detail_line(row, selected, width),
]
fn panel_row_lines(row: &PanelRow, selected: bool, width: u16) -> Vec<Line<'static>> {
if row.kind == PanelRowKind::TicketIntakePod {
vec![panel_row_title_line(row, selected, width)]
} else {
vec![
panel_row_title_line(row, selected, width),
panel_row_detail_line(row, selected, width),
]
}
}
fn panel_row_title_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
@ -5242,6 +5275,29 @@ fn push_ticket_marker_span(spans: &mut Vec<Span<'static>>, selected: bool, remai
}
fn panel_ticket_detail(row: &PanelRow) -> String {
if row.kind == PanelRowKind::InvalidTicket {
let mut parts = vec![panel_ticket_reference(row), "Gate: unavailable".to_string()];
if let Some(reason) = panel_ticket_reason(row) {
parts.push(format!("Reason: {reason}"));
}
return parts.join(" · ");
}
if row.kind == PanelRowKind::TicketIntakePod {
let mut parts = row
.subtitle
.as_ref()
.map(|subtitle| vec![subtitle.clone()])
.unwrap_or_else(|| vec![panel_ticket_reference(row)]);
if let Some(action) = row.next_action {
parts.push(format!("Action: {}", action.label()));
}
if let Some(reason) = panel_ticket_reason(row) {
parts.push(format!("Reason: {reason}"));
}
return parts.join(" · ");
}
let mut parts = vec![panel_ticket_reference(row)];
if let Some(blocked_reason) = row
.ticket
@ -5285,6 +5341,9 @@ fn panel_ticket_reason(row: &PanelRow) -> Option<&str> {
}
fn ticket_detail_style(row: &PanelRow) -> Style {
if row.kind == PanelRowKind::InvalidTicket {
return Style::default().fg(Color::Yellow);
}
if row
.ticket
.as_ref()
@ -5302,7 +5361,8 @@ fn panel_ticket_reference(row: &PanelRow) -> String {
.as_ref()
.map(|ticket| ticket.id.clone())
.unwrap_or_else(|| match &row.key {
PanelRowKey::Ticket(id) => id.clone(),
PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => id.clone(),
PanelRowKey::TicketIntakePod { ticket_id, .. } => ticket_id.clone(),
PanelRowKey::Pod(name) => name.clone(),
})
}
@ -7321,7 +7381,8 @@ branch = "orchestration/custom-panel"
"inprogress",
);
let [title, detail] = panel_row_lines(&row, true, 160);
let lines = panel_row_lines(&row, true, 160);
let (title, detail) = (&lines[0], &lines[1]);
let title_line = plain_line(&title);
let detail_line = plain_line(&detail);
let state_start = 2;
@ -7352,7 +7413,8 @@ branch = "orchestration/custom-panel"
"ready",
);
let [title, detail] = panel_row_lines(&row, false, 160);
let lines = panel_row_lines(&row, false, 160);
let (title, detail) = (&lines[0], &lines[1]);
let title_line = plain_line(&title);
let detail_line = plain_line(&detail);
let state_start = 2;
@ -7377,7 +7439,8 @@ branch = "orchestration/custom-panel"
"ready",
);
let [title, detail] = panel_row_lines(&row, false, 42);
let lines = panel_row_lines(&row, false, 42);
let (title, detail) = (&lines[0], &lines[1]);
let title_line = plain_line(&title);
let detail_line = plain_line(&detail);
let title_start = 2 + TICKET_STATE_COLUMN_WIDTH + 1;
@ -7402,7 +7465,8 @@ branch = "orchestration/custom-panel"
row.disabled_reason = Some("Queue disabled: waiting for BLOCKER-1".to_string());
row.ticket.as_mut().unwrap().blocked_reason = Some("BLOCKER-1 via depends_on".to_string());
let [_title, detail] = panel_row_lines(&row, true, 160);
let lines = panel_row_lines(&row, true, 160);
let detail = &lines[1];
let detail_line = plain_line(&detail);
assert!(detail_line.contains("Gate: waiting for BLOCKER-1 via depends_on"));
@ -8602,6 +8666,7 @@ branch = "orchestration/custom-panel"
blocked_reason: None,
related_pods: Vec::new(),
local_claim: None,
intake_pods: Vec::new(),
};
PanelRow {
key: PanelRowKey::Ticket(ticket.id.clone()),

View File

@ -489,7 +489,7 @@ async fn run_e2e_rewind_fixture(
truncate_entries: 1,
turn_index: 1,
timestamp_ms: Some(1),
preview: "revise the plan".to_string(),
preview: "candidate rewind target".to_string(),
eligible: true,
disabled_reason: None,
warning: None,
@ -500,7 +500,7 @@ async fn run_e2e_rewind_fixture(
"rewind_picker_opened",
serde_json::json!({
"targets": 1,
"selected_preview": "revise the plan",
"selected_preview": "candidate rewind target",
}),
);
}
@ -545,7 +545,7 @@ async fn run_e2e_rewind_fixture(
if submitted_at.elapsed() >= apply_delay {
app.handle_pod_event(Event::RewindApplied {
entries: Vec::new(),
input: vec![Segment::text("revise the plan")],
input: vec![Segment::text("rewind-live-refresh")],
summary: RewindSummary {
truncated_to_entries: 1,
discarded_entries: 2,

View File

@ -4,7 +4,7 @@ use protocol::PodStatus;
use ticket::config::{TICKET_CONFIG_RELATIVE_PATH, TicketConfig};
use ticket::{
LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug,
TicketMeta, TicketRelationBlocker, TicketSummary, TicketWorkflowState,
TicketInvalidRecord, TicketMeta, TicketRelationBlocker, TicketSummary, TicketWorkflowState,
};
use crate::pod_list::{PodList, PodListEntry, StoredMetadataState};
@ -182,16 +182,29 @@ impl OrchestratorPanelStatus {
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) enum PanelRowKey {
Ticket(String),
InvalidTicket(String),
TicketIntakePod { ticket_id: String, pod_name: String },
Pod(String),
}
impl PanelRowKey {
pub(crate) fn pod_name(&self) -> Option<&str> {
match self {
Self::Pod(name) | Self::TicketIntakePod { pod_name: name, .. } => Some(name),
Self::Ticket(_) | Self::InvalidTicket(_) => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PanelRowKind {
Planning,
Ticket,
Review,
ActiveWork,
TicketIntakePod,
Pod,
InvalidTicket,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
@ -236,6 +249,30 @@ pub(crate) struct TicketPanelEntry {
pub(crate) blocked_reason: Option<String>,
pub(crate) related_pods: Vec<String>,
pub(crate) local_claim: Option<TicketLocalClaimEntry>,
pub(crate) intake_pods: Vec<TicketAssociatedIntakeEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TicketAssociatedIntakeEntry {
pub(crate) ticket_id: String,
pub(crate) pod_name: String,
pub(crate) status: TicketLocalClaimStatus,
pub(crate) source: TicketAssociatedIntakeSource,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TicketAssociatedIntakeSource {
LocalClaim,
RelatedSession,
}
impl TicketAssociatedIntakeSource {
pub(crate) fn label(self) -> &'static str {
match self {
Self::LocalClaim => "local claim",
Self::RelatedSession => "related session",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -279,11 +316,27 @@ pub(crate) struct PanelRow {
impl PanelRow {
pub(crate) fn is_ticket_action(&self) -> bool {
!matches!(self.kind, PanelRowKind::Pod)
matches!(
self.kind,
PanelRowKind::Planning
| PanelRowKind::Ticket
| PanelRowKind::Review
| PanelRowKind::ActiveWork
)
}
pub(crate) fn is_ticket_section_row(&self) -> bool {
self.is_ticket_action()
|| matches!(
self.kind,
PanelRowKind::TicketIntakePod | PanelRowKind::InvalidTicket
)
}
}
const MAX_POD_NAME_CHARS: usize = 80;
const MAX_ASSOCIATED_INTAKE_ROWS_PER_TICKET: usize = 3;
const MAX_INVALID_TICKET_PLACEHOLDER_ROWS: usize = 5;
const ORCHESTRATOR_SUFFIX: &str = "-orchestrator";
#[derive(Debug, Clone, PartialEq, Eq)]
@ -543,7 +596,10 @@ fn build_workspace_panel_with_registry_model(
let backend = LocalTicketBackend::new(config.backend_root().to_path_buf())
.with_record_language(config.ticket_record_language());
match build_ticket_rows(&backend, pods, registry) {
Ok(rows) => model.rows.extend(rows),
Ok(ticket_rows) => {
model.rows.extend(ticket_rows.rows);
model.header.diagnostics.extend(ticket_rows.diagnostics);
}
Err(error) => {
model
.header
@ -574,12 +630,6 @@ fn build_workspace_panel_with_registry_model(
}
model.rows.extend(pod_rows(pods));
model.rows.sort_by(|a, b| {
a.priority
.cmp(&b.priority)
.then_with(|| row_updated_at(b).cmp(row_updated_at(a)))
.then_with(|| a.title.cmp(&b.title))
});
model
}
@ -623,26 +673,127 @@ fn ticket_summary_from_meta(meta: &TicketMeta) -> TicketSummary {
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct TicketRowsBuild {
rows: Vec<PanelRow>,
diagnostics: Vec<String>,
}
fn build_ticket_rows(
backend: &LocalTicketBackend,
pods: &PodList,
registry: &PanelRegistrySnapshot,
) -> ticket::Result<Vec<PanelRow>> {
let mut rows = Vec::new();
for summary in backend.list(TicketFilter::all())? {
) -> ticket::Result<TicketRowsBuild> {
let partial = backend.list_partial(TicketFilter::all())?;
let mut ticket_rows = Vec::new();
let mut invalid_records = partial.invalid_records;
for summary in partial.tickets {
if summary.workflow_state == TicketWorkflowState::Closed {
continue;
}
let ticket = backend.show(TicketIdOrSlug::Query(summary.id.clone()))?;
rows.push(ticket_row(
summary,
&ticket.events,
&ticket.relations.blockers,
pods,
registry,
));
match backend.show_partial(TicketIdOrSlug::Query(summary.id.clone())) {
Ok(ticket) => {
let current_ticket_invalid = ticket
.invalid_records
.iter()
.any(|record| record.label == summary.id);
invalid_records.extend(ticket.invalid_records);
if current_ticket_invalid {
continue;
}
ticket_rows.push(ticket_row(
summary,
&ticket.ticket.events,
&ticket.ticket.relations.blockers,
pods,
registry,
));
}
Err(_) => invalid_records.push(TicketInvalidRecord {
label: summary.id,
reason: "could not load ticket detail".to_string(),
}),
}
}
ticket_rows.sort_by(|a, b| {
a.priority
.cmp(&b.priority)
.then_with(|| row_updated_at(b).cmp(row_updated_at(a)))
.then_with(|| a.title.cmp(&b.title))
});
let mut rows = Vec::new();
for row in ticket_rows {
let intake_rows = ticket_intake_pod_rows(&row);
rows.push(row);
rows.extend(intake_rows);
}
let invalid_records = dedupe_invalid_ticket_records(invalid_records);
let diagnostics = invalid_ticket_diagnostics(invalid_records.len());
rows.extend(invalid_ticket_rows(&invalid_records));
Ok(TicketRowsBuild { rows, diagnostics })
}
fn dedupe_invalid_ticket_records(records: Vec<TicketInvalidRecord>) -> Vec<TicketInvalidRecord> {
let mut deduped = Vec::new();
for record in records {
if deduped
.iter()
.any(|existing: &TicketInvalidRecord| existing.label == record.label)
{
continue;
}
deduped.push(record);
}
deduped
}
fn invalid_ticket_diagnostics(invalid_count: usize) -> Vec<String> {
if invalid_count == 0 {
return Vec::new();
}
let suffix = if invalid_count > MAX_INVALID_TICKET_PLACEHOLDER_ROWS {
format!(
"; showing first {} placeholder rows",
MAX_INVALID_TICKET_PLACEHOLDER_ROWS
)
} else {
String::new()
};
vec![bounded_panel_diagnostic(format!(
"Ticket records partially loaded: {invalid_count} invalid record(s) unavailable for actions{suffix}."
))]
}
fn invalid_ticket_rows(records: &[TicketInvalidRecord]) -> Vec<PanelRow> {
records
.iter()
.take(MAX_INVALID_TICKET_PLACEHOLDER_ROWS)
.map(invalid_ticket_row)
.collect()
}
fn invalid_ticket_row(record: &TicketInvalidRecord) -> PanelRow {
PanelRow {
key: PanelRowKey::InvalidTicket(record.label.clone()),
kind: PanelRowKind::InvalidTicket,
title: format!("Invalid Ticket record: {}", record.label),
subtitle: Some(record.reason.clone()),
status: "invalid".to_string(),
priority: ActionPriority::Background,
next_action: None,
ticket: None,
related_pods: Vec::new(),
disabled_reason: Some(
"Invalid Ticket record is diagnostics-only; lifecycle actions are disabled."
.to_string(),
),
key_hint: Some(
"Actions unavailable until the Ticket record is repaired manually.".to_string(),
),
}
Ok(rows)
}
fn ticket_row(
@ -653,7 +804,17 @@ fn ticket_row(
registry: &PanelRegistrySnapshot,
) -> PanelRow {
let local_claim = local_claim_for_ticket(&summary, pods, registry);
let related_pods = related_pods_for_ticket(&summary, pods, registry);
let intake_pods =
associated_intake_entries_for_ticket(&summary, pods, registry, local_claim.as_ref());
let mut related_pods = Vec::new();
if let Some(claim) = local_claim.as_ref() {
related_pods.push(claim.pod_name.clone());
}
for pod_name in intake_pods.iter().map(|intake| intake.pod_name.clone()) {
if !related_pods.iter().any(|existing| existing == &pod_name) {
related_pods.push(pod_name);
}
}
let derived = derive_ticket_state(&summary, relation_blockers);
let latest_event = events.last();
let entry = TicketPanelEntry {
@ -669,6 +830,7 @@ fn ticket_row(
blocked_reason: derived.blocked_reason.clone(),
related_pods: related_pods.clone(),
local_claim,
intake_pods,
};
let subtitle = ticket_subtitle(&entry);
PanelRow {
@ -802,32 +964,111 @@ fn derive_ticket_state(
}
}
fn related_pods_for_ticket(
fn associated_intake_entries_for_ticket(
summary: &TicketSummary,
pods: &PodList,
registry: &PanelRegistrySnapshot,
) -> Vec<String> {
let id = lowercase(&summary.id);
let mut names = Vec::new();
if let Some(claim) = registry.claim_for_ticket(&summary.id) {
names.push(claim.pod_name.clone());
local_claim: Option<&TicketLocalClaimEntry>,
) -> Vec<TicketAssociatedIntakeEntry> {
let mut entries = Vec::new();
if let Some(claim) = local_claim.filter(|claim| is_intake_role(&claim.role)) {
entries.push(TicketAssociatedIntakeEntry {
ticket_id: summary.id.clone(),
pod_name: claim.pod_name.clone(),
status: claim.status,
source: TicketAssociatedIntakeSource::LocalClaim,
});
}
for pod in pods.entries.iter().filter_map(|pod| {
let name = lowercase(&pod.name);
if !id.is_empty() && name.contains(&id) {
Some(pod.name.clone())
} else {
None
let mut related_sessions = registry
.sessions
.iter()
.filter(|session| {
is_intake_role(&session.role)
&& session
.related_tickets
.iter()
.any(|related| related.id == summary.id.as_str())
})
.map(|session| session.pod_name.clone())
.collect::<Vec<_>>();
related_sessions.sort();
related_sessions.dedup();
for pod_name in related_sessions {
if entries.iter().any(|entry| entry.pod_name == pod_name) {
continue;
}
}) {
if !names.iter().any(|existing| existing == &pod) {
names.push(pod);
}
if names.len() >= 5 {
entries.push(TicketAssociatedIntakeEntry {
ticket_id: summary.id.clone(),
status: local_claim_status_for_pod(&pod_name, pods),
pod_name,
source: TicketAssociatedIntakeSource::RelatedSession,
});
if entries.len() >= MAX_ASSOCIATED_INTAKE_ROWS_PER_TICKET {
break;
}
}
names
entries.truncate(MAX_ASSOCIATED_INTAKE_ROWS_PER_TICKET);
entries
}
fn is_intake_role(role: &str) -> bool {
role.eq_ignore_ascii_case("intake")
}
fn ticket_intake_pod_rows(row: &PanelRow) -> Vec<PanelRow> {
row.ticket
.as_ref()
.map(|ticket| {
ticket
.intake_pods
.iter()
.map(ticket_intake_pod_row)
.collect()
})
.unwrap_or_default()
}
fn ticket_intake_pod_row(intake: &TicketAssociatedIntakeEntry) -> PanelRow {
let stale = intake.status == TicketLocalClaimStatus::Stale;
PanelRow {
key: PanelRowKey::TicketIntakePod {
ticket_id: intake.ticket_id.clone(),
pod_name: intake.pod_name.clone(),
},
kind: PanelRowKind::TicketIntakePod,
title: format!("↳ Intake Pod: {}", intake.pod_name),
subtitle: Some(format!(
"Ticket {} · {} · {}",
intake.ticket_id,
intake.source.label(),
intake.status.label()
)),
status: intake.status.label().to_string(),
priority: ActionPriority::ActiveWork,
next_action: if stale {
None
} else {
Some(NextUserAction::OpenPod)
},
ticket: None,
related_pods: vec![intake.pod_name.clone()],
disabled_reason: if stale {
Some(
"Associated Intake Pod is stale; no live or restorable Pod entry is available."
.to_string(),
)
} else {
None
},
key_hint: Some(if stale {
"Stale Intake claim/session; restore is unavailable".to_string()
} else {
"Open/attach this Ticket's Intake Pod".to_string()
}),
}
}
fn local_claim_for_ticket(
@ -962,15 +1203,12 @@ fn excerpt(markdown: &str, max_chars: usize) -> Option<String> {
}
}
fn lowercase(value: &str) -> String {
value.to_ascii_lowercase()
}
#[allow(dead_code)]
#[cfg(test)]
mod tests {
use super::*;
use crate::pod_list::{LivePodInfo, PodEntrySummary};
use crate::role_session_registry::{PanelRegistryStore, RelatedTicketRef, RoleSessionOrigin};
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
@ -1070,6 +1308,179 @@ mod tests {
assert_eq!(row.next_action, Some(NextUserAction::Queue));
}
#[test]
fn workspace_panel_keeps_valid_ticket_actions_with_invalid_records() {
let temp = TempDir::new().unwrap();
write_ticket_config(temp.path());
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
let mut ready_input = NewTicket::new("Ready Still Queueable");
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
let ready = backend.create(ready_input).unwrap();
backend
.create(NewTicket::new("Planning Still Clarifies"))
.unwrap();
for index in 0..6 {
let ticket = backend
.create(NewTicket::new(format!("Leaked Secret Invalid {index}")))
.unwrap();
fs::write(
temp.path()
.join(".yoi/tickets")
.join(&ticket.id)
.join("item.md"),
format!(
"---\ntitle: Leaked Secret Invalid {index}\nstate: super-secret-invalid-{index}\n---\nbody\n"
),
)
.unwrap();
}
let registry = PanelRegistryStore::from_root(temp.path().join("registry"));
registry
.claim_ticket(&ready.id, None, "ready-intake", "intake")
.unwrap();
let model = build_workspace_panel_with_registry(
temp.path(),
&live_pods(&["ready-intake"]),
&registry.snapshot().unwrap(),
);
let ready_index = model
.rows
.iter()
.position(|row| row.title == "Ready Still Queueable")
.unwrap();
let ready_row = &model.rows[ready_index];
assert_eq!(ready_row.next_action, Some(NextUserAction::Queue));
assert!(ready_row.is_ticket_action());
assert_eq!(
model.rows[ready_index + 1].key,
PanelRowKey::TicketIntakePod {
ticket_id: ready.id.clone(),
pod_name: "ready-intake".to_string(),
}
);
let planning = model
.rows
.iter()
.find(|row| row.title == "Planning Still Clarifies")
.unwrap();
assert_eq!(planning.next_action, Some(NextUserAction::Clarify));
assert!(planning.is_ticket_action());
let invalid_rows = model
.rows
.iter()
.filter(|row| row.kind == PanelRowKind::InvalidTicket)
.collect::<Vec<_>>();
assert_eq!(invalid_rows.len(), MAX_INVALID_TICKET_PLACEHOLDER_ROWS);
for row in invalid_rows {
assert_eq!(row.status, "invalid");
assert!(row.ticket.is_none());
assert_eq!(row.next_action, None);
assert!(!row.is_ticket_action());
assert!(row.disabled_reason.as_deref().unwrap().contains("disabled"));
}
let diagnostics = model.header.diagnostics.join("\n");
assert!(diagnostics.contains("Ticket records partially loaded: 6 invalid record"));
assert!(diagnostics.contains("showing first 5"));
assert!(!diagnostics.contains("super-secret-invalid"));
assert!(
!model
.rows
.iter()
.any(|row| row.title.contains("Leaked Secret Invalid"))
);
}
#[test]
fn workspace_panel_disables_current_ticket_when_detail_artifact_is_invalid() {
let temp = TempDir::new().unwrap();
write_ticket_config(temp.path());
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
let mut corrupt_input = NewTicket::new("Ready With Corrupt Relations");
corrupt_input.workflow_state = Some(TicketWorkflowState::Ready);
let corrupt = backend.create(corrupt_input).unwrap();
let mut other_input = NewTicket::new("Other Ready Still Queueable");
other_input.workflow_state = Some(TicketWorkflowState::Ready);
let other = backend.create(other_input).unwrap();
let artifacts = temp
.path()
.join(".yoi/tickets")
.join(&corrupt.id)
.join("artifacts");
fs::create_dir_all(&artifacts).unwrap();
fs::write(artifacts.join("relations.json"), "{").unwrap();
let model = build_workspace_panel(temp.path(), &empty_pods());
let corrupt_placeholders = model
.rows
.iter()
.filter(|row| row.key == PanelRowKey::InvalidTicket(corrupt.id.clone()))
.collect::<Vec<_>>();
assert_eq!(corrupt_placeholders.len(), 1);
let corrupt_placeholder = corrupt_placeholders[0];
assert_eq!(corrupt_placeholder.kind, PanelRowKind::InvalidTicket);
assert_eq!(corrupt_placeholder.next_action, None);
assert!(corrupt_placeholder.ticket.is_none());
assert!(!corrupt_placeholder.is_ticket_action());
assert!(
!model
.rows
.iter()
.any(|row| row.key == PanelRowKey::Ticket(corrupt.id.clone()))
);
let other_row = model
.rows
.iter()
.find(|row| row.key == PanelRowKey::Ticket(other.id.clone()))
.unwrap();
assert_eq!(other_row.next_action, Some(NextUserAction::Queue));
assert!(other_row.is_ticket_action());
let diagnostics = model.header.diagnostics.join("\n");
assert!(diagnostics.contains("Ticket records partially loaded: 1 invalid record"));
}
#[test]
fn workspace_panel_keeps_backend_config_unusable_as_whole_ticket_degradation() {
let temp = TempDir::new().unwrap();
let config_dir = temp.path().join(".yoi");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("ticket.config.toml"),
"[backend]\nprovider = \"unknown:provider\"\nroot = \".yoi/tickets\"\n",
)
.unwrap();
let model = build_workspace_panel(temp.path(), &live_pods(&["idle"]));
let diagnostics = model.header.diagnostics.join("\n");
assert!(diagnostics.contains("Ticket config is unusable"));
assert!(
model
.rows
.iter()
.all(|row| row.kind != PanelRowKind::InvalidTicket)
);
assert_eq!(
model.composer.available_targets,
vec![ComposerTarget::Companion]
);
assert!(
model
.rows
.iter()
.any(|row| row.key == PanelRowKey::Pod("idle".to_string()))
);
}
#[test]
fn workspace_panel_does_not_infer_workflow_state_from_readiness_or_title() {
let temp = TempDir::new().unwrap();
@ -1196,6 +1607,97 @@ mod tests {
assert_eq!(done.next_action, Some(NextUserAction::Close));
}
#[test]
fn workspace_panel_shows_ticket_associated_intake_pods_adjacent_to_ticket() {
let temp = TempDir::new().unwrap();
write_ticket_config(temp.path());
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
create_ticket(&backend, "Ticket With Intake", |input| {
input.workflow_state = Some(TicketWorkflowState::Ready);
});
let ticket_id = backend
.list(TicketFilter::all())
.unwrap()
.into_iter()
.find(|ticket| ticket.title == "Ticket With Intake")
.unwrap()
.id;
let preticket_pod = format!("pre-{ticket_id}-intake");
let registry = PanelRegistryStore::from_root(temp.path().join("registry"));
registry
.claim_ticket(&ticket_id, None, "claimed-intake", "intake")
.unwrap();
registry
.record_session(
"shared-intake",
"intake",
RoleSessionOrigin::RoleLaunch,
None,
[RelatedTicketRef {
id: ticket_id.clone(),
slug: None,
}],
)
.unwrap();
registry
.record_session(
&preticket_pod,
"intake",
RoleSessionOrigin::PreTicketIntake,
None,
[],
)
.unwrap();
let pods = live_pods(&["claimed-intake", "shared-intake", &preticket_pod]);
let model =
build_workspace_panel_with_registry(temp.path(), &pods, &registry.snapshot().unwrap());
let ticket_index = model
.rows
.iter()
.position(|row| row.key == PanelRowKey::Ticket(ticket_id.clone()))
.unwrap();
let ticket_row = &model.rows[ticket_index];
let ticket = ticket_row.ticket.as_ref().unwrap();
assert_eq!(
ticket
.intake_pods
.iter()
.map(|entry| entry.pod_name.as_str())
.collect::<Vec<_>>(),
vec!["claimed-intake", "shared-intake"]
);
assert_eq!(ticket.related_pods, vec!["claimed-intake", "shared-intake"]);
assert_eq!(
model.rows[ticket_index + 1].key,
PanelRowKey::TicketIntakePod {
ticket_id: ticket_id.clone(),
pod_name: "claimed-intake".to_string(),
}
);
assert_eq!(
model.rows[ticket_index + 1].kind,
PanelRowKind::TicketIntakePod
);
assert_eq!(model.rows[ticket_index + 1].status, "live");
assert_eq!(
model.rows[ticket_index + 1].next_action,
Some(NextUserAction::OpenPod)
);
assert_eq!(
model.rows[ticket_index + 2].key,
PanelRowKey::TicketIntakePod {
ticket_id: ticket_id.clone(),
pod_name: "shared-intake".to_string(),
}
);
assert!(model.rows.iter().all(|row| {
row.kind != PanelRowKind::TicketIntakePod
|| row.key.pod_name() != Some(preticket_pod.as_str())
}));
}
#[test]
fn workspace_panel_displays_local_ticket_claim_status() {
let temp = TempDir::new().unwrap();

View File

@ -11,9 +11,10 @@ It is not a dumping ground for external research, old plans, API inventories, or
3. [`design/pod-session-state.md`](design/pod-session-state.md) — Pod identity, replayable session logs, current metadata, and live process hints.
4. [`design/profiles-manifests-prompts.md`](design/profiles-manifests-prompts.md) — reusable Profiles, resolved Manifests, and prompt resources.
5. [`design/tool-permissions-scope.md`](design/tool-permissions-scope.md) — tool policy and filesystem scope.
6. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory, Knowledge, and audit records.
7. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed.
8. [`development/validation.md`](development/validation.md) — how to check changes.
6. [`design/plugin-packages.md`](design/plugin-packages.md) — plugin package distribution, discovery, and enablement boundaries.
7. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory, Knowledge, and audit records.
8. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed.
9. [`development/validation.md`](development/validation.md) — how to check changes.
## What belongs here

View File

@ -0,0 +1,201 @@
# Plugin packages and discovery
Plugin packages are a distribution format, not an authority boundary. A package can be found on disk, inspected, validated, and cached without registering any Hook, exposing any Tool, starting any process, or initializing any WASM module.
The initial goal is a durable `.yoi-plugin` package format that later Tickets can implement in independent layers: discovery, archive validation/cache materialization, manifest/profile enablement, Plugin permission policy, declarative hooks, WASM runtime support, and any future MCP bridge.
## Package shape
A `.yoi-plugin` file is a single-file archive. The initial archive format should be a constrained ZIP profile because it is easy to inspect without executing code and can carry text manifests, WASM modules, schemas, and license material.
The archive root must contain `plugin.toml` directly at the root. Packages should not require a wrapping directory whose name must match the plugin id.
Recommended root layout:
```text
plugin.toml # required package manifest
module.wasm # optional; required when plugin.toml declares a WASM runtime
hooks/*.toml # optional declarative hook definitions
schemas/*.schema.json # optional JSON schemas for configuration or tool input/output
README.md # recommended human description
LICENSE* # recommended license text
assets/** # optional non-executable data assets
```
The package layout is intentionally data-first. Placing a package in a store must never execute `module.wasm`, register `hooks/*.toml`, or scan assets as prompts. Those steps happen only after explicit enablement and policy resolution.
## `plugin.toml`
`plugin.toml` is the package authority for package identity and declared needs. It is not the authority for runtime grants.
Illustrative manifest shape:
```toml
schema_version = 1
id = "example"
name = "Example Plugin"
version = "0.1.0"
description = "Demonstrates declarative hooks and an optional WASM module."
[runtime]
kind = "wasm" # "declarative" or "wasm" for the initial plugin system
entry = "module.wasm"
abi = "yoi-plugin-wasm-1"
[package]
readme = "README.md"
license = "LICENSE"
[permissions]
tools = ["Bash"]
web = false
secrets = []
filesystem = []
[[hooks]]
id = "summarize-ticket"
file = "hooks/summarize-ticket.toml"
```
Fields proposed for the first implementation pass:
- `schema_version`: required integer; unsupported versions fail closed.
- `id`: required unqualified local id. It is scoped by the source that discovered the package; it is not globally unique by itself.
- `name`, `version`, `description`: human metadata used in listings and diagnostics.
- `runtime.kind`: required runtime family. Initial values should be `declarative` and `wasm`.
- `runtime.entry`: required for `wasm`, forbidden or ignored for purely declarative packages.
- `runtime.abi`: required for `wasm` so the host can reject incompatible modules before initialization.
- `hooks`, `schemas`, `package.readme`, `package.license`: package-relative paths that must pass the same normalized-path validation as archive entries.
- `permissions`: requested authority. These declarations are requests only; they do not grant access.
The `source` is not read from `plugin.toml`. It is assigned by the store that discovered the package.
## Stores, sources, and trust
Discovery should scan explicit stores and attach a source kind to each package:
- `builtin:<id>`: packages shipped with Yoi or installed as part of the binary distribution.
- `user:<id>`: packages discovered under `${XDG_DATA_HOME:-~/.local/share}/yoi/plugins/`.
- `project:<id>`: packages discovered under `<workspace>/.yoi/plugins/`.
Packages under `${XDG_DATA_HOME:-~/.local/share}/yoi/plugins/` or `<workspace>/.yoi/plugins/` are discovery only. Their presence is never permission to register Hooks or Tools, initialize WASM, start processes, open files, use network providers, read secrets, or launch MCP servers.
Trust differs by source, but none of the sources is self-authorizing:
- Builtin packages can be trusted as shipped code/data, but still require explicit enablement for a Pod/Profile when they affect runtime behavior.
- User packages are local user-installed artifacts and should be visible to workspaces, but they cannot bypass manifest/profile/tool/scope/secret policy.
- Project packages are repository-controlled artifacts and should be treated as untrusted until explicitly enabled by local policy. Cloning a repository must not be enough to execute a package.
## Identity and selector rules
Runtime identity is source-qualified: `builtin:<id>`, `user:<id>`, and `project:<id>` are distinct plugins even when `<id>` is the same string.
Durable enablement records should use source-qualified ids. Ambiguous unqualified ids fail closed. The implementation may offer convenience listing or search by bare id, but any operation that enables a package, grants permission, pins a digest, or records restored runtime state should require the fully qualified id.
Collision handling:
- Two packages with the same source-qualified id in the same effective store set are a discovery diagnostic and neither candidate is enabled implicitly.
- A `user:example` package does not override `builtin:example` unless a future explicit override rule says so.
- A `project:example` package does not override `user:example` or `builtin:example` by name alone.
## Discovery versus enablement
Discovery is a read-only inventory operation. It may report package metadata, validation errors, source, canonical store path, and deterministic digest. It must not initialize any runtime contribution.
Enablement is a resolved runtime plan. It should come from Profile/manifest configuration or another explicit local policy layer, then be recorded into the resolved Manifest/session metadata used to start the Pod. Restored Pods should use that resolved enabled-plugin plan instead of silently re-running fresh discovery and picking newer packages. Fresh discovery must not silently upgrade a restored Pod.
A future enablement record can be shaped like this, but the exact schema belongs to the implementation Ticket:
```toml
[[plugins.enabled]]
id = "user:example"
digest = "sha256:..." # optional pin in authoring, resolved in runtime metadata
config = { level = "concise" }
```
If no digest is pinned in authoring, fresh startup may resolve the newest acceptable discovered package according to explicit policy. Once a Pod is started, the resolved manifest/session metadata should record the exact source-qualified id and digest so restore is stable.
## Permissions and grants
Plugin permission declarations are requests, not grants. Effective grants are the result of Plugin-layer policy combined with existing Yoi authority layers:
- resolved manifest/profile plugin enablement;
- Plugin policy for the source-qualified package id and deterministic digest;
- normal tool permission policy;
- filesystem scope checks;
- web provider enablement and network safety checks;
- secret references and secret-store policy;
- runtime limits for WASM or other execution engines.
The Plugin package permission model must not reuse `pod::feature` HostAuthority or grant concepts. The feature layer is an API/contribution substrate; it is not a security boundary for untrusted plugin packages. Plugin grants need their own explicit policy that can fail closed before a Hook, Tool, WASM host function, provider bridge, or external runtime is exposed.
When a package requests authority outside policy, diagnostics should explain the denied category and package identity without leaking raw secret values, environment contents, full private config, or large plugin-provided text.
## Archive safety and materialization
Archive handling should validate before runtime use:
- Reject absolute paths, `..`, empty segments, Windows drive prefixes, NUL bytes, duplicate normalized paths, and paths that normalize outside the package root.
- Reject symlinks, hardlinks, device files, special files, and entries that are not regular files or directories.
- Enforce bounded extraction: maximum archive size, maximum expanded size, maximum entry count, maximum per-file size, and a compression-ratio limit.
- Validate every manifest-referenced path against the normalized entry set.
- Decode text manifests as UTF-8 and bound diagnostic excerpts.
- Ignore or normalize archive metadata such as mtimes, owners, groups, and executable bits; these should not affect runtime authority.
After validation, compute a deterministic digest over the normalized materialized package, not over incidental ZIP ordering or timestamps. A stable digest input should include the format version, normalized relative path, file length, and file content hash for each regular file in sorted order.
Runtime should materialize packages into a digest-keyed cache, for example:
```text
<cache>/plugins/sha256-<hex>/
plugin.toml
module.wasm
...
```
Initialization should read from the digest-keyed cache, not directly from the mutable user/workspace store. This makes restore, diagnostics, and lock/pin behavior reproducible.
Optional lock behavior can be added in a later Ticket:
- an authoring-time pin in Profile/manifest configuration;
- a workspace lock file recording source-qualified id, version, source store, digest, and selected package path;
- restore metadata that records the actual digest used by the Pod.
A lock or pin is selection authority, not execution authority. Enablement and grants are still required.
## Diagnostics
Diagnostics should be safe, bounded, and attributable:
- Include source-qualified id when available, source kind, validation phase, and digest when computed.
- Prefer canonical store-relative paths or redacted absolute paths; avoid dumping large path lists.
- Never print raw secret values, provider tokens, environment dumps, or plugin-supplied opaque payloads.
- Treat package metadata and README text as untrusted content when showing it to an LLM or UI.
- Report discovery errors without disabling unrelated valid packages.
## Runtime notes
Declarative hooks are data contributions. Loading a declarative hook still requires explicit package enablement. Hook text should enter the system through the normal Hook/Worker paths, preserving the rule that model-affecting inputs are committed to history before they affect context when applicable.
WASM packages should initialize only from the digest-keyed cache after enablement and grant resolution. The host should use a narrow ABI, bounded memory, fuel/time limits, bounded output, and explicit host functions. A WASM module must not inherit filesystem, network, tool, secret, process, or MCP authority from the package store path.
Tool contributions from plugins should pass through the normal ToolRegistry and permission checks. Plugin-provided schemas can describe arguments, but schema presence is not permission to execute a tool.
## MCP boundary
MCP remains a separate feature-backed integration and is out of the initial Plugin package runtime. A `.yoi-plugin` package must not launch an MCP server or imply MCP enablement.
A future MCP/plugin bridge would need its own Ticket covering external process authority, lifecycle, permission mapping, resource/prompt operations, diagnostics, and trust model. Until then, package metadata may mention compatibility for humans, but runtime packaging should ignore it.
## Follow-up implementation cuts
Good follow-up Tickets are intentionally separable:
1. Manifest/Profile plugin enablement schema and resolved-session metadata, including restore behavior and digest pins.
2. Package discovery for builtin, user, and project stores with source-qualified identity and collision diagnostics.
3. `.yoi-plugin` archive validation, deterministic digest computation, and digest-keyed cache materialization.
4. Plugin-layer permission policy that combines package requests with existing tool/scope/web/secret/runtime allowlists without using `pod::feature` HostAuthority concepts.
5. Declarative hook package loading from enabled, materialized packages.
6. WASM package ABI, initialization limits, host-function grants, and Tool/Hook contribution plumbing.
7. Optional lock-file or pin update workflow for reproducible fresh startup.
8. Future MCP/plugin bridge, only if explicitly approved as a separate design and implementation effort.

View File

@ -4,7 +4,7 @@ The conversation input is a bounded overview/index, not the full transcript. Tre
## Workflow
1. Read the provided overview/index and current TaskStore snapshot.
1. Read the provided overview/index, current TaskStore snapshot, and any Active Workflow Invocation State section.
2. If the overview does not contain enough detail, use `search_session_log` to find relevant compact-target history items, then `read_session_items` to inspect only the needed range.
3. Use `read_file` to inspect referenced files before deciding what the next session needs. Prefer skimming over blind inclusion.
4. For files whose current contents are load-bearing for the active work, call `mark_read_required` to inject them into the next session. These count against the auto-read token budget — spend it deliberately.
@ -39,5 +39,6 @@ Produce the summary in this exact format:
## Constraints
- Preserve active workflow invocation state when present: active slug, invocation scope/source/time, status, open obligations/checkpoints, and snapshotted workflow guidance. Do not replace a snapshotted invocation with merely advertised/latest workflow resources.
- Keep code snippets and raw tool output OUT of the summary — that is what auto-read and references are for.
- Follow the summary target stated in the run input; if asked to shrink, call `write_summary` again with a shorter version.

View File

@ -638,6 +638,43 @@ impl PanelHarness {
}
}
pub fn output_len(&self) -> usize {
self.output.lock().map(|output| output.len()).unwrap_or(0)
}
pub fn wait_for_output_contains_from(
&mut self,
start_offset: usize,
needle: &str,
timeout: Duration,
) -> Result<()> {
let start = Instant::now();
let needle = needle.as_bytes();
loop {
if self.output_after(start_offset, needle) {
return Ok(());
}
if let Some(status) = self.child.try_wait()? {
self.flush_output_artifact()?;
return Err(HarnessError::Protocol(format!(
"process exited with {status} before PTY output contained {:?}",
String::from_utf8_lossy(needle)
)));
}
if start.elapsed() >= timeout {
self.flush_output_artifact()?;
return Err(HarnessError::Timeout {
what: format!(
"PTY output containing {:?} after offset {start_offset}",
String::from_utf8_lossy(needle)
),
artifacts: self.artifacts.clone(),
});
}
thread::sleep(Duration::from_millis(20));
}
}
pub fn events(&mut self) -> Result<Vec<HarnessEvent>> {
let text = fs::read_to_string(&self.artifacts.events_jsonl)?;
text.lines()
@ -684,6 +721,21 @@ impl PanelHarness {
Ok(())
}
fn output_after(&self, start_offset: usize, needle: &[u8]) -> bool {
if needle.is_empty() {
return true;
}
self.output
.lock()
.map(|output| {
let start = start_offset.min(output.len());
output[start..]
.windows(needle.len())
.any(|window| window == needle)
})
.unwrap_or(false)
}
fn mouse_capture_enabled(&self) -> bool {
self.output
.lock()

View File

@ -135,14 +135,20 @@ fn panel_ctrl_c_exits_promptly_after_background_barrier() -> yoi_e2e::Result<()>
"quit latency {elapsed:?} exceeded threshold; artifacts at {}",
panel.artifacts().dir.display()
);
let events = panel.events()?;
assert!(
panel
.events()?
.iter()
.any(|event| event.event == "quit_requested"),
events.iter().any(|event| event.event == "quit_requested"),
"quit_requested observability event missing; artifacts at {}",
panel.artifacts().dir.display()
);
assert!(
events.iter().any(|event| {
event.event == "background_task_aborted"
&& event.data.get("task").and_then(serde_json::Value::as_str) == Some("reload")
}),
"pending reload task should be aborted before quit completes; artifacts at {}",
panel.artifacts().dir.display()
);
drop(panel);
assert_fixture_cleanup(fixture.cleanup()?);
Ok(())

View File

@ -13,6 +13,7 @@ fn single_pod_rewind_picker_applies_without_escape_and_suppresses_duplicate_ente
tui.assert_no_full_drag_mouse_capture()?;
tui.expect_event("rewind_fixture_ready", Duration::from_secs(5))?;
let before_rewind_output = tui.output_len();
tui.press(KeyPress::CtrlR)?;
tui.expect_event("rewind_picker_opened", Duration::from_secs(5))?;
@ -34,10 +35,15 @@ fn single_pod_rewind_picker_applies_without_escape_and_suppresses_duplicate_ente
.data
.get("composer_text")
.and_then(serde_json::Value::as_str),
Some("revise the plan"),
Some("rewind-live-refresh"),
"rewind should update the visible composer state without Esc/restart; artifacts at {}",
tui.artifacts().dir.display()
);
tui.wait_for_output_contains_from(
before_rewind_output,
"rewind-live-refresh",
Duration::from_secs(5),
)?;
assert_eq!(
tui.count_events("rewind_submit_sent")?,
submit_count,