0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

依存グラフで「企画を完了まで自走させる」タスクオーケストレーターを自作した話

0
Last updated at Posted at 2026-08-23

「積まれたタスクを1件処理したら止まる」——私が半年運用してきたAIワーカーの限界はここでした。

夜間に1件だけ調査して結果を書いて、あとは次の指示を待つ。人間が「じゃあ次はこれ」と言い続けないと前に進まない。これでは「自動化」ではなく「半自動」です。今回は、この受動モデルを卒業するために 依存グラフ(DAG)で工程を連鎖させ、企画を完了まで自走させるオーケストレーター を Node の純関数だけで自作した記録を残します。新しいキュー基盤もデーモンも増やさず、既存資産に薄く1枚乗せただけ、というのが今回いちばん伝えたい設計判断です。

何が足りなかったのか(受動モデルの壁)

私が使っていたワーカーは、tasks.md(Markdownのタスク台帳)から未着手タスクを1件選び、処理して、完了行を書いて終了する、という構造でした。1件=1セッションで確実に止まるので、少なくともタスク間の無制限な連鎖は抑制できます(1件の処理中のループ暴走やツール呼び出し過多、トークン浪費まで防げるわけではなく、そこはタイムアウトや実行回数・予算の上限を別途仕込む必要があります)。ただ「調査→下書き→品質チェック→台帳反映→投稿」のような工程の連なりを最後まで通す主体がどこにもいない。

