diff --git a/.claude/rules/autopilot/github-api.md b/.claude/rules/autopilot/github-api.md index ffadbf2ab9d..ee9e9cc8035 100644 --- a/.claude/rules/autopilot/github-api.md +++ b/.claude/rules/autopilot/github-api.md @@ -12,8 +12,26 @@ daemon は常駐で GitHub をポーリングするため、**API レート予 - **読み取り**(一覧・PR/Issue 情報・レビュー状態・アクティビティ)= **`project.readToken()`**。 解決順: env `AUTOPILOT_READ_TOKEN` → env `GH_TOKEN` → `gh auth token` → Bot フォールバック。 `AUTOPILOT_READS=bot` で従来動作(読みも Bot)に戻せる。 -- 新しい読み取りを Bot トークンで書く / 新しい書き込みを個人トークンで書くのは逸脱 - (前者は予算の一点集中、後者は名義の混在)。 +- **例外: 俯瞰ボード再取得の読み取り**(`refreshBoard` の listItems キャッシュミス時 / enrichment / + head-PR 補完)= **`project.boardToken()`(既定 Bot)**。これは *表示専用で可視な成果物を + 生まない* 読み取りなので、read(個人)トークンの GraphQL 予算に一点集中させず、遊んでいる + Bot の GraphQL 予算へ振り分けて実効予算を分散する。dispatch 判断の tick 系 read(listItems / + getIssueStates)は従来どおり `readToken()` のまま。`AUTOPILOT_BOARD_READS=read` で従来へ戻せる。 +- 上記 2 例外以外で、新しい読み取りを Bot トークンで書く / 新しい書き込みを個人トークンで書くのは + 逸脱(前者は予算の一点集中の逆・名義の混同、後者は名義の混在)。 + +## 1b. 俯瞰ボード再取得の GraphQL 節約(read 一点集中の緩和) + +`refreshBoard`(`gh project item-list` ≈ 100 GraphQL pt / enrichment)は read GraphQL の最大消費源。 +以下を守る(純粋関数 `shouldReuseItemsCache` / `shouldRefreshBoardPeriodic` + daemon の I/O): + +- **listItems の重複排除**: `tick` が取得した item スナップショットを `state.itemsCache` に保存し、 + 直後に走る `refreshBoard` は十分新しければ(既定 interval の半分以内)**再取得しない**。 + `POST /refresh` は `forceFetch=true` で明示的に取り直す。 +- **未観測時の抑制**: 定期(main loop)の board 追従は `maybeRefreshBoardPeriodic` を通し、 + 直近に `GET /board` が読まれていない間は board の read を撃たない。ただしトラッカー sticky が + 古くなり過ぎないよう、アップキープ間隔(既定 30 分)を超えたら 1 度は走らせる。 + `POST /refresh`・`POST /tick` 直後・起動時はこの判定を通さず常に実行する。 ## 2. GraphQL / REST の使い分け(別枠予算の並行活用) diff --git a/packages/admin/.gitignore b/packages/admin/.gitignore index 3e2e84b087a..7525967b155 100644 --- a/packages/admin/.gitignore +++ b/packages/admin/.gitignore @@ -1,2 +1,2 @@ build/ -node_modules/ +node_modules diff --git a/tools/autopilot/src/daemon.js b/tools/autopilot/src/daemon.js index 1930f96f080..d58de200a4f 100644 --- a/tools/autopilot/src/daemon.js +++ b/tools/autopilot/src/daemon.js @@ -41,6 +41,10 @@ const { itemOwner, orderItemsLikeBoard, selectBoardItems, + shouldReuseItemsCache, + shouldRefreshBoardPeriodic, + BOARD_WATCH_TTL_MS, + BOARD_UPKEEP_MS, selectClosedCheckIssues, rateLimitPlan, PR_SYNC_STATUSES, @@ -1416,6 +1420,9 @@ async function tick(cfg, state, log) { let items; try { items = await project.listItems(cfg.owner, cfg.project, await project.readToken()); + // 直後に走る refreshBoard が同じ listItems(~100 GraphQL pt)を撃ち直さず再利用できるよう + // スナップショットを保存する(#read-GraphQL 予算節約 / shouldReuseItemsCache)。 + state.itemsCache = { items, at: cfg.now() }; } catch (e) { log(`poll error: ${e.message}`); return { paused: false, picked: [] }; @@ -1629,12 +1636,22 @@ async function refreshBoard(cfg, state, log, deps = {}) { if (state.ratePlan && state.ratePlan.skipLowPriority) return; state.boardRefreshing = true; try { - // ボードは読み取り専用 → 個人トークン側の予算 - const token = deps.token || await project.readToken(); + // ボードは読み取り専用。read(個人)トークンの GraphQL 予算に一点集中させないため、 + // 既定で Bot の GraphQL 予算へ振り分ける(project.boardToken / C)。 + const token = deps.token || await project.boardToken(); const listItems = deps.listItems || project.listItems; const getBoardEnrichment = deps.getBoardEnrichment || project.getBoardEnrichment; const listHeadPrs = deps.listHeadPrs || project.listHeadPrs; - const items = await listItems(cfg.owner, cfg.project, token); + // tick が直前に取得した item スナップショットが十分新しければ listItems を撃たず再利用する + // (B・~100 GraphQL pt/サイクルの重複排除)。POST /refresh は forceFetch で最新を取り直す。 + const maxAgeMs = cfg.itemsCacheMs || Math.floor((cfg.intervalMs || 300_000) / 2); + let items; + if (shouldReuseItemsCache({ now: cfg.now(), cache: state.itemsCache, maxAgeMs, forceFetch: deps.forceFetch })) { + items = state.itemsCache.items; + } else { + items = await listItems(cfg.owner, cfg.project, token); + state.itemsCache = { items, at: cfg.now() }; + } // 先に表示対象へ絞る: selectBoardItems(= isAssignee)はディレクティブ非依存なので、 // owner 解決の**前**に非終端 &「自分が Assignees のいずれか」だけへ限定できる。これで // 終端 Status や他人の multi-assignee item の本文 fetch を避ける(#938・ @@ -1730,6 +1747,24 @@ async function refreshBoardAndProjectTrackers(cfg, state, log, deps = {}) { await applyTrackerStickies(state.board ? state.board.items : [], cfg, log, deps); } +/** + * 定期(main loop)専用の board 追従(D)。誰もモニタを見ていない間は board の read + * (listItems/enrichment)を撃たず、トラッカー sticky が古くなり過ぎないアップキープ間隔 + * ({@link BOARD_UPKEEP_MS})を超えたときだけ 1 度走らせる。判定は純粋関数 + * {@link shouldRefreshBoardPeriodic} に委ね、ここは fire-and-forget の I/O のみ。 + * POST /refresh・POST /tick 直後・起動時はこの関数を通さず常に実行する。 + */ +function maybeRefreshBoardPeriodic(cfg, state, log, deps = {}) { + const run = shouldRefreshBoardPeriodic({ + now: cfg.now(), + watchedAt: state.boardWatchedAt != null ? state.boardWatchedAt : null, + lastBoardAt: state.board ? state.board.updatedAt : null, + watchTtlMs: cfg.boardWatchTtlMs || BOARD_WATCH_TTL_MS, + upkeepMs: cfg.boardUpkeepMs || BOARD_UPKEEP_MS, + }); + if (run) refreshBoardAndProjectTrackers(cfg, state, log, deps); +} + /** * tick を1回だけ実行する(再入防止つき)。HTTP `POST /tick` と interval ループの両方から使い、 * 手動 tick と定期 tick が重ならないようにする。実行中(state.ticking)なら tick を呼ばず busy を返す。 @@ -1834,6 +1869,9 @@ function startHttp(cfg, state, log) { // usage をライブ読取(ローカルファイル・API 予算ゼロ)してから返す。monitor の // 5 秒 poll でほぼライブ追従させる(worker 完了を待たない)(#1027)。 updateClaudeUsage(state, cfg, log); + // 観測時刻を記録する(D): 誰も見ていない間は定期 refreshBoard を抑制して + // board の read(listItems/enrichment)を撃たないため(shouldRefreshBoardPeriodic)。 + state.boardWatchedAt = cfg.now(); return send(200, boardResponse(cfg, state)); } if (req.method === 'GET' && url.pathname === '/status') { @@ -1878,7 +1916,10 @@ function startHttp(cfg, state, log) { // レート残量が僅少なら refreshBoard は内部で no-op になる。正直にスキップを返す。 return send(200, { refreshed: false, skipped: 'rate-limited', minRemaining: state.ratePlan.minRemaining }); } - refreshBoardAndProjectTrackers(cfg, state, log) + // 「🔄 更新」は明示的な最新要求なので forceFetch=true で listItems を取り直す(B の + // キャッシュ再利用をバイパス)。観測扱いにして直後の定期サイクルも追従させる(D)。 + state.boardWatchedAt = cfg.now(); + refreshBoardAndProjectTrackers(cfg, state, log, { forceFetch: true }) .then(() => send(200, { refreshed: true, updatedAt: state.board ? state.board.updatedAt : null })) .catch((e) => { log(`refresh error: ${e.message}`); send(500, { error: e.message }); }); return; @@ -2005,14 +2046,15 @@ async function main(opts = {}) { await checkAuthHealth(cfg, state, log); // runTickOnce 経由にして、定期 tick と手動 POST /tick が重ならないようにする(再入防止) await runTickOnce(cfg, state, log); - // tick で Project が動いた直後はボードも追従させる(fire-and-forget) - refreshBoardAndProjectTrackers(cfg, state, log); + // tick で Project が動いた直後はボードも追従させる(fire-and-forget)。ただし誰も + // モニタを見ていない間は board の read を撃たない(D・トラッカー維持のアップキープ間隔は除く)。 + maybeRefreshBoardPeriodic(cfg, state, log); await sleep(cfg.intervalMs); } if (opts.once) { await checkAuthHealth(cfg, state, log); await runTickOnce(cfg, state, log); - refreshBoardAndProjectTrackers(cfg, state, log); + maybeRefreshBoardPeriodic(cfg, state, log); } } @@ -2026,7 +2068,7 @@ module.exports = { recordAuthFailure, handleProcessError, installProcessSafetyNet, parseSsoDeviceOutput, startReauth, refreshBoard, recordHistory, refreshRateLimits, patchBoardCache, - applyTrackerStickies, refreshBoardAndProjectTrackers, + applyTrackerStickies, refreshBoardAndProjectTrackers, maybeRefreshBoardPeriodic, updateClaudeUsage, boardResponse, statusResponse, startHttp, checkForUpdate, startUpdateChecks, ensureCheckpointCommit, readContinuationFromWorktree, applyCheckpointHandling, diff --git a/tools/autopilot/src/phases.js b/tools/autopilot/src/phases.js index 85f9fb0f913..2802a12ebde 100644 --- a/tools/autopilot/src/phases.js +++ b/tools/autopilot/src/phases.js @@ -1320,6 +1320,59 @@ function normalizeBoardEnrichment(node) { }; } +// ---- 俯瞰ボード再取得の GraphQL 予算節約(read GraphQL 一点集中の緩和) ---- + +/** + * 俯瞰ボード表示を「アクティブに見られている」とみなす猶予(ms)。モニタは開いている間 + * `GET /board` を 5 秒ごとに叩くので、直近このTTL以内に board が読まれていれば観測中と判断する。 + */ +const BOARD_WATCH_TTL_MS = 120_000; + +/** + * 誰もモニタを見ていないときでも、トラッカー sticky(sub-issue 進捗)が無限に古くならないよう + * 最低限この間隔では refreshBoard を回す(アップキープ)。観測中は毎サイクル走るので影響なし。 + */ +const BOARD_UPKEEP_MS = 1_800_000; + +/** + * tick が直前に取得した Project item スナップショット(`state.itemsCache`)を、後続の + * refreshBoard が `gh project item-list`(~100 GraphQL pt)を撃ち直さず再利用してよいか + * (純粋関数)。tick と refreshBoard は main loop で連続実行されるため、同一サイクル内では + * 数秒差でしかなく再取得は完全な無駄になる(`.claude/rules/autopilot/github-api.md` 予算規約)。 + * @param {object} p + * @param {number|string} p.now 現在時刻(ms epoch / ISO) + * @param {?{items:object[], at:number|string}} p.cache tick が保存したスナップショット + * @param {number} p.maxAgeMs これより新しければ再利用(既定は interval の半分) + * @param {boolean} [p.forceFetch] モニタ「🔄 更新」など、明示的に最新を要求する場合は再取得 + * @returns {boolean} true=キャッシュ再利用可 / false=listItems を撃つ + */ +function shouldReuseItemsCache({ now, cache, maxAgeMs, forceFetch = false }) { + if (forceFetch) return false; + if (!cache || !Array.isArray(cache.items)) return false; + if (!(maxAgeMs > 0)) return false; + return toMs(now) - toMs(cache.at) < maxAgeMs; +} + +/** + * 定期(main loop)の refreshBoard を実行すべきか(純粋関数)。俯瞰ボードは *表示専用* なので、 + * 誰も見ていない間は listItems / enrichment の read を撃たない。ただしトラッカー sticky が + * 無限に古くならないよう、未観測でもアップキープ間隔を超えたら 1 度は走らせる。 + * POST /refresh・POST /tick 直後・起動時はこの判定を通さず常に実行する(呼び出し側で分岐)。 + * @param {object} p + * @param {number|string} p.now 現在時刻 + * @param {?number|string} p.watchedAt 直近に `GET /board` が読まれた時刻(未観測なら null) + * @param {?number|string} p.lastBoardAt 直近に board を実構築した時刻(`state.board.updatedAt`) + * @param {number} p.watchTtlMs これ以内に観測されていれば「観測中」 + * @param {number} p.upkeepMs 未観測時にトラッカー維持のため走らせる最低間隔 + * @returns {boolean} + */ +function shouldRefreshBoardPeriodic({ now, watchedAt, lastBoardAt, watchTtlMs, upkeepMs }) { + const n = toMs(now); + if (watchedAt != null && n - toMs(watchedAt) <= watchTtlMs) return true; + if (lastBoardAt == null) return true; + return n - toMs(lastBoardAt) >= upkeepMs; +} + // ---- トラッカー sticky (#934): 分解済み EPIC に sub-issue 進捗 + Close 指示を出す ---- /** @@ -2182,6 +2235,10 @@ module.exports = { selectPrSyncCandidates, selectBoardItems, normalizeBoardEnrichment, + BOARD_WATCH_TTL_MS, + BOARD_UPKEEP_MS, + shouldReuseItemsCache, + shouldRefreshBoardPeriodic, TRACKER_STICKY_MARKER, needsTrackerSticky, renderTrackerSticky, diff --git a/tools/autopilot/src/project.js b/tools/autopilot/src/project.js index 99037969852..c94b769ba38 100644 --- a/tools/autopilot/src/project.js +++ b/tools/autopilot/src/project.js @@ -65,6 +65,20 @@ async function readToken() { return readTokenCache.token; } +/** + * 俯瞰ボード再取得(listItems キャッシュミス時 / enrichment / head-PR 補完)に使うトークン。 + * これらは *読み取り専用で可視な成果物を生まない* ため、read(個人)トークンの GraphQL 予算に + * 一点集中させず、遊んでいる Bot の GraphQL 予算へ **既定で** 振り分けて実効予算を分散する + * (dispatch 判断の tick 系 read は従来どおり readToken のまま)。名義が見える書き込みは + * 引き続き botToken なので、名義規約(書き込み=Bot / 読み取り=read)の趣旨は損なわない。 + * `AUTOPILOT_BOARD_READS=read` で従来(read トークン)へ戻せる。 + * @returns {Promise} + */ +async function boardToken() { + if (process.env.AUTOPILOT_BOARD_READS === 'read') return readToken(); + return botToken(); +} + async function gh(args, { token } = {}) { const env = { ...process.env, GH_TOKEN: token || await botToken() }; const { stdout } = await execFileP('gh', args, { encoding: 'utf8', env, maxBuffer: 64 * 1024 * 1024 }); @@ -757,7 +771,7 @@ async function applyIntents(ctx, itemId, intents, token) { } module.exports = { - botToken, readToken, gh, getProject, getFields, listItems, normalizeProjectItem, findItemId, addIssue, setField, applyIntents, + botToken, readToken, boardToken, gh, getProject, getFields, listItems, normalizeProjectItem, findItemId, addIssue, setField, applyIntents, botLogin, findPrForIssue, selectClosingPr, selectHeadPr, hasMergedHeadPr, getPrReviewState, getGateContext, getIssueActivity, hasMergedPullRequest, REPO_ROOT, getPrInfo, getIssueLabels, getIssueBody, editLabels, setPrDraft, upsertStickyComment, upsertMarkedComment, diff --git a/tools/autopilot/test/board-budget.test.js b/tools/autopilot/test/board-budget.test.js new file mode 100644 index 00000000000..069c79c817b --- /dev/null +++ b/tools/autopilot/test/board-budget.test.js @@ -0,0 +1,74 @@ +'use strict'; +// read GraphQL 予算の一点集中を緩和する純粋関数のテスト(B: listItems 重複排除 / +// D: 未観測時の refreshBoard 抑制)。I/O 無し。 +const { test } = require('node:test'); +const assert = require('node:assert'); +const { + shouldReuseItemsCache, + shouldRefreshBoardPeriodic, + BOARD_WATCH_TTL_MS, + BOARD_UPKEEP_MS, +} = require('../src/phases'); + +test('shouldReuseItemsCache: 新鮮なキャッシュは再利用する', () => { + const cache = { items: [{ issue: 1 }], at: 1000 }; + assert.equal(shouldReuseItemsCache({ now: 1000 + 5_000, cache, maxAgeMs: 150_000 }), true); +}); + +test('shouldReuseItemsCache: maxAge を超えたら再取得', () => { + const cache = { items: [{ issue: 1 }], at: 1000 }; + assert.equal(shouldReuseItemsCache({ now: 1000 + 200_000, cache, maxAgeMs: 150_000 }), false); +}); + +test('shouldReuseItemsCache: forceFetch は常に再取得', () => { + const cache = { items: [{ issue: 1 }], at: 1000 }; + assert.equal(shouldReuseItemsCache({ now: 1000, cache, maxAgeMs: 150_000, forceFetch: true }), false); +}); + +test('shouldReuseItemsCache: キャッシュ無し / 不正は再取得', () => { + assert.equal(shouldReuseItemsCache({ now: 1000, cache: null, maxAgeMs: 150_000 }), false); + assert.equal(shouldReuseItemsCache({ now: 1000, cache: { at: 1000 }, maxAgeMs: 150_000 }), false); + assert.equal(shouldReuseItemsCache({ now: 1000, cache: { items: [], at: 1000 }, maxAgeMs: 0 }), false); +}); + +test('shouldReuseItemsCache: ISO 文字列の時刻も toMs で扱える', () => { + const cache = { items: [{ issue: 1 }], at: '2026-07-26T00:00:00.000Z' }; + const now = Date.parse('2026-07-26T00:00:10.000Z'); + assert.equal(shouldReuseItemsCache({ now, cache, maxAgeMs: 150_000 }), true); +}); + +test('shouldRefreshBoardPeriodic: 直近観測されていれば毎回走る', () => { + const now = 1_000_000; + assert.equal(shouldRefreshBoardPeriodic({ + now, watchedAt: now - 10_000, lastBoardAt: now - 10_000, + watchTtlMs: BOARD_WATCH_TTL_MS, upkeepMs: BOARD_UPKEEP_MS, + }), true); +}); + +test('shouldRefreshBoardPeriodic: 未観測 & アップキープ未満はスキップ', () => { + const now = 1_000_000; + assert.equal(shouldRefreshBoardPeriodic({ + now, watchedAt: now - (BOARD_WATCH_TTL_MS + 60_000), lastBoardAt: now - 60_000, + watchTtlMs: BOARD_WATCH_TTL_MS, upkeepMs: BOARD_UPKEEP_MS, + }), false); +}); + +test('shouldRefreshBoardPeriodic: 未観測でもアップキープ超過なら1度走る(トラッカー維持)', () => { + const now = 1_000_000; + assert.equal(shouldRefreshBoardPeriodic({ + now, watchedAt: null, lastBoardAt: now - (BOARD_UPKEEP_MS + 1), + watchTtlMs: BOARD_WATCH_TTL_MS, upkeepMs: BOARD_UPKEEP_MS, + }), true); +}); + +test('shouldRefreshBoardPeriodic: board 未構築(lastBoardAt=null)なら走る', () => { + const now = 1_000_000; + assert.equal(shouldRefreshBoardPeriodic({ + now, watchedAt: null, lastBoardAt: null, + watchTtlMs: BOARD_WATCH_TTL_MS, upkeepMs: BOARD_UPKEEP_MS, + }), true); +}); + +test('BOARD_WATCH_TTL_MS / BOARD_UPKEEP_MS は妥当な既定値', () => { + assert.ok(BOARD_WATCH_TTL_MS > 0 && BOARD_WATCH_TTL_MS < BOARD_UPKEEP_MS); +}); diff --git a/tools/autopilot/test/daemon.test.js b/tools/autopilot/test/daemon.test.js index fc2d5a7fc34..4a66459bd92 100644 --- a/tools/autopilot/test/daemon.test.js +++ b/tools/autopilot/test/daemon.test.js @@ -1850,7 +1850,7 @@ test('startHttp GET /board: usage をライブ読取して返す(worker 完了 }) + '\n'); const cfg = { ...makeCfg(), port: 0, assignee: null, concurrency: 1, - usageFile, statSync: () => ({ mtimeMs: 111 }), + now: () => 111, usageFile, statSync: () => ({ mtimeMs: 111 }), }; const state = { paused: false, running: new Map(), board: null, claudeUsage: null }; const { server, port } = await startTestHttp(cfg, state);