Compare commits

..

2 Commits

6 changed files with 43 additions and 67 deletions

15
KNOWN_ISSUES.md Normal file
View File

@ -0,0 +1,15 @@
# Known Issues
Ticket を切るほどではないが、次に近所を触るときに合わせて拾いたい小粒な所見の置き場。
## 運用
- 1 項目 = 出典 (file:line) + 症状 (一文) + トリガー (いつ拾うか、一文)
- 関連 ticket があれば `→ [tickets/foo.md]` でリンク
- 修正したら同じコミットで該当エントリを削除する (履歴は git)
- ここに溜める基準: 「ticket は重い」「だが忘れたら次の触り手が踏む」もの。明確に作業すべきものは ticket 化する
## エントリ
- `crates/tui/src/app.rs:478-485` — bad workflow slug を含む `Method::Run` 送信時、`Event::UserMessage` の早期 broadcast で `turn_index += 1` されターンヘッダだけ残る ("ghost turn header")。次に TUI のターンヘッダ / エラー表示周りを触るときに整理。→ [tickets/pod-input-validate-internalize.md] の review 由来。
- `crates/pod/src/controller.rs:944``worker_error_code``PodError::WorkflowResolve(_) => InvalidRequest` が post-commit な resolve エラー (`KnowledgeNotFound` 等) にも適用される。意味論的には妥当方向だが、resolve 系のエラー粒度を分けたくなったタイミングで再評価。

View File

@ -7,7 +7,6 @@
- Pod: 空応答ターン (Submit 後 AI 応答ゼロで Pause/Cancel) を自動巻き戻し → [tickets/pod-empty-turn-rollback.md](tickets/pod-empty-turn-rollback.md)
- Pod: 任意ターンからの Fork複数ターン巻き戻しを汎用化 → [tickets/pod-session-fork.md](tickets/pod-session-fork.md)
- Pod: Paused→Run の interrupt 前処理を Pod 内部に閉じる → [tickets/pod-interrupt-prep-internalize.md](tickets/pod-interrupt-prep-internalize.md)
- Pod: Run 入力の事前 validate を Pod 内部に閉じる → [tickets/pod-input-validate-internalize.md](tickets/pod-input-validate-internalize.md)
- Pod: Inbound PodEvent ハンドリングの重複を統合 → [tickets/pod-inbound-pod-event-dedup.md](tickets/pod-inbound-pod-event-dedup.md)
- Pod: セッションログをバックエンドにした Pod 単位の永続化 → [tickets/pod-persistent-state.md](tickets/pod-persistent-state.md)
- 永続化層のセマンティック整理 → [tickets/persistence-semantics.md](tickets/persistence-semantics.md)

View File

@ -610,19 +610,15 @@ async fn controller_loop<C, St>(
});
continue;
}
if let Err(e) = pod.validate_workflow_invocations(&input) {
let _ = event_tx.send(Event::Error {
code: ErrorCode::InvalidRequest,
message: e.to_string(),
});
continue;
}
// Broadcast the accepted user message so every
// subscriber (including the submitter) can render the
// turn header + user line from a single source of
// truth. shared_state's `user_segments` is re-synced
// from `pod` after the run completes, so we don't push
// here.
// Broadcast the user message so every subscriber
// (including the submitter) can render the turn header
// + user line from a single source of truth.
// shared_state's `user_segments` is re-synced from
// `pod` after the run completes, so we don't push
// here. Workflow-invocation validation happens inside
// `Pod::run` / `Pod::interrupt_and_run`; on failure the
// turn errors out via `Event::Error { InvalidRequest }`
// before any UserInput is committed.
let _ = event_tx.send(Event::UserMessage {
segments: input.clone(),
});
@ -945,6 +941,7 @@ fn worker_error_code(e: &PodError) -> ErrorCode {
_ => ErrorCode::Internal,
},
PodError::Provider(_) => ErrorCode::ProviderError,
PodError::WorkflowResolve(_) => ErrorCode::InvalidRequest,
_ => ErrorCode::Internal,
}
}

View File