必要だったのは、次の3つを回す層でした。

  1. ある工程が終わったら、依存が解決した次の工程を自動で点火する
  2. 外部アクション(投稿・メール・課金)だけは自動実行せず承認へ退避する
  3. 変化がないのに毎回LLMを焼かない(コストの歯止め

設計判断:イベント駆動をやめて「周期スイープ」にした

最初はイベント駆動(工程完了時にフックで即座に次を起動)を考えました。でも個人運用でメッセージバスや常駐デーモンを抱えるのは重すぎるし、落ちたら連鎖が止まる。結局、以下の比較で周期スイープに振り切りました。

方式 遅延 無人耐性 追加インフラ 判定
周期スイープ(cron/launchdで定期起動→依存解決→点火) 最大1周期分 ○(落ちても次周期が未着手のまま拾い直す) ほぼ不要(既存の反映処理に相乗り) ★採用
真のイベント駆動(ローカルの自作フックを常駐デーモンで即時起動する構成) 通常はゼロに近い △(デーモンが落ちると連鎖停止) 常駐・キュー基盤が必要 見送り
会話駆動(人が話しかけた時だけ前進) 不定 ×(話しかけないと止まる) 不要 手動点火の入口としてのみ併用

※イベント駆動の遅延・無人耐性は実装次第です。ここで比較したのは「個人運用で自作する非永続フック」の場合で、マネージドの永続キュー(SQS等)を使うイベント駆動であればコンシューマが落ちてもイベント自体は保持されるため、無人耐性はここまで低くなりません。

夜間に回す個人PJなら「最大1周期分の遅延」は誰も困りません。無人で落ちても次のスイープが未着手のタスクを拾い直しに来る、という再評価のしやすさを最優先しました。ただしこれは「同じ工程を二度実行しない」ことまで保証するものではありません。点火直後〜完了記録前にプロセスが落ちた場合の再実行防止(ロック取得の原子性・クラッシュ位置ごとの整合)については、この記事のスコープでは検証していません。

実装:新しく書いたのは実質「依存フィルタ」1枚

肝は驚くほど小さいです。既存のパーサ(lib/task-queue.js・正規表現でMarkdownを読むだけ、LLM不使用)に「点火可能な工程だけを残すフィルタ」を1枚足しただけ。これが私の書いた lib/orchestrator.js の中核です。

// lib/orchestrator.js — 点火可能工程だけを抽出する純関数
const q = require("./task-queue");

// 点火可能 = 依存が全て解決済み かつ ineligibleReason() が null を返す工程。
// ineligibleReason は task-queue.js 側の別関数で、実質の安全弁はこちらに入っています。
// 関係する部分だけ抜粋すると:
//
//   if (!task.aiAllowed) return "🤖夜間AI可 タグがありません(AIに任せてよいと承認されていない)";
//   if (task.external)   return "⚠️(対外・承認必須・不可逆)が含まれています。AIは実行できません";
//
// つまり ignitableInternalTasks自身には `!t.external` という条件は書いていません。
// 「内部工程しか残らない」のは、この ineligibleReason が task.external を見て弾いているからです。
function ignitableInternalTasks(allTasks, opts = {}) {
  return q
    .getUnblockedTasks(allTasks) // 未完了&依存が全て完了しているタスク
    .filter((t) => q.ineligibleReason(t, { ...opts, phase1Only: false }) === null);
}

// 依存は解決済みだが「外部工程」のため自動実行できない → 承認キュー投入候補として分類する対象。
// ⚠️ ここではロック中/AI失敗済み/🤖可否といった ineligibleReason の他の条件は見ていません。
//   あくまで「依存解決済みの外部工程一覧」という粒度の可視化用です。
function externalPendingTasks(allTasks) {
  return q.getUnblockedTasks(allTasks).filter((t) => t.external);
}

// 1回のスイープで内部(点火候補)と外部(承認キュー投入候補)に仕分けるだけの純関数。
// ⚠️ 承認キューへの実際の書き込み・重複防止・永続化はこの関数の範囲外(後述)。
function classifyIgnition(allTasks, opts = {}) {
  return {
    internal: ignitableInternalTasks(allTasks, opts),
    external: externalPendingTasks(allTasks),
  };
}

module.exports = { ignitableInternalTasks, externalPendingTasks, classifyIgnition };

internal と external が交差しないことはコードの目視だけに頼らず、test/orchestrator.test.js に「外部工程(⚠️)は依存解決済みでも点火可能に含めない」「classifyIgnition: internal/externalに正しく分類する」というテストとして固定しています。

DAGの「器」も新設していません。既存のタスク台帳に 依存: フィールドを1つ足しただけです。

- [ ] [T-500] 記事の調査      | PJ:PJ-03 | 分類:調査/記事 | 担当:qiita-writer | 🤖
- [ ] [T-501] 下書き生成      | PJ:PJ-03 | 依存:T-500 | 分類:制作/記事 | 担当:qiita-writer | 🤖
- [ ] [T-502] 品質ゲート      | PJ:PJ-03 | 依存:T-501 | 分類:制作/記事 | 担当:qiita-writer | 🤖
- [ ] [T-503] 台帳へ記録      | PJ:PJ-03 | 依存:T-502 | 分類:台帳 | 担当:secretary | 🤖
- [ ] [T-504] Qiitaへ投稿     | PJ:PJ-03 | 依存:T-503 | 分類:外部/投稿 | 担当:publisher | ⚠️対外公開

末尾の🤖/⚠️は parseTasks() が行全体(IDより後ろ)から正規表現で拾うタグです。🤖(または【夜間AI可】)が無い工程は前段の ineligibleReason で弾かれてそもそもinternalに載らないので、T-500〜T-503には必ず付けています。T-504は「Qiitaへの本公開」という対外アクションなので🤖は付けず、代わりに外部判定用の⚠️(実際の正規表現は「⚠️の直後25文字以内に『対外』などのキーワードがある」ことを条件にしています)を付けています。

パーサ側は 依存:T-500 を配列に落とし、getUnblockedTasks() が「依存IDがすべて完了済みか」を見るだけ。完了タスクは別ファイル(tasks-done.md)へ退避する構造なので、判定時は両方をマージして渡します。

// 完了行は tasks-done.md 側にあるので、依存解決の判定には両方が要る
function mergeTasks(tasksText, doneText) {
  return [...q.parseTasks(tasksText), ...q.parseTasks(doneText || "")];
}

classifyIgnition自体は分類するだけの純関数で、承認キューへの書き込みは行いません。実際に書き込むのは呼び出し側(lib/reflect-nightly.js)で、二重登録を防ぐために「approval-queue.mdの本文に、そのタスクIDが既に出てきているか」を見てから追記します。

// lib/reflect-nightly.js(呼び出し側)— 外部工程を承認キューへ冪等に登録する
function applyExternalToApprovalQueue(aqText, groups) {
  const approvalQueue = require("./approval-queue");
  let text = aqText;
  const registered = new Set([...text.matchAll(/T-\d+/g)].map((m) => m[0]));
  const registrations = [];
  for (const group of groups || []) {
    const unqueued = group.tasks.filter((t) => !registered.has(t.id));
    if (unqueued.length === 0) continue; // 全工程が登録済み=このPJは今回はスキップ
    const r = approvalQueue.appendDagPreview(text, { pj: group.pj, tasks: unqueued });
    text = r.text;
    registrations.push({ id: r.id, pj: group.pj, taskIds: unqueued.map((t) => t.id) });
    unqueued.forEach((t) => registered.add(t.id)); // 同一スイープ内の重複登録も防ぐ
  }
  return { text, registrations };
}

専用の状態ストアは持たず、approval-queue.mdの本文そのものを正規表現でスキャンして「登録済みT-ID」を判定しているだけです。test/reflect-nightly.test.js の「同じ外部工程を2回目のスイープで渡しても再登録しない(重複防止の核心)」というテストで、classifyIgnitionが毎回同じ外部工程を返しても登録が増殖しないことを担保しています。

コストの歯止め:前置チェックで「実行できる工程が無ければLLMを起動しない」

自走させると怖いのがトークン浪費です。私の環境は限られた個人予算内で全部回すのが絶対条件なので、「実行できる工程が無ければ、Claudeを1トークンも起動しない」を仕組みで強制しました。これは今回新設したclassifyIgnition自身の役割ではなく、もともと調査ワーカー側にあった前置チェックです。

// lib/research-runner.js(既存・orchestrator導入前からある関数)
// N-2 前置チェック: 実行できるタスクが1件も無ければ Claude を起動すらしない
function hasWork() {
  try {
    const text = fs.readFileSync(TASKS_FILE, "utf8");
    return q.selectQueue(text, {
      lockedIds: locks.lockedIds(),
      allTasks: allTasksForDependencyCheck(text),
      allowedWorkTypes: allowedWorkTypes(),
    }).length > 0;
  } catch {
    return false;
  }
}

classifyIgnitionが担うのは「依存が解決した工程だけを見えるようにする」入口側の絞り込みで、その先でselectQueueが空ならhasWork()がClaude起動そのものを止めます。この歯止めを入れてから、空振り起動のログはまるごと消えました。ただし効果の測り方は「スイープのログでLLM呼び出しが立ち上がった回数を数えている」だけで、プロバイダ側のusage・課金記録と突き合わせたわけではありません。なので厳密には「起動ログ上は空振りが無くなった」ことの確認までで、「課金が実際にゼロだった」ことの直接証拠ではない、という留保付きです。

実際に一周させてみた(ドッグフーディング)

机上で終わらせたくなかったので、この記事の制作PJ自体(T-500T-504)を題材に、本番と同じ純関数でスイープを一周させました。完了イベントはインメモリで擬似発火させ、台帳へは書き込まない(単一書き手の原則を壊さないため)形です。

classifyIgnition().internal(点火候補として分類された工程)と、実際に research-runner のselectQueueが拾って実行した工程は別物なので、表も分けます。

スイープ 直前に完了した工程 classifyIgnition().internal(点火候補) 実行エンジンが受理(自動実行) classifyIgnition().external(承認キュー候補)
0 (なし・起点) T-500(調査) T-500
1 T-500 T-501(下書き) T-501
2 T-501 T-502(品質ゲート) T-502
3 T-502 T-503(台帳へ記録) —(後述の穴)
4 T-503 T-504(投稿)

期待どおり、依存が解けた順にちょうど1工程ずつ internal(点火候補)に上がってきます。最後の外部工程 T-504 は、どのスイープでも internal には一度も現れず、依存が解決したスイープ4で external にだけ現れました。外部アクションが classifyIgnition の分類として自動実行の経路を素通りできない、はここまでは実測で確認できています(承認キューへの実際の登録は前述のapplyExternalToApprovalQueue側の担当です)。

一方でスイープ3は、internal(点火候補)には T-503 が載ったのに、実行エンジンには一切受理されませんでした。次の「ハマった点」がこの穴の話です。

ハマった点:スイープ3で連鎖が「静かに」止まった

正直に書くと、一周させて初めて気づいた穴があります。スイープ3で T-503(台帳へ記録)が classifyIgnition().internal(点火候補)には上がったのに、どの実行エンジンも拾わずに連鎖が止まったんです。

原因は、私のPhase1の実行エンジンが担当する作業種別に「台帳」が入っていなかったこと。T-503 は分類が「台帳」なので、オーケストレーターの分類上は「点火候補」として可視化されるのに、実際に走らせる係がいない、という二層のギャップが残っていました。オーケストレーター(頭脳・classifyIgnition)が「これは依存解決済みで内部工程」と言えても、実行エンジン(手足・research-runnerのselectQueue)がその作業種別を担当していないと前に進みません。

ここでヘタに実行エンジン側を「全種別OK」に緩めると、まだ自動化していい範囲を超えてしまう。なので今回は T-503 は人手完了とみなして先へ通し、「止まった工程を担当する実行系を用意する配線」を次フェーズの宿題として切り出しました。自走の穴は、机上設計ではなく一周させて初めて姿を現す——これが一番の学びでした。

AIの自走に絶対つけた安全弁(チートシート)

「完了まで自走」と「暴走しない」を両立させるために、点火の判定に重ねた条件をまとめておきます。自分がAIエージェントに仕事を任せるとき、この辺を機械側で強制しておくと安心して寝られます。

安全弁 効き方
キルスイッチ(PAUSEDファイル) ファイルが在ればスイープ・点火・分解の全処理が即停止(no-op)
承認退避 外部工程(対外・支出・本番)は必ず承認キューへ。自動実行の経路に一切出さない
🤖タグ タグの無い工程はAIが構造的に選べない(分解時、外部工程には付けない)
二重着手防止 1工程=1ロックファイル。複数端末・複数AIが同じ工程を掴まない
前置ゲート 入力に変化がなければLLMを起動しない(コスト暴発の歯止め)

ポイントは、これらを「プロンプトでお願いする」のではなく 点火判定のコード(ineligibleReason)に畳み込んで機械的に効かせていることです。プロンプト内の「〜してはいけません」という約束はモデルの気分次第で破られますが、コードのフィルタなら単体テストで固定でき、少なくともプロンプトだけに頼るより機械的に検証しやすい、というのが実感です(フィルタ自体にパース漏れ・分類ミス・呼び出し側の迂回・実装バグが無いとまでは言い切れません。この記事でもスイープ3の穴のように、分類は正しくても実行系側が拾わないギャップが実際にありました)。

まとめ

一覧にすると要点は3つです。

  • 連鎖のエンジンは周期スイープ1本。落ちても次周期が冪等に拾うので無人耐性が高い
  • 新規に書くのは依存フィルタ1枚で足りる。DAGの器も実行系も既存資産に相乗りさせる
  • 外部アクションは点火判定のコードで承認へ退避。プロンプトの善意に頼らない

結局のところ、今回いちばん効いたのは「賢い分解ロジック」ではなく、一度も自分で一周させてみたことでした。机の上では完璧に見えた連鎖が、台帳工程で静かに止まる。あの手応えがなければ、私はきっと「点火可能=実行される」だと思い込んだままリリースしていたはずです。自走の設計は、書いた瞬間ではなく、初めて端から端まで通したときに本当のバグが見える。次は止まった台帳工程の実行系を配線して、人手ゼロで端まで抜けるところを見届けたいと思っています。


正直、ここに辿り着くまでに「点火可能なのに誰も実行しない」工程で一度つまずいて、設計を見直しました。同じように「AIに仕事を連鎖させたいけど暴走が怖い」で足踏みしている人が、遠回りせずに済めばこの記事を書いた意味があります。似た仕組みを組んでいる方で「うちはこう承認を挟んでいる」があれば、ぜひコメントで教えてください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?