本文へ移動
⏸️

LangGraph で Claude Code の作業を止めて後日再開する

37分
もくじ

仕事の開発の作業でも、個人のメモの整理でも、僕は AI への指示出しそのものに時間を取られていた。たとえばメモの整理では、セッションを区切って次のセッションを始めるたびに、AI が最初に「この理解で合ってますか?」と聞いてくる。僕が「ok」と返すまで、作業は先に進まない。本当は、AI に自動で進めてもらい、僕は意思決定だけをしたい。何が起きたかは、あとから見られる記録に残っていてほしい。そのうえで、従量課金に切り替えず定額プランの範囲のまま回し、人の判断が要る所でだけ止めて、続きはあとから動かしたい。

Claude Code は、Anthropic の AI(Claude)に、端末(コマンドを打ち込んで操作する画面)からプログラミングやファイルの作業を頼める道具だ。セッションは、Claude Code との会話の1回分を指す。定額プランは、月ごとに決まった料金を払い、決まった利用枠の中で Claude を使う契約だ。従量課金は、API キー(プログラムから Claude を呼ぶための鍵)を使い、使った量に応じて払う形である。僕は、メモを Obsidian(Markdown という書き方のテキストファイルでメモを書くアプリ)で書き、1つのフォルダーにまとめている。

工程の順番と判断待ちは LangGraph に持たせ、Claude Code を呼ぶのは1つの部品にする

AI に任せる作業は、工程に分ける。工程の順番と、止まって人の判断を待つ位置は LangGraph(作業の工程の順番を決めて動かす道具)に持たせ、Claude Code を呼ぶのは1つの部品だけにする。こうすると、人が呼ばれるのは判断のときだけになり、止めたあと別の日に続きから動かせる。

僕はこれを、メモのフォルダーのリンク切れを点検するという、読むだけの小さな作業で1回確かめた。本物の Claude Code が決めた形の答えを返し、判断待ちで終わったプログラムの続きを別のプロセス(動いているプログラム1つ)から動かせた。僕が打ったのは、再開の1行だけだった。とはいえ、1回動かしただけなので、付きっきりになる回数が減ったかまでは分からない。確かめたのは、仕組みがつながって動くことと、動かす手間までだ。

登場するもの: LangGraph、claude -p、手元のデータベース

登場するものの配置は、次のようになる。

start、resume 状態を保存し、読み出す 読むだけ 僕(端末) LangGraph のグラフ工程の順番と判断待ち SQLite のファイルメモのフォルダーの外 Claude Code を呼ぶ部品claude -p を包む 点検の対象のフォルダー 記録の Markdown1回の実行に1ファイル

LangGraph は、LangChain 社が作っている、作業の工程の順番を決めて動かすためのライブラリ(プログラムに組み込んで使う、ほかの人が書いたコード)だ。僕は TypeScript 版を使った。工程は関数1つで書き、どの工程の次にどの工程へ進むかを線でつなぐ。工程をつないだもの全体を、グラフと呼ぶ。たとえば「対象を読む」の次は「報告案を作る」、と書く。

LangGraph の公式の文書によると、工程の中で interrupt() を呼ぶと、グラフはそこで止まる。止まったときの状態(どの工程まで進んだかと、工程の間で受け渡す値)は保存され、実行の識別子(thread_id)を指定して Command({ resume }) を渡すと、同じ位置から続きが動く。

claude -p は、Claude Code を画面で会話せずに使う方法だ。指示を1回渡すと、答えを1回返して終わる。だからプログラムから呼べる。

echo "say ok" | claude -p --output-format json

--output-format json を付けると、答えが JSON(JavaScript Object Notation。データを文字で書き表す書き方の1つ)で返る。さらに --json-schema に JSON Schema(JSON の形を書くための決まり)で書いた定義を渡すと、答えの JSON の structured_output に、その形のデータが入る。この「決めた形の答え」を、構造化した出力と呼ぶ。

手元のデータベースは、SQLite だ。SQLite は、1つのファイルで動く小さなデータベースである。LangGraph の TypeScript 版には、止まったときの状態を SQLite に保存する仕組み(SqliteSaver)がある。

最初から LangGraph を使い、役目を3つに絞った理由

