docs: make workflow and goal lessons harness-first

This commit is contained in:
Haoran
2026-07-30 20:05:33 +08:00
parent cb8fae1bdd
commit 4bc33ec858
12 changed files with 115 additions and 207 deletions

View File

@@ -8,9 +8,7 @@ s01 → ... → s19 → s20 → `s21` → [s22](../s22_goal_loop/)
>
> **Harness 層**: Orchestration — single-agent loop の上に、決定的な multi-agent script runtime を追加します。
> **情報源の境界:** この章の製品詳細は Claude Code 2.1.177 の clean-room 行動再構成に基づく。後続リリースで名称や制限は変わり得る。`code.py` はオフライン教材モデルであり、製品ソースの複製ではない
>
> 教材 CLI は `async_launched` を出した後、再現可能な出力のため同じプロセスで完了を待つ。示すのは lifecycle と journal であり、main loop の並行実行そのものではない。
`code.py` は demo を決定的に保つため、`async_launched` を出した後、同じ process で完了を待ちます。常駐 background service を用意しなくても、lifecycle と journal を確認できます
---
@@ -26,9 +24,9 @@ s01 から s20 まで、loop は常にモデル駆動で 1 step ずつ進みま
## 計画は chat のラウンドを重ねず、コードに書く
Claude Code の tool pool に `Workflow` ツールがあります。あなたが渡すか、モデルが high-intensity mode で起動した script は、`agent() / parallel() / pipeline() / phase()` という少数の primitive を使い、orchestration を決定的なコードとして表します。
harness の tool pool に `Workflow` ツールを追加します。ユーザーまたはモデルが渡す script は、`agent() / parallel() / pipeline() / phase()` という少数の primitive を使い、orchestration を決定的なコードとして表します。
main loop から見えるのは 1 回の `tool_use` だけで、すぐ「バックグラウンドで起動済み」という結果を受け取ります。本当の実行は background runtime で進み、進捗をリアルタイムに報告し、全過程をディスク上の journal へ記録します。script の中間結果は変数に保存され、会話履歴の場所を取りません。`resumeFromRunId` で再開すると、変更されていない `agent()` は journal cache に当たり、以前の結果を直接使って checkpoint から続行します。
main loop から見えるのは 1 回の `tool_use` だけで、すぐ「バックグラウンドで起動済み」という結果を受け取ります。本当の実行は background runtime で進み、進捗をリアルタイムに報告し、全過程をディスク上の journal へ記録します。script の中間結果は変数に保存され、会話履歴の場所を取りません。`resume_from_run_id` で再開すると、変更されていない `agent()` は journal cache に当たり、以前の結果を直接使って checkpoint から続行します。
![Workflow Runtime Overview](images/workflow-runtime-overview.svg)
@@ -45,7 +43,7 @@ async def sample_workflow(ctx, args):
## Workflow ツール: バックグラウンド起動、main loop には 1 回の call だけ
`Workflow`(別名 `RunWorkflow`は main Agent の tool pool にあります。明示的に「この workflow 実行」と頼む、保存済みの `/command` を使う、またはモデルが自動で high-intensity path へ入ると、モデルが `Workflow(...)` tool call を出します。
`Workflow` は main Agent の tool pool にあります。ユーザーが保存済み workflow 実行を求めるか、タスクが既知の orchestration に一致したときにモデルがこのツールを選びます。どちらも 1 回の `Workflow(...)` tool call になります。
ツールは argument を parse し、meta 情報を検証し、permission check を通し、local workflow task を登録すると、すぐ「非同期で起動済み」と返します。main loop は block せず別の仕事を続け、workflow は background で実行されます。これは s13 の引換券 pattern を拡大したものです。先に引換券を渡し、結果ができたら通知します。
@@ -60,11 +58,9 @@ class WorkflowTool:
... # 残りはバックグラウンドで進む
```
> 実際の Claude Code は `{status:'async_launched', taskId, taskType:'local_workflow', runId, summary, transcriptDir, scriptPath}` をすぐ返し、background task の完了後に通知します。
## Workflow metadata: 起動前に検証する
## Script と meta: 1 行目を正しく書く
script の 1 行目は必ず `export const meta = { name, description, phases }` とし、変数、関数呼び出し、文字列連結を含まない純粋な literal でなければなりません。runtime はコードを一切実行する前に parse します。`name``description` は task と UI の表示に使い、`phases` は progress bar の group 名を定義します。
各 workflow は `name``description`、任意の `phases` を持つ metadata object を登録します。runtime は workflow code を実行する前に検証します。`name``description` は task と UI の表示に使い、`phases` は progress bar の group 名を定義します。
不正な入力はすぐ `WorkflowInputError` になり、登録時に止まります。s14 の cron 式検証と同じ考えです。不正な script が実行時まで進んでから壊れないようにします。
@@ -86,8 +82,6 @@ def validate_meta(meta):
return meta
```
> 実際の Claude Code の `parseWorkflowScript` は、meta を 1 行目の純粋な literal に限定します。教材版は dict を直接受け取り、この部分を簡略化しています。
## Orchestration primitive: この少数だけで、すべての flow を書ける
script は独立した context で動き、global variable として使えるのは少数の orchestration primitive だけです。script 自身はファイルを直接読み書きせず、shell も実行しません。実際のコード操作は、派遣された subagent が自分の tool permission で行います。primitive はすべて `ExecutionState` の method です。
@@ -113,8 +107,6 @@ async def pipeline(self, items, *stages):
return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)])
```
> 実際の Claude Code は同名 primitive を script VM の context へ注入します。さらに `args`、total/spent/remaining を持つ `budget`、最大 1000 Agent の上限、concurrency semaphore も提供します。
## 構造化出力: Subagent に散文を返させない
`agent({schema})` は、schema に一致する JSON object を subagent に要求します。内部では structured output call を 1 回使い、runtime が結果を schema で検証し、不一致なら 1 回 retry します。下流コードが受け取るのは規則的な object であり、再 parse が必要な長文ではありません。
@@ -132,8 +124,6 @@ if schema is not None:
raise WorkflowInputError(f"agent({{schema}}) の出力が不正です: {err}")
```
> 実際の Claude Code は `SimpleJsonSchema`、`StructuredOutput` ツール、schema-aware retry を組み合わせ、出力形式を保証します。
## Background task と progress event
`LocalWorkflowTask` は status と token usage を管理し、SDK style の event stream を外へ出します。`task_started` → phase change、subagent start、log batch を含む一連の `task_progress` → 完了、失敗、停止に加え、output file、token 数、tool call 数、所要時間を含む最後の `task_notification` です。
@@ -147,11 +137,9 @@ class LocalWorkflowTask:
print(f" progress {ptype} ...")
```
> 実際の Claude Code は進捗を task state へまとめ、`task_progress.workflow_progress` として UI と SDK へ送ります。
## 保存: Snapshot + journal で中断から再開する
各 run は `~/.claude/projects/<project>/<session>/` に 5 種類を書きます。`<runId>.json` snapshot、`<runId>.output.json` output、`<runId>.journal.jsonl` journal`scripts/<runId>.js` の script copy、`subagents/workflows/<runId>/` の subagent transcript です。保存した再利用可能な workflow は project scope の `.claude/workflows/` または user scope の `~/.claude/workflows/` に置きます。
この最小 runtime は各 run を `s21_workflow_runtime/.runtime/` に保存します。`<runId>.json` snapshot、`<runId>.output.json` output、`<runId>.journal.jsonl` journal です。production harness では workflow script や subagent transcript も保存できますが、snapshot と journal が安定した `runId` を共有することが重要です。
journal は checkpoint resume の中心で、各 `agent()` の結果を 1 行ずつ記録します。
@@ -165,7 +153,7 @@ class WorkflowJournal:
## Resume: runId から続行し、変更のないものを再利用する
`Workflow({scriptPath, resumeFromRunId, args})`呼ぶと script を再実行しますが、各 `agent()` は決定的な semantic key を計算します。journal に key があれば、再実行せず cached result を返します。変更のない call はすべて cache hit し、変更された call とそれに依存する後続 step だけが本当に動きます。
`resume_from_run_id` を渡して workflow を再度呼ぶと script を再実行しますが、各 `agent()` は決定的な semantic key を計算します。journal に key があれば、再実行せず cached result を返します。変更された call とそれに依存する後続 step だけが本当に動きます。
key は concurrency の完了順に依存してはいけません。`parallel``pipeline` の Agent は不定の順番で完了します。「何番目に完了したか」を key にすると、次回の cache が別の call へ対応してしまいます。そのため key は競合する counter ではなく、call の内容、つまり type、label、prompt、schema の stable hash です。
@@ -181,11 +169,9 @@ if cached is not MISS:
return cached
```
> 実際の Claude Code も「決定的 semantic key + journal cache」という考えです。同じ session で resume すると、完了済み `agent()` は cached result を直接返し、その後だけを実行します。
## 決定性: Resume に意味を持たせる再現性
resume が動くには、まず script が再現可能でなければなりません。runtime は `Date.now()`、引数なしの `new Date()``Math.random()` などの非決定的なものを script context から取り除き、Node native API も渡しません。同じ script + 同じ argument 同じ key → 100% cache hit になります。教材版は stable hash で同じ性質を得ます。実際の版は、非決定的な source を除いた sandbox VM で JavaScript 全体を実行します。
resume が動くには、workflow が再現可能でなければなりません。この最小 Python runtime は stable hash と決定的な mock runner を使い、同じ workflow + 同じ argument から同じ key を作ります。production harness では workflow code も隔離し、制御されていない clock、randomness、filesystem access などの非決定的な source を除くべきです。
## 実際に動かす

