チケットの「保留」を、期日が来たら自動で起こす — OpenProjectの自動復帰ジョブを作った

タスク管理でいちばん怖いのは、期限を過ぎることよりも「忘れること」だと思っている。期限があれば誰かが見る。だが「保留」にしたチケットには期限がないに等しく、状況が変わって再開できるようになっても、こちらが思い出すまで永久に眠り続ける。

私は日々の作業を OpenProject で管理している。そこで「保留」ステータスのチケットに期日を入れておき、その日が来たら自動で起こす仕組みを作った。

目次

前提として:私の作業は AI エージェントとの対話で進んでいる

この仕組みの話に入る前に、私の作業の前提を書いておく。ここを省くと、後で出てくる「会話を起こす」という処理が何を指しているのか伝わらない。

私は、LibreChat というチャット画面で AI エージェントと会話しながら作業を進めている。思いつきをその場で投げ、返ってきた答えを見ながら次の手を決める。調べ物も、サーバーの操作も、原稿の下書きも、原則はこの会話の中で完結する。

繰り返し発生する作業は、その会話の中でエージェントに任せて自動化してしまう。今回のジョブもその一つで、私が毎朝手で起動するものではなく、決まった時刻に無人で動く。私の作業環境は「自分」「対話の相手である AI エージェント」「自動で回る処理」の三つで出来ていて、この記事に出てくる「会話」は、すべて LibreChat 上に立ち上がる AI エージェントとの会話スレッドを指す。

ここで、道具の名前を一つ区別しておきたい。作業の会話は LibreChat、通知を受け取るのは Chatwork だ。私はふだんの連絡や各種の通知を Chatwork で受け取っており、このジョブが URL を送る先も Chatwork である。以降この記事で「チャットへ通知する」と書いたときは、すべて Chatwork への通知を指す。LibreChat はあくまで「作業する場所」、Chatwork は「知らせを受け取る場所」で、この二つは役割が違う。

この前提を置くと、「復帰と同時に会話を起こす」という設計の意味が通るはずだ。

なぜ作ったか

タスク管理の道具はたくさんある。それでも「あの件、どうなったっけ」という問いが消えないのは、道具が足りないからではない。思い出す側の負荷が残っているからだ。

特に「保留」は厄介だ。優先度が低いだけならまだいい。問題は、保留にした理由そのものを忘れることにある。状況が変わって再開できるようになっていても、こちらが気づかなければ、そのチケットは動かない。

保留には、性質の違うものが混ざっている。ひとつは「他者待ち」で、返事が来るまで動かせない。もうひとつは「前提待ち」で、別の作業が終わるまで着手できない。最後に「時期待ち」があり、これは締め切りや季節など、カレンダー上の都合で決まる。

この三つを同じ棚に放り込んでしまうと、棚卸しのたびに全部を読み返すことになる。だが自動で起こせるのは、実は三つ目だけだ。日付が決まっているなら、人間が思い出す必要はない。「その日」を期日として入れておき、機械に起こしてもらえばいい。そう考えて、期日を「起こしてほしい日」として読み替える運用にした。

人間が思い出さなくても、期日が来たら勝手に起きて、やるべきことが目の前に用意されている状態を作る。それがこの仕組みの目的だ。

作ったもの

期待する動作はこうだ。「保留」のチケットに「リマインドしたい日」を期日として入れておく。その日が来ると、ジョブが毎朝ひとりでに動き、次のことを順番にこなす。

  1. 復帰したチケットで「やるべきこと」を本文から読み取る
  2. そのチケット用の作業会話を、LibreChat に新しく立ち上げる
  3. 立ち上げた会話の URL を付けて通知する
  4. チケットを「進行中」に戻し、期日を次のスプリント末へ延長する

ポイントは、状態を戻すだけで終わらせないことだ。チケットを「進行中」にしたところで、人間が「で、何をするんだっけ」と考えるのでは意味が薄い。復帰の瞬間に、やるべきことが目の前に用意され、そのまま作業に入れる状態まで持っていく。ここまでやって初めて、仕組みとして意味があると考えた。

状態を戻す・期日を延ばす・通知する、という三つの処理は互いに独立している。だから途中で一つが失敗しても、残りを無理に進めない設計にした。中途半端に状態だけが変わったチケットは、かえって人間を混乱させる。

全体の流れ

毎日決まった時刻に systemd のタイマーがジョブを起動する。ジョブはまず OpenProject から「ステータス=保留」のチケットを全件取得し、期日が今日以前のものだけを対象にする。一件ずつ次の処理を回す。

保留チケットの自動復帰ジョブ フロー(データソース=OpenProject、通知先=Chatwork)