@ -28,6 +28,14 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
&mut self,
input: Vec<Segment>,
) -> Result<PodRunResult, PodError> {
// Validate before any side effects so a bad workflow slug does
// not leave half-applied interrupt prep (orphan closure +
// system note) in worker history. `Pod::run` validates again at
// its own entry; the duplicate call is cheap (read-only) and
// collapses naturally once `interrupt_and_run` folds into
// `Pod::run` (see ticket pod-interrupt-prep-internalize).
self.validate_workflow_invocations(&input)?;
let tool_result_summary = self
.prompts()
.interrupt_tool_result_summary()

View File

@ -1136,6 +1136,12 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
/// the Worker is aborted, history is compacted, and execution resumes
/// automatically.
pub async fn run(&mut self, input: Vec<Segment>) -> Result<PodRunResult, PodError> {
// Validate workflow invocations up front so an invalid slug
// never commits a UserInput entry, never triggers pre-run
// compaction, and never half-applies interrupt prep when run
// from `interrupt_and_run`. Read-only against `workflow_registry`.
self.validate_workflow_invocations(&input)?;
self.prepare_for_run().await?;
// Persist the user input as typed segments before the worker
@ -1394,9 +1400,10 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
}
/// Validate explicit workflow invocations without reading dependency
/// bodies. Used by the controller before broadcasting `UserMessage` so
/// user-invocation errors are returned immediately and never reach the
/// Worker or client history.
/// bodies. Called from `Pod::run` / `Pod::interrupt_and_run` entry so
/// an invalid slug aborts the turn before any session-log commit or
/// interrupt-prep side effects; `pub` so completion / preview paths
/// can also dry-check inputs.
pub fn validate_workflow_invocations(
&self,
segments: &[Segment],

View File

@ -1,50 +0,0 @@
# Run 入力の事前 validate を Pod 内部に閉じる
## 背景
`controller_loop``Method::Run { input }` を受けた直後、`drive_turn` に流す前に `pod.validate_workflow_invocations(&input)` を呼んで invalid なら `ErrorCode::InvalidRequest` を返している (`crates/pod/src/controller.rs:613`):
```rust
if let Err(e) = pod.validate_workflow_invocations(&input) {
let _ = event_tx.send(Event::Error {
code: ErrorCode::InvalidRequest,
message: e.to_string(),
});
continue;
}
```
これは Pod 内部の `workflow_registry` を覗いてバリデーション判定する責務漏れになっている。`pod.validate_workflow_invocations` は read-only 関数で、`Pod::run` の冒頭でも当然呼べる。
なぜ controller が肩代わりしているかというと、`Pod::run` (`crates/pod/src/pod.rs:1138`) は冒頭で:
```rust
self.commit_entry(LogEntry::UserInput { ts: ..., segments: input.clone() })?;
self.user_segments.push(input.clone());
// ...
attachments.extend(self.resolve_workflow_invocations(&input)?);
```
の順で動く。`resolve_workflow_invocations` が失敗を返すころには既に UserInput が session log に commit されてしまっており、「invalid な input が history に残る」状態になる。それを避けるために controller 側で先打ち validate している。
[[pod-interrupt-prep-internalize]] と同じパターン: Pod 内部の整合性を保つ前処理が Controller 層に染み出している。
## 要件
- `Pod::run` の冒頭、`commit_entry(LogEntry::UserInput { .. })` よりも前に `validate_workflow_invocations` を呼び、エラー時は session log に何も commit せず `PodError` で early return する。
- `controller_loop``Method::Run` ハンドリングから事前 validate 呼び出しを削除する。
- `Pod::run` から返る `PodError`(新規に追加する workflow validation 失敗のバリアントを含む)を `worker_error_code` 等の controller 側エラーコードマッピングで `ErrorCode::InvalidRequest` にマップする。
- `PodError` に既存の InvalidRequest 相当バリアントがあればそれを使う。無ければ `WorkflowResolveError` をラップする最小限のバリアントを追加する。
- `Pod::validate_workflow_invocations` メソッドの可視性は `pub` のままでよいIPC `ListCompletions` 経路や他テストから参照される可能性があるので外向き API を狭めない)。
## 完了条件
- `controller.rs``Method::Run` ブランチに `pod.validate_workflow_invocations` の呼び出しが残っていない。
- 不正な workflow slug を含む `Method::Run` を投げると、UserInput が session log に commit されないまま `Event::Error { code: InvalidRequest, .. }` が flow する(既存の挙動と同等)。
- 既存の workflow invocation 関連テスト(成功 / NotFound / NotUserInvocable / InvalidSlugが通る。
## 範囲外
- `resolve_workflow_invocations` 側のロジック変更。
- `validate_workflow_invocations` の判定基準変更user_invocable / slug parse 等)。
- `Method::Run` 以外の経路Resume / RunForNotificationからの入力検証。Resume は入力を取らず、RunForNotification の入力は notify buffer drain で system message として入るため workflow invocation の経路に乗らない。