止まって人の判断を待つ作りには、ほかの案もあった。

案人の判断を待つ方法選ばなかった理由、選んだ理由
自作の薄いプログラムで始め、苦しくなってから LangGraph を入れる止める仕組みも、続きから動かす仕組みも自分で書く止まる、再開する、介入を数える、の3つを確かめるのが後になる
Claude Code の Dynamic Workflows実行の途中では人の入力を待てない。一時停止と再開はできる人の判断を1つの実行の途中に挟めない
最初から LangGraph を使うinterrupt() で止まり、保存した状態から続きを動かす3つを最初の1回から確かめられる

Dynamic Workflows は、Claude が作業専用の手順を組み、複数の AI を並べたり段階的に動かしたりする Claude Code の機能だ。公式の文書には、実行の途中で人の入力を待てず、段階の間で承認を取るなら段階ごとに別の実行にする、とある。実行を一時停止して、あとで再開する機能はある。とはいえ、僕が欲しいのは、途中で止まって人の判断を受け取り、その判断を使って続きを動かす作りだ。人の判断を実行の途中に挟めないので、選ばなかった。

だから最初から LangGraph を入れた。ただし、LangGraph に持たせる役目は3つだけに絞った。工程の順番、再開する位置、人の判断待ちである。Claude Code を呼ぶこと、候補を集めること、記録を書くことは、どれもふつうの関数で書き、LangGraph には工程として並べるだけにした。付きっきりの回数を減らすのに効くのは、どの道具を選ぶかより、あいまいな確認を何回求めるかのほうだと考えたからだ。LangGraph にたくさん任せても、確認の回数は減らない。

リンク切れの点検を4つの工程に分ける

Obsidian では、[会議メモ](/会議メモ) のように書くと、「会議メモ」という名前のノートへのリンクになる。リンク切れは、リンク先の名前のノートが見つからないリンクだ。たとえば [会議メモ](/会議メモ) と書いたのに、会議メモというノートがどこにも無い。打ち間違いのこともあれば、これから書くつもりのノートへ先にリンクを張っておいたこともある。

点検は、次の4つの工程に分けた。

  • 対象を読む(collect): 指定したフォルダーのノートを読み、リンク切れの候補を集める
  • 報告案を作る(draft): Claude Code に、候補ごとに「打ち間違い」「まだ無いノートへの、わざと張ったリンク」「無視してよい」のどれかを、理由つきで判定させる
  • 人の判断を待つ(review): 止まって、僕が報告案を承認するか却下するかを待つ
  • 記録する(record): 報告案、僕の判断、介入の回数を Markdown のファイルに書く

候補を集めるのは、プログラムが機械的に行う。名前のノートがあるかどうかは、機械で決まるからだ。Claude Code には、判断が要る部分だけを任せる。Claude Code が候補に無いリンクを報告に入れてきたら、その項目は捨て、捨てた件数を記録に書く。

候補が0件のときや、Claude Code を呼んで失敗したときは、判断を求めずに記録へ進む。判断する材料が無いのに僕を呼んでも、「ok」と返す仕事が1つ増えるだけだからだ。

ある できた 0件 失敗 collect対象を読みリンク切れの候補を集める 候補はあるか draftClaude Code に報告案を作らせる 報告案ができたか review止まって人の判断を待つ record記録をMarkdown に書く

グラフのコードは、次のようになる。候補を集める関数、指示文を組み立てる関数、記録を書く関数は、ふつうの関数なので載せていない。

// graph.ts
import { Annotation, END, START, StateGraph, interrupt } from "@langchain/langgraph";
import type { BaseCheckpointSaver } from "@langchain/langgraph";
import { runClaudeWithRetry } from "./claude.ts";
import { REPORT_SCHEMA, type Report } from "./report.ts";
import { buildPrompt, collectCandidates, writeRecord, type Candidate } from "./steps.ts";

type Decision = { choice: "approve" | "reject"; reason: string };

const State = Annotation.Root({
  targetDir: Annotation<string>,
  candidates: Annotation<Candidate[]>,
  report: Annotation<Report | undefined>,
  droppedItems: Annotation<number>,
  error: Annotation<string | undefined>,
  decision: Annotation<Decision | undefined>,
});

