From c22186bee10d466f3bb5d56d65935fe0636d4ba7 Mon Sep 17 00:00:00 2001 From: mikim <1441941+mikim@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:59:06 +0900 Subject: [PATCH] =?UTF-8?q?cron:=20persona=20tick=C2=B7=EB=8B=B5=EA=B8=80?= =?UTF-8?q?=EC=9D=84=20=ED=95=98=EB=A3=A8=20=EB=8B=A8=EC=9C=84=20=EB=A9=B1?= =?UTF-8?q?=EB=93=B1=EC=9C=BC=EB=A1=9C,=20=EB=B0=B0=ED=8F=AC=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0=EC=B0=BD=206=EC=8B=9C=EA=B0=84=20=E2=86=92=204?= =?UTF-8?q?=EC=8B=9C=EA=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "왜 배포를 그렇게 늦게 하나" 에 대한 답. 12:14 KST 에 머지한 것이 14:00 까지 기다린 이유는 DEPLOY_QUIET_HOURS_KST="6 7 8 9 12 13" 이고, 그 목록이 긴 이유는 cron 하나가 재실행에 안전하지 않아서였다. swap 은 모든 cron 앱을 재등록하고 pm2 가 각각 1회 즉시 실행한다. 다른 cron 은 그걸 견딘다 — brief 는 날짜 PK, why-moved 는 pulse 집합 비교, calls 는 이미 call 있는 글을 건너뛴다. 그런데 persona-tick 은 그런 가드가 없어서 09시대에 swap 하면 그날 10개를 한 번 더 발행했다. persona-replies 도 페르소나별 캡(dailyCap/2)만 있어 두 번째 라운드가 들어갈 여지가 있었다. 두 스크립트가 이제 그날(KST) 이미 발행된 수를 세고 차이만큼만 쓴다: - persona-tick: 오늘 top-level agent 글이 --pages 이상이면 즉시 종료 - persona-replies: 오늘 답글이 --max 이상이면 즉시 종료 부수 효과로 중단된 실행이 이어진다 — 4개 쓰고 죽었으면 다음 실행이 6개를 채운다. 그래서 09·12 를 quiet 목록에서 뺀다. 남은 "6 7 8 13" 은 각 스크립트의 가드를 같은 방식으로 확인하지 않은 시간대다. 하나씩 검증하며 줄일 수 있다. 검증 (프로덕션 DB 사본, 오늘 이미 10개 발행된 상태): --pages=10 → "already posted 10/10 today — nothing to do." --pages=12 → "target=2 (10 already today, cap 12)" 로 2개만 답글 --max=8 → 오늘 0개였으므로 8개 (같은 가드가 반대 방향으로 동작) Co-Authored-By: Claude Opus 5 --- scripts/deploy.sh | 15 +++++++++++---- scripts/persona-replies.ts | 25 +++++++++++++++++++++++-- scripts/persona-tick.ts | 36 ++++++++++++++++++++++++++++++++---- 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 3be772b..9ac40ec 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -60,9 +60,9 @@ # DEPLOY_KEEP_RELEASES old releases to keep on disk (default 4) # DEPLOY_KEEP_BACKUPS pre-swap DB/pm2 backups to keep (default 10) # DEPLOY_QUIET_HOURS_KST hours (KST) in which to defer the deploy entirely -# (default "6 7 8 9 12 13" — the cron slots; a swap -# re-registers every cron and pm2 runs each once). -# Set to an empty string to disable. +# (default "6 7 8 13" — cron slots whose script is not +# yet safe to re-run; a swap re-registers every cron +# and pm2 runs each once). Empty string disables. # DEPLOY_HOLD_FILE operator hold — exists ⇒ do nothing # DEPLOY_CI_WAIT_MIN how long to keep waiting for a CI verdict (run not # created yet, or GitHub API failing) before deploying @@ -439,7 +439,14 @@ main() { # `-` not `:-`: unset takes the default, but an explicit empty string means # "no quiet hours". With `:-` the empty value was silently replaced by the # default and the guard could not be turned off. - DEPLOY_QUIET_HOURS_KST=${DEPLOY_QUIET_HOURS_KST-"6 7 8 9 12 13"} + # 09 (persona-tick) and 12 (persona-replies) came off the list on 2026-08-20: + # both scripts now count what the KST day already has and write only the + # difference, so the immediate re-run a swap triggers is a no-op. The hours + # still listed are the ones whose crons have not been verified re-run-safe + # the same way — 06 macro, 07 synthesis/seed-qa/connections, 08 brief/ + # translate/why-moved, 13 calls. Each can be dropped once its script carries + # the same guard. + DEPLOY_QUIET_HOURS_KST=${DEPLOY_QUIET_HOURS_KST-"6 7 8 13"} # The webhook is a credential and this repo is public, so it lives only in # a server-side .env.local (gitignored). The TS scripts read the LIVE # RELEASE's copy; that is preferred once LIVE_DIR is known (below, in diff --git a/scripts/persona-replies.ts b/scripts/persona-replies.ts index 8ad280a..998c564 100644 --- a/scripts/persona-replies.ts +++ b/scripts/persona-replies.ts @@ -78,6 +78,25 @@ async function main() { const { getDb } = await import("../lib/db"); const db = getDb(); + // Same day-level guard as scripts/persona-tick.ts: a deploy swap re-runs + // every cron immediately, and the per-persona daily cap (agent.dailyCap / 2) + // leaves room for a second round. Count what today already has and write + // only the difference — which also makes a crashed run resumable. + const repliedToday = ( + db + .prepare( + `SELECT COUNT(*) AS n FROM alpha_posts + WHERE author_kind = 'agent' AND parent_id IS NOT NULL AND is_deleted = 0 + AND datetime(created_at, '+9 hours') >= date('now', '+9 hours')` + ) + .get() as { n: number } + ).n; + if (repliedToday >= max) { + console.log(`Already replied ${repliedToday}/${max} today — nothing to do.`); + return; + } + const remaining = max - repliedToday; + // 최근 24시간 페르소나 글 (답글 < 5) // // Agent-authored parents only, unless explicitly opted into. @@ -118,13 +137,15 @@ async function main() { const agents = getActiveAgents(); - console.log(`Reply candidates: ${candidates.length}, max ${max} replies`); + console.log( + `Reply candidates: ${candidates.length}, target ${remaining}${repliedToday ? ` (${repliedToday} already today, cap ${max})` : ""} replies` + ); let posted = 0; let totalCost = 0; let attempts = 0; for (const c of candidates) { - if (posted >= max) break; + if (posted >= remaining) break; attempts++; // Pick replier: stance-contrast preferred diff --git a/scripts/persona-tick.ts b/scripts/persona-tick.ts index 5a7390c..4a084f3 100644 --- a/scripts/persona-tick.ts +++ b/scripts/persona-tick.ts @@ -97,6 +97,34 @@ async function main() { const agents = getActiveAgents(); const today = new Date().toISOString().slice(0, 10); + // Already done today? Then this run has nothing to add. + // + // A deploy swap re-registers every cron app and pm2 runs each one once, + // immediately. Every other cron survives that — brief is keyed by date, + // why-moved compares pulse sets, calls skips posts that already have one — + // but this one had no such guard, so a swap inside the 09:00 KST hour + // published a second full round of ten. That is the whole reason + // DEPLOY_QUIET_HOURS_KST blocks 09 and 12 (scripts/deploy.sh), which costs + // up to two hours of deploy latency every day. + // + // Counting what today already has also makes a partial run resumable: if the + // process died after four posts, the next invocation writes six, not ten. + const { getDb } = await import("../lib/db"); + const postedToday = ( + getDb() + .prepare( + `SELECT COUNT(*) AS n FROM alpha_posts + WHERE author_kind = 'agent' AND parent_id IS NULL AND is_deleted = 0 + AND datetime(created_at, '+9 hours') >= date('now', '+9 hours')` + ) + .get() as { n: number } + ).n; + if (postedToday >= pages) { + console.log(`Tick ${today}: already posted ${postedToday}/${pages} today — nothing to do.`); + return; + } + const remaining = pages - postedToday; + // Build candidate pool — entity/topic/event with videoCount ≥ 3 type Candidate = { refType: "entity" | "topic" | "event" | "asset"; refId: string }; const pool: Candidate[] = []; @@ -159,16 +187,16 @@ async function main() { } console.log( - `Tick ${today}: pool=${pool.length} (persons skipped ${skippedPersons}, priceable reserved ${reserved.length}), types=${[...selectedTypes].join(",")}, agents=${agents.length}, target=${pages} posts` + `Tick ${today}: pool=${pool.length} (persons skipped ${skippedPersons}, priceable reserved ${reserved.length}), types=${[...selectedTypes].join(",")}, agents=${agents.length}, target=${remaining}${postedToday ? ` (${postedToday} already today, cap ${pages})` : ""} posts` ); let posted = 0; let totalCost = 0; let attempts = 0; - const maxAttempts = pages * 5; // safety bound + const maxAttempts = remaining * 5; // safety bound for (const c of pool) { - if (posted >= pages || attempts >= maxAttempts) break; + if (posted >= remaining || attempts >= maxAttempts) break; attempts++; // Pick an agent, prefer those that haven't posted recently @@ -195,7 +223,7 @@ async function main() { } console.log( - `\nTick done. Posted: ${posted}/${pages} · cost: $${totalCost.toFixed(4)} · attempts: ${attempts}` + `\nTick done. Posted: ${posted}/${remaining} · cost: $${totalCost.toFixed(4)} · attempts: ${attempts}` ); }