図にすると、上のとおりになる。データソースは OpenProject、通知先は Chatwork だ。ジョブはこの二つと LibreChat のあいだをつなぐ中継役で、自分では何も判断しない。決められた手順を、決められた順番で実行するだけにしている。

  1. チケット本文の「## 復帰時の指示」節を抽出する
  2. LibreChat のスケジュール機能に登録してあるプロンプトを、そのチケット用に差し替える
  3. スケジュールを即時実行し、新しい会話スレッドを起こして URL を得る
  4. チケットを「進行中」へ戻し、期日をスプリント末へ延長する
  5. チケットに復帰記録のコメントを残す
  6. Chatwork へ URL 付きで通知する

これを毎日 09:10 に実行している。記事化バッチが 08:00 に走るので、通知が重ならないよう少し後にずらした。毎朝の Chatwork の通知欄に、昨日の続きと今日起きたチケットが並ぶ。この順番にしてから、朝いちばんに何を見ればいいかが迷わなくなった。

対象を「今日以前」に限るのも意図がある。期日が未来のチケットは、その日が来るまで触らない。期日を過ぎたまま放置されたものは、取りこぼしとみなして拾う。過去に溜まったチケットも、放置している限り毎朝対象になり続ける。

なぜ「会話まで」起こすのか

ステータスを戻すだけなら、実は簡単に書ける。API を一つ叩けば済む。だがそれでは、復帰したチケットを見た人間が、改めて「何をするんだっけ」を思い出すところからやり直しになる。

私の場合、作業の実体は AI エージェントとの会話だ。ならば復帰と同時に、その会話の場まで用意しておけばいい。ジョブは複製元のスケジュールから新しい会話スレッドを一本立ち上げ、その URL を通知に載せる。Chatwork に届いた通知を開くと LibreChat の会話が開き、そこには「このチケットを復帰させた。まず何をすべきか」が最初のメッセージとして並んでいる。それを読んで、そのまま会話を続ければ作業に入れる。

考え方としては、通知を「思い出させるもの」ではなく「作業を始められるもの」に寄せた、ということだ。通知を読んでから探し始めるのでは、人間の手数が残る。リンクを開けば、そこがすでに作業場になっている。自動化で削れるのは作業そのものよりも、こういう「始めるまでの手数」なのだと感じている。

運用ルール:「復帰時の指示」を本文に書き残す

この仕組みを使うには、保留にするときに二つだけ決めておく。

  • 期日=リマインドしたい日を入れる
  • 本文に「復帰したら何をすべきか」を次の見出しで書く
## 復帰時の指示
1. 〇〇を確認する
2. △△を実施する

人間がやるのはこれだけだ。あとは期日が来れば自動で起きて、Chatwork に通知が届く。通知のリンクから会話に入れば、そのまま作業を続けられる。

指示の見出しは表記ゆれを許容している。「## 復帰時の指示」のほか、似た見出しをいくつか拾うようにした。書き手が毎回同じ見出しを書くとは限らないからだ。厳密に一つへ統一するより、多少の揺れを吸収するほうが、結果として書き忘れが減る。

指示が見つからなかったチケットは、復帰させずにそのまま残す。指示のないチケットを起こしても、人間は結局本文を読み直すことになる。抽出に失敗したら黙って進めず、通知の本文にその旨を出して知らせるようにした。自動化でいちばん困るのは、静かに間違えることだからだ。

実装の構成

実体は Python のスクリプト一本だ。追加ライブラリに依存したくなかったので、HTTP も JWT の発行も標準ライブラリだけで書いた。

  • 本体:resume_job.py(約 500 行)
  • タイマー:wp-resume.timer(毎日 09:10 JST)
  • サービス:wp-resume.service
  • 配備:deploy_wp_resume_timer.yml(冪等・再実行可)
  • 停止:stop_wp_resume_timer.yml(ロールバック)
  • 状態:state/resume_state.json(二重起動ガード)
  • 秘密:secrets/op_token(リポジトリ管理外)

標準ライブラリだけで書くことには、はっきりした理由がある。常時動く小さなジョブに、外部依存を増やしたくないからだ。依存が一つ増えるたびに、更新のときに壊れる箇所が増える。誰も見ていない早朝に動くものだからこそ、壊れる要素は少ないほうがいい。

タイマーは Persistent=true にしてある。サーバが落ちて実行時刻を逃しても、復帰後に一度だけ取り戻してくれる。RandomizedDelaySec=120 も入れた。毎朝きっかり同じ秒にアクセスが集中するのを避けるためだ。

スクリプトの実体は systemd のユニットから直接指している。おかげでスクリプトの修正は配備し直さなくても次の起動から反映される。ユニット定義そのものを変えたときだけ Playbook を流し直せばいい。小さな改善を何度も重ねる前提だと、この差は効いてくる。