const keyOf = (x: { file: string; line: number; link: string }) => `${x.file}:${x.line}:${x.link}`;

export function buildGraph(checkpointer: BaseCheckpointSaver) {
  return new StateGraph(State)
    .addNode("collect", async (s) => ({ candidates: await collectCandidates(s.targetDir) }))
    .addNode("draft", async (s) => {
      const out = await runClaudeWithRetry({
        prompt: buildPrompt(s.candidates),
        schema: REPORT_SCHEMA,
        cwd: s.targetDir,
      });
      if (!out.ok) return { error: out.error.message };

      // 候補に無いリンクの項目は捨て、捨てた件数を残す
      const report = out.data as Report;
      const known = new Set(s.candidates.map(keyOf));
      const items = report.items.filter((item) => known.has(keyOf(item)));
      return {
        report: { ...report, items },
        droppedItems: report.items.length - items.length,
      };
    })
    .addNode("review", (s) => {
      // interrupt より前に処理を置かない。再開のとき、この関数は頭からもう一度動く
      const decision = interrupt({ report: s.report }) as Decision;
      return { decision };
    })
    .addNode("record", async (s) => {
      await writeRecord(s);
      return {};
    })
    .addEdge(START, "collect")
    .addConditionalEdges("collect", (s) => (s.candidates.length === 0 ? "record" : "draft"))
    .addConditionalEdges("draft", (s) => (s.report ? "review" : "record"))
    .addEdge("review", "record")
    .addEdge("record", END)
    .compile({ checkpointer });
}

review の工程は、最初の行で interrupt() を呼ぶ。公式の文書に、interrupt() より前の処理は再開のときにもう一度動く、とあるからだ。前に何かを置くと、再開するたびにそれが2回目、3回目と動く。

介入は、僕が判断を入力した回数で数える。作業を起動するのは数えず、再開して判断を渡す1回を、介入1回とする。

判断待ちで止まり、別のコマンドから続きを動かす

僕が打つコマンドは2つにした。start は、判断待ちまで進めて終了する。resume は、別のプロセスで続きから動かす。

// cli.ts
import { randomUUID } from "node:crypto";
import { homedir } from "node:os";
import { Command } from "@langchain/langgraph";
import { SqliteSaver } from "@langchain/langgraph-checkpoint-sqlite";
import { buildGraph } from "./graph.ts";

// 状態はメモのフォルダーの外の SQLite に置く
const checkpointer = SqliteSaver.fromConnString(`${homedir()}/.link-check/checkpoints.sqlite`);
const graph = buildGraph(checkpointer);
const [command, ...args] = process.argv.slice(2);

if (command === "start") {
  const threadId = randomUUID();
  const config = { configurable: { thread_id: threadId } };
  const result = await graph.invoke({ targetDir: args[0] }, config);

  if ("__interrupt__" in result) {
    console.log(`判断待ち: ${threadId}`);
    console.log(result.__interrupt__[0].value.report.summary);
  } else {
    console.log("判断を待たずに記録まで進んだ");
  }
}

if (command === "resume") {
  const [threadId, choice, reason] = args;
  const config = { configurable: { thread_id: threadId } };

  // 判断待ちの実行でなければ断る。無い実行や終わった実行で、記録を作らないため
  const state = await graph.getState(config);
  if (!state.next.includes("review")) {
    throw new Error(`判断待ちの実行ではない: ${threadId}`);
  }

  await graph.invoke(new Command({ resume: { choice, reason } }), config);
  console.log("記録まで進んだ");
}

使うときは、こう打つ。

node src/cli.ts start /path/to/notes/folder
node src/cli.ts resume <実行の識別子> approve "まだ無いノートへのリンクで合っている"

start のプロセスは、判断待ちで終わる。報告案を読むのは、その日の夜でも次の日でもよい。resume は新しいプロセスなので、start が手元に持っていた値は何も残っていない。それでも続きから動くのは、start が止まったときの状態を SQLite のファイルに書いてあり、resume が実行の識別子でそれを探し当てるからだ。

start 対象のフォルダー 判断待ちまでの状態 実行の識別子と報告案の要約 resume 識別子 判断 理由 状態を読む 記録まで進んだ プロセスが終わる review の続きから record へ 僕 start resume SQLite