View File

@@ -8,9 +8,7 @@ s01 → ... → s19 → s20 → `s21` → [s22](../s22_goal_loop/)
>
> **Harness layer**: Orchestration — a deterministic multi-agent script runtime above the single-agent loop.
> **Source boundary:** Product details in this chapter are a clean-room behavioral reconstruction of Claude Code 2.1.177. Names and limits may change in later releases; `code.py` is an offline teaching model, not copied product source.
>
> The teaching CLI emits `async_launched` and then awaits completion in one process for deterministic output. It demonstrates the lifecycle and journal, not a concurrently running main loop.
`code.py` keeps the demo deterministic: it emits `async_launched` and then awaits completion in one process. This demonstrates the lifecycle and journal without requiring a long-running background service.
---
@@ -26,9 +24,9 @@ Making the model drive this process one round at a time in the main loop is slow
## Put the Plan in Code, Not in a Sequence of Chat Turns
Claude Code includes a `Workflow` tool in its tool pool. You, or the model when it enters a high-intensity mode, provide a script that expresses deterministic orchestration through a few simple primitives: `agent()`, `parallel()`, `pipeline()`, and `phase()`.
Add a `Workflow` tool to the harness tool pool. The user or model provides a script that expresses deterministic orchestration through a few simple primitives: `agent()`, `parallel()`, `pipeline()`, and `phase()`.
The main loop sees only one `tool_use` and immediately receives a "started in the background" result. Real execution continues inside the background runtime, which reports progress in real time and records every step in a journal on disk. Intermediate script results live in variables instead of taking space in conversation history. When restarted with `resumeFromRunId`, unchanged `agent()` calls hit the journal cache and reuse previous results, resuming from the checkpoint.
The main loop sees only one `tool_use` and immediately receives a "started in the background" result. Real execution continues inside the background runtime, which reports progress in real time and records every step in a journal on disk. Intermediate script results live in variables instead of taking space in conversation history. When restarted with `resume_from_run_id`, unchanged `agent()` calls hit the journal cache and reuse previous results, resuming from the checkpoint.
![Workflow Runtime Overview](images/workflow-runtime-overview.svg)
@@ -45,7 +43,7 @@ async def sample_workflow(ctx, args):
## The Workflow Tool: Start in the Background; the Main Loop Sees One Call
`Workflow`, also known as `RunWorkflow`, lives in the main agent's tool pool. You may explicitly ask to "run this workflow," invoke a saved `/command`, or let the model enter a high-intensity path automatically. In each case, the model emits a `Workflow(...)` tool call.
`Workflow` lives in the main agent's tool pool. The user can request a saved workflow, or the model can select the tool when a task matches a known orchestration. In either case, the model emits one `Workflow(...)` tool call.
The tool parses the arguments, validates metadata, checks permissions, registers a local workflow task, and immediately returns "started asynchronously." The main loop does not block and can continue with other work while the workflow runs in the background. This is the claim-ticket pattern from s13 at a larger scale: hand over the ticket now, notify the user when the result is ready.
@@ -60,11 +58,9 @@ class WorkflowTool:
... # The rest proceeds in the background
```
> The real Claude Code immediately returns `{status:'async_launched', taskId, taskType:'local_workflow', runId, summary, transcriptDir, scriptPath}`, then sends a notification when the background task finishes.
## Workflow Metadata: Validate Before Launch
## Script and Meta: The First Line Must Be Correct
The script's first line must be `export const meta = { name, description, phases }`, and it must contain only literals: no variables, function calls, or string concatenation. The runtime parses it before executing any code. `name` and `description` identify the task in the UI, while `phases` names groups in the progress display.
Each workflow registers a metadata object with `name`, `description`, and optional `phases`. The runtime validates it before executing any workflow code. `name` and `description` identify the task in the UI, while `phases` names groups in the progress display.
Invalid input raises `WorkflowInputError` immediately and is rejected during registration. This is the same idea as validating cron expressions in s14: do not wait until execution to discover a bad script.
@@ -86,8 +82,6 @@ def validate_meta(meta):
return meta
```
> The real Claude Code's `parseWorkflowScript` requires meta to be the first line and a pure literal. The teaching version accepts a dict directly to simplify this part.
## Orchestration Primitives: A Small Set Is Enough for Every Flow
A script runs in an isolated context with only a small set of orchestration primitives as globals. The script does not read files or run shell commands directly. All real code operations are performed by dispatched subagents under their own tool permissions. These primitives are methods on `ExecutionState`:
@@ -113,8 +107,6 @@ async def pipeline(self, items, *stages):
return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)])
```
> The real Claude Code injects same-named primitives into the script VM. It also exposes `args`, `budget` with total/spent/remaining values, an agent limit of up to 1000, and a concurrency semaphore.
## Structured Output: Do Not Let Subagents Return Essays
`agent({schema})` requires a subagent to return a JSON object matching the schema, internally through one structured-output call. The runtime validates the result and retries once if it does not match. Downstream code receives a regular object instead of a long essay that must be parsed again.
@@ -132,8 +124,6 @@ if schema is not None:
raise WorkflowInputError(f"agent({{schema}}) returned invalid output: {err}")
```
> The real Claude Code combines `SimpleJsonSchema`, a `StructuredOutput` tool, and schema-aware retries to enforce the output format.
## Background Tasks and Progress Events
`LocalWorkflowTask` maintains status and token usage and emits an SDK-style event stream: `task_started` → a sequence of `task_progress` events containing phase changes, subagent starts, and log batches → one final `task_notification` reporting completion, failure, or stop, plus output files, token count, tool calls, and elapsed time.
@@ -147,11 +137,9 @@ class LocalWorkflowTask:
print(f" progress {ptype} ...")
```
> The real Claude Code folds progress into task state and sends it to the UI and SDK as `task_progress.workflow_progress`.
## Storage: Snapshot + Journal for Resuming after Interruptions
Each run writes five artifacts under `~/.claude/projects/<project>/<session>/`: a `<runId>.json` snapshot, `<runId>.output.json` output, `<runId>.journal.jsonl` journal, a `scripts/<runId>.js` script copy, and subagent transcripts under `subagents/workflows/<runId>/`. Reusable workflows that you save live in `.claude/workflows/` at project scope or `~/.claude/workflows/` at user scope.
The minimal runtime stores each run under `s21_workflow_runtime/.runtime/`: a `<runId>.json` snapshot, `<runId>.output.json` output, and `<runId>.journal.jsonl` journal. A production harness may also persist the workflow script and subagent transcripts, but the key requirement is that the snapshot and journal share a stable `runId`.
The journal is the core of checkpointed resume. It records every `agent()` result one line at a time:
@@ -165,7 +153,7 @@ class WorkflowJournal:
## Resume: Continue by runId and Reuse Everything Unchanged
Calling `Workflow({scriptPath, resumeFromRunId, args})` reruns the script, but every `agent()` computes a deterministic semantic key. If that key is present in the journal, it returns the cached result without executing again. Every unchanged call hits the cache; only a changed call and the downstream steps that depend on it actually rerun.
Calling the workflow again with `resume_from_run_id` reruns the script, but every `agent()` computes a deterministic semantic key. If that key is present in the journal, it returns the cached result without executing again. Every unchanged call hits the cache; only a changed call and the downstream steps that depend on it actually rerun.
The key detail is that keys cannot depend on concurrency order. Agents in `parallel` and `pipeline` finish in nondeterministic order. If "the nth completion" became the key, cache entries would map to the wrong calls on the next run. A key therefore uses a stable hash of call content, including type, label, prompt, and schema, rather than a shared counter:
@@ -181,11 +169,9 @@ if cached is not MISS:
return cached
```
> The real Claude Code uses the same idea: deterministic semantic keys plus a journal cache. Resuming within the same session returns cached results for completed `agent()` calls and runs only the remaining ones.
## Determinism: Reproducibility Makes Resume Meaningful
Resume works only if the script is reproducible. The runtime therefore removes nondeterministic sources such as `Date.now()`, no-argument `new Date()`, and `Math.random()` from the script context, and does not expose native Node APIs. The same script plus the same arguments produces the same keys and a 100% cache hit. The teaching version obtains the same property through stable key hashing; the real version runs the entire JavaScript inside a sandboxed VM with those sources removed.
Resume works only if the workflow is reproducible. The minimal Python runtime uses stable hashes and a deterministic mock runner, so the same workflow plus the same arguments produces the same keys. A production harness should also isolate workflow code and remove uncontrolled clocks, randomness, filesystem access, and other sources of nondeterminism.
## See It Run