スプリント末の定義

期日の延長先は「スプリント末」だ。ここは運用の前提になるので、定義をはっきり決めておいた。

  • 2 週間スプリントとする
  • 開始=ISO 奇数週の月曜日
  • 終了=ISO 偶数週の日曜日(開始日の 13 日後)
  • 名称=年+終了日が属する ISO 週番号(偶数週)。例:2026W40

2026W40 は 2026-09-21(月)から 2026-10-04(日)になる。ジョブは今日が属するスプリントの末日を返し、今日がその末日なら翌スプリント末を返す。期日を過去や当日にしないためだ。OpenProject に同名のバージョン(2026Wxx)が存在する場合は、その終了日を優先して採用する。運用上の「正」はバージョン側にあるからだ。

延長先を「一週間後」ではなく「スプリント末」にしたのは、計画の単位と揃えるためだ。復帰したチケットは、その時点のスプリントの残り期間で扱う。期日がばらばらに散らないので、週次の見直しのときにまとめて判断できる。

なお、この定義は最初から正しかったわけではない。開始と終了の週を取り違えていたのを指摘されて直した。実装者は自分の定義の誤りに気づきにくい。だからこそ、定義そのものを一箇所に固定し、そこを「正」として参照する形にした。

つまずいたところ

期日の比較演算子が API で通らない

dueDate <= 今日 のようなフィルタを API 側でかけたかったが、受け付けられなかった。終了日に使える演算子が限られており、比較演算子を渡すと「許可されていない値」として弾かれる。結局、取得は粗く行い、比較はクライアント側で行うことにした。API の仕様に寄せるより、アプリ側で正しく判定する方が確実だった。

Cloudflare に弾かれる

OpenProject の API を叩くとき、User-Agent を付けないと Cloudflare の Browser Integrity Check に引っかかり、403(Error 1010)になった。ブラウザ相当の User-Agent を常時付与することで解決している。標準ライブラリの既定の User-Agent では足りない、というのは最初は気づきにくい。

認証トークンの扱い

LibreChat の認証トークンは、既存の環境ファイルにある秘密鍵から標準ライブラリだけで自己発行している。追加のライブラリ依存を避けたかったからだ。あわせて、OpenProject の API トークンは平文でコードに埋め込まず、環境変数か、リポジトリ管理外の秘密ファイルから読むように直した。秘密情報をコミットしない、という原則は最初から守るべきだったと反省している。

説明欄が文字列で返ってこない

チケットの説明欄は API では単なる文字列ではなく、辞書の形で返る。最初は文字列前提で書いていたので、復帰時の指示を抽出する処理が例外で落ちた。文字列でも辞書でも安全に本文テキストへ正規化する処理を挟んで解決した。API が返す形は、ドキュメントの印象より複雑なことが多い。実際に叩いて確かめるのが結局は近道だ。

スプリント名が「7W40」になった

日付からスプリント名を組み立てる処理で、ISO 週の取得結果の並び順を取り違えていた。戻り値は「ISO 年・ISO 週・曜日」の順なのに、年と曜日の位置を混同していたため、年として曜日が入り、スプリント名が 7W40 のように算出されていた。並び順を取り直して 2026W40 になることを確認した。命名がおかしいと、原因が日付計算にあると気づくまで少し時間がかかる。

期日が勝手に翌営業日へずれる

スプリント末は必ず日曜(非稼働日)になる。ところが OpenProject は既定で、非稼働日を期日として受理せず、翌営業日へ自動でスナップする。日曜 2026-10-04 を指定したのに 2026-10-05(月)になって返ってきた。更新リクエストに「非稼働日を無視する」フラグを付けて送ることで、日曜のまま確定させた。これは仕様どおりの親切機能なのだが、期日を計算で決めている側からすると、黙って値が変わるのは避けたい挙動だった。

スケジュールの頻度指定が通らない

会話を起こすために作るスケジュールには、頻度の指定が要る。最初は「毎月」を意味する値を入れたが、実際に受け付ける値の一覧にそれは無く、弾かれた。正しい一覧を実測で確かめたうえで、最小の頻度に置き換えた。このスケジュールは定期発火させない前提で作っており、頻度は申告のためだけの項目だった。それでも、不正な値は残したくない。

安全側に倒した設計

