Перейти к содержанию

Продолжение

Запустите 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.jsonattempts=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 раз от того запуска. Вечное продолжение означает, что однажды окно кончится.

Этим управляют два механизма:

  1. Усечение (flower --no-trim выключает, на пути flower включено по умолчанию) — при resume старые крупные результаты инструментов заменяются указателями на файлы, содержимое не потеряно, просто не находится в контексте постоянно
  2. Передача — по достижении порога пишется документ передачи и берётся новая сессия. Это не compact: документ читаемый и правимый, вам видно, что потеряно. Из-за этого контекст периодически падает, а не растёт до упора в стену

Поэтому строка при пробуждении обязана показывать размер контекста: человек видит его и получает шанс сам решить перезапуститься до удара в стену.

Заодно: --rounds (общее число раундов работы) сбрасывается при каждом пробуждении. Это сделано намеренно — новое пробуждение — это новое намерение, и оно не должно наследовать израсходованные в прошлый раз раунды.

Начать другое дело

flower --new "另一件事"

Или прямо в приглашении пробуждения набрать /new:

接着上次? 直接回车 = 接着做;也可以说点新的;/new = 重开一件事(Ctrl-C 退出)
> /new
要做什么? 一句话就够,回车开始(Ctrl-C 退出)
> …

Архивируем, не удаляем. 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'ов.

Что читать дальше