Продолжение¶
Запустите flower в том же каталоге ещё раз — и он продолжит тот же разговор с того места, где он оборвался: процесс убили, терминал упал, машину перезагрузили — разницы нет. Вам не нужно знать слово «session» и не нужно запоминать никакие id. Эта страница про то, на чём это держится, когда оно молча перестаёт работать и как намеренно не продолжать.
Продолжение — это не передача
Продолжение — межпроцессное: следующий процесс подхватывает предыдущий запуск. Передача — внутри одного запуска: контекст почти полон, текущая сессия пишет документ передачи, и дело подхватывает новая сессия — см. передачу.
Эти две вещи сцепляются автоматически, ничего доводить руками не надо: родословная всегда помнит последнюю сессию, принявшую этот шаг, поэтому следующее пробуждение подхватывает именно преемника.
Какую задачу решает¶
На диске, вообще-то, есть всё. В runs/sessions.db лежит полный transcript каждой исторической сессии, 需求.md / 目标.md заморожены, код — в рабочем каталоге.
Теряется ровно одна строка отображения — «какой шаг использовал какую сессию». Раньше она жила только в памяти, в ctx["_sessions"], и исчезала вместе с процессом. Новый процесс поднимался, и координатор оказывался новичком с амнезией: кого он отправлял, какие тупики уже пробовал, почему отверг тот или иной вариант — всё заново.
В HT002 он потратил час на перебор флагов компиляции. Смените процесс — и этот час прожит впустую.
Как пользоваться (минимальный код)¶
В командной строке настраивать нечего, на пути flower продолжение включено по умолчанию:
cd ~/proj && flower "写个 md 转 html 的脚本" # первый раз
# …доработало, или вы нажали Ctrl-C и ушли, или машину перезагрузили
cd ~/proj && flower "顺便支持代码块高亮" # продолжает тот же разговор
cd ~/proj && flower # ничего не сказали = просто продолжай
cd ~/proj && flower --new "另一件事" # на этот раз не продолжать
Когда вы пишете свой workflow, продолжение тоже включено по умолчанию — значение 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() — это read-only проба, она не пишет ни байта:
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, ключ — project_key/session_id[/subpath]: transcript'ы subagent'ов хранятся отдельно по subpath |
runs/manifest.json | JSON-массив, манифест запуска, накапливающийся между процессами. Строка на шаг — единственное место, где потом можно найти 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 | Берёт сессию одноимённого шага из родословной и продолжает её |
resume_from="上一步名" | Продолжает ту же сессию, полный контекст | То же |
resume_from=…, fork=True | Ветвление, исходная сессия не загрязняется | То же |
Чтобы каждый процесс начинал с чистой новой сессии, надо явно написать Workflow(..., continuous=False).
При загрузке родословной есть ещё одна проверка: каждая прочитанная пара (имя шага, session_id) сперва проверяется через runtime.has_session(sid) — жива ли она ещё в sessions.db, и используется, только если жива. Причина в том, что файл родословной может пережить sessions.db, а resume несуществующей сессии взрывается только после того, как поднимется подпроцесс.
Имя шага — межпроцессно стабильный ключ
Родословная индексируется по Step.name. Поменяли имя шага — оборвали родословную: ошибки не будет, просто следующий запуск начнёт с новой сессии. Имена с суффиксами (#retry, #round, ·判定#) в родословную не попадают, Lineage.remember всегда использует исходное имя.
Два инварианта¶
Первый: получили session_id — сразу пишем на диск, не дожидаясь конца шага.
Жёсткое убийство процесса — как раз тот сценарий, от которого защищаемся. На этом уже обжигались: 2026-09-07 Terminal.app падал дважды, ядро слало 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 как раз вытащили из контейнера), сессии уже нет в базе (sessions.db удаляли), файл родословной побился. Любой из случаев молча откатывает к «начинаем с нуля».
Поле workspace — это охранник: project_key в SDK выводится из пути рабочего каталога (/, _, . заменяются на -), и после копирования каталога старый 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: его отправляет напрямую rt.run() внутри gate от with_goal (см. страж цели), и он никогда не проходит через путь родословной. Поэтому в каждом раунде и при каждом пробуждении это свежая пара глаз.
В этом вся его ценность: он не знает, сколько раз исполнитель пробовал и как ему было тяжело, — а значит, не станет искать за него оправдания. Пусти его через продолжение — и страж цели выродится в самоаудит.
Раздел 4 в tests/lineage_offline.py прибивает это гвоздями.
Фраза, сказанная при пробуждении, должна попасть в три места¶
flower "顺便支持代码块高亮" в уже использованном каталоге — это не новая задача, это ещё одна реплика. Она делает сразу три вещи, и если убрать хоть одну, всё молча перестаёт работать:
| Куда попадает | Что будет, если этого нет |
|---|---|
Дописывается в 需求.md (## 唤醒时追加) | Не переживёт границу шага. Следующий шаг — новая сессия, она читает только замороженные файлы |
| Идёт как prompt шага 干活 | Координатор её просто не получит |
Запускает перевывод 目标.md | Судья читает старый список, и сделано ли новое, в вердикт вообще не попадёт |
Третий пункт упускают чаще всего. Судья читает только замороженный 目标.md, добавленного по ходу он не видит; без перевывода он по старому списку вынесет «достигнуто», а то, что вам было нужно, не проверено вовсе. Цена — лишний прогон постановки цели при каждом добавлении (в HT002 замерено $0.41 / 3 минуты).
Пробуждение без единого слова (просто Enter) ничего не дописывает, ничего не перевыводит и не стоит ни цента.
Сбой внутри первого шага (确认需求) тоже подхватывается¶
У 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нейтральным пояснением «[上一轮在此处被中断,该工具结果未产生]», меняя только текст, а не убирая запись
Оставлять 1, а не 0, есть за что: отклонённый вызов никогда не выполнялся, информации в результате нет, а места он занимает немало (замерено: 273 символа = 93 знака формулы отказа + 180 знаков текста самой запрещённой команды), и вдобавок он вводит в заблуждение — на практике координатор, прочитав несколько «не использовать Bash напрямую», переставал пробовать даже разрешённый git status, обучившись выученной беспомощности. Но самая свежая запись полезна: она мешает модели в том же раунде раз за разом повторять одну и ту же перехваченную команду.
У снятия есть структурная красная линия: 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/раунд; сжатия не было ни разу, израсходовано 18.6% окна в 1M. Экстраполируя этот наклон, в стену упрёмся примерно на 440 раундах — предел «long-horizon» в нынешней форме составляет около 6 раз от того запуска. Вечное продолжение означает, что однажды окно кончится.
Этим управляют два механизма:
- Усечение (
flower --no-trimвыключает, на путиflowerвключено по умолчанию) — при resume старые крупные результаты инструментов заменяются указателями на файлы, содержимое не потеряно, просто не находится в контексте постоянно - Передача — по достижении порога пишется документ передачи и берётся новая сессия. Это не compact: документ читаемый и правимый, вам видно, что потеряно. Из-за этого контекст периодически падает, а не растёт до упора в стену
Поэтому строка при пробуждении обязана показывать размер контекста: человек видит его и получает шанс сам решить перезапуститься до удара в стену.
Заодно: --rounds (общее число раундов работы) сбрасывается при каждом пробуждении. Это сделано намеренно — новое пробуждение — это новое намерение, и оно не должно наследовать израсходованные в прошлый раз раунды.
Начать другое дело¶
Или прямо в приглашении пробуждения набрать /new:
Архивируем, не удаляем. lineage.json, 需求.md, 目标.md вместе переносятся в notes/archive/<YYYYmmdd-HHMMSS>/, а steps и woke в родословной одновременно обнуляются. Все три — три грани одного отрезка истории, и убрать только часть значит оставить полусостояние вида «цель ещё есть, а разговора уже нет».
sessions.db не трогается — это архив, каждый transcript в нём по-прежнему можно посмотреть.
В коде этому соответствует Lineage.archive(into, extra=[...]).
Когда этим пользоваться не надо¶
- Сценарии, где каждый раз нужен чистый старт. Пакетный прогон одного и того же workflow, контрольные замеры, воспроизведение бага для другого человека — сюда нельзя тащить прошлый контекст. Пишите
Workflow(..., continuous=False)или каждый раз меняйтеrun_dir. - Каталог будут переносить, копировать, или
run_dirнепостоянен. Запуск в контейнере, гдеruns/лежит на внутренней файловой системе контейнера, или rsync рабочего каталога на другую машину — продолжение молча перестанет работать (охранник пути отвергнет несовпадающую родословную), не считайте его гарантией. - Намерение новое, а контекст уже большой. Продолжение потащит с собой не относящуюся к делу историю, и вы будете платить за неё токенами в каждом раунде. Чем терпеть, лучше
--new— заархивировать и начать заново. - Одноразовый одиночный agent.
flower onceне проходит черезWorkflow, родословной у него нет; чтобы продолжить, придётся самому передать--resume <session_id>. - Считать продолжение резервной копией. Оно помнит только «какой шаг использовал какую сессию». Код, результаты и решения должны оседать в рабочем каталоге и на верстаке, а не выкапываться из 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