存在しない識別子で resume したときと、終わった実行にもう一度 resume したときは、失敗にして、新しい記録を作らない。同じ実行の記録が2つできると、あとから読んだときに、どちらが本当なのか分からなくなる。

状態は SQLite に、ずっと残す記録だけを Markdown に

置き場所は2つに分けた。

  • 止まったときの状態は、SQLite のファイルに保存する。ファイルはメモのフォルダーの外に置く。メモのフォルダーは同期しているので、同期の途中の複製などで SQLite のファイルが壊れうるからだ
  • ずっと残したい記録は、Markdown のファイルに書く。1回の実行に1ファイルで、報告案、僕の判断と理由、介入の回数、やり直した回数、失敗したときはその内容を書く。すでにあるファイルは上書きしない

SQLite の中身は、続きを動かすための途中の値で、あとから僕が読むものではない。僕があとから読みたいのは、何が起きて、だれが何を決めたかだ。それは Markdown に書く。

点検の対象のフォルダーには、プログラムから一切書かない。

Claude Code を呼ぶ部品を1つにまとめて、定額プランのまま動かす

Anthropic のヘルプ記事「Use the Claude Agent SDK with your Claude plan」には、claude -p は定額プランの利用枠で使える、と書いてある。だから、自分で起動したプログラムから claude -p を呼んでも、定額プランの範囲で使える。ただし、プログラムは僕だけが使い、他人には提供しない。また、別のヘルプ記事によると、定額プランの利用上限(5時間ごとと週ごと)は、ふだんの Claude との会話と Claude Code で共有される。

定額プランのまま動かすには、呼び方にも気をつける。うっかり従量課金の道を通らないためだ。

  • claude -p に --bare を付けない。公式の文書によると、--bare を付けると定額プランのログインを読まず、API キーを求める
  • claude -p を起動するときに、親のプログラムの環境変数を丸ごと渡さない。許可した名前だけを渡し、API キーのような、従量課金に流れる認証の環境変数を落とす

claude -p を起動するのは、子プロセス(プログラムの中から起動した、別のプログラム)である。子プロセスは、何も指定しなければ親の環境変数を引き継ぐ。親の端末に API キーが入っていれば、それも一緒に渡ってしまう。

// claude.ts
import { spawn } from "node:child_process";

export type RunError = { kind: "exit" | "timeout" | "no_structured_output"; message: string };
export type ClaudeOutcome = { ok: true; data: unknown } | { ok: false; error: RunError };

// 子プロセスに渡す環境変数は、許可した名前だけにする
const ALLOWED_ENV = ["PATH", "HOME", "LANG"];

export function runClaude(input: { prompt: string; schema: object; cwd: string }): Promise<ClaudeOutcome> {
  const env = Object.fromEntries(
    ALLOWED_ENV.filter((name) => process.env[name] !== undefined).map((name) => [name, process.env[name]]),
  );
  // --bare は付けない。--restricted で、読める範囲を起動したフォルダーに閉じ込める
  const args = ["-p", "--output-format", "json", "--json-schema", JSON.stringify(input.schema), "--restricted", "--strict-mcp-config", "--tools", "Read,Grep,Glob"];

  return new Promise((resolve) => {
    const child = spawn("claude", args, { cwd: input.cwd, env });
    let stdout = "";
    child.stdout.on("data", (chunk) => (stdout += chunk));
    child.on("close", (code) => {
      let out: { result?: string; structured_output?: unknown } = {};
      try {
        out = JSON.parse(stdout);
      } catch {}
      if (code !== 0) return resolve({ ok: false, error: { kind: "exit", message: out.result ?? "" } });
      if (out.structured_output === undefined) {
        return resolve({ ok: false, error: { kind: "no_structured_output", message: "" } });
      }
      resolve({ ok: true, data: out.structured_output });
    });
    // 指示文は標準入力で渡す
    child.stdin.end(input.prompt);
  });
}

時間切れの扱い(決めた時間を過ぎたら子プロセスを止め、timeout の失敗を返す)は、長くなるので省いた。だから、このコードがやり直すのは、構造化した出力が返ってこなかったときだけである。実際に動かした仕組みでは時間切れも検出し、そのときも1回だけやり直した。

グラフの工程は、この部品を通してしか Claude Code を呼ばない。--bare を付けない決まりも、環境変数を絞る決まりも、この1か所に書けば済む。定額プランでの扱いはこれまでにも変わっていて、この先も変わりうる。そのときに直すのも、この1か所だ。

--tools で、組み込みの道具を読むだけのもの(Read、Grep、Glob)に絞った。さらに --strict-mcp-config を付け、--mcp-config は渡さないので、MCP(Model Context Protocol。外部の道具を Claude Code につなぐ仕組み)の道具は1つも読み込まれない。claude -p は画面で会話しないので、途中で僕に聞き返してこない。僕が呼ばれるのは、グラフが review で止まったときだけになる。

やり直すのは、時間切れと出力の形の欠落のときだけ

claude -p が失敗したときに、やり直すかどうかも決めた。

// claude.ts の続き
export async function runClaudeWithRetry(input: { prompt: string; schema: object; cwd: string }) {
  const first = await runClaude(input);
  if (first.ok || first.error.kind === "exit") return first;
  // 時間切れと、構造化した出力が無いときだけ、1回やり直す
  return runClaude(input);
}

利用上限に当たったときや、認証に失敗したときは、やり直さない。判断も求めず、claude -p が返した文を記録に残して終える。公式の文書によると、claude -p は利用上限に当たっても待たずに失敗する。上限が戻らないうちにやり直しても、同じ失敗が増えるだけだ。定額プランの範囲に収めるなら、追加の API クレジットを選ばず、回復を待つしかない。認証の失敗も、やり直して直るものではない。

ただ、利用上限に当たったときに claude -p がどの終了コードで、どんな文を返すかは確かめていない。わざと上限に当たるわけにもいかない。だから、0以外で終わった失敗は、まとめて「やり直さない」側に入れた。

時間切れと、構造化した出力が返ってこなかったときは、上限や認証と違い、もう一度呼んでも同じ結果になるとは限らない。それでも回数は1回までにした。やり直すたびに、利用枠が減るからだ。

読める範囲を対象のフォルダーに閉じ込めた代わりに、諦めたこと

claude -p は、点検の対象のフォルダーで起動する。さらに --restricted という指定を付けた。claude --help の説明と公式の文書によると、この指定を付けると、Claude Code は利用者、プロジェクト、ローカルの設定ファイルを読まない(管理者の設定と --settings で渡した設定だけは読む)。そして、ファイルを読む道具を、起動したフォルダーの中に閉じ込める。メモのフォルダーの根で起動すると、Claude Code が個人のメモまで読めてしまうので、それを避けた。

代わりに、Claude Code はほかのフォルダーのノートを見られない。打ち間違いかどうかは、名前の近いノートがほかにあるかを見ないと決めにくい。たとえば [会議メモ](/会議メモ) が打ち間違いだと言うには、「会議のメモ」というノートが別のフォルダーにあると知っている必要がある。そこで、見える範囲を指示文で Claude Code に伝え、確かめられないときは、理由にそう書かせた。名前の近いノートをプログラムで探して渡す案は、まだ採っていない。

読むだけの小さな作業で1回動かした結果

候補が数件ある小さなフォルダーを選び、本物の Claude Code で1回動かした。

  • 本物の Claude Code でも、構造化した出力が取れた。先に、ログインが定額プランであること、従量課金に流れる環境変数と API キーを出す設定が無いことを確かめた。--restricted を付けても、定額プランのログインで動いた
  • start のプロセスは判断待ちで終わり、別のプロセスで打った resume で、止めた位置から記録まで進んだ
  • 僕の操作は、resume の1行だった。実験のために利用状況の表示を前後で見たのを除けば、それだけである。承認と理由は記録に入り、介入は1回と数えられた
  • 点検の対象のフォルダーのファイルは、実行の前後で中身も更新時刻も変わらなかった。メモのフォルダーで増えたのは、記録の Markdown のファイル1つだけだった

従量課金に切り替わっていないかは、判定できる範囲で確かめた。言えるのは、起動の経路に API の認証が無かったことと、利用が定額プランの利用枠の側に出たことまでだ。同じ時間に別の Claude Code の会話も動いていたので、この1回の分だけは切り出せない。請求が確定したわけでもない。

付きっきりの回数が減ったかは、1回では判定できない。比べる相手が無いからだ。この1回で確かめたのは、LangGraph、claude -p、SQLite がつながって動くことと、動かす手間が再開の1行で済むことだけである。

偽の Claude Code のテストでは見つからなかった不具合

本物で動かす前に、テストを書いてあった。テストでは、本物の claude の代わりに、決まった構造化した出力を返すだけの偽の claude を使う。偽物は、何を渡されても同じ答えを返す。テストは全部通った。

ところが、本物の claude -p に替えると、起動の段階で失敗した。原因は、--json-schema に渡した定義の1行目だった。

悪い例: JSON Schema の書式の版に draft 2020-12 を書いた定義

// report.ts
export const REPORT_SCHEMA = {
  $schema: "https://json-schema.org/draft/2020-12/schema",
  type: "object",
  properties: {
    summary: { type: "string" },
    items: {
      type: "array",
      items: {
        type: "object",
        properties: {
          file: { type: "string" },
          line: { type: "integer" },
          link: { type: "string" },
          verdict: { enum: ["typo", "missing-note", "ignore"] },
          reason: { type: "string" },
        },
        required: ["file", "line", "link", "verdict", "reason"],
      },
    },
  },
  required: ["summary", "items"],
};

JSON Schema には書式の版がいくつかあり、$schema には、どの版で書いたかを書く。$schema に draft 2020-12 を書いたこの定義を渡すと、僕の環境では claude -p が起動の段階で失敗した。claude -p に同梱された検査が draft-07 という版を前提にしているからだと僕は見ているが、公式の文書には受け付ける版が書かれておらず、この制約は確かめられていない。そこで $schema の行を外した定義に変え、本物の claude -p で失敗が消えることを確かめた。

良い例: $schema に draft 2020-12 を書かない定義

// report.ts
export const REPORT_SCHEMA = {
  type: "object",
  properties: {
    summary: { type: "string" },
    items: {
      type: "array",
      items: {
        type: "object",
        properties: {
          file: { type: "string" },
          line: { type: "integer" },
          link: { type: "string" },
          verdict: { enum: ["typo", "missing-note", "ignore"] },
          reason: { type: "string" },
        },
        required: ["file", "line", "link", "verdict", "reason"],
      },
    },
  },
  required: ["summary", "items"],
};

export type Report = {
  summary: string;
  items: { file: string; line: number; link: string; verdict: "typo" | "missing-note" | "ignore"; reason: string }[];
};

偽の claude は、渡された定義を読まない。だから、定義の版が何であっても通る。この不具合は、全体を通して動かす段で見つかり、本物の claude -p で同じ失敗が起きるのを確かめてから直した。替え玉は、本番の癖までは演じてくれない。本物とつなぐ境目は、本物で1回動かさないと分からない。

手順が決まっていない作業では、引き継ぎと再開の確認が残る

リンク切れの点検は、毎回同じ順に進む。だから工程を先に並べられ、止まる位置も1か所に決められた。

メモの構造の見直しや、設計や調査のように、手順をやりながら決める作業は違う。工程を先に並べられないので、LangGraph に持たせる順番が無い。そういう作業では、セッションを区切るたびに引き継ぎの資料を書き、再開したときに「この理解で合ってますか?」と確かめる手間は残る。導入で書いた「ok」を返す手間が、これで全部消えるわけではない。LangGraph が引き受けられるのは、手順が決まっている作業の中の「止まる」と「続ける」だけである。

まとめ: 判断待ちを LangGraph に持たせると、人が呼ばれるのは判断のときだけになる

AI に任せる作業を工程に分け、工程の順番と、止まって人の判断を待つ位置を LangGraph に持たせた。Claude Code を呼ぶのは、claude -p を包んだ1つの部品だけにした。状態は SQLite に保存するので、判断待ちで終わったプログラムの続きを、別の日に別のプロセスから動かせる。読むだけの小さな作業で1回動かした範囲では、僕の操作は再開の1行で済んだ。

止まる位置を先に決め、本物の道具で小さな作業を1回動かしてから広げる。偽物のテストが全部通っても、本物の claude -p は起動の段階で止まった。

おわり😊

RELATED

つくることで、見える景色がある。