Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions .claude/rules/autopilot/github-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 の使い分け(別枠予算の並行活用)

Expand Down
2 changes: 1 addition & 1 deletion packages/admin/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
build/
node_modules/
node_modules
58 changes: 50 additions & 8 deletions tools/autopilot/src/daemon.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ const {
itemOwner,
orderItemsLikeBoard,
selectBoardItems,
shouldReuseItemsCache,
shouldRefreshBoardPeriodic,
BOARD_WATCH_TTL_MS,
BOARD_UPKEEP_MS,
selectClosedCheckIssues,
rateLimitPlan,
PR_SYNC_STATUSES,
Expand Down Expand Up @@ -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: [] };
Expand Down Expand Up @@ -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・
Expand Down Expand Up @@ -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 を返す。
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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,
Expand Down
57 changes: 57 additions & 0 deletions tools/autopilot/src/phases.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 指示を出す ----

/**
Expand Down Expand Up @@ -2182,6 +2235,10 @@ module.exports = {
selectPrSyncCandidates,
selectBoardItems,
normalizeBoardEnrichment,
BOARD_WATCH_TTL_MS,
BOARD_UPKEEP_MS,
shouldReuseItemsCache,
shouldRefreshBoardPeriodic,
TRACKER_STICKY_MARKER,
needsTrackerSticky,
renderTrackerSticky,
Expand Down
16 changes: 15 additions & 1 deletion tools/autopilot/src/project.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>}
*/
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 });
Expand Down Expand Up @@ -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,
Expand Down
74 changes: 74 additions & 0 deletions tools/autopilot/test/board-budget.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
2 changes: 1 addition & 1 deletion tools/autopilot/test/daemon.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading