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'
|
title: 'Plugin distribution package format and discovery'
|
||||||
state: 'queued'
|
state: 'done'
|
||||||
created_at: '2026-06-01T06:49:53Z'
|
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_by: 'workspace-panel'
|
||||||
queued_at: '2026-06-14T15:40:15Z'
|
queued_at: '2026-06-14T15:40:15Z'
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -134,4 +134,245 @@ Marked ready by `yoi ticket state`.
|
||||||
Ticket を `workspace-panel` が queued にしました。
|
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"
|
title: "Preserve active workflows across compaction"
|
||||||
state: 'inprogress'
|
state: 'done'
|
||||||
created_at: "2026-06-07T02:23:28Z"
|
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_by: 'workspace-panel'
|
||||||
queued_at: '2026-06-14T15:23:07Z'
|
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 を記録する。
|
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'
|
title: 'Workspace panel: show Ticket-associated Intake Pods adjacent to Ticket rows'
|
||||||
state: 'inprogress'
|
state: 'done'
|
||||||
created_at: '2026-06-13T10:54:31Z'
|
created_at: '2026-06-13T10:54:31Z'
|
||||||
updated_at: '2026-06-14T15:24:58Z'
|
updated_at: '2026-06-14T15:55:36Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
readiness: 'implementation_ready'
|
readiness: 'implementation_ready'
|
||||||
risk_flags: ['panel-ux', 'local-role-session-registry', 'pod-session-state']
|
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 を記録する。
|
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-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-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 機能全体を無効化しない'
|
title: 'Panel: invalid Ticket があっても Ticket 機能全体を無効化しない'
|
||||||
state: 'queued'
|
state: 'done'
|
||||||
created_at: '2026-06-14T14:56:51Z'
|
created_at: '2026-06-14T14:56:51Z'
|
||||||
updated_at: '2026-06-14T15:37:04Z'
|
updated_at: '2026-06-14T16:38:01Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
readiness: 'implementation_ready'
|
readiness: 'implementation_ready'
|
||||||
risk_flags: ['tui-panel', 'ticket-backend', 'partial-failure', 'diagnostics']
|
risk_flags: ['tui-panel', 'ticket-backend', 'partial-failure', 'diagnostics']
|
||||||
|
|
|
||||||
|
|
@ -39,3 +39,316 @@ Next action:
|
||||||
- planning return ではなく queued のまま waiting とする。
|
- 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-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-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 で確認する'
|
title: '対象 TUI/Panel merge commit の挙動を現行 E2E で確認する'
|
||||||
state: 'queued'
|
state: 'done'
|
||||||
created_at: '2026-06-14T15:24:05Z'
|
created_at: '2026-06-14T15:24:05Z'
|
||||||
updated_at: '2026-06-14T15:37:04Z'
|
updated_at: '2026-06-14T16:54:05Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
readiness: 'implementation_ready'
|
readiness: 'implementation_ready'
|
||||||
risk_flags: ['e2e', 'tui', 'panel', 'regression-evidence']
|
risk_flags: ['e2e', 'tui', 'panel', 'regression-evidence']
|
||||||
|
|
|
||||||
|
|
@ -39,3 +39,200 @@ Next action:
|
||||||
- planning return ではなく queued のまま waiting とする。
|
- 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::info;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
|
use crate::active_workflow::ActiveWorkflowStore;
|
||||||
use crate::compact::state::CompactState;
|
use crate::compact::state::CompactState;
|
||||||
use crate::compact::usage_tracker::UsageTracker;
|
use crate::compact::usage_tracker::UsageTracker;
|
||||||
use session_store::SystemItem;
|
use session_store::SystemItem;
|
||||||
|
|
@ -71,6 +72,10 @@ pub(crate) struct PodInterceptor {
|
||||||
/// worker. `None` in tests / `Pod::new` paths where no writer is
|
/// worker. `None` in tests / `Pod::new` paths where no writer is
|
||||||
/// attached.
|
/// attached.
|
||||||
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
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 assigned by `on_prompt_submit`.
|
||||||
next_turn_index: AtomicUsize,
|
next_turn_index: AtomicUsize,
|
||||||
/// Tool calls observed in the current turn (reset on each new prompt).
|
/// Tool calls observed in the current turn (reset on each new prompt).
|
||||||
|
|
@ -86,6 +91,7 @@ impl PodInterceptor {
|
||||||
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
|
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
|
||||||
prompts: Arc<PromptCatalog>,
|
prompts: Arc<PromptCatalog>,
|
||||||
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
||||||
|
active_workflows: ActiveWorkflowStore,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
registry,
|
registry,
|
||||||
|
|
@ -96,6 +102,7 @@ impl PodInterceptor {
|
||||||
pending_attachments,
|
pending_attachments,
|
||||||
prompts,
|
prompts,
|
||||||
log_writer,
|
log_writer,
|
||||||
|
active_workflows,
|
||||||
next_turn_index: AtomicUsize::new(0),
|
next_turn_index: AtomicUsize::new(0),
|
||||||
tool_calls_this_turn: 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 {
|
async fn pre_llm_request(&self, context: &mut Vec<Item>) -> PreRequestAction {
|
||||||
|
self.active_workflows.sanitize_context(context);
|
||||||
|
|
||||||
let initial_tokens = self.estimated_tokens(context);
|
let initial_tokens = self.estimated_tokens(context);
|
||||||
if self.request_threshold_exceeded(initial_tokens, context) {
|
if self.request_threshold_exceeded(initial_tokens, context) {
|
||||||
return PreRequestAction::Yield;
|
return PreRequestAction::Yield;
|
||||||
|
|
@ -449,13 +458,15 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SystemItemCommitter for RecordingSystemItemCommitter {
|
impl SystemItemCommitter for RecordingSystemItemCommitter {
|
||||||
fn commit_system_item(&self, item: SystemItem) {
|
fn commit_log_entry(&self, entry: session_store::LogEntry) {
|
||||||
|
if let session_store::LogEntry::SystemItem { item, .. } = entry {
|
||||||
self.committed
|
self.committed
|
||||||
.lock()
|
.lock()
|
||||||
.expect("committed system-item list poisoned")
|
.expect("committed system-item list poisoned")
|
||||||
.push(item);
|
.push(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct AppendingPreRequestHook {
|
struct AppendingPreRequestHook {
|
||||||
saw_handle: Arc<AtomicBool>,
|
saw_handle: Arc<AtomicBool>,
|
||||||
|
|
@ -525,6 +536,7 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
|
ActiveWorkflowStore::new(),
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||||
|
|
@ -557,6 +569,7 @@ mod tests {
|
||||||
Some(Arc::new(RecordingSystemItemCommitter {
|
Some(Arc::new(RecordingSystemItemCommitter {
|
||||||
committed: Arc::clone(&committed),
|
committed: Arc::clone(&committed),
|
||||||
})),
|
})),
|
||||||
|
ActiveWorkflowStore::new(),
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||||
|
|
@ -593,6 +606,7 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
|
ActiveWorkflowStore::new(),
|
||||||
)
|
)
|
||||||
.with_usage_tracker(usage_tracker);
|
.with_usage_tracker(usage_tracker);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
|
|
@ -618,6 +632,7 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
|
ActiveWorkflowStore::new(),
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||||
|
|
@ -659,6 +674,7 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
|
ActiveWorkflowStore::new(),
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||||
|
|
@ -686,6 +702,7 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
|
ActiveWorkflowStore::new(),
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||||
|
|
@ -707,6 +724,7 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
|
ActiveWorkflowStore::new(),
|
||||||
);
|
);
|
||||||
let mut ctx: Vec<Item> = Vec::new();
|
let mut ctx: Vec<Item> = Vec::new();
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||||
|
|
@ -735,6 +753,7 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
Some(committer),
|
Some(committer),
|
||||||
|
ActiveWorkflowStore::new(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut ctx: Vec<Item> = Vec::new();
|
let mut ctx: Vec<Item> = Vec::new();
|
||||||
|
|
@ -782,6 +801,7 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
|
ActiveWorkflowStore::new(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut ctx: Vec<Item> = Vec::new();
|
let mut ctx: Vec<Item> = Vec::new();
|
||||||
|
|
@ -839,6 +859,7 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
|
ActiveWorkflowStore::new(),
|
||||||
);
|
);
|
||||||
let mut info = task_tool_call_info("TaskList", serde_json::json!({"scope": "all"}));
|
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())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
|
ActiveWorkflowStore::new(),
|
||||||
);
|
);
|
||||||
let info = task_tool_call_info("TaskList", serde_json::json!({}));
|
let info = task_tool_call_info("TaskList", serde_json::json!({}));
|
||||||
let mut result_info = ToolResultInfo {
|
let mut result_info = ToolResultInfo {
|
||||||
|
|
@ -935,6 +957,7 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
|
ActiveWorkflowStore::new(),
|
||||||
);
|
);
|
||||||
let history = vec![Item::user_message("hi"), Item::assistant_message("done")];
|
let history = vec![Item::user_message("hi"), Item::assistant_message("done")];
|
||||||
|
|
||||||
|
|
@ -969,6 +992,7 @@ mod tests {
|
||||||
Some(Arc::new(RecordingSystemItemCommitter {
|
Some(Arc::new(RecordingSystemItemCommitter {
|
||||||
committed: Arc::clone(&committed),
|
committed: Arc::clone(&committed),
|
||||||
})),
|
})),
|
||||||
|
ActiveWorkflowStore::new(),
|
||||||
)
|
)
|
||||||
.with_usage_tracker(Arc::clone(&usage_tracker));
|
.with_usage_tracker(Arc::clone(&usage_tracker));
|
||||||
|
|
||||||
|
|
@ -1028,6 +1052,7 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
|
ActiveWorkflowStore::new(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let items = interceptor.pending_history_appends().await;
|
let items = interceptor.pending_history_appends().await;
|
||||||
|
|
@ -1065,6 +1090,7 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
|
ActiveWorkflowStore::new(),
|
||||||
);
|
);
|
||||||
let mut ctx: Vec<Item> = vec![Item::user_message("hi")];
|
let mut ctx: Vec<Item> = vec![Item::user_message("hi")];
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||||
|
|
@ -1095,6 +1121,7 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
|
ActiveWorkflowStore::new(),
|
||||||
);
|
);
|
||||||
let mut ctx: Vec<Item> = Vec::new();
|
let mut ctx: Vec<Item> = Vec::new();
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
pub mod active_workflow;
|
||||||
pub mod compact;
|
pub mod compact;
|
||||||
pub mod controller;
|
pub mod controller;
|
||||||
pub mod discovery;
|
pub mod discovery;
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ use manifest::{
|
||||||
ScopeError, ScopeRule, SharedScope, WorkerManifest,
|
ScopeError, ScopeRule, SharedScope, WorkerManifest,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::active_workflow::{self, ActiveWorkflowStore};
|
||||||
use crate::compact::state::CompactState;
|
use crate::compact::state::CompactState;
|
||||||
use crate::compact::usage_tracker::UsageTracker;
|
use crate::compact::usage_tracker::UsageTracker;
|
||||||
use crate::feature::builtin::TaskFeature;
|
use crate::feature::builtin::TaskFeature;
|
||||||
|
|
@ -145,6 +146,7 @@ struct EmptyTurnRollbackSnapshot {
|
||||||
usage_history_len: usize,
|
usage_history_len: usize,
|
||||||
ai_activity_count: usize,
|
ai_activity_count: usize,
|
||||||
last_run_interrupted: bool,
|
last_run_interrupted: bool,
|
||||||
|
active_workflows: active_workflow::ActiveWorkflowSnapshot,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_ai_materialized_item(item: &Item) -> bool {
|
fn is_ai_materialized_item(item: &Item) -> bool {
|
||||||
|
|
@ -196,20 +198,23 @@ where
|
||||||
/// interceptor commit `SystemItem`s without being generic over the
|
/// interceptor commit `SystemItem`s without being generic over the
|
||||||
/// concrete `Store` type.
|
/// concrete `Store` type.
|
||||||
pub trait SystemItemCommitter: Send + Sync {
|
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>
|
impl<St> SystemItemCommitter for LogWriterHandle<St>
|
||||||
where
|
where
|
||||||
St: Store + Clone + Send + Sync + 'static,
|
St: Store + Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
fn commit_system_item(&self, item: SystemItem) {
|
fn commit_log_entry(&self, entry: LogEntry) {
|
||||||
let entry = LogEntry::SystemItem {
|
|
||||||
ts: segment_log::now_millis(),
|
|
||||||
item,
|
|
||||||
};
|
|
||||||
if let Err(err) = self.append_entry(entry) {
|
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.
|
/// the narrow snapshot/restore surface Pod needs for compaction and rewind.
|
||||||
/// Store/reminder ownership stays inside the Task feature module.
|
/// Store/reminder ownership stays inside the Task feature module.
|
||||||
task_feature: TaskFeature,
|
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.
|
/// Parsed system-prompt template awaiting first-turn materialisation.
|
||||||
/// `Some` until `ensure_system_prompt_materialized` renders it once,
|
/// `Some` until `ensure_system_prompt_materialized` renders it once,
|
||||||
/// then `None` forever — including after compaction.
|
/// 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(),
|
usage_history: self.usage_history.clone(),
|
||||||
tracker: None,
|
tracker: None,
|
||||||
task_feature: self.task_feature.clone(),
|
task_feature: self.task_feature.clone(),
|
||||||
|
active_workflows: self.active_workflows.clone(),
|
||||||
system_prompt_template: None,
|
system_prompt_template: None,
|
||||||
alerter: self.alerter.clone(),
|
alerter: self.alerter.clone(),
|
||||||
event_tx: self.event_tx.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())),
|
usage_history: Arc::new(Mutex::new(Vec::<UsageRecord>::new())),
|
||||||
tracker: None,
|
tracker: None,
|
||||||
task_feature: TaskFeature::new(),
|
task_feature: TaskFeature::new(),
|
||||||
|
active_workflows: ActiveWorkflowStore::new(),
|
||||||
system_prompt_template: None,
|
system_prompt_template: None,
|
||||||
alerter: None,
|
alerter: None,
|
||||||
event_tx: None,
|
event_tx: None,
|
||||||
|
|
@ -813,7 +824,16 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
registry: FeatureRegistryBuilder,
|
registry: FeatureRegistryBuilder,
|
||||||
) -> FeatureRegistryInstallReport {
|
) -> FeatureRegistryInstallReport {
|
||||||
let worker = self.worker.as_mut().expect("worker taken during run");
|
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.
|
/// Reference to the store.
|
||||||
|
|
@ -876,7 +896,11 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
self.sink.truncate_silent(truncate_entries);
|
self.sink.truncate_silent(truncate_entries);
|
||||||
|
|
||||||
self.task_feature.restore_from_history(&state.history);
|
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_request_config(state.config);
|
||||||
self.worker_mut().set_turn_count(state.turn_count);
|
self.worker_mut().set_turn_count(state.turn_count);
|
||||||
self.worker_mut()
|
self.worker_mut()
|
||||||
|
|
@ -1242,6 +1266,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
self.pending_attachments.clone(),
|
self.pending_attachments.clone(),
|
||||||
self.prompts.clone(),
|
self.prompts.clone(),
|
||||||
self.log_writer.clone(),
|
self.log_writer.clone(),
|
||||||
|
self.active_workflows.clone(),
|
||||||
)
|
)
|
||||||
.with_usage_tracker(self.usage_tracker.clone());
|
.with_usage_tracker(self.usage_tracker.clone());
|
||||||
self.worker_mut().set_interceptor(interceptor);
|
self.worker_mut().set_interceptor(interceptor);
|
||||||
|
|
@ -1428,6 +1453,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
usage_history_len,
|
usage_history_len,
|
||||||
ai_activity_count: self.ai_activity_counter.load(Ordering::SeqCst),
|
ai_activity_count: self.ai_activity_counter.load(Ordering::SeqCst),
|
||||||
last_run_interrupted: self.worker().last_run_interrupted(),
|
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);
|
.truncate(snapshot.usage_history_len);
|
||||||
let _ = self.usage_tracker.drain();
|
let _ = self.usage_tracker.drain();
|
||||||
let _ = self.metrics_tracker.drain();
|
let _ = self.metrics_tracker.drain();
|
||||||
|
self.active_workflows
|
||||||
|
.replace_with(snapshot.active_workflows);
|
||||||
|
|
||||||
let loc = self.segment_state.location();
|
let loc = self.segment_state.location();
|
||||||
self.store
|
self.store
|
||||||
|
|
@ -1535,6 +1563,14 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
let mut attachments = self.resolve_file_refs(&input);
|
let mut attachments = self.resolve_file_refs(&input);
|
||||||
attachments.extend(self.resolve_knowledge_refs(&input));
|
attachments.extend(self.resolve_knowledge_refs(&input));
|
||||||
attachments.extend(self.resolve_workflow_invocations(&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() {
|
if !attachments.is_empty() {
|
||||||
*self
|
*self
|
||||||
.pending_attachments
|
.pending_attachments
|
||||||
|
|
@ -1542,8 +1578,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
.expect("pending_attachments poisoned") = attachments;
|
.expect("pending_attachments poisoned") = attachments;
|
||||||
}
|
}
|
||||||
|
|
||||||
let flattened = self.flatten_segments(&input);
|
|
||||||
|
|
||||||
let history_before = self.worker.as_ref().unwrap().history().len();
|
let history_before = self.worker.as_ref().unwrap().history().len();
|
||||||
|
|
||||||
// lock → run → unlock
|
// 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 worker = self.worker.as_ref().expect("worker taken during run");
|
||||||
let history = worker.history();
|
let history = worker.history();
|
||||||
let retain_from = cut.index.min(history.len());
|
let retain_from = cut.index.min(history.len());
|
||||||
let retained_items = history[retain_from..].to_vec();
|
let mut retained_items = history[retain_from..].to_vec();
|
||||||
let items_to_summarise = 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-related knobs. Fall through to manifest defaults when
|
||||||
// `[compaction]` is omitted entirely.
|
// `[compaction]` is omitted entirely.
|
||||||
|
|
@ -2428,13 +2464,15 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Input text fed to the compact worker. Includes the default
|
// Input text fed to the compact worker. Includes the default
|
||||||
// references, current TaskStore snapshot, and the (pruned)
|
// references, current TaskStore snapshot, active workflow invocation
|
||||||
// conversation text.
|
// state, and the (pruned) conversation text.
|
||||||
let task_snapshot_text = self.task_feature.snapshot_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(
|
let summary_input = build_summary_input(
|
||||||
&items_to_summarise,
|
&items_to_summarise,
|
||||||
&default_refs,
|
&default_refs,
|
||||||
Some(task_snapshot_text.as_str()),
|
Some(task_snapshot_text.as_str()),
|
||||||
|
active_workflow_snapshot_text.as_deref(),
|
||||||
SummaryInputOptions {
|
SummaryInputOptions {
|
||||||
overview_target_tokens,
|
overview_target_tokens,
|
||||||
overview_warning_tokens,
|
overview_warning_tokens,
|
||||||
|
|
@ -2610,6 +2648,10 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
.count();
|
.count();
|
||||||
|
|
||||||
// Build new history: [summary, ...auto-read, references, ...retained, task snapshot, TaskList synthetic call/result].
|
// 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,
|
// The TaskStore snapshot trails the retained items so that, on resume,
|
||||||
// `replay_history` walks any pre-compact Task* calls preserved verbatim
|
// `replay_history` walks any pre-compact Task* calls preserved verbatim
|
||||||
// in retained_items first and the trailing snapshot's `replace_with`
|
// 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,
|
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
|
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 {
|
self.segment_state.set_location(SegmentLocation {
|
||||||
session_id: old_loc.session_id,
|
session_id: old_loc.session_id,
|
||||||
segment_id: new_segment_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;
|
let session_start = entry;
|
||||||
// Broadcast the SegmentStart through the sink. This atomically
|
// Broadcast the SegmentStart through the sink. This atomically
|
||||||
// resets the mirror to `[SegmentStart]` so any subscriber
|
// resets the mirror to the replacement segment prefix so any subscriber
|
||||||
// querying after this point sees the post-compaction prefix.
|
// querying after this point sees the post-compaction prefix, including
|
||||||
self.sink.reset_with_initial(session_start);
|
// 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
|
// Keep pods.json pointing at the live segment_id. Without this
|
||||||
// a concurrent `restore_from_manifest(new_segment_id)` would
|
// a concurrent `restore_from_manifest(new_segment_id)` would
|
||||||
// see no live writer and grab the session this Pod just moved
|
// 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())),
|
usage_history: Arc::new(Mutex::new(Vec::new())),
|
||||||
tracker: None,
|
tracker: None,
|
||||||
task_feature: TaskFeature::new(),
|
task_feature: TaskFeature::new(),
|
||||||
|
active_workflows: ActiveWorkflowStore::new(),
|
||||||
system_prompt_template: common.system_prompt_template,
|
system_prompt_template: common.system_prompt_template,
|
||||||
alerter: None,
|
alerter: None,
|
||||||
event_tx: None,
|
event_tx: None,
|
||||||
|
|
@ -3902,6 +3950,7 @@ where
|
||||||
usage_history: Arc::new(Mutex::new(Vec::new())),
|
usage_history: Arc::new(Mutex::new(Vec::new())),
|
||||||
tracker: None,
|
tracker: None,
|
||||||
task_feature: TaskFeature::new(),
|
task_feature: TaskFeature::new(),
|
||||||
|
active_workflows: ActiveWorkflowStore::new(),
|
||||||
system_prompt_template: common.system_prompt_template,
|
system_prompt_template: common.system_prompt_template,
|
||||||
alerter: None,
|
alerter: None,
|
||||||
event_tx: 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_request_config(state.config.clone());
|
||||||
worker.set_turn_count(state.turn_count);
|
worker.set_turn_count(state.turn_count);
|
||||||
worker.set_last_run_interrupted(state.last_run_interrupted);
|
worker.set_last_run_interrupted(state.last_run_interrupted);
|
||||||
|
|
@ -4111,6 +4162,8 @@ where
|
||||||
|
|
||||||
let extract_pointer = memory::extract::fold_pointer(&state.extensions);
|
let extract_pointer = memory::extract::fold_pointer(&state.extensions);
|
||||||
let task_feature = TaskFeature::from_history(&state.history);
|
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 pod_metadata_writer = Some(pod_metadata_writer_for_store(&store));
|
||||||
|
|
||||||
let mut pod = Self {
|
let mut pod = Self {
|
||||||
|
|
@ -4131,6 +4184,7 @@ where
|
||||||
usage_history: Arc::new(Mutex::new(state.usage_history)),
|
usage_history: Arc::new(Mutex::new(state.usage_history)),
|
||||||
tracker: None,
|
tracker: None,
|
||||||
task_feature,
|
task_feature,
|
||||||
|
active_workflows,
|
||||||
// Restore replays the saved system_prompt verbatim — no
|
// Restore replays the saved system_prompt verbatim — no
|
||||||
// template re-render on resume.
|
// template re-render on resume.
|
||||||
system_prompt_template: None,
|
system_prompt_template: None,
|
||||||
|
|
@ -4335,12 +4389,13 @@ struct SummaryInputBuild {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the compact worker's input: default-reference instructions,
|
/// Build the compact worker's input: default-reference instructions,
|
||||||
/// the list of recently-touched files, task snapshot, and a bounded overview
|
/// the list of recently-touched files, task snapshot, active workflow snapshot,
|
||||||
/// rather than a prefix-wide transcript.
|
/// and a bounded overview rather than a prefix-wide transcript.
|
||||||
fn build_summary_input(
|
fn build_summary_input(
|
||||||
items: &[Item],
|
items: &[Item],
|
||||||
default_refs: &[PathBuf],
|
default_refs: &[PathBuf],
|
||||||
task_snapshot: Option<&str>,
|
task_snapshot: Option<&str>,
|
||||||
|
active_workflow_snapshot: Option<&str>,
|
||||||
options: SummaryInputOptions,
|
options: SummaryInputOptions,
|
||||||
) -> SummaryInputBuild {
|
) -> SummaryInputBuild {
|
||||||
let overview = build_summary_overview(
|
let overview = build_summary_overview(
|
||||||
|
|
@ -4392,6 +4447,17 @@ fn build_summary_input(
|
||||||
out.push_str(task_snapshot);
|
out.push_str(task_snapshot);
|
||||||
out.push_str("\n\n");
|
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("## Conversation overview/index\n");
|
||||||
out.push_str(&overview);
|
out.push_str(&overview);
|
||||||
out.push_str("\n\nWhen you are done, call `write_summary` with the final 5-section text.");
|
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,
|
items,
|
||||||
&[],
|
&[],
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
SummaryInputOptions {
|
SummaryInputOptions {
|
||||||
overview_target_tokens: 512,
|
overview_target_tokens: 512,
|
||||||
overview_warning_tokens: 1024,
|
overview_warning_tokens: 1024,
|
||||||
|
|
@ -5326,6 +5393,27 @@ mod build_summary_prompt_tests {
|
||||||
assert!(!prompt.contains("deliberation"));
|
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]
|
#[test]
|
||||||
fn overview_warning_does_not_drop_input() {
|
fn overview_warning_does_not_drop_input() {
|
||||||
let items = vec![Item::user_message("x".repeat(4_000))];
|
let items = vec![Item::user_message("x".repeat(4_000))];
|
||||||
|
|
@ -5333,6 +5421,7 @@ mod build_summary_prompt_tests {
|
||||||
&items,
|
&items,
|
||||||
&[],
|
&[],
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
SummaryInputOptions {
|
SummaryInputOptions {
|
||||||
overview_target_tokens: 10,
|
overview_target_tokens: 10,
|
||||||
overview_warning_tokens: 100,
|
overview_warning_tokens: 100,
|
||||||
|
|
@ -5352,6 +5441,7 @@ mod build_summary_prompt_tests {
|
||||||
&items,
|
&items,
|
||||||
&[],
|
&[],
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
SummaryInputOptions {
|
SummaryInputOptions {
|
||||||
overview_target_tokens: 10,
|
overview_target_tokens: 10,
|
||||||
overview_warning_tokens: 10,
|
overview_warning_tokens: 10,
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,24 @@ impl SegmentLogSink {
|
||||||
let _ = self.inner.broadcast_tx.send(initial);
|
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.
|
/// Replace the mirror with the supplied prefix without broadcasting.
|
||||||
///
|
///
|
||||||
/// Used by restore paths that load a session's complete log into
|
/// Used by restore paths that load a session's complete log into
|
||||||
|
|
|
||||||
|
|
@ -765,6 +765,24 @@ pub struct TicketSummary {
|
||||||
pub updated_at: Option<String>,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct TicketDocument {
|
pub struct TicketDocument {
|
||||||
pub body: MarkdownText,
|
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 {
|
fn generated_heading(&self, default: &'static str, japanese: &'static str) -> &'static str {
|
||||||
if is_japanese_record_language(self.record_language()) {
|
if is_japanese_record_language(self.record_language()) {
|
||||||
japanese
|
japanese
|
||||||
|
|
@ -1045,6 +1106,27 @@ impl LocalTicketBackend {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ticket_from_dir(&self, dir: &Path) -> Result<Ticket> {
|
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 item_path = dir.join("item.md");
|
||||||
let parsed = read_item_file(&item_path)?;
|
let parsed = read_item_file(&item_path)?;
|
||||||
let meta = ticket_meta_for_dir(dir, parsed.frontmatter.clone())?;
|
let meta = ticket_meta_for_dir(dir, parsed.frontmatter.clone())?;
|
||||||
|
|
@ -1059,7 +1141,7 @@ impl LocalTicketBackend {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
let artifacts = collect_artifacts(&dir.join("artifacts"))?;
|
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_path = dir.join("resolution.md");
|
||||||
let resolution = if resolution_path.exists() {
|
let resolution = if resolution_path.exists() {
|
||||||
Some(MarkdownText::new(
|
Some(MarkdownText::new(
|
||||||
|
|
@ -1223,13 +1305,25 @@ impl LocalTicketBackend {
|
||||||
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
||||||
relations.extend(self.read_ticket_relations_for_dir(&dir)?);
|
relations.extend(self.read_ticket_relations_for_dir(&dir)?);
|
||||||
}
|
}
|
||||||
relations.sort_by(|a, b| {
|
sort_ticket_relations(&mut relations);
|
||||||
a.ticket_id
|
Ok(relations)
|
||||||
.cmp(&b.ticket_id)
|
}
|
||||||
.then_with(|| a.kind.cmp(&b.kind))
|
|
||||||
.then_with(|| a.target.cmp(&b.target))
|
fn all_ticket_relation_records_tolerant(
|
||||||
.then_with(|| a.at.cmp(&b.at))
|
&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)
|
Ok(relations)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1239,6 +1333,17 @@ impl LocalTicketBackend {
|
||||||
Ok(relation_view_from_records(meta, &all, &states))
|
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>> {
|
fn ticket_state_index(&self) -> Result<HashMap<String, TicketWorkflowState>> {
|
||||||
let mut states = HashMap::new();
|
let mut states = HashMap::new();
|
||||||
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
||||||
|
|
@ -1249,6 +1354,28 @@ impl LocalTicketBackend {
|
||||||
Ok(states)
|
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>> {
|
fn relation_blockers_for_meta(&self, meta: &TicketMeta) -> Result<Vec<TicketRelationBlocker>> {
|
||||||
Ok(self.relation_view_for_meta(meta)?.blockers)
|
Ok(self.relation_view_for_meta(meta)?.blockers)
|
||||||
}
|
}
|
||||||
|
|
@ -1274,21 +1401,7 @@ impl TicketBackend for LocalTicketBackend {
|
||||||
}
|
}
|
||||||
let parsed = read_item_file(&item)?;
|
let parsed = read_item_file(&item)?;
|
||||||
let meta = ticket_meta_for_dir(&dir, parsed.frontmatter)?;
|
let meta = ticket_meta_for_dir(&dir, parsed.frontmatter)?;
|
||||||
tickets.push(TicketSummary {
|
tickets.push(ticket_summary_from_meta(meta));
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Ok(tickets)
|
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 {
|
fn trim_owned(value: String) -> String {
|
||||||
value.trim().to_string()
|
value.trim().to_string()
|
||||||
}
|
}
|
||||||
|
|
@ -3633,6 +3812,47 @@ state: planning
|
||||||
assert!(report.is_ok(), "{:?}", report.diagnostics);
|
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]
|
#[test]
|
||||||
fn create_uses_configured_japanese_record_language_for_generated_defaults() {
|
fn create_uses_configured_japanese_record_language_for_generated_defaults() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -48,11 +48,11 @@ use crate::workspace_panel::{
|
||||||
ActionPriority, CompanionLifecyclePlan, CompanionPanelState, CompanionPanelStatus,
|
ActionPriority, CompanionLifecyclePlan, CompanionPanelState, CompanionPanelStatus,
|
||||||
CompanionPodPresence, ComposerTarget, NextUserAction, OrchestratorLifecyclePlan,
|
CompanionPodPresence, ComposerTarget, NextUserAction, OrchestratorLifecyclePlan,
|
||||||
OrchestratorPanelState, OrchestratorPanelStatus, OrchestratorPodPresence, PanelRow,
|
OrchestratorPanelState, OrchestratorPanelStatus, OrchestratorPodPresence, PanelRow,
|
||||||
PanelRowKey, TicketConfigAvailability, TicketLocalClaimStatus, WorkspacePanelViewModel,
|
PanelRowKey, PanelRowKind, TicketConfigAvailability, TicketLocalClaimStatus,
|
||||||
bounded_panel_diagnostic, build_current_ticket_row, build_workspace_panel,
|
WorkspacePanelViewModel, bounded_panel_diagnostic, build_current_ticket_row,
|
||||||
companion_pod_presence, decide_companion_lifecycle, decide_orchestrator_lifecycle,
|
build_workspace_panel, companion_pod_presence, decide_companion_lifecycle,
|
||||||
local_claim_status_for_pod, orchestrator_pod_presence, ticket_config_availability,
|
decide_orchestrator_lifecycle, local_claim_status_for_pod, orchestrator_pod_presence,
|
||||||
workspace_companion_pod_name, workspace_orchestrator_pod_name,
|
ticket_config_availability, workspace_companion_pod_name, workspace_orchestrator_pod_name,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_ENTRIES: usize = 50;
|
const MAX_ENTRIES: usize = 50;
|
||||||
|
|
@ -958,6 +958,17 @@ fn panel_e2e_row_key(key: &PanelRowKey) -> PanelE2eRowKey {
|
||||||
kind: "ticket",
|
kind: "ticket",
|
||||||
id: id.clone(),
|
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 {
|
PanelRowKey::Pod(name) => PanelE2eRowKey {
|
||||||
kind: "pod",
|
kind: "pod",
|
||||||
id: name.clone(),
|
id: name.clone(),
|
||||||
|
|
@ -1207,12 +1218,8 @@ impl MultiPodApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn selected_pod_entry(&self) -> Option<&PodListEntry> {
|
fn selected_pod_entry(&self) -> Option<&PodListEntry> {
|
||||||
match self.selected_row.as_ref() {
|
let name = self.selected_row.as_ref().and_then(PanelRowKey::pod_name)?;
|
||||||
Some(PanelRowKey::Pod(name)) => {
|
self.list.entries.iter().find(|entry| entry.name == name)
|
||||||
self.list.entries.iter().find(|entry| &entry.name == name)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -1238,11 +1245,14 @@ impl MultiPodApp {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let entry = self.selected_pod_entry()?;
|
if let Some(entry) = self.selected_pod_entry() {
|
||||||
if entry.actions.can_open {
|
if entry.actions.can_open {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(open_disabled_reason(entry))
|
return 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) {
|
pub(crate) fn select_next(&mut self) {
|
||||||
|
|
@ -1353,7 +1363,12 @@ impl MultiPodApp {
|
||||||
),
|
),
|
||||||
None => match &hit.key {
|
None => match &hit.key {
|
||||||
PanelRowKey::Pod(name) => (name.clone(), None, None),
|
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 {
|
PanelE2eRenderedRow {
|
||||||
|
|
@ -1406,7 +1421,9 @@ impl MultiPodApp {
|
||||||
}
|
}
|
||||||
if let Some(key) = visible.iter().find(|key| match key {
|
if let Some(key) = visible.iter().find(|key| match key {
|
||||||
PanelRowKey::Pod(name) => Some(name.as_str()) != orchestrator_pod_name,
|
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());
|
self.select_panel_key(key.clone());
|
||||||
return;
|
return;
|
||||||
|
|
@ -4677,6 +4694,18 @@ fn selected_ticket_notice(row: Option<&PanelRow>) -> String {
|
||||||
row.title
|
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(),
|
_ => "No Pod is selected.".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4793,7 +4822,7 @@ fn visible_panel_keys(panel: &WorkspacePanelViewModel, list: &PodList) -> Vec<Pa
|
||||||
let mut keys = panel
|
let mut keys = panel
|
||||||
.rows
|
.rows
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|row| row.is_ticket_action())
|
.filter(|row| row.is_ticket_section_row())
|
||||||
.map(|row| row.key.clone())
|
.map(|row| row.key.clone())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
keys.extend(
|
keys.extend(
|
||||||
|
|
@ -5145,7 +5174,7 @@ fn panel_action_rows(
|
||||||
let rows = panel
|
let rows = panel
|
||||||
.rows
|
.rows
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|row| row.is_ticket_action())
|
.filter(|row| row.is_ticket_section_row())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
if rows.is_empty() {
|
if rows.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
|
|
@ -5181,12 +5210,16 @@ fn panel_action_header_line(total: usize, width: u16) -> Line<'static> {
|
||||||
const TICKET_STATE_COLUMN_WIDTH: usize = 10;
|
const TICKET_STATE_COLUMN_WIDTH: usize = 10;
|
||||||
const POD_STATUS_COLUMN_WIDTH: usize = 18;
|
const POD_STATUS_COLUMN_WIDTH: usize = 18;
|
||||||
|
|
||||||
fn panel_row_lines(row: &PanelRow, selected: bool, width: u16) -> [Line<'static>; 2] {
|
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_title_line(row, selected, width),
|
||||||
panel_row_detail_line(row, selected, width),
|
panel_row_detail_line(row, selected, width),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn panel_row_title_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
|
fn panel_row_title_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
|
||||||
let title_style = if selected {
|
let title_style = if selected {
|
||||||
|
|
@ -5242,6 +5275,29 @@ fn push_ticket_marker_span(spans: &mut Vec<Span<'static>>, selected: bool, remai
|
||||||
}
|
}
|
||||||
|
|
||||||
fn panel_ticket_detail(row: &PanelRow) -> String {
|
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)];
|
let mut parts = vec![panel_ticket_reference(row)];
|
||||||
if let Some(blocked_reason) = row
|
if let Some(blocked_reason) = row
|
||||||
.ticket
|
.ticket
|
||||||
|
|
@ -5285,6 +5341,9 @@ fn panel_ticket_reason(row: &PanelRow) -> Option<&str> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ticket_detail_style(row: &PanelRow) -> Style {
|
fn ticket_detail_style(row: &PanelRow) -> Style {
|
||||||
|
if row.kind == PanelRowKind::InvalidTicket {
|
||||||
|
return Style::default().fg(Color::Yellow);
|
||||||
|
}
|
||||||
if row
|
if row
|
||||||
.ticket
|
.ticket
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
@ -5302,7 +5361,8 @@ fn panel_ticket_reference(row: &PanelRow) -> String {
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ticket| ticket.id.clone())
|
.map(|ticket| ticket.id.clone())
|
||||||
.unwrap_or_else(|| match &row.key {
|
.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(),
|
PanelRowKey::Pod(name) => name.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -7321,7 +7381,8 @@ branch = "orchestration/custom-panel"
|
||||||
"inprogress",
|
"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 title_line = plain_line(&title);
|
||||||
let detail_line = plain_line(&detail);
|
let detail_line = plain_line(&detail);
|
||||||
let state_start = 2;
|
let state_start = 2;
|
||||||
|
|
@ -7352,7 +7413,8 @@ branch = "orchestration/custom-panel"
|
||||||
"ready",
|
"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 title_line = plain_line(&title);
|
||||||
let detail_line = plain_line(&detail);
|
let detail_line = plain_line(&detail);
|
||||||
let state_start = 2;
|
let state_start = 2;
|
||||||
|
|
@ -7377,7 +7439,8 @@ branch = "orchestration/custom-panel"
|
||||||
"ready",
|
"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 title_line = plain_line(&title);
|
||||||
let detail_line = plain_line(&detail);
|
let detail_line = plain_line(&detail);
|
||||||
let title_start = 2 + TICKET_STATE_COLUMN_WIDTH + 1;
|
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.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());
|
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);
|
let detail_line = plain_line(&detail);
|
||||||
|
|
||||||
assert!(detail_line.contains("Gate: waiting for BLOCKER-1 via depends_on"));
|
assert!(detail_line.contains("Gate: waiting for BLOCKER-1 via depends_on"));
|
||||||
|
|
@ -8602,6 +8666,7 @@ branch = "orchestration/custom-panel"
|
||||||
blocked_reason: None,
|
blocked_reason: None,
|
||||||
related_pods: Vec::new(),
|
related_pods: Vec::new(),
|
||||||
local_claim: None,
|
local_claim: None,
|
||||||
|
intake_pods: Vec::new(),
|
||||||
};
|
};
|
||||||
PanelRow {
|
PanelRow {
|
||||||
key: PanelRowKey::Ticket(ticket.id.clone()),
|
key: PanelRowKey::Ticket(ticket.id.clone()),
|
||||||
|
|
|
||||||
|
|
@ -489,7 +489,7 @@ async fn run_e2e_rewind_fixture(
|
||||||
truncate_entries: 1,
|
truncate_entries: 1,
|
||||||
turn_index: 1,
|
turn_index: 1,
|
||||||
timestamp_ms: Some(1),
|
timestamp_ms: Some(1),
|
||||||
preview: "revise the plan".to_string(),
|
preview: "candidate rewind target".to_string(),
|
||||||
eligible: true,
|
eligible: true,
|
||||||
disabled_reason: None,
|
disabled_reason: None,
|
||||||
warning: None,
|
warning: None,
|
||||||
|
|
@ -500,7 +500,7 @@ async fn run_e2e_rewind_fixture(
|
||||||
"rewind_picker_opened",
|
"rewind_picker_opened",
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"targets": 1,
|
"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 {
|
if submitted_at.elapsed() >= apply_delay {
|
||||||
app.handle_pod_event(Event::RewindApplied {
|
app.handle_pod_event(Event::RewindApplied {
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
input: vec![Segment::text("revise the plan")],
|
input: vec![Segment::text("rewind-live-refresh")],
|
||||||
summary: RewindSummary {
|
summary: RewindSummary {
|
||||||
truncated_to_entries: 1,
|
truncated_to_entries: 1,
|
||||||
discarded_entries: 2,
|
discarded_entries: 2,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use protocol::PodStatus;
|
||||||
use ticket::config::{TICKET_CONFIG_RELATIVE_PATH, TicketConfig};
|
use ticket::config::{TICKET_CONFIG_RELATIVE_PATH, TicketConfig};
|
||||||
use ticket::{
|
use ticket::{
|
||||||
LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug,
|
LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug,
|
||||||
TicketMeta, TicketRelationBlocker, TicketSummary, TicketWorkflowState,
|
TicketInvalidRecord, TicketMeta, TicketRelationBlocker, TicketSummary, TicketWorkflowState,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::pod_list::{PodList, PodListEntry, StoredMetadataState};
|
use crate::pod_list::{PodList, PodListEntry, StoredMetadataState};
|
||||||
|
|
@ -182,16 +182,29 @@ impl OrchestratorPanelStatus {
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
pub(crate) enum PanelRowKey {
|
pub(crate) enum PanelRowKey {
|
||||||
Ticket(String),
|
Ticket(String),
|
||||||
|
InvalidTicket(String),
|
||||||
|
TicketIntakePod { ticket_id: String, pod_name: String },
|
||||||
Pod(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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(crate) enum PanelRowKind {
|
pub(crate) enum PanelRowKind {
|
||||||
Planning,
|
Planning,
|
||||||
Ticket,
|
Ticket,
|
||||||
Review,
|
Review,
|
||||||
ActiveWork,
|
ActiveWork,
|
||||||
|
TicketIntakePod,
|
||||||
Pod,
|
Pod,
|
||||||
|
InvalidTicket,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
|
@ -236,6 +249,30 @@ pub(crate) struct TicketPanelEntry {
|
||||||
pub(crate) blocked_reason: Option<String>,
|
pub(crate) blocked_reason: Option<String>,
|
||||||
pub(crate) related_pods: Vec<String>,
|
pub(crate) related_pods: Vec<String>,
|
||||||
pub(crate) local_claim: Option<TicketLocalClaimEntry>,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|
@ -279,11 +316,27 @@ pub(crate) struct PanelRow {
|
||||||
|
|
||||||
impl PanelRow {
|
impl PanelRow {
|
||||||
pub(crate) fn is_ticket_action(&self) -> bool {
|
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_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";
|
const ORCHESTRATOR_SUFFIX: &str = "-orchestrator";
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[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())
|
let backend = LocalTicketBackend::new(config.backend_root().to_path_buf())
|
||||||
.with_record_language(config.ticket_record_language());
|
.with_record_language(config.ticket_record_language());
|
||||||
match build_ticket_rows(&backend, pods, registry) {
|
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) => {
|
Err(error) => {
|
||||||
model
|
model
|
||||||
.header
|
.header
|
||||||
|
|
@ -574,12 +630,6 @@ fn build_workspace_panel_with_registry_model(
|
||||||
}
|
}
|
||||||
|
|
||||||
model.rows.extend(pod_rows(pods));
|
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
|
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(
|
fn build_ticket_rows(
|
||||||
backend: &LocalTicketBackend,
|
backend: &LocalTicketBackend,
|
||||||
pods: &PodList,
|
pods: &PodList,
|
||||||
registry: &PanelRegistrySnapshot,
|
registry: &PanelRegistrySnapshot,
|
||||||
) -> ticket::Result<Vec<PanelRow>> {
|
) -> ticket::Result<TicketRowsBuild> {
|
||||||
let mut rows = Vec::new();
|
let partial = backend.list_partial(TicketFilter::all())?;
|
||||||
for summary in backend.list(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 {
|
if summary.workflow_state == TicketWorkflowState::Closed {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let ticket = backend.show(TicketIdOrSlug::Query(summary.id.clone()))?;
|
match backend.show_partial(TicketIdOrSlug::Query(summary.id.clone())) {
|
||||||
rows.push(ticket_row(
|
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,
|
summary,
|
||||||
&ticket.events,
|
&ticket.ticket.events,
|
||||||
&ticket.relations.blockers,
|
&ticket.ticket.relations.blockers,
|
||||||
pods,
|
pods,
|
||||||
registry,
|
registry,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(rows)
|
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(),
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ticket_row(
|
fn ticket_row(
|
||||||
|
|
@ -653,7 +804,17 @@ fn ticket_row(
|
||||||
registry: &PanelRegistrySnapshot,
|
registry: &PanelRegistrySnapshot,
|
||||||
) -> PanelRow {
|
) -> PanelRow {
|
||||||
let local_claim = local_claim_for_ticket(&summary, pods, registry);
|
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 derived = derive_ticket_state(&summary, relation_blockers);
|
||||||
let latest_event = events.last();
|
let latest_event = events.last();
|
||||||
let entry = TicketPanelEntry {
|
let entry = TicketPanelEntry {
|
||||||
|
|
@ -669,6 +830,7 @@ fn ticket_row(
|
||||||
blocked_reason: derived.blocked_reason.clone(),
|
blocked_reason: derived.blocked_reason.clone(),
|
||||||
related_pods: related_pods.clone(),
|
related_pods: related_pods.clone(),
|
||||||
local_claim,
|
local_claim,
|
||||||
|
intake_pods,
|
||||||
};
|
};
|
||||||
let subtitle = ticket_subtitle(&entry);
|
let subtitle = ticket_subtitle(&entry);
|
||||||
PanelRow {
|
PanelRow {
|
||||||
|
|
@ -802,32 +964,111 @@ fn derive_ticket_state(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn related_pods_for_ticket(
|
fn associated_intake_entries_for_ticket(
|
||||||
summary: &TicketSummary,
|
summary: &TicketSummary,
|
||||||
pods: &PodList,
|
pods: &PodList,
|
||||||
registry: &PanelRegistrySnapshot,
|
registry: &PanelRegistrySnapshot,
|
||||||
) -> Vec<String> {
|
local_claim: Option<&TicketLocalClaimEntry>,
|
||||||
let id = lowercase(&summary.id);
|
) -> Vec<TicketAssociatedIntakeEntry> {
|
||||||
let mut names = Vec::new();
|
let mut entries = Vec::new();
|
||||||
if let Some(claim) = registry.claim_for_ticket(&summary.id) {
|
if let Some(claim) = local_claim.filter(|claim| is_intake_role(&claim.role)) {
|
||||||
names.push(claim.pod_name.clone());
|
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);
|
let mut related_sessions = registry
|
||||||
if !id.is_empty() && name.contains(&id) {
|
.sessions
|
||||||
Some(pod.name.clone())
|
.iter()
|
||||||
} else {
|
.filter(|session| {
|
||||||
None
|
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;
|
||||||
}
|
}
|
||||||
}) {
|
entries.push(TicketAssociatedIntakeEntry {
|
||||||
if !names.iter().any(|existing| existing == &pod) {
|
ticket_id: summary.id.clone(),
|
||||||
names.push(pod);
|
status: local_claim_status_for_pod(&pod_name, pods),
|
||||||
}
|
pod_name,
|
||||||
if names.len() >= 5 {
|
source: TicketAssociatedIntakeSource::RelatedSession,
|
||||||
|
});
|
||||||
|
if entries.len() >= MAX_ASSOCIATED_INTAKE_ROWS_PER_TICKET {
|
||||||
break;
|
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(
|
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)]
|
#[allow(dead_code)]
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::pod_list::{LivePodInfo, PodEntrySummary};
|
use crate::pod_list::{LivePodInfo, PodEntrySummary};
|
||||||
|
use crate::role_session_registry::{PanelRegistryStore, RelatedTicketRef, RoleSessionOrigin};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
@ -1070,6 +1308,179 @@ mod tests {
|
||||||
assert_eq!(row.next_action, Some(NextUserAction::Queue));
|
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]
|
#[test]
|
||||||
fn workspace_panel_does_not_infer_workflow_state_from_readiness_or_title() {
|
fn workspace_panel_does_not_infer_workflow_state_from_readiness_or_title() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
@ -1196,6 +1607,97 @@ mod tests {
|
||||||
assert_eq!(done.next_action, Some(NextUserAction::Close));
|
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]
|
#[test]
|
||||||
fn workspace_panel_displays_local_ticket_claim_status() {
|
fn workspace_panel_displays_local_ticket_claim_status() {
|
||||||
let temp = TempDir::new().unwrap();
|
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.
|
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.
|
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.
|
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.
|
6. [`design/plugin-packages.md`](design/plugin-packages.md) — plugin package distribution, discovery, and enablement boundaries.
|
||||||
7. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed.
|
7. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory, Knowledge, and audit records.
|
||||||
8. [`development/validation.md`](development/validation.md) — how to check changes.
|
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
|
## 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
|
## 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.
|
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.
|
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.
|
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
|
## 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.
|
- 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.
|
- 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>> {
|
pub fn events(&mut self) -> Result<Vec<HarnessEvent>> {
|
||||||
let text = fs::read_to_string(&self.artifacts.events_jsonl)?;
|
let text = fs::read_to_string(&self.artifacts.events_jsonl)?;
|
||||||
text.lines()
|
text.lines()
|
||||||
|
|
@ -684,6 +721,21 @@ impl PanelHarness {
|
||||||
Ok(())
|
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 {
|
fn mouse_capture_enabled(&self) -> bool {
|
||||||
self.output
|
self.output
|
||||||
.lock()
|
.lock()
|
||||||
|
|
|
||||||
|
|
@ -135,14 +135,20 @@ fn panel_ctrl_c_exits_promptly_after_background_barrier() -> yoi_e2e::Result<()>
|
||||||
"quit latency {elapsed:?} exceeded threshold; artifacts at {}",
|
"quit latency {elapsed:?} exceeded threshold; artifacts at {}",
|
||||||
panel.artifacts().dir.display()
|
panel.artifacts().dir.display()
|
||||||
);
|
);
|
||||||
|
let events = panel.events()?;
|
||||||
assert!(
|
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 {}",
|
"quit_requested observability event missing; artifacts at {}",
|
||||||
panel.artifacts().dir.display()
|
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);
|
drop(panel);
|
||||||
assert_fixture_cleanup(fixture.cleanup()?);
|
assert_fixture_cleanup(fixture.cleanup()?);
|
||||||
Ok(())
|
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.assert_no_full_drag_mouse_capture()?;
|
||||||
tui.expect_event("rewind_fixture_ready", Duration::from_secs(5))?;
|
tui.expect_event("rewind_fixture_ready", Duration::from_secs(5))?;
|
||||||
|
|
||||||
|
let before_rewind_output = tui.output_len();
|
||||||
tui.press(KeyPress::CtrlR)?;
|
tui.press(KeyPress::CtrlR)?;
|
||||||
tui.expect_event("rewind_picker_opened", Duration::from_secs(5))?;
|
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
|
.data
|
||||||
.get("composer_text")
|
.get("composer_text")
|
||||||
.and_then(serde_json::Value::as_str),
|
.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 {}",
|
"rewind should update the visible composer state without Esc/restart; artifacts at {}",
|
||||||
tui.artifacts().dir.display()
|
tui.artifacts().dir.display()
|
||||||
);
|
);
|
||||||
|
tui.wait_for_output_contains_from(
|
||||||
|
before_rewind_output,
|
||||||
|
"rewind-live-refresh",
|
||||||
|
Duration::from_secs(5),
|
||||||
|
)?;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tui.count_events("rewind_submit_sent")?,
|
tui.count_events("rewind_submit_sent")?,
|
||||||
submit_count,
|
submit_count,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user