merge: integrate orchestration branch
This commit is contained in:
commit
169f29e960
|
|
@ -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"}
|
||||
|
|
@ -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'
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
|
|
|||
27
.yoi/tickets/00001KV3BQ7Q3/artifacts/e2e-evidence.md
Normal file
27
.yoi/tickets/00001KV3BQ7Q3/artifacts/e2e-evidence.md
Normal 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.
|
||||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
|
|
|||
736
crates/pod/src/active_workflow.rs
Normal file
736
crates/pod/src/active_workflow.rs
Normal 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(¶ms.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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod active_workflow;
|
||||
pub mod compact;
|
||||
pub mod controller;
|
||||
pub mod discovery;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
®istry.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, ®istry.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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
201
docs/design/plugin-packages.md
Normal file
201
docs/design/plugin-packages.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user