View File

@@ -8,9 +8,7 @@ s01 → ... → s19 → s20 → `s21` → [s22](../s22_goal_loop/)
>
> **Harness 层**: 编排 — 在单 agent 循环之上,加一层确定的多 agent 脚本运行时。
> **来源边界:** 本章产品细节来自对 Claude Code 2.1.177 的 clean-room 行为重建。后续版本可能更改名称与限制;`code.py` 是离线教学模型,不是产品源码复制
>
> 教学 CLI 会先发出 `async_launched`,随后在同一进程等待完成,以保证输出可复现。它演示的是生命周期与 journal不是并发运行的主循环。
`code.py` 为了让演示保持确定,会先发出 `async_launched`,随后在同一进程里等待执行完成。这样不用启动常驻后台服务,也能看清生命周期和 journal
---
@@ -26,9 +24,9 @@ s01 → ... → s19 → s20 → `s21` → [s22](../s22_goal_loop/)
## 计划写在代码里,不是靠聊天一轮轮凑
Claude Code 在工具池里放了一个 `Workflow` 工具。你(或者模型在高强度模式下触发)给它一段脚本,脚本用 `agent() / parallel() / pipeline() / phase()` 这几个简单的原语,把编排写成确定的代码。
在 harness 的工具池里加入一个 `Workflow` 工具。用户或模型给它一段脚本,脚本用 `agent() / parallel() / pipeline() / phase()` 这几个简单的原语,把编排写成确定的代码。
主循环这边只看到一次 `tool_use`,立刻拿到"已在后台启动"的返回:真正的执行在后台运行时里推进,实时上报进度,所有过程都写到磁盘的 journal 文件里。脚本里的中间结果存在变量里,不会塞进对话历史占地方。下次用 `resumeFromRunId` 重启时,没改过的 `agent()` 直接命中 journal 缓存,直接用之前的结果,断点续跑。
主循环这边只看到一次 `tool_use`,立刻拿到"已在后台启动"的返回:真正的执行在后台运行时里推进,实时上报进度,所有过程都写到磁盘的 journal 文件里。脚本里的中间结果存在变量里,不会塞进对话历史占地方。下次用 `resume_from_run_id` 重启时,没改过的 `agent()` 直接命中 journal 缓存,直接用之前的结果,断点续跑。
![Workflow Runtime 总览](images/workflow-runtime-overview.svg)
@@ -45,7 +43,7 @@ async def sample_workflow(ctx, args):
## Workflow 工具:后台启动,主循环只看到一次调用
`Workflow`(别名 `RunWorkflow`)就在主 agent 的工具池里。触发可能来自你显式说"跑一下这个 workflow"、一个保存好的 `/命令`,或者模型自动进入高强度路径,这时候模型会发一个 `Workflow(...)` 工具调用。
`Workflow` 就在主 agent 的工具池里。用户可以要求运行一个保存好的 workflow模型也可以在任务匹配已知编排时选择这个工具两种情况最终都只发出一次 `Workflow(...)` 工具调用。
工具收到后会解析参数、校验 meta 信息、过权限检查、注册一个本地 workflow 任务,然后立刻返回"已异步启动"。主循环不阻塞该干嘛干嘛workflow 自己在后台跑。这其实就是 s13 后台任务那套"凭条模式"的放大版:先给你个取件条,结果好了再通知你。
@@ -60,11 +58,9 @@ class WorkflowTool:
... # 剩下的后台慢慢跑
```
> 真实 Claude Code工具会立刻返回 `{status:'async_launched', taskId, taskType:'local_workflow', runId, summary, transcriptDir, scriptPath}`,后台任务跑完了再通知。
## Workflow 元数据:启动前先校验
## 脚本和 meta第一行必须写对
脚本的第一行必须是 `export const meta = { name, description, phases }`,而且必须是纯字面量,不能有变量、函数调用、字符串拼接。运行时在执行任何代码之前先解析它:`name``description` 用来显示任务和 UI`phases` 给进度条分组命名。
每个 workflow 都要注册一个元数据对象,包含 `name``description` 和可选的 `phases`。运行时会在执行任何 workflow 代码之前校验它:`name``description` 用来标识任务,`phases` 给进度条分组命名。
不对的输入直接抛 `WorkflowInputError`,注册的时候就拦住——这和 s14 校验 cron 表达式是一个思路:坏脚本别让它跑到执行的时候才炸。
@@ -86,8 +82,6 @@ def validate_meta(meta):
return meta
```
> 真实 Claude Code`parseWorkflowScript` 强制 meta 必须是第一行且是纯字面量;教学版直接收一个 dict简化了这部分。
## 编排原语:就这几个,够写所有流程
脚本跑在一个独立的上下文里,能用的全局变量就这几个编排原语。脚本本身不直接读写文件、不跑 shell真正的代码操作都由派出去的子 agent 用它们自己的工具权限完成。这些原语都是 `ExecutionState` 上的方法:
@@ -113,8 +107,6 @@ async def pipeline(self, items, *stages):
return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)])
```
> 真实 Claude Code同名原语由 VM 注入脚本上下文;还提供 `args`、`budget`(总预算/已花/剩余、agent 数量上限(最多 1000 个)、并发信号量这些控制。
## 结构化输出:别让子 agent 回来写散文
`agent({schema})` 会强制子 agent 返回一个匹配 schema 的 JSON 对象(内部通过一次结构化输出调用实现),运行时会按 schema 校验结果,不对就重试一次。这样下游代码拿到的是规整的对象,不是需要再解析的一大段散文。
@@ -132,8 +124,6 @@ if schema is not None:
raise WorkflowInputError(f"agent({{schema}}) 输出不合法: {err}")
```
> 真实 Claude Code用 `SimpleJsonSchema` + `StructuredOutput` 工具 + schema 重试机制保证输出格式。
## 后台任务和进度事件
`LocalWorkflowTask` 维护状态和 token 用量,向外发一条 SDK 风格的事件流:`task_started` → 一串 `task_progress`(包含阶段切换、子 agent 启动、日志输出这些批次)→ 最后一个 `task_notification`(完成/失败/停止带输出文件、token 数、工具调用数、耗时)。
@@ -147,11 +137,9 @@ class LocalWorkflowTask:
print(f" 进度 {ptype} ...")
```
> 真实 Claude Code进度会折叠进任务状态作为 `task_progress.workflow_progress` 发给 UI 和 SDK。
## 存储:快照 + journal断了能续
跑完会写五样东西,都存在 `~/.claude/projects/<项目>/<会话>/` 目录下:快照 `<runId>.json`、输出 `<runId>.output.json`journal `<runId>.journal.jsonl`、脚本副本 `scripts/<runId>.js`、子 agent 的对话记录 `subagents/workflows/<runId>/`。你自己保存的常用 workflow 放在 `.claude/workflows/`(项目级)或 `~/.claude/workflows/`(用户级)
这个最小运行时把每次运行的数据存在 `s21_workflow_runtime/.runtime/`:快照 `<runId>.json`、输出 `<runId>.output.json`journal `<runId>.journal.jsonl`。生产级 harness 还可以保存 workflow 脚本与子 agent 对话记录,但关键约束是快照和 journal 必须共享稳定的 `runId`
journal 是断点续跑的核心,它一条一条记下来每个 `agent()` 的结果:
@@ -165,7 +153,7 @@ class WorkflowJournal:
## resume用 runId 续跑,没改的直接用缓存
调用 `Workflow({scriptPath, resumeFromRunId, args})` 会重新跑脚本,但每个 `agent()` 算一个确定的语义 keykey 在 journal 里有记录,就直接返回缓存结果(不重跑),没改过的全部命中缓存;只有改过的那个以及它后面的步骤才会真的
带着 `resume_from_run_id` 再次调用 workflow 时,脚本会重新执行,但每个 `agent()` 都会计算一个确定的语义 keykey 在 journal 里有记录,就直接返回缓存结果;只有改过的调用以及依赖它的后续步骤才会真的运行
这里有个关键点key 不能依赖并发顺序。`parallel``pipeline` 里 agent 完成的顺序是不确定的,用"第几个完成"当 key两次跑缓存就对错位了。所以 key 是根据调用内容类型、标签、prompt、schema算的稳定哈希不是一个会竞争的计数器
@@ -181,11 +169,9 @@ if cached is not MISS:
return cached
```
> 真实 Claude Code同样是"确定语义 key + journal 缓存"的思路;同会话内续跑时,已经完成的 `agent()` 直接返回缓存,后面的才实跑。
## 确定性:能复现,续跑才有意义
续跑要能工作,脚本首先得可复现。所以运行时会把 `Date.now()`、无参 `new Date()``Math.random()` 这些不确定的东西从脚本上下文里去掉,也不给 Node 原生 API。同一份脚本 + 同样的参数同样的 key → 100% 缓存命中。教学版用稳定哈希算 key 达到同样的效果(真实版是把整段 JS 脚本跑在去掉了这些不确定源的沙箱 VM 里)
续跑要能工作,workflow 首先得可复现。这个最小 Python 运行时使用稳定哈希和确定性的 mock runner让同一份 workflow + 同样的参数产生同样的 key。生产级 harness 还应该隔离 workflow 代码,并移除不受控的时钟、随机数、文件系统访问等不确定来源
## 跑起来看看