自動で状態を変える仕組みは、暴走すると厄介だ。次のように「壊れにくく、戻せる」ことを優先した。

  • 変更系の操作はすべて --dry-run で事前確認できる。dry-run は状態ファイルを書かず、本番の自動復帰を誤ってスキップさせない。
  • 二重起動ガードを入れた。状態ファイルで「同一チケット・同一日」の二重処理を防ぐ。ただし記録はチケット更新が成功した後にだけ行い、更新に失敗したときに永久スキップへ落ちないようにした。
  • 途中で失敗したらチケットを復帰させず、次回にリトライする。会話の起動に失敗した場合は処理を中断する。
  • ロールバック手段を用意した。タイマーは停止用の Playbook 一つで無効化できる。配備も冪等な Playbook で再現できる。
  • 秘密情報はファイルに書かない。トークン類は環境変数や既存ファイルから読む。

特に効いたのは、状態の記録タイミングだ。最初は更新の前に記録していたが、それだと更新に失敗したチケットが「処理済み」として永久にスキップされる。記録を成功後に移したことで、失敗しても翌日にやり直せるようになった。自動化の事故は、失敗することよりも、失敗したまま黙って進むことで起きる。

通知にも冪等キーを付けた。同じチケット・同じ日で二重に送らないための鍵だ。自動で動くものは、黙って二回動くのがいちばん怖い。

通知の設計

自動で動くものは、動いたことが見えなければ意味がない。反面、見えすぎると読まれなくなる。通知先である Chatwork への通知は、次の形に落ち着いた。

  • チケットの番号と件名を先頭に出す
  • 復帰時の指示をそのまま引用する
  • 作業用の会話の URL を添える
  • 失敗した場合は、どの段階で止まったかを書く

通知の本文は短く、しかし開けば作業に入れるだけの情報は載せる。件名だけでは「何の件か」しか分からない。指示まで載せておくと、Chatwork の通知欄を見た時点で「今日はこれをやる」と決められる。

成功と失敗で文面を分けたのも意図がある。失敗の通知を成功と同じ見た目にしてしまうと、気づかないまま一日が終わる。復帰しなかったチケットは、その日のうちに手で拾えるようにしておきたかった。

どう検証したか

この手の仕組みは、本番でいきなり動かすのがいちばん危ない。そこで確認の順序を決めて進めた。

  1. --dry-run で対象チケットの抽出と延長先の計算だけを確認する。状態ファイルは書かない。
  2. テスト用のチケットを一つ作り、保留+期日を昨日にして、実際に復帰させる。
  3. 復帰後にチケットが「進行中」へ戻り、期日がスプリント末へ延びていることを API の応答で確認する。
  4. 会話が起動し、Chatwork の通知に URL が載っていることを確認する。
  5. 同じ日にもう一度実行し、二重起動ガードが効いて何も起きないことを確認する。
  6. タイマーを止める Playbook を流し、止まることを確認してから再度有効化する。

5 と 6 を必ず入れるようにしている。二度動かないこと、止められることは、機能そのものと同じくらい大事だ。新しいことを始めるより、やめられることを先に確かめておく。この順番にしておくと、後から仕様を変えるときも安心して触れる。

動かして分かったこと

作ってから分かったのは、自動化の難しさは「動かすこと」ではなく「安心して任せること」にある、ということだ。状態を変える処理を一つ書くのは簡単だ。難しいのは、途中で失敗したときに壊れず、戻せて、二度動かないようにすることだった。

もう一つ、定義を先に固めることの効き目も大きかった。スプリントの開始と終了を決めておかなければ、期日をどこへ延長すべきかが決まらない。逆に言えば、曖昧なまま実装すると、必ず後で直すことになる。

実際に毎朝動かしてみると、副次的な効果もあった。保留のチケットが勝手に起きてくるので、保留のまま眠り続けるチケットが減った。すると「保留」というステータスが、本当に止まっているものだけを指すようになる。棚卸しのたびに全部を読み返す手間が減り、状態の意味が研ぎ澄まされていった。自動化は、処理を一つ減らすだけでなく、言葉の意味を揃える効果もあるのだと思っている。

まとめ

「人間が思い出さなくても、期日が来たら勝手に起きて、やるべきことが目の前に用意されている」状態を作った。

タスク管理の価値は、チケットを書くことではない。書いたチケットを忘れない仕組みにある。そして忘れないだけでなく、復帰の瞬間に作業が始められる形にする。今回作ったのは、その一段先までを自動化する仕組みだ。

「保留」は、放っておけば必ず闇に沈む。だからこそ、期日という単純な仕掛けで必ず浮かび上がらせる。派手な機能ではないが、これを入れてから、棚卸しの心理的な重さが確かに軽くなった。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

金重総合研究所の主席研究員。
子供の頃から研究者を目指し、ライフワークとして日々様々な研究をしています。
経営・マネジメント・金融・DXあたりが本職です。
私を採用したい人、私と一緒に働きたい人、一緒に知識を肥やしていきたい人はぜひお声がけ下さい。

目次