接続¶
同じディレクトリでもう一度 flower を実行すると、前回の会話の続きから話し始める —— プロセスが kill された、ターミナルが落ちた、マシンが再起動した、どれでも同じだ。session という単語を知る必要はないし、id を覚えておく必要もない。このページでは、それが何によって成り立つのか、どんなときに静かに失効するのか、そしてわざと接続しない方法を扱う。
接続は世代交代ではない
接続はプロセスをまたぐものだ:次のプロセスが前回の実行を引き継ぐ。 世代交代は同一実行の内部のものだ:コンテキストが満杯に近づくと、現在のセッションが 引き継ぎ書を書き、新しいセッションが引き継ぐ —— 世代交代を参照。
両者は自動で噛み合い、追加の配線は要らない:血縁が記録するのは常にそのステップで最後に 引き継いだセッションなので、次回の起動で接続されるのは引き継ぎ先だ。
何を解決するのか¶
ディスク上には実のところ全部ある。runs/sessions.db には各履歴セッションの完全な transcript があり、需求.md / 目标.md は凍結物、コードはワークスペースにある。
失われるのは一行のマッピングだけ —— 「どのステップがどの session を使ったか」。これは以前メモリ上の ctx["_sessions"] にしか存在せず、 プロセスが終われば消えた。だから新しいプロセスが立ち上がると、コーディネーターは記憶を失った新人になる:誰を派遣したか、 どの袋小路を試したか、なぜある案を却下したか、すべて一からやり直しだ。
HT002 では、コンパイルフラグを試して1時間まわり道した。プロセスが変われば、その1時間は無駄になる。
使い方(最小コード)¶
コマンドラインでは何も設定しなくていい。flower というパスでは接続はデフォルトで有効だ:
cd ~/proj && flower "写个 md 转 html 的脚本" # 初回
# …完走、あるいは Ctrl-C で離席、あるいはマシンが再起動した
cd ~/proj && flower "顺便支持代码块高亮" # 前回の会話の続き
cd ~/proj && flower # 何も言わない = 続きをやる
cd ~/proj && flower --new "另一件事" # 今回は前回に接続しない
自分でワークフローを書くときも、接続はデフォルトで有効だ —— Workflow.continuous のデフォルト値が True である:
import asyncio
from flower import AgentSpec, Runtime, Step, Workflow
terse = AgentSpec(
name="terse",
instructions="回答极简,一行以内,不解释不寒暄。",
allowed_tools=["Read", "Glob"],
max_turns=4,
)
async def main() -> None:
wf = Workflow([Step("取词", terse, "读 seed.txt,只回文件里那个词。")]) # continuous はデフォルト True
rt = Runtime(workspace=".", run_dir="runs")
try:
ctx = await wf.run(rt)
finally:
rt.close()
print(ctx["_woke"]) # 何回目の起動か、初回は 1
print(ctx["_sessions"]) # {"取词": "<session_id>"}
asyncio.run(main())
同じディレクトリでこのコードを二度目に実行すると、ctx["_woke"] は 2 になり、ctx["_sessions"]["取词"] は初回と 同じ id になる —— 「取词」のステップは新規セッションを開かず、前回のセッションを継続実行したということだ。
このディレクトリが接続できるかだけ知りたい
wake_state() は読み取り専用の探査で、1バイトも書かない:
from flower import wake_state
st = wake_state(".", run_dir="runs")
print(st["waking"], st["checks"], st["woke"], st["steps"])
返るのは {"waking", "brief", "goal", "checks", "woke", "steps"}。waking = ブリーフが存在し四つの節が揃っている; checks = 判定リストの件数;woke = すでに何回起動したか;steps = ステップ名から session_id へのマッピング。 コマンドラインはこれを使って、プロンプトで「何をするか」と訊くのか「前回の続き」と訊くのかを決めている。
実際に何をしているのか¶
ディスクに落ちる三つのファイル¶
run_dir のデフォルトは ./runs で、カレントワーキングディレクトリからの相対であり、workspace からの相対ではない。
| パス | 何が入るか |
|---|---|
runs/lineage.json | 血縁:{"workspace": "…", "woke": N, "steps": {"步骤名": "session_id"}}。接続はすべてこれ頼み |
runs/sessions.db | SQLite、全量 transcript。テーブルは entries / meta / summaries、key は project_key/session_id[/subpath] —— subagent の transcript は subpath で分けて格納 |
runs/manifest.json | JSON 配列、プロセスをまたいで累積する実行マニフェスト。1ステップ1行で、後から session_id を調べる唯一の場所 |
血縁ファイルはこうなっている:
{
"workspace": "/Users/you/proj",
"woke": 3,
"steps": {"干活": "47395075-bec7-466e-80cd-f4d60b360235"}
}
manifest.json の各行は StepResult の全フィールド —— step、session_id、ok、cost_usd、 num_turns、text、error、started_at、ended_at、attempts、errors[]、resumed、 retired[]、context —— に加えて手で補った duration_s(これは @property なので asdict() では拾えない)と run(プロセス標識、YYYYmmdd-HHMMSS-<6 位 hex>)。
ステップ名にはそこで四つの形があり、そのステップがどう完了したか一目でわかる:<步骤名>(初回の試行)、 <步骤名>#retry<N>(通常のリトライ)、<步骤名>#round<N>(判定が通らず差し戻されて続行)、 <步骤名>·判定#<N>(ジャッジのラウンド)。
書き込みは追記であって上書きではない:落とすたびにファイルを読み直し、run フィールドで重複排除する —— 本プロセスに属する行は最新のものに差し替え、 他プロセスの行はそのまま残す。だから同じディレクトリで複数の flower を並行実行しても安全だ。
continuous=True は resume_from の意味を変える¶
これがいちばん見落とされやすい:Workflow.continuous はデフォルト True なので、resume_from=None は 「まっさらな新規セッション」を意味しない。
| 書き方 | 同一実行の内部 | プロセスをまたぐ(continuous=True) |
|---|---|---|
resume_from=None(デフォルト) | 新規セッション、prompt で渡したコンテキストだけが頼り | 血縁の同名ステップの session を取って継続実行 |
resume_from="上一步名" | 同一セッションを継続実行、完全なコンテキスト | 同左 |
resume_from=…, fork=True | 分岐、元のセッションを汚さない | 同左 |
プロセスごとにきれいな新規セッションにしたければ、明示的に Workflow(..., continuous=False) と書く必要がある。
血縁をロードするときにもう一段の検証がある:読み戻した各 (ステップ名, session_id) について、まず runtime.has_session(sid) で sessions.db にまだ存在するか確認し、生きているものだけを使う。理由は、血縁ファイルが sessions.db より長生きすることがあり、 存在しない session を resume すると子プロセスが立ち上がるまで失敗が判明しないからだ。
ステップ名はプロセスをまたいで安定なキー
血縁は Step.name で索引される。ステップ名を変えれば血縁は切れる —— エラーにはならず、次回の実行がまっさらな新規セッションになるだけだ。 サフィックス付きの名前(#retry、#round、·判定#)は血縁に入らず、Lineage.remember が使うのは常に元の名前だ。
二つの不変条件¶
一、session_id を得た瞬間に書き込む。ステップの完了を待たない。
プロセスが強制的に殺される、まさにそれが防ぎたい場面だ。実際にやられている:2026-09-07 に Terminal.app が2回クラッシュし、カーネルが SIGHUP を送った。 SIGHUP のデフォルト動作は即時終了なので、finally は一行も走らない。当時の血縁はステップ境界で書いていたため、 最初のステップの途中で死んだその実行の steps は空になり、人はすでに答えた質問にもう一度答えさせられた(issue #6 参照)。
いまは Runtime.on_session が id を得た瞬間に落とす —— 実際にいちばん早いのは最初の assistant メッセージで、 init のシステムメッセージには Python SDK では session_id が付かない。書くときはまず .tmp に書いてからアトミックに置換するので、 途中で殺されても半端なファイルは残らない。書き込み失敗(OSError)は静かに飲み込み、この実行を巻き添えにしない。
このフックはruntime.run の一文だけを覆う。gate の前で try/finally により外す。ジャッジは同じ Runtime を使うので、掛けたままだとそのセッションが作業ステップの血縁に書き込まれてしまう。
二、合わなければ無かったことにする。エラーにはしない。
合わないケースは三つ:ワークスペースのパスが変わった(ディレクトリがコピーされた —— HT001 はコンテナからコピーして出したものだ)、 session がもう DB にない(sessions.db を削除した)、血縁ファイルが壊れた。どれでも静かに「最初から」へ戻る。
workspace というフィールドはガードだ:SDK の project_key はワークスペースのパスから導出される(/、_、. をすべて - に置換)ので、 ディレクトリがコピーされた後は古い session_id は新しい場所では引けない。だからパスが合わなければ無かったことにする。
接続は添え物であり、それが失効したからといって人の作業を止めてはならない。
プロセスが殺される場合と、マシンが再起動する場合¶
結果は同じ —— どちらも接続できる —— が、過程は違う:
| 状況 | 何が起きるか | 次回の実行 |
|---|---|---|
Ctrl-C 一回 | 協調的な中断で、メッセージ境界できれいに切れる。ついでに一言添えることもでき、同一プロセス内で同じセッションを resume して続行できる。中断は失敗試行に数えず、リトライ枠を消費しない | 接続とは無関係 |
Ctrl-C 二回 | 直接 KeyboardInterrupt を投げて終了。後始末はストアを閉じるところまでで、飛行中のステップは manifest.json に入らない | 血縁はとっくに落ちているので、接続できる |
SIGTERM / SIGHUP | ハンドラがまず rescue() を呼び、飛行中のステップも manifest.json に書き(error="killed-by-signal" を付す)、それからデフォルト動作に戻して本当に終了する | 同上、接続できる |
SIGKILL、停電、マシン再起動 | 後始末は一切なし | 同じく接続できる —— 三つのファイルはディスク上にあり、血縁は id を得た瞬間に書かれている |
前提はひとつだけ:同じ workspace と同じ run_dir。run_dir はカレントワーキングディレクトリからの相対なので、 別のディレクトリで flower を叩くと別の runs/ を見にいき、接続できない。
ジャッジは常に新規セッション¶
これは構造上の保証であり、覚えているかどうかに依存しない。
ジャッジは Step ではない —— with_goal の gate から直接 rt.run() で派遣される (ゴールガード参照)ので、血縁の経路を一度も通らない。だから毎ラウンド、毎回の起動でまっさらな目になる。
それこそが価値のすべてだ:ワーカーが何回試したか、どれだけ苦労したかを知らないから、代わりに言い訳を作らない。 これを接続させたら、ゴールガードは自己監査に成り下がる。
tests/lineage_offline.py の第 4 節がこれを固定している。
起動時に添えた一言は、三か所に落ちる必要がある¶
使用済みのディレクトリでの flower "顺便支持代码块高亮" は、新しいタスクではなく、また一言添えただけだ。 これは同時に三つのことをする —— 一つでも欠けると静かに失効する:
| どこに落ちるか | 欠けるとどうなるか |
|---|---|
需求.md に追記(## 唤醒时追加) | ステップ境界を越えられない。次のステップは新規 session で、凍結物しか読まない |
| 作業ステップの prompt として使う | コーディネーターがそもそも受け取れない |
目标.md の再導出を起動する | ジャッジが読むのは古いリストのままで、追加した事項が終わったかどうかが判定に入らない |
三つ目がいちばん漏れやすい。ジャッジは凍結された 目标.md しか読まないので、途中で追加したものは見えない —— 再導出しなければ古いリストに従って 「達成」と判定し、あなたが欲しかったその一件はまったく検証されない。代償は、追記のたびにゴール設定を一回余計に走らせること(HT002 の実測で $0.41 / 3 分)。
何も言わずに起動する(そのままエンター)場合は追記も再導出もせず、一銭も余計にかからない。
最初のステップ(要件確認)の途中でクラッシュしても接続できる¶
clarify_step には resume_prompt(定数 CLARIFY_RESUME)が付いている:確認の途中でクラッシュして再起動したとき、 クラリファイアに伝えるのは「さっき最後まで訊けなかった要件確認の続きだ —— やり直しではない」であって、元の要望を新しいタスクとしてもう一度投げ直すのではない。上の「session_id を得た瞬間に書き込む」と合わせて、 最初のステップで死に、需求.md がまだ凍結されていなかった実行も、いまは接続でき、答え直す必要がない。
逆に、すでに凍結された前段のステップは丸ごとスキップされる:需求.md の四つの節が揃っていれば要件確認をスキップし(内容は ctx には流し込む)、 目标.md が揃っていればゴール設定をスキップする。
接続時に送るのは同じ一言ではない¶
Step.resume_prompt がこれを司る。相手のコンテキストにはすでにブリーフ、ゴール、前回どこまでやったかが入っている。そこへ 「このブリーフに従ってやれ:<ブリーフ全文>」をそのまま再送するのは純粋なノイズだし、もっと悪いことに「要件が変わった、読み直せ」と解釈されうる。
resume_prompt を与えなければ prompt がそのまま使われる —— 全文を再送すべきステップもある(ゴール設定がリストを再導出するときに 必要なのは、まさにその完全なブリーフだ)。
起動時にまず一行報告する¶
<- 在 ~/explore/test-ide 接上上次 需求已确认 · 目标 15 条 · 干活上下文 80.2K · 第 3 次唤醒
== 干活 ============================== 3/3 <- 接上次 · 第 3 次唤醒
報告しなければ「本当に覚えているのか」がまったく感じ取れず、それこそがこの層の価値のすべてだ。バナーの 需求已确认 は常に出る。目标 N 条 は判定リストが空でないときだけ、干活上下文 X は sessions.db から そのセッションの最終ラウンドのコンテキスト規模を引けたときだけ出る。
コンテキストのその数字は意図的に見せている —— 理由は下の「代償」の節で述べる。
レジリエンス:切断時はぶら下がって待ち、しかもエラーは接続後のコンテキストに入らない¶
レジリエンスは接続とセットだ:一回に数時間走れば、ネットワークは必ず一度は切れる。そしてデフォルトの挙動はひどい —— 切断した瞬間に harness が transcript に合成 assistant メッセージ(isApiErrorMessage=true、 model="<synthetic>")を差し込み、本文は "API Error: Can't reach the API server …" になる。このメッセージがセッションの葉になり、 以降の resume では「モデルが直前に言ったこと」として食わせ直されるので、モデルは自分がネットワーク障害について議論していると思い込む。
Resilience は三つのことをする:
一、プローブは DNS + TCP だけ。 reachable(host, port, timeout=5.0) は getaddrinfo と TCP ハンドシェイク一回を走らせるだけで、 HTTP を送らず、資格情報も持たず、費用もかからない。あらゆる例外は到達不能とみなす。どのアドレスを探るかは endpoint() が決め、 ANTHROPIC_BASE_URL に追随する。デフォルトは https://api.anthropic.com、ポートのデフォルトは 443(http なら 80)。 自前のゲートウェイを使うならゲートウェイを探る必要がある —— api.anthropic.com が通じてもゲートウェイが通じる証明にはならない。
二、待つべきものと止めるべきものを区別する。 classify(text) は "transient" / "fatal" / "unknown" を返し、 先に fatal を判定してから transient を判定する —— 401 のようなテキストにはしばしば "connection" の文字が混じるので、順序が逆だと永久に待つことになる。 デフォルト値:max_attempts=6(初回を含む)、base_delay=4.0、max_delay=120.0、probe_timeout=5.0、 probe_interval=15.0、max_offline_wait=3600.0(1 時間)、retry_unknown=True。 バックオフは min(base_delay * 2**(attempt-1), max_delay) に ±25% のジッタを掛ける。
session_id を得ていれば やり直しではなく resume で継続するので、それまでの費用は無駄にならない。継続時に送るのは Resilience.resume_prompt:「前のラウンドは途中で中断され、完走していない。ワークベンチにすでに落ちているものを確認し、 中断点から続けよ、最初からやり直すな。」これは意図的にエラーの詳細を一切含まない —— モデルが知る必要があるのは「中断された、続けよ」であって、ENOTFOUND なのか 503 なのかではない。
三、リトライ嵐が生んだエラーは resume 後のコンテキストに入らない。 これは剪定の仕事だ。 Runtime のセッションストアは PruningSessionStore に固定されており、load() のときに三つのことをする:
- 合成 API エラーメッセージを取り除く。SQLite にはそのまま残す、食わせ直さないだけだ
- 古すぎる拒否された呼び出しを取り除き、直近
keep_denials=1件だけ残す - 中断で残った
tool_resultを中立的な説明「[前のラウンドはここで中断され、このツール結果は生成されていない]」に差し替える。本文だけ差し替え、エントリ自体は取り除かない
0 件ではなく 1 件残すのには理由がある:拒否された呼び出しは実行されていないので結果に情報はないが、占める場所は小さくなく (実測で一件 273 文字 = 拒否文 93 字 + 殺されたコマンド原文 180 字)、しかも誤誘導する —— 実測では、コーディネーターが「Bash を直接使うな」を数件読んだあと、許可されている git status すら試さなくなり、学習性無力感を身につけた。 だが最新の 1 件は役に立つ:同じラウンド内で同じ遮断済みコマンドを何度も再試行するのを防げる。
取り除きには構造上のレッドラインがある:transcript は parentUuid の単一リンクなので、一件取り除いたらその子を最も近い生存祖先に接ぎ直さなければならない。 さもないとチェーンがそこで切れ、それ以前の履歴が丸ごと失われる。
HT001 では実測で一度これに当たった:切断のタイムラインは 01:52:40 → 01:55:41、manifest.json のそのステップは attempts=2 / resumed=True / ok=True で、継続後さらに 8 時間以上走って完了した。
最後に誤解されやすい点:Runtime(trim=False) は「何も掃除しない」ではない。trim のデフォルトはそもそも False だが、 これが切るのは大きなツール結果のトリムの層だけだ。切断残骸の除去、拒否呼び出しの除去、 中断残留の中立化、使い捨てコマンド結果の期限切れマーキング —— この四つはそのまま行われる (ephemeral のデフォルトは True、keep_denials のデフォルトは 1)。
代償:コンテキストは伸び続け、しかも終わりがない¶
これは接続に固有の代償であって、バグではない。
HT001 の「作業」ステップは連続 10.44 時間走り、メインスレッドのコンテキストは 第 1 ラウンド 28.7K、第 20 ラウンド 35.2K、第 35 ラウンド 108.6K、第 50 ラウンド 158.2K、第 70 ラウンド 185.9K と 単調に増え、傾きは約 2.2K/ラウンド。全行程で圧縮は起きず、1M ウィンドウの 18.6% を使った。この傾きで外挿すると、 壁に当たるのは約 440 ラウンド —— 現在の形態の「長距離」の上限は、あの実行のおよそ 6 倍だ。永久接続とは、いつかウィンドウに当たるということだ。
二層の仕組みがこれを管理している:
- トリム(
flower --no-trimで無効化可、flowerのパスではデフォルト有効)—— resume 時に古い大きなツール結果を ファイルポインタに置き換える。内容は失われず、常駐しないだけだ - 世代交代 —— 閾値に達したら引き継ぎ書を書いて新しいセッションに替える。圧縮ではない: 文書は読めるし直せる、何が失われたかが見える。だからコンテキストは周期的に下がり、壁まで一直線に上がり続けることはない
だから起動時のあの一行はコンテキストの数字を報告しなければならない:人に見えて初めて、壁に当たる前に自分で作り直す判断ができる。
ついでに一つ:--rounds(作業の総ラウンド数)は起動のたびにリセットされる。これは意図的で —— 新しい起動は新しい意図であり、 前回消費したラウンド数を継承すべきではない。
別件を始める¶
あるいは起動プロンプトで直接 /new と打つ:
アーカイブであって、削除ではない。 lineage.json、需求.md、目标.md をまとめて notes/archive/<YYYYmmdd-HHMMSS>/ に移し、 血縁の steps と woke を同時にゼロにする。三つは同じ一段の歴史の三つの面であり、一部だけ片付けると「ゴールは残っているのに会話がない」 という中途半端な状態になる。
sessions.db は触らない —— これはアーカイブで、中の transcript はどれも引ける。
コードで書くときは Lineage.archive(into, extra=[...]) が対応する。
使うべきでないとき¶
- 毎回きれいな出発点であることが求められる場面。 同じワークフローを一括で走らせる、対照評価をする、他人にバグを再現してもらう —— これらは前回のコンテキストを持ち込むべきではない。
Workflow(..., continuous=False)と書くか、毎回run_dirを変える。 - ディレクトリが移動・コピーされる、または
run_dirが永続しない。 コンテナ内で走らせてruns/がコンテナの内層ファイルシステムに落ちる、 あるいはワークスペースを別のマシンへ rsync する —— 接続は静かに失効する(パスガードが合わない血縁を拒む)。 保証として当てにしないこと。 - 今回は新しい意図で、しかもコンテキストがすでに大きい。 接続は無関係な履歴まで背負い込み、毎ラウンドそのぶんの token を払うことになる。 我慢するより
--newでアーカイブして作り直すほうがいい。 - 使い捨ての単一 agent。
flower onceはWorkflowを通らず、血縁を持たない。継続したければ自分で--resume <session_id>を渡す。 - 接続をバックアップ代わりにする。 これは「どのステップがどの session を使ったか」しか記録しない。コード、成果物、決定はワークスペースと ワークベンチに落とすべきで、transcript から掘り返すことを当てにしてはいけない。
次に読むもの¶
- 世代交代 —— 同一実行の内部でコンテキストが満杯になったらどうするか。このページと同じ事柄の二つの方向
- ゴールガード —— ジャッジがなぜ接続しないのか
- コンテキストの経済学 —— トリム、剪定、スピルがそれぞれ何を担うか
- Python API ——
Lineage、Workflow.continuous、Step.resume_prompt、wake_state - コマンドライン ——
--new、--no-trim、--rounds、-r/--run-dir - ソース:
core/lineage.py·core/resilience.py·stores/prune.py·workflow/base.py