View File

@@ -1,9 +1,5 @@
"""
s21_workflow_runtime — Dynamic Workflow runtime (teaching version)
Clean-room behavioral reconstruction of Claude Code's `Workflow` tool / dynamic
workflow runtime. Grounded in @anthropic-ai/claude-code@2.1.177 observed
behavior (reverse-research/cc_workflow), NOT leaked source.
s21_workflow_runtime — minimal dynamic Workflow runtime for a teaching harness
Idea:
s01-s20 build a single, model-driven agent loop. s21 adds a deterministic
@@ -16,14 +12,13 @@ Run:
python code.py # run the sample workflow, print the event stream
python code.py resume # resume the last run; unchanged agent() calls hit cache
Teaching simplifications (vs real runtime.mjs):
Implementation choices:
- The "subagent" is a deterministic MockAgentRunner, not a real LLM.
- A workflow is a plain async Python function, not a sandboxed JS script
string. The real runtime runs the script in an isolated JS VM with
Date.now()/Math.random() removed so resume is reproducible.
- A workflow is a plain async Python function. A production harness may use a
declarative format or run user-authored scripts in an isolated VM.
- The CLI emits `async_launched` and then awaits completion so the demo stays
deterministic. The real tool returns while execution continues in background.
- Storage is a local .runtime/ dir instead of ~/.claude/projects/.../workflows/.
deterministic. A long-running host can return while execution continues.
- Storage is a local .runtime/ directory beside this file.
"""
import asyncio
@@ -33,7 +28,7 @@ import re
import sys
from pathlib import Path
# ---- knobs that mirror the real runtime's guards ----
# ---- runtime guards ----
AGENT_CAP = 1000 # hard cap on agent() calls per run
CONCURRENCY = 8 # parallelism cap (semaphore)
STORE = Path(__file__).parent / ".runtime" # snapshots + journals live here
@@ -50,7 +45,7 @@ def _stable_hash(s: str) -> int:
def create_run_id(meta) -> str:
# Deterministic in the teaching version so the journal path is predictable
# and `resume` lands on the same file. The real runtime mints a random id.
# and `resume` lands on the same file.
return f"wf_{meta['name']}_{_stable_hash(meta['name']) % 10000:04d}"
@@ -68,15 +63,14 @@ def validate_run_id(run_id):
# Errors
# ============================================================
class WorkflowInputError(Exception):
"""Bad script / meta / schema input (mirrors WorkflowInputError)."""
"""Bad workflow, metadata, or schema input."""
# ============================================================
# meta validation
# ============================================================
def validate_meta(meta):
"""Real runtime requires `export const meta = {...}` as the FIRST statement,
a pure literal, with name + description (+ optional phases). We take a dict."""
"""Validate name, description, and optional phases before launch."""
if not isinstance(meta, dict):
raise WorkflowInputError("meta must be an object literal")
if not meta.get("name") or not meta.get("description"):
@@ -251,7 +245,7 @@ class WorkflowJournal:
# ============================================================
class Budget:
"""budget.total / spent() / remaining(). Once spent reaches total, agent()
calls raise (the real runtime enforces the same ceiling)."""
calls raise instead of silently overspending."""
def __init__(self, total=None):
self.total = total
@@ -313,8 +307,7 @@ class ExecutionLimits:
class ExecutionState:
"""Injected into the workflow script. Provides the orchestration primitives.
Mirrors ExecutionState in runtime.mjs."""
"""Injected into the workflow script with the orchestration primitives."""
def __init__(self, task, journal, runner, budget, args, depth=0, limits=None):
self.task = task
@@ -415,9 +408,8 @@ class ExecutionState:
# ============================================================
class WorkflowTool:
"""The Workflow tool. .call() validates meta, runs the permission check,
creates runId/taskId, registers a LocalWorkflowTask, and emits the same
lifecycle while this teaching CLI awaits the final result. Supports
resumeFromRunId. Mirrors WorkflowTool.call in runtime.mjs."""
creates runId/taskId, registers a LocalWorkflowTask, and emits lifecycle
events while this teaching CLI awaits the final result. Supports resume."""
async def call(self, meta, script_fn, args=None, resume_from_run_id=None):
validate_meta(meta)
@@ -479,7 +471,6 @@ def _read_last_run():
# ============================================================
# Sample workflow: review changed code across dimensions, verify each finding.
# Mirrors cc_workflow/runtime/workflows/review_workflow.js (pipeline + parallel).
# ============================================================
FINDINGS_SCHEMA = {
"type": "object", "required": ["findings"],
@@ -532,7 +523,7 @@ async def sample_workflow(ctx, args):
return {"confirmed": confirmed}
# saved workflow registry (.claude/workflows/ analogue)
# Saved workflow registry
WORKFLOWS = {SAMPLE_META["name"]: (SAMPLE_META, sample_workflow)}

View File

@@ -40,7 +40,7 @@
<!-- Workflow tool_use -->
<rect x="284" y="130" width="196" height="52" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5"/>
<text x="382" y="151" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700" font-family="monospace">Workflow({script, args})</text>
<text x="382" y="169" text-anchor="middle" fill="#888888" font-size="8.5" font-family="monospace">(or name | scriptPath) · resumeFromRunId</text>
<text x="382" y="169" text-anchor="middle" fill="#888888" font-size="8.5" font-family="monospace">(or name | script) · resume_from_run_id</text>
<line x1="480" y1="156" x2="518" y2="156" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
<!-- tool_result -->
@@ -104,7 +104,7 @@
<!-- Journal -> Script VM: resume cached -->
<path d="M 662 449 L 500 449 L 500 382" fill="none" stroke="#888888" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#arrow-gray)"/>
<text x="556" y="443" text-anchor="middle" fill="#888888" font-size="9" font-weight="600">resumeFromRunId -&gt; cached agent()</text>
<text x="556" y="443" text-anchor="middle" fill="#888888" font-size="9" font-weight="600">resume_from_run_id -&gt; cached agent()</text>
<!-- ===== Cross arrows between lanes ===== -->
<!-- launch (outer Workflow tool_use -> inner runtime), left side -->

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB