diff --git a/docs/routines/automation-candidate-pipeline-prd.md b/docs/routines/automation-candidate-pipeline-prd.md index ac82c3b140..5c24cff33a 100644 --- a/docs/routines/automation-candidate-pipeline-prd.md +++ b/docs/routines/automation-candidate-pipeline-prd.md @@ -10,6 +10,8 @@ created_at: "2026-05-02" updated_at: "2026-05-02" --- +> **2026-09-05 status note:** the QuickJS routine scripts this document describes (`routines/monitoring/*.js`) were removed from the repository — they were never attached as production routines. The Rust automation-candidate API/DB surface remains; treat the routine-side sections below as historical. + # PRD: AgentDesk Automation Candidate Pipeline > status: `implemented` diff --git a/docs/routines/automation-candidate-pipeline-spec.md b/docs/routines/automation-candidate-pipeline-spec.md index a338e4c33c..e68df7576c 100644 --- a/docs/routines/automation-candidate-pipeline-spec.md +++ b/docs/routines/automation-candidate-pipeline-spec.md @@ -11,6 +11,8 @@ created_at: "2026-05-02" updated_at: "2026-05-02" --- +> **2026-09-05 status note:** the QuickJS routine scripts this document describes (`routines/monitoring/*.js`) were removed from the repository — they were never attached as production routines. The Rust automation-candidate API/DB surface remains; treat the routine-side sections below as historical. + # SPEC SOURCE: AgentDesk Automation Candidate Pipeline ## Linked Documents diff --git a/docs/routines/daily-log-digest.md b/docs/routines/daily-log-digest.md deleted file mode 100644 index 40dfe44e23..0000000000 --- a/docs/routines/daily-log-digest.md +++ /dev/null @@ -1,92 +0,0 @@ -# Daily dcserver log digest routine - -Issue #4263 adds `monitoring/daily-log-digest.js` to the existing PostgreSQL-backed routine -worker. It is an agent-backed monitoring routine because the QuickJS routine sandbox intentionally -has no filesystem or network bridge. The routine dispatches one fresh agent turn per day; that turn -runs the deterministic sibling helper, and the existing routine Discord logger posts the final -summary to the configured routine channel/thread. - -## Attach once - -Routines use `routines.default_timezone` (Asia/Seoul by default). Attach one row on the cluster -leader and target the operations channel with `discord_thread_id`: - -```bash -REL_PORT="${AGENTDESK_REL_PORT:-8791}" -API="http://127.0.0.1:${REL_PORT}" - -curl -sf "$API/api/routines" -X POST -H 'Content-Type: application/json' -d '{ - "script_ref": "monitoring/daily-log-digest.js", - "name": "daily-dcserver-log-digest", - "agent_id": "project-agentdesk", - "execution_strategy": "fresh", - "schedule": "10 9 * * *", - "discord_thread_id": "YOUR_OPS_CHANNEL_OR_THREAD_ID", - "timeout_secs": 900 -}' -``` - -The cron schedule is persisted in the normal routines table and claimed through the existing -routine lease. A checkpoint day key is a second guard against a manual or duplicate same-day run, -so the routine dispatches at most one digest agent turn per KST day. - -## Inputs and configuration - -`routines/monitoring/daily_log_digest.py` resolves the runtime root in the same order used by -release tooling: `AGENTDESK_ROOT_DIR`, then `ADK_REL`, then `$HOME/.adk/release`. It reads: - -- `logs/dcserver.stdout.log` and its numbered rotations (the internal tracing writer); -- `logs/dcserver.launchd.stderr.log` (the path emitted by AgentDesk's launchd/systemd setup). - -Timestamped lines are limited to the preceding 24 hours. Undated launchd stderr uses a persistent -device/inode/byte-offset checkpoint under `runtime/daily-log-digest/`, so a range is counted at -most once; rotation or truncation starts a new range. The first observation establishes the -watermark, and a stale file outside the window is baselined without counting its old contents. - -Optional environment settings, normally placed in the deployment's preserved -`config/launchd.env`, are: - -- `AGENTDESK_LOG_DIGEST_THRESHOLD`: positive daily count threshold, default `50`; a pattern must - be strictly greater than the threshold. Invalid values warn and fall back to `50`. -- `AGENTDESK_LOG_DIGEST_REPO`: GitHub repository for open-issue dedup, default - `itismyfield/AgentDesk`. -- `AGENTDESK_LOG_DIGEST_CREATE_ISSUE`: default `off`. Only the literal `confirmed`, set by a human - after reviewing pending drafts, allows the approval path to inspect per-draft markers. - -## Normalization, dedup, and drafts - -`log_digest_issue_drafts.py` is the reusable API for this routine and #4265. Its public pipeline is: - -```python -patterns = aggregate_normalized_signatures(lines) -decisions = decide_issue_drafts(patterns, open_issues, threshold=50) -drafts = write_pending_drafts( - [decision.draft for decision in decisions if decision.draft], - pending_dir, -) -post = maybe_post_approved_drafts(drafts, approval_mode, create_issue) -``` - -Normalization removes ANSI decoration and timestamps, canonicalizes ERROR/WARN, and replaces UUIDs, -hashes, known embedded/dynamic IDs, request tokens, and most bare numbers with placeholders. HTTP -status codes and explicitly labelled ports remain distinct; unlabeled semantic numbers may still -collapse. Counts are grouped by severity plus normalized signature. Threshold crossings are -compared against the bounded issue title and first non-empty body line using at least three shared -tokens and symmetric Jaccard similarity; direct containment is accepted only for signature-like -candidates. This can miss a duplicate described only deep in a long body, but avoids suppressing a -new short signature merely mentioned in an unrelated epic. If the open-issue query is unavailable, -invalid, or reaches the 1,000-result cap, draft generation fails closed to avoid duplicate pending -work from an incomplete dedup set. - -Pending Markdown files use a stable signature hash and live at: - -```text -${AGENTDESK_ROOT_DIR:-$HOME/.adk/release}/runtime/pending-issue-drafts/daily-log-digest/ -``` - -The normal/default path never creates an issue. Approval is deliberately two-step: after reviewing -one pending file, create its adjacent marker (for example -`error-0123456789abcdef.md.approved`) and set -`AGENTDESK_LOG_DIGEST_CREATE_ISSUE=confirmed`. Both the literal environment gate and that specific -draft's `.approved` marker must exist before the injected issue-creation callback can run. A future -caller using the shared helper inherits both checks. diff --git a/docs/routines/local-worktree-inventory.md b/docs/routines/local-worktree-inventory.md deleted file mode 100644 index adc20b570b..0000000000 --- a/docs/routines/local-worktree-inventory.md +++ /dev/null @@ -1,80 +0,0 @@ -# Local agent worktree inventory (#4684) - -Isolation `worktree` agent spawns leave `.claude/worktrees/agent-*` git worktrees -behind whenever the sub-agent commits (auto-clean only removes *unchanged* -worktrees). With no reclamation these accumulate — the issue observed 70GB / 134 -worktrees, 52 orphaned. This routine provides the missing **visibility**: a -scheduled, report-only inventory that surfaces sizes, ages, and orphan -classifications so the leak is measurable and a later prune step has trustworthy -input. - -## Two pieces - -- `routines/monitoring/local_worktree_inventory.js` — the deterministic, - **read-only** helper that does the real work: enumerates `agent-*` worktree - directories (no symlink follow), cross-references `git worktree list - --porcelain`, and per candidate reads mtime/age, apparent disk size (`du - -sk`), dirty state (`git status --porcelain`), lock state, git registration, - and merge state (`git merge-base --is-ancestor origin/main`). It emits - one schema-validated JSON report. -- `routines/local-worktree-gc.js` — the QuickJS routine. QuickJS routines have - no filesystem bridge, so (matching `daily-log-digest`) it only dispatches one - fresh agent turn per KST day that runs the helper and returns its JSON stdout - verbatim. A checkpoint day-key prevents duplicate same-day dispatch. - -## Safety: report-only, safe by construction - -The helper performs **zero destructive operations** — its entire source contains -no worktree remove/prune, branch delete, ref delete, `rm -rf`, `find -delete`, or -`fs` unlink/rm call (asserted by test). Every child process is a read-only git or -`du` subcommand. It never deletes anything and never claims deletion authority: -every reported entry sets `positive_ownership_proof: false` and the report sets -`destructive_actions: 0`. - -Dispositions are advisory labels for a human or a future prune step, never an -instruction the helper acts on: - -- **PRESERVE** — dirty (uncommitted work), locked, unknown/uninspectable, - registered-but-missing directory, or clean-but-unmerged-and-recent. This is the - #4595 lesson: a naive GC could have destroyed the exact uncommitted work this - work was recovered from, so anything dirty or locked is preserved - unconditionally (schema validation rejects any dirty/locked entry not marked - PRESERVE). -- **AGED_ORPHAN_REVIEW** — clean but unmerged and older than the age threshold - (default 7 days). Flagged for human review; any future removal must first back - up the branch tip to `refs/archive/worktree-gc/` (issue proposal #2). The - helper does not remove it. -- **SAFE_MERGED_CANDIDATE** — clean AND merged into `origin/main` AND registered - (issue proposal #1, the session-verified safe-reclaim condition). Surfaced as a - candidate only; still report-only. - -## Scheduling (attach once) - -Like `daily-log-digest`, scheduling is a persisted routine row, not JS metadata. -Attach one row on the cluster leader targeting the operations channel: - -```bash -REL_PORT="${AGENTDESK_REL_PORT:-8791}" -API="http://127.0.0.1:${REL_PORT}" - -curl -sf "$API/api/routines" -X POST -H 'Content-Type: application/json' -d '{ - "script_ref": "local-worktree-gc.js", - "name": "local-agent-worktree-inventory", - "agent_id": "project-agentdesk", - "execution_strategy": "fresh", - "schedule": "0 9 * * *", - "discord_thread_id": "YOUR_OPS_CHANNEL_OR_THREAD_ID", - "timeout_secs": 900 -}' -``` - -The cron schedule (09:00 `routines.default_timezone`, Asia/Seoul by default) is -persisted in the routines table and claimed through the existing routine lease. - -## Configuration - -The helper resolves the repository from `AGENTDESK_REPO_DIR` (the routine prompt -sets it to `$ROOT/workspaces/agentdesk`). The age threshold defaults to 7 days -and the integration ref to `origin/main`; both are parameters of `runInventory`. -Running `node routines/monitoring/local_worktree_inventory.js` directly prints the -current inventory report for ad-hoc inspection. diff --git a/docs/routines/observation-provider-enrichment-prd.md b/docs/routines/observation-provider-enrichment-prd.md index 3f4b002019..ad85f4f73f 100644 --- a/docs/routines/observation-provider-enrichment-prd.md +++ b/docs/routines/observation-provider-enrichment-prd.md @@ -11,6 +11,8 @@ updated_at: "2026-05-02" p0_pr: "https://github.com/itismyfield/AgentDesk/pull/1497" --- +> **2026-09-05 status note:** the QuickJS routine scripts this document describes (`routines/monitoring/*.js`) were removed from the repository — they were never attached as production routines. The Rust automation-candidate API/DB surface remains; treat the routine-side sections below as historical. + # PRD: AgentDesk Observation Provider Enrichment > status: `p0-implemented` — PR #1497 merged. P1/P2 미착수. diff --git a/docs/routines/observation-provider-enrichment-spec.md b/docs/routines/observation-provider-enrichment-spec.md index 98bda9ca31..67c7cd9ca1 100644 --- a/docs/routines/observation-provider-enrichment-spec.md +++ b/docs/routines/observation-provider-enrichment-spec.md @@ -12,6 +12,8 @@ updated_at: "2026-05-02" p0_pr: "https://github.com/itismyfield/AgentDesk/pull/1497" --- +> **2026-09-05 status note:** the QuickJS routine scripts this document describes (`routines/monitoring/*.js`) were removed from the repository — they were never attached as production routines. The Rust automation-candidate API/DB surface remains; treat the routine-side sections below as historical. + # SPEC SOURCE: AgentDesk Observation Provider Enrichment ## Linked Documents diff --git a/docs/routines/weekly-churn-audit.md b/docs/routines/weekly-churn-audit.md deleted file mode 100644 index 0530ce7981..0000000000 --- a/docs/routines/weekly-churn-audit.md +++ /dev/null @@ -1,64 +0,0 @@ -# Weekly regression-churn audit - -Issue #4265 adds the offline `routines/monitoring/weekly_churn_audit.py` cron entry point. It reads -the local repository's `git log --since='7 days'`, recognizes conventional `fix:` and -`fix(scope):` subjects (including the optional breaking-change `!` marker), and counts each fix -commit once per changed file and once per containing module directory. A file with at least the -configured threshold (default `3`) is reported as a `재설계 후보`. - -Genuine issue references such as a leading `#4262` and references in the commit body are parsed in -text order. Terminal squash-merge PR suffixes such as `(#4511) (#4523)` are removed from the -subject before parsing, so they never become regression generations. Adjacent remaining references -form `#A→#B` edges; edges that meet across commits are joined, and the longest chain in each -connected lineage is reported with its generation count. Lineage path exploration is bounded and -logs a warning if a dense component must be truncated. The audit uses only commit text from every -commit in the window and the local git object database for this analysis; the file/module churn -tallies remain restricted to conventional fix commits. - -## Weekly cron entry point - -The default invocation is a dry run and prints the report to stdout: - -```bash -ROOT="${AGENTDESK_ROOT_DIR:-$HOME/.adk/release}" -python3 "$ROOT/routines/monitoring/weekly_churn_audit.py" \ - --repo-root "$ROOT" \ - --runtime-root "$ROOT" -``` - -For example, an operator-managed KST cron can run it at 09:20 every Monday: - -```cron -20 9 * * 1 python3 "$HOME/.adk/release/routines/monitoring/weekly_churn_audit.py" --repo-root "$HOME/.adk/release" --runtime-root "$HOME/.adk/release" -``` - -Optional configuration: - -- `AGENTDESK_CHURN_AUDIT_THRESHOLD`: positive candidate threshold, default `3`. Invalid values log - a warning and fall back to the default. -- `AGENTDESK_CHURN_AUDIT_SINCE`: local git window, default `7 days`. -- `AGENTDESK_CHURN_AUDIT_REPO`: repository used only by confirmed GitHub dedup/creation, default - `itismyfield/AgentDesk`. -- `AGENTDESK_CHURN_AUDIT_API`: local AgentDesk send endpoint, default - `http://127.0.0.1:8791/api/discord/send`. -- `AGENTDESK_CHURN_AUDIT_CHANNEL_ID`: weekly operations channel or thread ID. - -## Default-off side effects - -Both side-effect paths require a literal, human-set confirmation: - -- `AGENTDESK_CHURN_AUDIT_POST_CHANNEL=confirmed` posts the stdout report to - `AGENTDESK_CHURN_AUDIT_CHANNEL_ID`. The default is `off`. A successful report fingerprint is - stored under `runtime/weekly-churn-audit/post-state.json`, so rerunning an identical weekly report - does not post it twice. -- `AGENTDESK_CHURN_AUDIT_CREATE_ISSUE=confirmed` enables the GitHub open-issue scan and pending - draft emission. The default is `off`, so an ordinary run makes no `gh` or network call and writes - no draft. If dedup is unavailable or truncated, draft emission fails closed. - -Pending drafts use the stable writer shared with the daily log digest and live under -`runtime/pending-issue-drafts/weekly-churn-audit/`. Confirmation alone does not create an issue: -the operator must review a draft and add its adjacent `.approved` marker. Only a subsequent run -with both that marker and `AGENTDESK_CHURN_AUDIT_CREATE_ISSUE=confirmed` can invoke issue creation. -Created drafts include a stable per-file `churn-audit:candidate` marker; later runs use that exact -marker to suppress duplicates while the issue remains open. This is the same two-step human -approval boundary used by the sibling daily digest. diff --git a/policies/__tests__/automation-candidate-executor.test.js b/policies/__tests__/automation-candidate-executor.test.js deleted file mode 100644 index 754db1fe4f..0000000000 --- a/policies/__tests__/automation-candidate-executor.test.js +++ /dev/null @@ -1,473 +0,0 @@ -const test = require("node:test"); -const assert = require("node:assert/strict"); -const { loadRoutine } = require("./support/routine-harness"); - -const ROUTINE_PATH = "routines/monitoring/automation-candidate-executor.js"; - -const BASE_NOW = new Date("2026-05-12T10:00:00Z"); - -const MAX_ITERATIONS = 10; -const DISPATCH_RETRY_MS = 30 * 60 * 1000; -const MAX_DISPATCH_RETRIES = 3; - -function makeReadyObs(cardId, overrides = {}) { - return { - source: "kanban_ready", - pipeline_stage_id: overrides.pipeline_stage_id ?? "automation-candidate", - card_id: cardId, - summary: overrides.summary || `Test card ${cardId}`, - metadata: { - automation_candidate: { - source: overrides.source || "test", - dedupe_key: overrides.dedupe_key || `test:${cardId}`, - ...(overrides.automation_candidate || {}), - }, - program: { - repo_dir: overrides.repo_dir || "/tmp/repo", - description: overrides.description || "Fix something", - allowed_write_paths: overrides.allowed_write_paths || ["src/"], - metric_name: overrides.metric_name || "score", - metric_target: overrides.metric_target ?? 0.9, - current_iteration: overrides.current_iteration ?? 0, - ...(overrides.program || {}), - }, - }, - }; -} - -function makeDispatchedObs(cardId) { - return { - source: "kanban_dispatched", - card_id: cardId, - }; -} - -// --- No candidates --- - -test("no ready observations → complete with no-candidates summary", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - - const r = tick({ now: BASE_NOW, checkpoint: null, observations: [], automationInventory: [] }); - - assert.equal(r.action, "complete"); - assert.ok(r.result.summary.includes("없음"), `summary should mention no candidates: ${r.result.summary}`); - assert.equal(r.checkpoint.stats.ticks, 1); - assert.equal(r.checkpoint.stats.dispatched, 0); -}); - -test("general kanban ready card without automation discriminator is skipped", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const obs = [makeReadyObs("card-general", { - pipeline_stage_id: null, - automation_candidate: null, - })]; - delete obs[0].pipeline_stage_id; - delete obs[0].metadata.automation_candidate; - - const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] }); - - assert.equal(r.action, "complete"); - assert.equal(r.checkpoint.stats.dispatched, 0); - assert.equal(r.checkpoint.stats.skipped, 1); -}); - -test("automation candidate marker without complete program is skipped", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const obs = [makeReadyObs("card-incomplete-program")]; - delete obs[0].metadata.program.repo_dir; - - const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] }); - - assert.equal(r.action, "complete"); - assert.equal(r.checkpoint.stats.dispatched, 0); - assert.equal(r.checkpoint.stats.skipped, 1); -}); - -// --- Single ready card dispatch --- - -test("single ready card → agent action with correct prompt content", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const cardId = "card-abc-123"; - const obs = [makeReadyObs(cardId, { - summary: "Improve login security", - allowed_write_paths: ["src/auth/"], - metric_name: "security_score", - metric_target: 0.95, - current_iteration: 0, - })]; - - const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] }); - - assert.equal(r.action, "agent", "should emit agent action"); - // Card ID in prompt - assert.ok(r.prompt.includes(cardId), "prompt must include card_id"); - // Branch name - const expectedBranch = `automation/${cardId}/iter-1`; - assert.ok(r.prompt.includes(expectedBranch), `prompt must include branch name: ${expectedBranch}`); - // API endpoint - assert.ok(r.prompt.includes(`/api/automation-candidates/${cardId}/iteration-result`), - "prompt must include the iteration-result API endpoint"); - // allowed_write_paths - assert.ok(r.prompt.includes("src/auth/"), "prompt must mention allowed_write_paths"); - // metric name - assert.ok(r.prompt.includes("security_score"), "prompt must include metric_name"); - - // Checkpoint: pending entry created - assert.ok(r.checkpoint.pending[cardId], "pending entry should be created"); - assert.equal(r.checkpoint.pending[cardId].attempt_count, 1); - assert.equal(r.checkpoint.stats.dispatched, 1); -}); - -test("prompt iteration number equals current_iteration + 1", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const cardId = "card-iter-test"; - // current_iteration = 2 → should dispatch iter 3 - const obs = [makeReadyObs(cardId, { current_iteration: 2 })]; - - const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] }); - - assert.equal(r.action, "agent"); - assert.ok(r.prompt.includes("iter-3"), "prompt branch should be iter-3"); - assert.ok(r.prompt.includes("3 /"), "prompt should show iteration 3"); -}); - -test("previous iterations are included in prompt when automationInventory provided", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const cardId = "card-with-history"; - const obs = [makeReadyObs(cardId, { current_iteration: 1 })]; - const prevIterations = [ - { iteration: 1, status: "keep", metric_before: 0.7, metric_after: 0.8, description: "First attempt" }, - ]; - - const r = tick({ - now: BASE_NOW, - checkpoint: null, - observations: obs, - automationInventory: { [cardId]: prevIterations }, - }); - - assert.equal(r.action, "agent"); - assert.ok(r.prompt.includes("First attempt"), "prompt should include previous iteration description"); - assert.ok(r.prompt.includes("0.7"), "prompt should include metric_before from previous iter"); -}); - -test("previous iterations are read from API inventory response shape", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const cardId = "card-api-inventory"; - const obs = [makeReadyObs(cardId, { current_iteration: 1 })]; - const prevIterations = [ - { iteration: 1, status: "keep", metric_before: 5, metric_after: 3, description: "API shaped history" }, - ]; - - const r = tick({ - now: BASE_NOW, - checkpoint: null, - observations: obs, - automationInventory: { card_id: cardId, iterations: prevIterations }, - }); - - assert.equal(r.action, "agent"); - assert.ok(r.prompt.includes("API shaped history"), "prompt should include API-shaped history"); - assert.ok(r.prompt.includes("5"), "prompt should include metric_before from API-shaped history"); -}); - -test("array automation inventory is filtered by card before prompt injection", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const cardId = "card-array-inventory"; - const obs = [makeReadyObs(cardId, { current_iteration: 1 })]; - - const r = tick({ - now: BASE_NOW, - checkpoint: null, - observations: obs, - automationInventory: [ - { pattern_id: "routine-only", status: "active", description: "Unrelated routine inventory" }, - { card_id: "other-card", iteration: 1, status: "discard", description: "Other card history" }, - { card_id: cardId, iterations: [ - { iteration: 1, status: "keep", metric_before: 4, metric_after: 2, description: "Matching card history" }, - ] }, - ], - }); - - assert.equal(r.action, "agent"); - assert.ok(r.prompt.includes("Matching card history"), "prompt should include matching card history"); - assert.ok(!r.prompt.includes("Unrelated routine inventory"), "prompt must not include routine inventory rows"); - assert.ok(!r.prompt.includes("Other card history"), "prompt must not include another card history"); -}); - -test("previous iterations are read from keyed API inventory response shape", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const cardId = "card-keyed-api-inventory"; - const obs = [makeReadyObs(cardId, { current_iteration: 1 })]; - const prevIterations = [ - { iteration: 1, status: "discard", metric_before: 5, metric_after: 5, description: "Keyed API history" }, - ]; - - const r = tick({ - now: BASE_NOW, - checkpoint: null, - observations: obs, - automationInventory: { [cardId]: { iterations: prevIterations } }, - }); - - assert.equal(r.action, "agent"); - assert.ok(r.prompt.includes("Keyed API history"), "prompt should include keyed API-shaped history"); -}); - -// --- kanban_dispatched suppression --- - -test("card in kanban_dispatched observation → suppressed (complete)", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const cardId = "card-dispatched"; - const obs = [ - makeReadyObs(cardId), - makeDispatchedObs(cardId), - ]; - - const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] }); - - assert.equal(r.action, "complete", "card already dispatched should not produce agent action"); - assert.equal(r.checkpoint.stats.dispatched, 0); - assert.equal(r.checkpoint.stats.skipped, 1); -}); - -test("kanban_dispatched by evidence_ref format is also suppressed", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const cardId = "card-evidence-ref"; - const obs = [ - makeReadyObs(cardId), - // evidence_ref format instead of card_id field - { source: "kanban_dispatched", evidence_ref: `kanban_cards:${cardId}` }, - ]; - - const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] }); - - assert.equal(r.action, "complete"); - assert.equal(r.checkpoint.stats.skipped, 1); -}); - -// --- Checkpoint.dispatched suppression --- - -test("card already in checkpoint.dispatched → skipped", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const cardId = "card-in-checkpoint"; - const obs = [makeReadyObs(cardId)]; - const checkpoint = { - version: 2, - dispatched: { - [cardId]: { dispatched_at: BASE_NOW.toISOString(), status: "ok", iteration: 1 }, - }, - pending: {}, - stats: { ticks: 0, dispatched: 0, skipped: 0, max_iterations_reached: 0 }, - }; - - const r = tick({ now: BASE_NOW, checkpoint, observations: obs, automationInventory: [] }); - - assert.equal(r.action, "complete", "checkpointed card should not be re-dispatched"); - assert.equal(r.checkpoint.stats.skipped, 1); -}); - -// --- MAX_ITERATIONS boundary --- - -test("card at iteration > MAX_ITERATIONS → max_iterations_reached, skipped", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const cardId = "card-max-iter"; - // current_iteration = 10 → next would be iter 11 > MAX_ITERATIONS(10) - const obs = [makeReadyObs(cardId, { current_iteration: MAX_ITERATIONS })]; - - const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] }); - - assert.equal(r.action, "complete"); - assert.equal(r.checkpoint.stats.max_iterations_reached, 1); - assert.equal(r.checkpoint.dispatched[cardId].status, "max_iterations_reached"); - assert.equal(r.checkpoint.stats.dispatched, 0); -}); - -test("card at iteration == MAX_ITERATIONS (iter 10) is still dispatched", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const cardId = "card-at-limit"; - // current_iteration = 9 → next is iter 10 == MAX_ITERATIONS → still dispatch - const obs = [makeReadyObs(cardId, { current_iteration: 9 })]; - - const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] }); - - assert.equal(r.action, "agent", "iter 10 should still be dispatched"); - assert.ok(r.prompt.includes("iter-10"), "prompt should show iter-10"); -}); - -// --- Retry window --- - -test("card retried within DISPATCH_RETRY_MS → skipped", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const cardId = "card-retry-window"; - const obs = [makeReadyObs(cardId)]; - // last_attempted_at is 10 min ago (< 30 min window) - const recentlyAttempted = new Date(BASE_NOW.getTime() - 10 * 60 * 1000).toISOString(); - const checkpoint = { - version: 2, - dispatched: {}, - pending: { - [cardId]: { - first_attempted_at: recentlyAttempted, - last_attempted_at: recentlyAttempted, - attempt_count: 1, - iteration: 1, - }, - }, - stats: { ticks: 0, dispatched: 0, skipped: 0, max_iterations_reached: 0 }, - }; - - const r = tick({ now: BASE_NOW, checkpoint, observations: obs, automationInventory: [] }); - - assert.equal(r.action, "complete", "card within retry window should be skipped"); - assert.equal(r.checkpoint.stats.skipped, 1); -}); - -test("card attempted after DISPATCH_RETRY_MS → re-dispatched", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const cardId = "card-retry-expired"; - const obs = [makeReadyObs(cardId)]; - // last_attempted_at is 40 min ago (> 30 min window) - const expiredAttempt = new Date(BASE_NOW.getTime() - 40 * 60 * 1000).toISOString(); - const checkpoint = { - version: 2, - dispatched: {}, - pending: { - [cardId]: { - first_attempted_at: expiredAttempt, - last_attempted_at: expiredAttempt, - attempt_count: 1, - iteration: 1, - }, - }, - stats: { ticks: 0, dispatched: 0, skipped: 0, max_iterations_reached: 0 }, - }; - - const r = tick({ now: BASE_NOW, checkpoint, observations: obs, automationInventory: [] }); - - assert.equal(r.action, "agent", "card past retry window should be re-dispatched"); - assert.equal(r.checkpoint.pending[cardId].attempt_count, 2); -}); - -// --- MAX_DISPATCH_RETRIES --- - -test("card at MAX_DISPATCH_RETRIES limit waits for retry window before stalled", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const cardId = "card-max-retries-in-flight"; - const obs = [makeReadyObs(cardId)]; - const recentAttempt = new Date(BASE_NOW.getTime() - 60 * 1000).toISOString(); - const checkpoint = { - version: 2, - dispatched: {}, - pending: { - [cardId]: { - first_attempted_at: recentAttempt, - last_attempted_at: recentAttempt, - attempt_count: MAX_DISPATCH_RETRIES, - iteration: 1, - }, - }, - stats: { ticks: 0, dispatched: 0, skipped: 0, max_iterations_reached: 0 }, - }; - - const withinWindow = tick({ - now: BASE_NOW, - checkpoint, - observations: obs, - automationInventory: [], - }); - - assert.equal(withinWindow.action, "complete"); - assert.equal(withinWindow.checkpoint.stats.stalled_candidates, 0); - assert.equal(withinWindow.checkpoint.pending[cardId].status, undefined); - assert.equal(withinWindow.checkpoint.pending[cardId].stalled_at, undefined); - - const afterWindow = new Date(BASE_NOW.getTime() + 30 * 60 * 1000); - const stalled = tick({ - now: afterWindow, - checkpoint: withinWindow.checkpoint, - observations: obs, - automationInventory: [], - }); - - assert.equal(stalled.checkpoint.stats.stalled_candidates, 1); - assert.equal(stalled.checkpoint.pending[cardId].status, "stalled"); - assert.equal(stalled.checkpoint.pending[cardId].stalled_at, afterWindow.toISOString()); -}); - -test("card at MAX_DISPATCH_RETRIES limit → stalled once across repeated ticks", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const cardId = "card-max-retries"; - const obs = [makeReadyObs(cardId)]; - // attempt_count == MAX_DISPATCH_RETRIES(3) - const expiredAttempt = new Date(BASE_NOW.getTime() - 40 * 60 * 1000).toISOString(); - const checkpoint = { - version: 2, - dispatched: {}, - pending: { - [cardId]: { - first_attempted_at: expiredAttempt, - last_attempted_at: expiredAttempt, - attempt_count: MAX_DISPATCH_RETRIES, - iteration: 1, - }, - }, - stats: { ticks: 0, dispatched: 0, skipped: 0, max_iterations_reached: 0 }, - }; - - const r = tick({ now: BASE_NOW, checkpoint, observations: obs, automationInventory: [] }); - - assert.equal(r.action, "complete", "card at max retries should be permanently skipped"); - assert.equal(r.checkpoint.stats.dispatched, 0, "should not count as dispatched"); - assert.equal(r.checkpoint.stats.stalled_candidates, 1, "should count as stalled"); - assert.equal(r.checkpoint.pending[cardId].status, "stalled"); - assert.equal(r.checkpoint.pending[cardId].stalled_at, BASE_NOW.toISOString()); - - const nextNow = new Date(BASE_NOW.getTime() + 60 * 1000); - const repeated = tick({ - now: nextNow, - checkpoint: r.checkpoint, - observations: obs, - automationInventory: [], - }); - - assert.equal(repeated.action, "complete"); - assert.equal(repeated.checkpoint.stats.stalled_candidates, 1, - "the same stalled candidate must not be recounted on later ticks"); - assert.equal(repeated.checkpoint.pending[cardId].stalled_at, BASE_NOW.toISOString(), - "the first stalled transition timestamp must remain stable"); -}); - -// --- Checkpoint version mismatch --- - -test("stale checkpoint version is reset to empty", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const obs = []; - const staleCheckpoint = { - version: 1, // old version - dispatched_signatures: { "some-sig": true }, - stats: { ticks: 999 }, - }; - - const r = tick({ now: BASE_NOW, checkpoint: staleCheckpoint, observations: obs, automationInventory: [] }); - - assert.equal(r.checkpoint.version, 2, "checkpoint version should be reset to 2"); - assert.equal(r.checkpoint.stats.ticks, 1, "stats should reset (stale checkpoint discarded)"); -}); - -// --- Multiple ready cards: first wins --- - -test("two ready cards → only first is dispatched per tick", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const obs = [ - makeReadyObs("card-first"), - makeReadyObs("card-second"), - ]; - - const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] }); - - assert.equal(r.action, "agent", "should dispatch first card"); - assert.ok(r.prompt.includes("card-first"), "first card should be dispatched"); - assert.ok(!r.prompt.includes("card-second"), "second card should not appear in this tick's prompt"); - assert.equal(r.checkpoint.stats.dispatched, 1); -}); diff --git a/policies/__tests__/daily-log-digest.test.js b/policies/__tests__/daily-log-digest.test.js deleted file mode 100644 index f63ff7fa5f..0000000000 --- a/policies/__tests__/daily-log-digest.test.js +++ /dev/null @@ -1,64 +0,0 @@ -const test = require("node:test"); -const assert = require("node:assert/strict"); -const { loadRoutine } = require("./support/routine-harness"); - -const ROUTINE_PATH = "routines/monitoring/daily-log-digest.js"; - -test("daily log digest uses the monitoring agent-action frame", () => { - const { routine, tick } = loadRoutine(ROUTINE_PATH); - const now = new Date("2026-07-14T00:10:00Z"); - - const result = tick({ now, checkpoint: null, observations: [], automationInventory: [] }); - - assert.equal(routine.name, "Daily dcserver Log Digest"); - assert.equal(result.action, "agent"); - assert.match(result.prompt, /python3 "\$ROOT\/routines\/monitoring\/daily_log_digest\.py"/); - assert.match(result.prompt, /Do not call `gh issue create` directly/); - assert.match(result.prompt, /AGENTDESK_LOG_DIGEST_CREATE_ISSUE=confirmed/); - assert.equal(result.checkpoint.last_dispatched_day, "2026-07-14"); -}); - -test("daily checkpoint suppresses a second digest on the same day", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const first = tick({ - now: new Date("2026-07-14T00:10:00Z"), - checkpoint: null, - observations: [], - automationInventory: [], - }); - const duplicate = tick({ - now: new Date("2026-07-14T12:10:00Z"), - checkpoint: first.checkpoint, - observations: [], - automationInventory: [], - }); - - assert.equal(duplicate.action, "complete"); - assert.equal(duplicate.result.status, "already_dispatched"); -}); - -test("daily checkpoint dispatches again on the next day", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const result = tick({ - now: new Date("2026-07-15T00:10:00Z"), - checkpoint: { version: 1, last_dispatched_day: "2026-07-14" }, - observations: [], - automationInventory: [], - }); - - assert.equal(result.action, "agent"); - assert.equal(result.checkpoint.last_dispatched_day, "2026-07-15"); -}); - -test("daily checkpoint day key follows the routine's default KST timezone", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const result = tick({ - now: new Date("2026-07-14T16:10:00Z"), - checkpoint: { version: 1, last_dispatched_day: "2026-07-14" }, - observations: [], - automationInventory: [], - }); - - assert.equal(result.action, "agent"); - assert.equal(result.checkpoint.last_dispatched_day, "2026-07-15"); -}); diff --git a/policies/__tests__/detector.test.js b/policies/__tests__/detector.test.js deleted file mode 100644 index 3a2b63f414..0000000000 --- a/policies/__tests__/detector.test.js +++ /dev/null @@ -1,153 +0,0 @@ -const test = require("node:test"); -const assert = require("node:assert/strict"); -const { loadRoutine } = require("./support/routine-harness"); - -const ROUTINE_PATH = "routines/monitoring/automation-candidate-detector.js"; - -const BASE_NOW = new Date("2026-05-02T10:00:00Z"); - -function makeReviewObs(signature, overrides = {}) { - return { - key: `routine_observation:candidate_review:${signature}`, - value: { - signature, - score: overrides.score !== undefined ? overrides.score : 85, - evidence_count: overrides.evidence_count !== undefined ? overrides.evidence_count : 8, - category: overrides.category || "routine-candidate", - suggested_automation: overrides.suggested_automation || "자동화 제안", - outcome_summary: overrides.outcome_summary || "결과 요약", - last_seen_at: overrides.last_seen_at || BASE_NOW.toISOString(), - }, - summary: `candidate_review for ${signature}`, - }; -} - -function makeNormalizedReviewObs(signature, overrides = {}) { - const obs = makeReviewObs(signature, overrides); - return { - evidence_ref: `kv_meta:${obs.key}`, - value: obs.value, - summary: obs.summary, - }; -} - -function makeApprovedObs(signature) { - return { - key: `routine_observation:candidate_approved:${signature}`, - summary: `candidate_approved for ${signature}`, - }; -} - -function makeDispatchedObs(signature) { - return { - key: `routine_observation:candidate_dispatched:${signature}`, - summary: `candidate_dispatched for ${signature}`, - }; -} - -test("quality gate pass: emits agent action for valid candidate", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const obs = [makeReviewObs("valid-sig")]; - - const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] }); - assert.equal(r.action, "agent", "should emit agent action for valid candidate"); - assert.ok(r.prompt.includes("valid-sig"), "prompt should include candidate signature"); - assert.ok(r.prompt.includes("routine_observation:candidate_approved:valid-sig"), - "prompt should instruct to write candidate_approved kv_meta"); -}); - -test("normalized provider candidate_review emits agent action", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const obs = [makeNormalizedReviewObs("normalized-sig")]; - - const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] }); - assert.equal(r.action, "agent", "normalized kv_meta observation should emit agent action"); - assert.ok(r.prompt.includes("routine_observation:candidate_approved:normalized-sig")); -}); - -test("already approved candidate is skipped without re-emitting", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const obs = [ - makeReviewObs("approved-sig"), - makeApprovedObs("approved-sig"), - ]; - - const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] }); - assert.equal(r.action, "complete", "should return complete when candidate already approved"); - assert.equal(r.result.review_count, 1, "should have processed 1 review obs"); -}); - -test("evidence_age reject: candidate older than 48h is rejected at quality gate", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const oldTimestamp = new Date(BASE_NOW.getTime() - 49 * 3600_000).toISOString(); - const obs = [makeReviewObs("stale-sig", { last_seen_at: oldTimestamp })]; - - const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] }); - assert.equal(r.action, "complete", "stale candidate should not produce agent action"); - assert.equal(r.checkpoint.stats.skipped_quality_gate, 1, "skipped_quality_gate should be 1"); -}); - -test("invalid last_seen_at is rejected at quality gate", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const obs = [makeReviewObs("invalid-ts-sig", { last_seen_at: "not-a-date" })]; - - const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] }); - assert.equal(r.action, "complete", "invalid timestamp candidate should not produce agent action"); - assert.equal(r.checkpoint.stats.skipped_quality_gate, 1, "skipped_quality_gate should be 1"); -}); - -test("previously emitted candidate is not re-emitted on second tick", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const obs = [makeReviewObs("dup-emit-sig")]; - - // First tick: emits agent action - const r1 = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] }); - assert.equal(r1.action, "agent", "first tick should emit"); - - // Second tick: same obs, same checkpoint — should not re-emit - const nowT2 = new Date(BASE_NOW.getTime() + 60_000); - const r2 = tick({ now: nowT2, checkpoint: r1.checkpoint, observations: obs, automationInventory: [] }); - assert.equal(r2.action, "complete", "second tick should not re-emit"); -}); - -test("emitted candidate is retried when durable approval is still missing", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const obs = [makeReviewObs("retry-sig")]; - - const r1 = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] }); - assert.equal(r1.action, "agent", "first tick should emit"); - - const nowT2 = new Date(BASE_NOW.getTime() + 61 * 60_000); - const r2 = tick({ now: nowT2, checkpoint: r1.checkpoint, observations: obs, automationInventory: [] }); - assert.equal(r2.action, "agent", "stale emit should retry when no approval marker exists"); - assert.equal(r2.checkpoint.seen_candidates["retry-sig"].first_seen_at, BASE_NOW.toISOString()); - assert.equal(r2.checkpoint.seen_candidates["retry-sig"].last_emitted_at, nowT2.toISOString()); -}); - -test("multiple review observations only mark the emitted candidate", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const obs = [ - makeReviewObs("first-sig"), - makeReviewObs("second-sig"), - ]; - - const r1 = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] }); - assert.equal(r1.action, "agent", "first tick should emit one agent action"); - assert.ok(r1.prompt.includes("first-sig"), "first candidate should be emitted first"); - assert.equal(r1.checkpoint.seen_candidates["first-sig"].status, "emitted"); - assert.equal( - r1.checkpoint.seen_candidates["second-sig"], - undefined, - "non-emitted candidates must remain available for later ticks" - ); - - const r2 = tick({ - now: new Date(BASE_NOW.getTime() + 60_000), - checkpoint: r1.checkpoint, - observations: obs, - automationInventory: [], - }); - assert.equal(r2.action, "agent", "second tick should emit the remaining candidate"); - assert.ok(r2.prompt.includes("second-sig"), "second candidate should be emitted on the next tick"); - assert.equal(r2.checkpoint.seen_candidates["second-sig"].status, "emitted"); -}); diff --git a/policies/__tests__/executor.test.js b/policies/__tests__/executor.test.js deleted file mode 100644 index e1914cb217..0000000000 --- a/policies/__tests__/executor.test.js +++ /dev/null @@ -1,106 +0,0 @@ -const test = require("node:test"); -const assert = require("node:assert/strict"); -const { loadRoutine } = require("./support/routine-harness"); - -const ROUTINE_PATH = "routines/monitoring/automation-executor.js"; - -const BASE_NOW = new Date("2026-05-02T10:00:00Z"); - -function makeApprovedObs(signature, overrides = {}) { - return { - key: `routine_observation:candidate_approved:${signature}`, - value: { - signature, - score: overrides.score !== undefined ? overrides.score : 85, - category: overrides.category || "routine-candidate", - approved_at: overrides.approved_at || BASE_NOW.toISOString(), - suggested_automation: overrides.suggested_automation || "자동화 제안", - outcome_summary: overrides.outcome_summary || "결과 요약", - }, - summary: `candidate_approved for ${signature}`, - }; -} - -function makeNormalizedApprovedObs(signature, overrides = {}) { - const obs = makeApprovedObs(signature, overrides); - return { - evidence_ref: `kv_meta:${obs.key}`, - value: obs.value, - summary: obs.summary, - }; -} - -function makeDispatchedObs(signature, overrides = {}) { - return { - key: `routine_observation:candidate_dispatched:${signature}`, - value: { - signature, - dispatched_at: overrides.dispatched_at, - timestamp: overrides.timestamp, - category: overrides.category || "routine-candidate", - }, - summary: `candidate_dispatched for ${signature}`, - }; -} - -test("approved candidate triggers agent dispatch with GitHub Issue prompt", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const obs = [makeApprovedObs("dispatch-sig")]; - - const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] }); - assert.equal(r.action, "agent", "should emit agent action for approved candidate"); - assert.ok(r.prompt.includes("dispatch-sig"), "prompt should include signature"); - assert.ok(r.prompt.includes("GitHub Issue"), "prompt should mention GitHub Issue"); - assert.ok(r.prompt.includes("routine_observation:candidate_dispatched:dispatch-sig"), - "prompt should instruct to write candidate_dispatched kv_meta"); - assert.ok(!r.checkpoint.dispatched_signatures["dispatch-sig"], - "signature should be checkpointed only after durable candidate_dispatched kv_meta is observed"); -}); - -test("normalized approved observation triggers agent dispatch", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const obs = [makeNormalizedApprovedObs("normalized-dispatch-sig")]; - - const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] }); - assert.equal(r.action, "agent", "normalized kv_meta observation should emit agent action"); - assert.ok(r.prompt.includes("normalized-dispatch-sig")); -}); - -test("already dispatched candidate is skipped", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const obs = [ - makeApprovedObs("skip-sig"), - makeDispatchedObs("skip-sig"), - ]; - - const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] }); - assert.equal(r.action, "complete", "already dispatched candidate should not produce agent action"); - assert.equal(r.checkpoint.stats.skipped_already_dispatched, 1, "skipped counter should be 1"); - assert.ok(r.checkpoint.dispatched_signatures["skip-sig"], - "durable dispatched observation should be mirrored into checkpoint"); -}); - -test("durable dispatched observation preserves marker timestamp", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const dispatchedAt = new Date(BASE_NOW.getTime() - 6 * 24 * 3600_000).toISOString(); - const obs = [ - makeApprovedObs("old-dispatch-sig"), - makeDispatchedObs("old-dispatch-sig", { dispatched_at: dispatchedAt }), - ]; - - const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] }); - assert.equal(r.action, "complete", "already dispatched candidate should not produce agent action"); - assert.equal( - r.checkpoint.dispatched_signatures["old-dispatch-sig"], - dispatchedAt, - "checkpoint should mirror durable marker time instead of now" - ); -}); - -test("no approved candidates returns complete with empty summary", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - - const r = tick({ now: BASE_NOW, checkpoint: null, observations: [], automationInventory: [] }); - assert.equal(r.action, "complete", "no candidates → complete"); - assert.equal(r.result.approved_count, 0, "approved_count should be 0"); -}); diff --git a/policies/__tests__/local-worktree-gc.test.js b/policies/__tests__/local-worktree-gc.test.js deleted file mode 100644 index 61e470c616..0000000000 --- a/policies/__tests__/local-worktree-gc.test.js +++ /dev/null @@ -1,249 +0,0 @@ -const test = require("node:test"); -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const { loadRoutine } = require("./support/routine-harness"); - -const REPO_ROOT = path.resolve(__dirname, "..", ".."); -const helper = require(path.join(REPO_ROOT, "routines/monitoring/local_worktree_inventory.js")); - -const DAY = 24 * 60 * 60; - -// --- Pure classification: the #4595 data-loss invariant --- - -test("dirty worktrees are always PRESERVE (uncommitted work protected)", () => { - const d = helper.classifyWorktree({ - registered: true, - locked: false, - dirty: true, - merged: true, // even if merged, dirty wins - age_seconds: 30 * DAY, - worktree_state: "dirty", - }); - assert.equal(d.disposition, "PRESERVE"); - assert.equal(d.positive_ownership_proof, false); -}); - -test("locked worktrees are always PRESERVE", () => { - const d = helper.classifyWorktree({ - registered: true, - locked: true, - dirty: false, - merged: true, - age_seconds: 90 * DAY, - worktree_state: "clean", - }); - assert.equal(d.disposition, "PRESERVE"); -}); - -test("unknown inspection state never authorizes cleanup", () => { - const d = helper.classifyWorktree({ - registered: true, - locked: false, - dirty: false, - merged: null, - age_seconds: 90 * DAY, - worktree_state: "unknown", - }); - assert.equal(d.disposition, "PRESERVE"); -}); - -test("clean + unmerged + recent stays PRESERVE (possible live work)", () => { - const d = helper.classifyWorktree({ - registered: true, - locked: false, - dirty: false, - merged: false, - age_seconds: 2 * DAY, - worktree_state: "clean", - }); - assert.equal(d.disposition, "PRESERVE"); -}); - -test("clean + unmerged + aged is AGED_ORPHAN_REVIEW, not deletion", () => { - const d = helper.classifyWorktree({ - registered: true, - locked: false, - dirty: false, - merged: false, - age_seconds: 30 * DAY, - worktree_state: "clean", - }); - assert.equal(d.disposition, "AGED_ORPHAN_REVIEW"); - assert.equal(d.positive_ownership_proof, false); -}); - -test("clean + merged + registered is SAFE_MERGED_CANDIDATE (report-only)", () => { - const d = helper.classifyWorktree({ - registered: true, - locked: false, - dirty: false, - merged: true, - age_seconds: 30 * DAY, - worktree_state: "clean", - }); - assert.equal(d.disposition, "SAFE_MERGED_CANDIDATE"); - assert.equal(d.positive_ownership_proof, false); -}); - -// --- Full inventory over a real temp fixture with injected read-only deps --- - -function makeFixture() { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "wt-inv-")); - const worktreesRoot = path.join(root, ".claude", "worktrees"); - fs.mkdirSync(worktreesRoot, { recursive: true }); - - const now = Date.parse("2026-07-21T00:00:00Z"); - const old = now - 30 * DAY * 1000; - const recent = now - 1 * DAY * 1000; - - const mk = (name, mtimeMs) => { - const p = path.join(worktreesRoot, name); - fs.mkdirSync(p); - fs.utimesSync(p, new Date(mtimeMs), new Date(mtimeMs)); - return p; - }; - - const dirtyP = mk("agent-dirty", old); - const lockedP = mk("agent-locked", old); - const mergedP = mk("agent-merged", old); - const unmergedAgedP = mk("agent-unmerged-aged", old); - const unmergedRecentP = mk("agent-live", recent); - // A non-agent dir that must be ignored entirely. - mk("release-main", old); - - const heads = { - [dirtyP]: "aaa", - [lockedP]: "bbb", - [mergedP]: "ccc", - [unmergedAgedP]: "ddd", - [unmergedRecentP]: "eee", - }; - const registered = {}; - for (const [p, head] of Object.entries(heads)) { - registered[p] = { path: p, head, branch: `refs/heads/${path.basename(p)}`, locked: p === lockedP }; - } - // Registered-but-missing agent worktree (git knows it, directory gone). - const missingP = path.join(worktreesRoot, "agent-missing"); - registered[missingP] = { path: missingP, head: "fff", branch: "refs/heads/gone", locked: false }; - - const deps = { - worktreeList: () => registered, - statusPorcelain: (p) => (p === dirtyP ? " M file.txt\n" : ""), - isMerged: (head) => head === "ccc", // only agent-merged is merged - sizeKb: (p) => - ({ [dirtyP]: 100, [lockedP]: 200, [mergedP]: 300, [unmergedAgedP]: 400, [unmergedRecentP]: 500 }[p] || 10), - }; - - return { root, worktreesRoot, now, deps, paths: { dirtyP, lockedP, mergedP, unmergedAgedP, unmergedRecentP, missingP } }; -} - -test("runInventory classifies a mixed fixture correctly and validates schema", () => { - const fx = makeFixture(); - try { - const report = helper.runInventory({ - repoDir: fx.root, - worktreesRoot: fx.worktreesRoot, - nowMs: fx.now, - deps: fx.deps, - agedOrphanSeconds: 7 * DAY, - }); - - // Report-only, safe-by-construction invariants. - assert.equal(report.mode, "report_only"); - assert.equal(report.destructive_actions, 0); - assert.equal(report.schema_version, helper.SCHEMA_VERSION); - - const by = Object.fromEntries(report.worktrees.map((w) => [w.name, w])); - - // Non-agent directory excluded. - assert.ok(!by["release-main"], "non-agent dirs must be excluded"); - - assert.equal(by["agent-dirty"].disposition, "PRESERVE"); - assert.equal(by["agent-dirty"].dirty, true); - assert.equal(by["agent-locked"].disposition, "PRESERVE"); - assert.equal(by["agent-locked"].locked, true); - assert.equal(by["agent-live"].disposition, "PRESERVE"); // unmerged + recent - assert.equal(by["agent-unmerged-aged"].disposition, "AGED_ORPHAN_REVIEW"); - assert.equal(by["agent-merged"].disposition, "SAFE_MERGED_CANDIDATE"); - - // Registered-but-missing directory surfaces and is preserved. - assert.equal(by["agent-missing"].worktree_state, "missing"); - assert.equal(by["agent-missing"].disposition, "PRESERVE"); - - // Sizes are captured so the 70GB problem is visible; totals aggregate them. - assert.equal(by["agent-merged"].size_kb, 300); - assert.equal(report.totals.total_size_kb, 100 + 200 + 300 + 400 + 500); - assert.equal(report.totals.count, 6); // 5 present agent-* + 1 missing - - // Every entry carries no ownership proof. - for (const w of report.worktrees) assert.equal(w.positive_ownership_proof, false); - } finally { - fs.rmSync(fx.root, { recursive: true, force: true }); - } -}); - -test("validateReport rejects a dirty worktree marked for anything but PRESERVE", () => { - const bad = { - schema_version: helper.SCHEMA_VERSION, - mode: "report_only", - destructive_actions: 0, - totals: {}, - inspection_errors: [], - worktrees: [ - { - path: "/x/agent-bad", - disposition: "SAFE_MERGED_CANDIDATE", - worktree_state: "dirty", - dirty: true, - locked: false, - positive_ownership_proof: false, - age_seconds: 1, - size_kb: 1, - }, - ], - }; - assert.throws(() => helper.validateReport(bad), /must be PRESERVE/); -}); - -// --- Safety by construction: the helper module has no destructive code path --- - -test("inventory helper source contains zero destructive operations", () => { - const raw = fs.readFileSync(path.join(REPO_ROOT, "routines/monitoring/local_worktree_inventory.js"), "utf8"); - // Scan executable code only: strip block and line comments so prose that - // merely names a destructive command (e.g. explaining what is NOT done) does - // not trip the guard. The guarantee is that no destructive call is reachable. - const src = raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); - const forbidden = [ - /\brm\b[^"']*-r?f/i, - /\bfind\b[^"']*-delete/i, - /worktree\s+remove/i, - /worktree\s+prune/i, - /branch\s+-D/i, - /update-ref\s+-d/i, - /\.rmSync\b/, - /\.rmdirSync\b/, - /\.unlinkSync\b/, - ]; - for (const pat of forbidden) { - assert.doesNotMatch(src, pat, `helper must not contain ${pat}`); - } -}); - -// --- The QuickJS routine is a schedule/dispatch shim only --- - -test("routine dispatches the deterministic helper and self-guards per day", () => { - const { routine, tick } = loadRoutine("routines/local-worktree-gc.js"); - assert.equal(routine.name, "Local agent worktree inventory"); - - const first = tick({ now: new Date("2026-07-21T00:00:00Z"), checkpoint: null }); - assert.equal(first.action, "agent"); - assert.match(first.prompt, /local_worktree_inventory\.js/); - assert.match(first.prompt, /report-only inventory/i); - assert.match(first.prompt, /Do NOT remove, prune, or modify/); - - // Same KST day => no duplicate dispatch. - const second = tick({ now: new Date("2026-07-21T05:00:00Z"), checkpoint: first.checkpoint }); - assert.equal(second.action, "complete"); -}); diff --git a/policies/__tests__/pipeline.test.js b/policies/__tests__/pipeline.test.js deleted file mode 100644 index c564e70e44..0000000000 --- a/policies/__tests__/pipeline.test.js +++ /dev/null @@ -1,307 +0,0 @@ -// End-to-end pipeline simulation: Recommender → Detector → Executor -// Verifies the full automation candidate lifecycle across all three routines. - -const test = require("node:test"); -const assert = require("node:assert/strict"); -const { loadRoutine } = require("./support/routine-harness"); - -const RECOMMENDER_PATH = "routines/monitoring/automation-candidate-recommender.js"; -const DETECTOR_PATH = "routines/monitoring/automation-candidate-detector.js"; -const EXECUTOR_PATH = "routines/monitoring/automation-executor.js"; - -const BASE_NOW = new Date("2026-05-02T10:00:00Z"); -const SIGNATURE = "routine-candidate:my-repeated-script.js"; - -// --- Observation factories --- - -function makeRunObs(signature, evidenceRef, opts = {}) { - return { - signature, - evidence_ref: evidenceRef, - category: opts.category || "routine-candidate", - summary: `Repeated failure: ${signature}`, - occurrences: opts.occurrences || 1, - weight: opts.weight || 1, - timestamp: opts.timestamp || BASE_NOW.toISOString(), - }; -} - -// Simulates precomputed_observation_from_kv output for candidate_review marker -function makeCandidateReviewKvObs(signature, candidate, overrides = {}) { - return { - key: `routine_observation:candidate_review:${signature}`, - evidence_ref: `kv_meta:routine_observation:candidate_review:${signature}`, - value: { - signature, - score: candidate.score || 85, - evidence_count: candidate.evidence_count || 8, - category: candidate.category || "routine-candidate", - suggested_automation: candidate.suggested_automation || "자동화 제안", - outcome_summary: candidate.outcome_summary || "결과 요약", - last_seen_at: overrides.last_seen_at || BASE_NOW.toISOString(), - }, - source: "candidate_review", - category: "routine-candidate", - signature: `routine-candidate:${signature}`, - summary: `candidate_review: ${signature}`, - timestamp: BASE_NOW.toISOString(), - occurrences: 1, - weight: 1, - }; -} - -// Simulates precomputed_observation_from_kv output for candidate_approved marker -function makeCandidateApprovedKvObs(signature, opts = {}) { - const approvedAt = opts.approved_at || BASE_NOW.toISOString(); - return { - key: `routine_observation:candidate_approved:${signature}`, - evidence_ref: `kv_meta:routine_observation:candidate_approved:${signature}`, - value: { - signature, - score: opts.score || 85, - category: opts.category || "routine-candidate", - approved_at: approvedAt, - suggested_automation: opts.suggested_automation || "자동화 제안", - outcome_summary: opts.outcome_summary || "결과 요약", - }, - source: "candidate_approved", - category: "routine-candidate", - signature: `routine-candidate:${signature}`, - summary: `candidate_approved: ${signature}`, - timestamp: approvedAt, - occurrences: 1, - weight: 1, - }; -} - -// Simulates precomputed_observation_from_kv output for candidate_dispatched marker -function makeCandidateDispatchedKvObs(signature, opts = {}) { - const dispatchedAt = opts.dispatched_at || BASE_NOW.toISOString(); - return { - key: `routine_observation:candidate_dispatched:${signature}`, - evidence_ref: `kv_meta:routine_observation:candidate_dispatched:${signature}`, - value: { - signature, - dispatched_at: dispatchedAt, - category: opts.category || "routine-candidate", - }, - source: "candidate_dispatched", - category: "routine-candidate", - signature: `routine-candidate:${signature}`, - summary: `candidate_dispatched: ${signature}`, - timestamp: dispatchedAt, - occurrences: 1, - weight: 1, - }; -} - -// Build 8 distinct run observations for the same pattern to exceed SCORE_THRESHOLD=80 -function makeRunObsSet(signature, count = 8) { - return Array.from({ length: count }, (_, i) => - makeRunObs(signature, `routine_runs:${signature}:run:failed:sample-${i}`) - ); -} - -// --- Phase 1: Recommender reaches escalation threshold --- - -test("pipeline phase 1: recommender accumulates evidence and escalates candidate", () => { - const { tick } = loadRoutine(RECOMMENDER_PATH); - - const obs = makeRunObsSet(SIGNATURE); - const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] }); - - assert.equal(r.action, "agent", "recommender should escalate when score >= 80 and evidence >= 5"); - assert.ok(r.prompt.includes(SIGNATURE), "prompt should reference the candidate signature"); - assert.ok( - r.prompt.includes(""), - "materialize draft should use the detector-recognized repo_dir placeholder" - ); - assert.ok( - !r.prompt.includes(""), - "materialize draft must not include stale repo_dir placeholder" - ); - assert.ok(r.checkpoint.candidates[SIGNATURE], "candidate should be in checkpoint"); - assert.ok( - r.checkpoint.candidates[SIGNATURE].score >= 80, - `candidate score should be >= 80, got ${r.checkpoint.candidates[SIGNATURE].score}` - ); -}); - -test("pipeline phase 1b: ROI-aware high-impact category escalates with three evidence points", () => { - const { tick } = loadRoutine(RECOMMENDER_PATH); - const signature = "session-pattern:maker"; - const obs = [ - makeRunObs(signature, "session_transcripts:maker", { - category: "session-pattern", - occurrences: 3, - weight: 2, - }), - ]; - - const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] }); - - assert.equal(r.action, "agent", "session-pattern should use ROI-aware gate below the global evidence=5 threshold"); - const candidate = r.checkpoint.candidates[signature]; - assert.equal(candidate.evidence_count, 3); - assert.equal(candidate.category, "session-pattern"); - assert.ok(candidate.score >= 60, `candidate score should satisfy ROI gate, got ${candidate.score}`); - assert.ok(r.prompt.includes("gate=60/3"), "prompt should explain the category-specific gate"); -}); - -// --- Phase 2: Detector passes quality gate for a candidate_review observation --- - -test("pipeline phase 2: detector quality-gates candidate_review and emits agent", () => { - const { tick } = loadRoutine(DETECTOR_PATH); - - const candidate = { score: 87, evidence_count: 8, category: "routine-candidate" }; - const reviewObs = [makeCandidateReviewKvObs(SIGNATURE, candidate)]; - - const r = tick({ now: BASE_NOW, checkpoint: null, observations: reviewObs, automationInventory: [] }); - - assert.equal(r.action, "agent", "detector should emit agent action for valid candidate_review"); - assert.ok(r.prompt.includes(SIGNATURE), "approval prompt should include the signature"); - assert.ok( - r.prompt.includes(`routine_observation:candidate_approved:${SIGNATURE}`), - "prompt should instruct agent to write candidate_approved kv_meta" - ); - assert.equal(r.checkpoint.seen_candidates[SIGNATURE].status, "emitted"); - assert.ok( - r.checkpoint.seen_candidates[SIGNATURE].status !== "approved" && - r.checkpoint.seen_candidates[SIGNATURE].status !== "dispatched", - "candidate should not be prematurely approved/dispatched in checkpoint" - ); -}); - -// --- Phase 3: Executor dispatches approved candidate --- - -test("pipeline phase 3: executor dispatches approved candidate and does not pre-mark dispatched", () => { - const { tick } = loadRoutine(EXECUTOR_PATH); - - const approvedObs = [makeCandidateApprovedKvObs(SIGNATURE)]; - - const r = tick({ now: BASE_NOW, checkpoint: null, observations: approvedObs, automationInventory: [] }); - - assert.equal(r.action, "agent", "executor should emit agent dispatch action for approved candidate"); - assert.ok(r.prompt.includes(SIGNATURE), "dispatch prompt should include the signature"); - assert.ok( - r.prompt.includes(`routine_observation:candidate_dispatched:${SIGNATURE}`), - "prompt should instruct agent to write candidate_dispatched kv_meta" - ); - assert.ok( - !r.checkpoint.dispatched_signatures[SIGNATURE], - "dispatched_signatures must NOT be pre-set before durable kv_meta is observed" - ); -}); - -// --- Phase 4: Executor skips re-dispatch after durable dispatched marker appears --- - -test("pipeline phase 4: executor skips candidate once durable dispatched kv_meta is observed", () => { - const { tick } = loadRoutine(EXECUTOR_PATH); - - const dispatchedAt = new Date(BASE_NOW.getTime() - 2 * 3600_000).toISOString(); - const obs = [ - makeCandidateApprovedKvObs(SIGNATURE), - makeCandidateDispatchedKvObs(SIGNATURE, { dispatched_at: dispatchedAt }), - ]; - - const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] }); - - assert.equal(r.action, "complete", "executor should complete when durable dispatched marker exists"); - assert.equal( - r.checkpoint.dispatched_signatures[SIGNATURE], - dispatchedAt, - "checkpoint should preserve the actual dispatch time, not now" - ); -}); - -// --- Phase 5: Recommender suppresses candidate after dispatched marker appears --- - -test("pipeline phase 5: recommender suppresses re-recommendation after dispatched kv_meta observed", () => { - const { tick } = loadRoutine(RECOMMENDER_PATH); - - // Regular run observations plus a dispatched marker - const runObs = makeRunObsSet(SIGNATURE); - const dispatchedObs = makeCandidateDispatchedKvObs(SIGNATURE); - const allObs = [...runObs, dispatchedObs]; - - const r = tick({ now: BASE_NOW, checkpoint: null, observations: allObs, automationInventory: [] }); - - // Should not escalate because SIGNATURE is now suppressed via dispatched marker - assert.equal(r.action, "complete", "recommender should not escalate a dispatched candidate"); - assert.ok( - !r.checkpoint.candidates[SIGNATURE] || - r.checkpoint.candidates[SIGNATURE].state !== "recommended", - "dispatched candidate should be dropped or not in recommended state" - ); - assert.ok( - (r.result.suppression_summary || "").includes("dispatched") || - !r.checkpoint.candidates[SIGNATURE], - "suppression summary should mention dispatched suppression or candidate removed" - ); -}); - -// --- Phase 6: Full sequential pipeline simulation --- - -test("pipeline phase 6: full sequential recommender→detector→executor flow", () => { - const recommender = loadRoutine(RECOMMENDER_PATH); - const detector = loadRoutine(DETECTOR_PATH); - const executor = loadRoutine(EXECUTOR_PATH); - - const t0 = BASE_NOW; - - // Step 1: Recommender sees enough evidence and escalates - const runObs = makeRunObsSet(SIGNATURE); - const r1 = recommender.tick({ now: t0, checkpoint: null, observations: runObs, automationInventory: [] }); - assert.equal(r1.action, "agent", "step 1: recommender escalates"); - const recommenderCp = r1.checkpoint; - - // Step 2: (Agent writes candidate_review kv_meta) → Detector sees it - const t1 = new Date(t0.getTime() + 5 * 60_000); - const candidate = recommenderCp.candidates[SIGNATURE] || {}; - const reviewObs = [makeCandidateReviewKvObs(SIGNATURE, { score: candidate.score || 85, evidence_count: candidate.evidence_count || 8 })]; - const r2 = detector.tick({ now: t1, checkpoint: null, observations: reviewObs, automationInventory: [] }); - assert.equal(r2.action, "agent", "step 2: detector emits approval request"); - const detectorCp = r2.checkpoint; - - // Step 3: (Agent writes candidate_approved kv_meta) → Executor sees it - const t2 = new Date(t1.getTime() + 5 * 60_000); - const approvedObs = [makeCandidateApprovedKvObs(SIGNATURE, { score: candidate.score || 85 })]; - const r3 = executor.tick({ now: t2, checkpoint: null, observations: approvedObs, automationInventory: [] }); - assert.equal(r3.action, "agent", "step 3: executor dispatches"); - assert.ok(!r3.checkpoint.dispatched_signatures[SIGNATURE], "dispatched not pre-set"); - - // Step 4: (Agent writes candidate_dispatched kv_meta) → Executor now skips on next tick - const t3 = new Date(t2.getTime() + 5 * 60_000); - const dispatchedAt = t2.toISOString(); - const dispatchedObs = [makeCandidateDispatchedKvObs(SIGNATURE, { dispatched_at: dispatchedAt })]; - const obsWithDispatched = [...approvedObs, ...dispatchedObs]; - const r4 = executor.tick({ now: t3, checkpoint: r3.checkpoint, observations: obsWithDispatched, automationInventory: [] }); - assert.equal(r4.action, "complete", "step 4: executor skips already dispatched candidate"); - assert.equal(r4.checkpoint.dispatched_signatures[SIGNATURE], dispatchedAt); - - // Step 5: Recommender on next cycle sees dispatched marker → suppresses candidate - const t4 = new Date(t3.getTime() + 5 * 60_000); - const allObs = [...runObs, ...dispatchedObs]; - const r5 = recommender.tick({ now: t4, checkpoint: recommenderCp, observations: allObs, automationInventory: [] }); - assert.equal(r5.action, "complete", "step 5: recommender suppresses dispatched candidate"); - - // Step 6: Detector on next cycle — if candidate_review marker expires or is missing, no action - const t5 = new Date(t4.getTime() + 5 * 60_000); - const r6 = detector.tick({ now: t5, checkpoint: detectorCp, observations: dispatchedObs, automationInventory: [] }); - assert.equal(r6.action, "complete", "step 6: detector has no pending reviews"); -}); - -// --- Normalized observation variants (simulating Rust provider output with both key+evidence_ref) --- - -test("pipeline: normalized kv_meta obs (key+evidence_ref) works across all three routines", () => { - const detector = loadRoutine(DETECTOR_PATH); - const executor = loadRoutine(EXECUTOR_PATH); - - const reviewObs = [makeCandidateReviewKvObs(SIGNATURE, { score: 90, evidence_count: 10 })]; - const r1 = detector.tick({ now: BASE_NOW, checkpoint: null, observations: reviewObs, automationInventory: [] }); - assert.equal(r1.action, "agent", "detector handles kv obs with both key and evidence_ref"); - - const approvedObs = [makeCandidateApprovedKvObs(SIGNATURE)]; - const r2 = executor.tick({ now: BASE_NOW, checkpoint: null, observations: approvedObs, automationInventory: [] }); - assert.equal(r2.action, "agent", "executor handles kv obs with both key and evidence_ref"); -}); diff --git a/policies/__tests__/recommender-dedup.test.js b/policies/__tests__/recommender-dedup.test.js deleted file mode 100644 index a6d4d8c280..0000000000 --- a/policies/__tests__/recommender-dedup.test.js +++ /dev/null @@ -1,198 +0,0 @@ -const test = require("node:test"); -const assert = require("node:assert/strict"); -const { loadRoutine } = require("./support/routine-harness"); - -const ROUTINE_PATH = "routines/monitoring/automation-candidate-recommender.js"; - -function makeObs(signature, evidenceRef, opts = {}) { - return { - signature, - evidence_ref: evidenceRef || `routine_runs:${signature}.js:run:failed`, - category: "failure-pattern", - summary: `Test observation for ${signature}`, - occurrences: opts.occurrences || 1, - weight: opts.weight || 1, - }; -} - -function parseScoringLine(result) { - const summary = result.result && result.result.scoring_summary; - if (!summary) return { scored: 0, deduped: 0 }; - const m = summary.match(/scored=(\d+).*deduped=(\d+)/); - return m ? { scored: parseInt(m[1], 10), deduped: parseInt(m[2], 10) } : { scored: 0, deduped: 0 }; -} - -function buildCheckpointWithEvidence(entries, nowBase) { - const seen_evidence = {}; - for (let i = 0; i < entries; i++) { - seen_evidence[`key-${i}`] = new Date(nowBase + i * 1000).toISOString(); - } - return { - version: 1, - cursors: {}, - candidates: {}, - suppressions: {}, - seen_evidence, - recommendations: [], - last_tick_at: null, - stats: { - ticks: 0, - observations_seen: 0, - agent_escalations: 0, - recommendations_today: 0, - recommendation_day: null, - }, - }; -} - -test("same evidence_ref is deduped on consecutive ticks", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const obs = [makeObs("repeated-pattern", "routine_runs:heavy.js:run:failed")]; - - const result1 = tick({ now: new Date("2026-05-02T10:00:00Z"), checkpoint: null, observations: obs, automationInventory: [] }); - const s1 = parseScoringLine(result1); - assert.equal(s1.scored, 1, "first tick should score 1"); - assert.equal(s1.deduped, 0, "first tick should have 0 deduped"); - - const result2 = tick({ now: new Date("2026-05-02T10:01:00Z"), checkpoint: result1.checkpoint, observations: obs, automationInventory: [] }); - const s2 = parseScoringLine(result2); - assert.equal(s2.deduped, 1, "second tick with same evidence_ref should dedup 1"); - assert.equal(s2.scored, 0, "second tick should score 0 new"); -}); - -test("dedup hit refreshes seen_evidence timestamp for LRU", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const evidenceRef = "routine_runs:lru.js:run:failed"; - const obs = [makeObs("lru-pattern", evidenceRef)]; - - const result1 = tick({ now: new Date("2026-05-02T10:00:00Z"), checkpoint: null, observations: obs, automationInventory: [] }); - const result2 = tick({ now: new Date("2026-05-02T10:10:00Z"), checkpoint: result1.checkpoint, observations: obs, automationInventory: [] }); - - assert.equal(parseScoringLine(result2).deduped, 1, "second tick should dedup"); - assert.equal(result2.checkpoint.seen_evidence[evidenceRef].seen_at, "2026-05-02T10:10:00.000Z"); -}); - -test("rolling grouped evidence scores only newly increased occurrences", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const evidenceRef = "routine_runs:rolling.js:run:failed"; - - const result1 = tick({ - now: new Date("2026-05-02T10:00:00Z"), - checkpoint: null, - observations: [makeObs("rolling-pattern", evidenceRef, { occurrences: 2 })], - automationInventory: [], - }); - assert.equal(parseScoringLine(result1).scored, 1, "first aggregate should score"); - - const result2 = tick({ - now: new Date("2026-05-02T10:05:00Z"), - checkpoint: result1.checkpoint, - observations: [makeObs("rolling-pattern", evidenceRef, { occurrences: 5 })], - automationInventory: [], - }); - const s2 = parseScoringLine(result2); - assert.equal(s2.scored, 1, "aggregate with new occurrences should score once"); - assert.equal(s2.deduped, 0, "aggregate with higher count should not be fully deduped"); - assert.equal(result2.checkpoint.candidates["rolling-pattern"].evidence_count, 5); -}); - -test("rolling grouped evidence tracks the latest observed count after a window drop", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const evidenceRef = "routine_runs:sliding.js:run:failed"; - - const result1 = tick({ - now: new Date("2026-05-02T10:00:00Z"), - checkpoint: null, - observations: [makeObs("sliding-pattern", evidenceRef, { occurrences: 10 })], - automationInventory: [], - }); - assert.equal(parseScoringLine(result1).scored, 1, "first aggregate should score"); - - const result2 = tick({ - now: new Date("2026-05-02T10:05:00Z"), - checkpoint: result1.checkpoint, - observations: [makeObs("sliding-pattern", evidenceRef, { occurrences: 4 })], - automationInventory: [], - }); - const s2 = parseScoringLine(result2); - assert.equal(s2.scored, 0, "smaller rolling window count should not rescore"); - assert.equal(s2.deduped, 1, "smaller rolling window count should be treated as already seen"); - assert.equal(result2.checkpoint.seen_evidence[evidenceRef].occurrences, 4); - - const result3 = tick({ - now: new Date("2026-05-02T10:10:00Z"), - checkpoint: result2.checkpoint, - observations: [makeObs("sliding-pattern", evidenceRef, { occurrences: 6 })], - automationInventory: [], - }); - const s3 = parseScoringLine(result3); - assert.equal(s3.scored, 1, "increase after the window drop should score the fresh delta"); - assert.equal(s3.deduped, 0, "increase after the window drop should not be pinned to the old peak"); - assert.equal(result3.checkpoint.candidates["sliding-pattern"].evidence_count, 12); - assert.equal(result3.checkpoint.seen_evidence[evidenceRef].occurrences, 6); -}); - -test("evidence is re-scored after 25h TTL expires", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const obs = [makeObs("stale-pattern", "routine_runs:stale.js:run:failed")]; - - const result1 = tick({ now: new Date("2026-05-01T10:00:00Z"), checkpoint: null, observations: obs, automationInventory: [] }); - assert.equal(parseScoringLine(result1).scored, 1, "first tick scores 1"); - - // 26h later — TTL of 25h has fully expired - const result2 = tick({ now: new Date("2026-05-02T12:00:00Z"), checkpoint: result1.checkpoint, observations: obs, automationInventory: [] }); - const s2 = parseScoringLine(result2); - assert.equal(s2.deduped, 0, "after TTL expiry evidence should not be deduped"); - assert.equal(s2.scored, 1, "evidence is re-scored after TTL expiry"); -}); - -test("seen_evidence LRU cap trims entries above 500", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - - // 501 entries, all timestamped within the 25h TTL window - const nowMs = new Date("2026-05-02T10:00:00Z").getTime(); - const cp = buildCheckpointWithEvidence(501, nowMs - 10 * 3600 * 1000); // entries 10h ago - - const result = tick({ now: new Date("2026-05-02T10:00:00Z"), checkpoint: cp, observations: [], automationInventory: [] }); - - const seenCount = Object.keys(result.checkpoint.seen_evidence || {}).length; - assert.ok(seenCount <= 500, `seen_evidence should be capped at 500, got ${seenCount}`); -}); - -test("composite key deduplication works when evidence_ref is absent", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const obs = [{ - signature: "no-ref-pattern", - source: "kv_meta", - category: "failure-pattern", - summary: "Some kv meta observation without evidence_ref", - occurrences: 1, - weight: 1, - }]; - - const result1 = tick({ now: new Date("2026-05-02T10:00:00Z"), checkpoint: null, observations: obs, automationInventory: [] }); - assert.equal(parseScoringLine(result1).scored, 1, "first tick scores 1"); - - const result2 = tick({ now: new Date("2026-05-02T10:01:00Z"), checkpoint: result1.checkpoint, observations: obs, automationInventory: [] }); - assert.equal(parseScoringLine(result2).deduped, 1, "composite key dedup works without evidence_ref"); -}); - -test("multiple distinct evidence_refs are each scored independently", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const obs = [ - makeObs("pattern-a", "routine_runs:script-a.js:run:failed"), - makeObs("pattern-b", "routine_runs:script-b.js:run:failed"), - makeObs("pattern-c", "routine_runs:script-c.js:run:succeeded"), - ]; - - const result1 = tick({ now: new Date("2026-05-02T10:00:00Z"), checkpoint: null, observations: obs, automationInventory: [] }); - const s1 = parseScoringLine(result1); - assert.equal(s1.scored, 3, "all 3 distinct observations scored on first tick"); - assert.equal(s1.deduped, 0); - - // Same observations next tick — all 3 deduped - const result2 = tick({ now: new Date("2026-05-02T10:01:00Z"), checkpoint: result1.checkpoint, observations: obs, automationInventory: [] }); - const s2 = parseScoringLine(result2); - assert.equal(s2.deduped, 3, "all 3 deduped on second tick"); - assert.equal(s2.scored, 0); -}); diff --git a/policies/__tests__/recommender-saturation.test.js b/policies/__tests__/recommender-saturation.test.js deleted file mode 100644 index c226b2de18..0000000000 --- a/policies/__tests__/recommender-saturation.test.js +++ /dev/null @@ -1,258 +0,0 @@ -const test = require("node:test"); -const assert = require("node:assert/strict"); -const { loadRoutine } = require("./support/routine-harness"); - -const ROUTINE_PATH = "routines/monitoring/automation-candidate-recommender.js"; - -const BASE_NOW = new Date("2026-05-02T10:00:00Z"); - -function makeObs(signature, evidenceRef, opts = {}) { - return { - signature, - evidence_ref: evidenceRef || `ref:${signature}`, - category: opts.category || "routine-candidate", - summary: `obs for ${signature}`, - occurrences: opts.occurrences || 1, - weight: opts.weight || 1, - timestamp: opts.timestamp || BASE_NOW.toISOString(), - }; -} - -function nextMs(base, deltaMs) { - return new Date(base.getTime() + deltaMs); -} - -function parseSummary(result) { - const s = result.result && result.result.scoring_summary; - if (!s) return {}; - const out = {}; - for (const part of s.split(",")) { - const [k, v] = part.trim().split("="); - out[k] = isNaN(Number(v)) ? v : Number(v); - } - return out; -} - -// Returns a checkpoint pre-filled so that all observations are already in seen_evidence -function buildFullyDedupedCheckpoint(obs, nowStr) { - const seen_evidence = {}; - for (const o of obs) { - const key = o.evidence_ref || `${o.source || ""}|${o.category || ""}|${o.signature || ""}`; - seen_evidence[key] = nowStr; - } - return { - version: 1, - cursors: {}, - candidates: {}, - suppressions: {}, - seen_evidence, - recommendations: [], - last_tick_at: null, - ema_scored: 0, - saturation_ticks: 0, - fast_fail_ticks: 0, - reopt_count: 0, - diversity_mode_ticks_remaining: 0, - last_reopt_at: null, - stats: { - ticks: 0, - observations_seen: 0, - agent_escalations: 0, - recommendations_today: 0, - recommendation_day: null, - category_scored: {}, - }, - }; -} - -test("fast-fail: 2 consecutive all-dedup ticks trigger reopt", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const obs = [makeObs("dup-sig", "ref:dup-sig")]; - - // Tick 1: score once (establishes seen_evidence) - const t1Now = BASE_NOW; - const r1 = tick({ now: t1Now, checkpoint: null, observations: obs, automationInventory: [] }); - assert.equal(parseSummary(r1).scored, 1, "tick1 should score 1"); - - // Tick 2: all dedup (fast_fail_ticks becomes 1) - const t2Now = nextMs(t1Now, 60_000); - const r2 = tick({ now: t2Now, checkpoint: r1.checkpoint, observations: obs, automationInventory: [] }); - assert.equal(parseSummary(r2).deduped, 1, "tick2 all deduped"); - assert.equal(parseSummary(r2).fast_fail_ticks, 1, "fast_fail_ticks=1 after tick2"); - assert.equal(parseSummary(r2).reopt_count, 0, "no reopt yet"); - - // Tick 3: all dedup (fast_fail_ticks becomes 2 → reopt triggered) - const t3Now = nextMs(t2Now, 60_000); - const r3 = tick({ now: t3Now, checkpoint: r2.checkpoint, observations: obs, automationInventory: [] }); - const s3 = parseSummary(r3); - assert.equal(s3.reopt_count, 1, "reopt_count=1 after fast-fail trigger"); - assert.ok(s3.reopt_triggered === "fast_fail" || r3.result.scoring_summary.includes("reopt_triggered=fast_fail"), - "reopt reason should be fast_fail"); -}); - -test("EMA tier: 5 consecutive low-ema ticks trigger reopt", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - // Use unique evidence_refs per tick to avoid dedup, but scored=1 each time. - // ema_scored = 0.9^5 * 0 + 0.1 each tick, quickly < 0.3 so saturation_ticks should accumulate. - // After 5 ticks of scored=1 (ema stays low since 0.1 base), saturation may or may not trigger. - // Instead: use zero obs ticks to guarantee scored=0, ema_scored stays at 0. - - const t1Now = BASE_NOW; - let cp = null; - for (let i = 1; i <= 5; i++) { - const now = nextMs(t1Now, i * 60_000); - // No observations → scored=0, ema_scored stays near 0 - const r = tick({ now, checkpoint: cp, observations: [], automationInventory: [] }); - cp = r.checkpoint; - const s = parseSummary(r); - if (i < 5) { - assert.equal(s.reopt_count, 0, `no reopt before tick 5, got at tick ${i}`); - } else { - assert.equal(s.reopt_count, 1, "reopt_count=1 after 5 EMA-saturation ticks"); - assert.ok(r.result.scoring_summary.includes("reopt_triggered=ema_saturation"), - "reopt reason should be ema_saturation"); - } - } -}); - -test("EMA calculation: scored=1 produces ema≈0.1 from zero", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const obs = [makeObs("ema-sig", "ref:ema-sig-unique1")]; - - const r = tick({ now: BASE_NOW, checkpoint: null, observations: obs, automationInventory: [] }); - const s = parseSummary(r); - assert.equal(s.scored, 1, "should score 1"); - // ema_scored = 0.9 * 0 + 0.1 * 1 = 0.1 - assert.ok(Math.abs(s.ema_scored - 0.1) < 0.001, `ema_scored should be ~0.1, got ${s.ema_scored}`); -}); - -test("partial seen_evidence reset removes old entries that were not just refreshed", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - - const recentNow = BASE_NOW; - // Both entries are within 25h TTL; the matching one is refreshed by the dedup hit. - const staleTs = nextMs(recentNow, -13 * 3600_000).toISOString(); // 13h ago - const recentTs = nextMs(recentNow, -1 * 3600_000).toISOString(); // 1h ago - - const baseCheckpoint = { - version: 1, - cursors: {}, - candidates: {}, - suppressions: {}, - seen_evidence: { - "old-unmatched-key": staleTs, - "recent-key": recentTs, - }, - recommendations: [], - last_tick_at: null, - ema_scored: 0, - saturation_ticks: 0, - fast_fail_ticks: 1, // one more all-dedup tick will reach FAST_FAIL_TICKS=2 - reopt_count: 0, - diversity_mode_ticks_remaining: 0, - last_reopt_at: null, - stats: { ticks: 0, observations_seen: 0, agent_escalations: 0, recommendations_today: 0, recommendation_day: null, category_scored: {} }, - }; - - // Provide one obs whose evidence_ref matches the recent key → all obs are deduped, - // while old-unmatched-key stays old enough for partial reset to remove. - const obs = [ - { signature: "sig-recent", evidence_ref: "recent-key", category: "routine-candidate", summary: "r", occurrences: 1, weight: 1 }, - ]; - - const r = tick({ now: recentNow, checkpoint: baseCheckpoint, observations: obs, automationInventory: [] }); - const cpAfter = r.checkpoint; - assert.equal(parseSummary(r).reopt_count, 1, "reopt should have triggered (fast_fail_ticks reached 2)"); - assert.ok(!cpAfter.seen_evidence["old-unmatched-key"], "old unmatched key (13h old) should be removed after partial reset"); - assert.ok(cpAfter.seen_evidence["recent-key"], "recent key refreshed by dedup should be kept"); -}); - -test("diversity mode activates after reopt and decrements each tick", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - - // Trigger reopt by feeding 5 empty ticks (EMA tier) - let cp = null; - let reoptTick = null; - for (let i = 1; i <= 5; i++) { - const now = nextMs(BASE_NOW, i * 60_000); - const r = tick({ now, checkpoint: cp, observations: [], automationInventory: [] }); - cp = r.checkpoint; - if (i === 5) reoptTick = r; - } - assert.equal(parseSummary(reoptTick).reopt_count, 1, "reopt should have triggered"); - // After reopt diversity_mode_ticks_remaining should be 10 - assert.equal(cp.diversity_mode_ticks_remaining, 10, "diversity mode should be 10 after reopt"); - - // One more tick should decrement it - const r2 = tick({ now: nextMs(BASE_NOW, 6 * 60_000), checkpoint: cp, observations: [], automationInventory: [] }); - assert.equal(r2.checkpoint.diversity_mode_ticks_remaining, 9, "diversity_mode_ticks_remaining should decrement"); -}); - -test("saturation counters reset after reopt", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - - // 5 empty ticks → EMA saturation → reopt - let cp = null; - for (let i = 1; i <= 5; i++) { - const r = tick({ now: nextMs(BASE_NOW, i * 60_000), checkpoint: cp, observations: [], automationInventory: [] }); - cp = r.checkpoint; - } - assert.equal(cp.saturation_ticks, 0, "saturation_ticks reset after reopt"); - assert.equal(cp.fast_fail_ticks, 0, "fast_fail_ticks reset after reopt"); -}); - -test("scoring_summary includes ema_scored, saturation_ticks, reopt_count fields", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - const r = tick({ now: BASE_NOW, checkpoint: null, observations: [], automationInventory: [] }); - const s = r.result.scoring_summary; - assert.ok(s.includes("ema_scored="), "scoring_summary should include ema_scored"); - assert.ok(s.includes("saturation_ticks="), "scoring_summary should include saturation_ticks"); - assert.ok(s.includes("reopt_count="), "scoring_summary should include reopt_count"); -}); - -test("candidate_dispatched obs suppresses re-recommendation (REQ-P1-004)", () => { - const { tick } = loadRoutine(ROUTINE_PATH); - - // First tick: score a candidate until it is eligible for escalation - // Build a checkpoint with a high-score candidate - const sig = "dispatched-sig"; - const baseCheckpoint = { - version: 1, - cursors: {}, - candidates: { - [sig]: { - category: "routine-candidate", - state: "observing", - score: 95, - evidence_count: 10, - first_seen_at: BASE_NOW.toISOString(), - last_seen_at: BASE_NOW.toISOString(), - examples: [], - last_recommended_at: null, - last_recommendation_hash: null, - cooldown_until: null, - automation_ref: null, - has_error_evidence: false, - }, - }, - suppressions: {}, - seen_evidence: {}, - recommendations: [], - last_tick_at: null, - ema_scored: 0, saturation_ticks: 0, fast_fail_ticks: 0, reopt_count: 0, - diversity_mode_ticks_remaining: 0, last_reopt_at: null, - stats: { ticks: 0, observations_seen: 0, agent_escalations: 0, recommendations_today: 0, recommendation_day: null, category_scored: {}, category_scored_history: [] }, - }; - - // Without dispatched obs: should escalate - const r1 = tick({ now: BASE_NOW, checkpoint: baseCheckpoint, observations: [], automationInventory: [] }); - assert.equal(r1.action, "agent", "candidate above threshold should escalate"); - - // With candidate_dispatched obs: same candidate should be suppressed - const dispatchedObs = [{ - evidence_ref: `kv_meta:routine_observation:candidate_dispatched:${sig}`, - summary: "dispatched", - }]; - const r2 = tick({ now: new Date(BASE_NOW.getTime() + 60_000), checkpoint: baseCheckpoint, observations: dispatchedObs, automationInventory: [] }); - assert.equal(r2.action, "complete", "dispatched obs should suppress candidate and prevent escalation"); -}); diff --git a/routines/local-worktree-gc.js b/routines/local-worktree-gc.js deleted file mode 100644 index 7eec18c3bc..0000000000 --- a/routines/local-worktree-gc.js +++ /dev/null @@ -1,71 +0,0 @@ -// Local agent worktree inventory routine (#4684) -// -// QuickJS routines intentionally have no filesystem/network bridge, so this -// routine cannot enumerate `.claude/worktrees` itself. Matching the reviewed -// daily-log-digest frame, it dispatches one fresh agent turn per KST day whose -// only job is to run the deterministic, READ-ONLY sibling helper -// (`routines/monitoring/local_worktree_inventory.js`) and return its JSON stdout -// verbatim. The helper performs zero destructive actions by construction; this -// routine likewise issues no cleanup — it only schedules the inventory. - -const CHECKPOINT_VERSION = 1; - -function dayKey(now) { - const value = typeof now === "string" ? now : now.toISOString(); - const kst = new Date(new Date(value).getTime() + 9 * 60 * 60 * 1000); - return kst.toISOString().slice(0, 10); -} - -function loadCheckpoint(raw) { - if (!raw || raw.version !== CHECKPOINT_VERSION) { - return { version: CHECKPOINT_VERSION, last_dispatched_day: null }; - } - return { version: CHECKPOINT_VERSION, last_dispatched_day: raw.last_dispatched_day || null }; -} - -function buildPrompt(day) { - return [ - "# Local agent worktree inventory", - "", - `Inventory day: ${day}`, - "", - "Run the repository-bundled deterministic, read-only helper:", - "```bash", - 'ROOT="${AGENTDESK_ROOT_DIR:-${ADK_REL:-$HOME/.adk/release}}"', - 'REPO="${AGENTDESK_REPO_DIR:-$ROOT/workspaces/agentdesk}"', - 'AGENTDESK_REPO_DIR="$REPO" node "$REPO/routines/monitoring/local_worktree_inventory.js"', - "```", - "", - "Return the helper stdout (a single JSON report) verbatim as your final response, with no preface.", - "This is a report-only inventory. Do NOT remove, prune, or modify any worktree, ref, branch, or file.", - "The helper never deletes anything; you must not either. Uncommitted, locked, and unmerged worktrees", - "are reported with disposition PRESERVE and must be left untouched.", - ].join("\n"); -} - -agentdesk.routines.register({ - name: "Local agent worktree inventory", - - tick(ctx) { - const day = dayKey(ctx.now); - const checkpoint = loadCheckpoint(ctx.checkpoint); - if (checkpoint.last_dispatched_day === day) { - return { - action: "complete", - result: { - status: "already_dispatched", - summary: `local worktree inventory already dispatched for ${day}`, - }, - checkpoint, - }; - } - - checkpoint.last_dispatched_day = day; - return { - action: "agent", - prompt: buildPrompt(day), - lastResult: `local worktree inventory dispatched for ${day}`, - checkpoint, - }; - }, -}); diff --git a/routines/monitoring/automation-candidate-detector.js b/routines/monitoring/automation-candidate-detector.js deleted file mode 100644 index 43d14c1712..0000000000 --- a/routines/monitoring/automation-candidate-detector.js +++ /dev/null @@ -1,309 +0,0 @@ -// Automation Candidate Detector -// Reads candidate_review:* observations (written by recommender escalation handler) -// and applies a quality gate. Passing candidates are forwarded to the agent for -// approval kv_meta write. Already approved/dispatched candidates are skipped. - -const EVIDENCE_AGE_MAX_MS = 48 * 3600 * 1000; // 48h — matches candidate_review TTL -const MIN_SCORE_THRESHOLD = 80; -const CHECKPOINT_VERSION = 1; -const SEEN_CANDIDATE_TTL_MS = 72 * 3600 * 1000; // matches candidate_approved TTL -const EMITTED_RETRY_MS = 60 * 60 * 1000; // retry if no durable approval marker appears -const MAX_EMIT_RETRIES = 5; // give up and mark stalled after this many no-shows - -// --- Checkpoint helpers --- - -function emptyCheckpoint() { - return { - version: CHECKPOINT_VERSION, - seen_candidates: {}, // signature -> { first_seen_at, last_emitted_at, status } - stats: { - ticks: 0, - materialized: 0, - skipped_already_approved: 0, - skipped_quality_gate: 0, - stalled_candidates: 0, - }, - }; -} - -function loadCheckpoint(raw) { - if (!raw || typeof raw !== "object" || raw.version !== CHECKPOINT_VERSION) { - return emptyCheckpoint(); - } - const cp = Object.assign(emptyCheckpoint(), raw); - cp.seen_candidates = raw.seen_candidates || {}; - cp.stats = Object.assign(emptyCheckpoint().stats, raw.stats || {}); - return cp; -} - -function nowIso(now) { - return typeof now === "string" ? now : now.toISOString ? now.toISOString() : String(now); -} - -function isRecentIso(value, nowStr, maxAgeMs) { - const timestamp = new Date(value || "").getTime(); - if (!Number.isFinite(timestamp)) return false; - return new Date(nowStr).getTime() - timestamp < maxAgeMs; -} - -function observationKey(obs) { - if (typeof obs.key === "string") return obs.key; - if (typeof obs.evidence_ref === "string") { - if (obs.evidence_ref.startsWith("kv_meta:routine_observation:")) { - return obs.evidence_ref.slice("kv_meta:".length); - } - if (obs.evidence_ref.startsWith("routine_observation:")) { - return obs.evidence_ref; - } - } - return ""; -} - -function observationSignature(obs, prefix) { - const key = observationKey(obs); - return key.startsWith(prefix) ? key.slice(prefix.length) : null; -} - -function observationPayload(obs) { - return obs.value && typeof obs.value === "object" ? obs.value : obs; -} - -// --- Prune expired seen_candidates --- - -function pruneSeen(cp, nowStr) { - const cutoff = new Date(nowStr).getTime() - SEEN_CANDIDATE_TTL_MS; - for (const [sig, entry] of Object.entries(cp.seen_candidates)) { - if (new Date(entry.first_seen_at).getTime() < cutoff) { - delete cp.seen_candidates[sig]; - } - } -} - -// --- Quality gate --- - -function passesQualityGate(candidate, nowStr) { - const score = typeof candidate.score === "number" ? candidate.score : 0; - if (score < MIN_SCORE_THRESHOLD) return { pass: false, reason: `score=${score} < ${MIN_SCORE_THRESHOLD}` }; - - if (candidate.evidence_age_ms != null) { - if (candidate.evidence_age_ms > EVIDENCE_AGE_MAX_MS) { - return { pass: false, reason: `evidence_age=${candidate.evidence_age_ms}ms > ${EVIDENCE_AGE_MAX_MS}ms` }; - } - } else if (candidate.last_seen_at) { - const seenAtMs = new Date(candidate.last_seen_at).getTime(); - if (!Number.isFinite(seenAtMs)) { - return { pass: false, reason: `invalid_last_seen_at=${candidate.last_seen_at}` }; - } - const ageMs = new Date(nowStr).getTime() - seenAtMs; - if (ageMs > EVIDENCE_AGE_MAX_MS) { - return { pass: false, reason: `evidence_age=${ageMs}ms > ${EVIDENCE_AGE_MAX_MS}ms` }; - } - } - - return { pass: true, reason: null }; -} - -// --- Build materialization prompt --- -// After quality gate passes, instruct the LLM to: -// 1. Create the Kanban card via POST /api/automation-candidates -// 2. Write candidate_approved:* kv_meta with the resulting card_id - -function buildMaterializationPrompt(signature, candidate) { - const mr = candidate.materialize_request || {}; - const mrBody = mr.body || {}; - const program = mrBody.program || {}; - - const materializeBody = { - title: mrBody.title || `[automation-candidate] ${signature}`, - source: mrBody.source || "routine_recommender", - dedupe_key: mrBody.dedupe_key || signature, - start_ready: true, - program: { - repo_dir: program.repo_dir || "", - allowed_write_paths: program.allowed_write_paths || ["src"], - metric_name: program.metric_name || "automation_friction_count", - metric_target: program.metric_target ?? 0, - metric_direction: program.metric_direction || "lower_is_better", - final_gate: program.final_gate || "manual_review", - iteration_budget: program.iteration_budget || 3, - }, - }; - - const lines = [ - "# 자동화 후보 카드 생성 요청", - "", - `**후보 ID**: \`${signature}\``, - `**카테고리**: ${candidate.category || "routine-candidate"}`, - `**점수**: ${candidate.score} / 100`, - `**증거 수**: ${candidate.evidence_count || 0}`, - `**제안된 자동화**: ${candidate.suggested_automation || "(없음)"}`, - `**결과 요약**: ${candidate.outcome_summary || "(없음)"}`, - "", - "---", - "## 실행 지침", - "", - "이 후보는 품질 게이트를 통과했습니다. 아래 두 단계를 순서대로 실행하세요.", - "", - "### 1단계: Kanban 카드 생성", - "", - "아래 API를 호출하여 자동화 후보 카드를 생성합니다:", - "", - "```", - "POST /api/automation-candidates", - "Content-Type: application/json", - "", - JSON.stringify(materializeBody, null, 2), - "```", - "", - "**중요**: `program.repo_dir`가 `` 상태라면,", - "현재 워크스페이스의 agentdesk 리포지터리 절대 경로로 대체하세요.", - "예: `/Users/example/workspaces/agentdesk`", - "", - "API 응답에서 `card_id`를 저장해두세요.", - "", - "### 2단계: 승인 마커 기록", - "", - "카드 생성에 성공한 경우, 아래 kv_meta를 기록합니다 (TTL: 72h):", - "```", - `routine_observation:candidate_approved:${signature}`, - "```", - `값(JSON): {\"signature\":\"${signature}\",\"score\":${candidate.score},\"card_id\":\"<생성된 card_id>\",\"approved_at\":\"<현재시각ISO>\",\"category\":\"${candidate.category || "routine-candidate"}\"}`, - "", - "카드 생성에 실패하거나 후보가 자동화 가치가 없다고 판단되면 kv_meta를 기록하지 마세요.", - "", - "**주의**: 카드를 생성하지 않고 kv_meta만 기록하면 자동화 루프가 동작하지 않습니다.", - ]; - return lines.join("\n"); -} - -// --- Main tick --- - -agentdesk.routines.register({ - name: "Automation Candidate Detector", - - tick(ctx) { - const nowStr = nowIso(ctx.now); - const cp = loadCheckpoint(ctx.checkpoint); - const observations = ctx.observations || []; - - pruneSeen(cp, nowStr); - - // Find candidate_review observations - const reviewPrefix = "routine_observation:candidate_review:"; - const approvedPrefix = "routine_observation:candidate_approved:"; - const dispatchedPrefix = "routine_observation:candidate_dispatched:"; - const reviewObs = observations.filter((obs) => observationSignature(obs, reviewPrefix)); - - // Find already-approved and dispatched signatures from observations - const approvedSigs = new Set( - observations - .map((obs) => observationSignature(obs, approvedPrefix)) - .filter(Boolean) - ); - const dispatchedSigs = new Set( - observations - .map((obs) => observationSignature(obs, dispatchedPrefix)) - .filter(Boolean) - ); - - cp.stats.ticks++; - - if (reviewObs.length === 0) { - return { - action: "complete", - result: { - status: "ok", - summary: "검토할 후보 없음", - review_count: 0, - }, - checkpoint: cp, - }; - } - - // Process each candidate_review observation - const emitPrompts = []; - const queuedSignatures = new Set(); - for (const obs of reviewObs) { - const signature = observationSignature(obs, reviewPrefix); - const candidate = observationPayload(obs); - - // Skip already approved/dispatched - if (approvedSigs.has(signature) || dispatchedSigs.has(signature)) { - cp.stats.skipped_already_approved++; - cp.seen_candidates[signature] = cp.seen_candidates[signature] || { - first_seen_at: nowStr, - status: "skipped_already_handled", - }; - continue; - } - - // Skip only recent emits. If no durable approved/dispatched marker appears, - // retry before the candidate_review marker can age out. - const seen = cp.seen_candidates[signature]; - if (queuedSignatures.has(signature)) { - cp.stats.skipped_already_approved++; - continue; - } - // Give up if LLM has repeatedly failed to write the approved marker. - if (seen && (seen.emit_count || 0) >= MAX_EMIT_RETRIES) { - if (seen.status !== "stalled") { - cp.seen_candidates[signature] = Object.assign({}, seen, { status: "stalled" }); - } - cp.stats.stalled_candidates++; - continue; - } - if ( - seen && - seen.status === "emitted" && - isRecentIso(seen.last_emitted_at || seen.first_seen_at, nowStr, EMITTED_RETRY_MS) - ) { - cp.stats.skipped_already_approved++; - continue; - } - - // Quality gate - const gate = passesQualityGate(candidate, nowStr); - if (!gate.pass) { - cp.stats.skipped_quality_gate++; - cp.seen_candidates[signature] = { first_seen_at: nowStr, status: "rejected", reason: gate.reason }; - continue; - } - - emitPrompts.push({ signature, candidate }); - queuedSignatures.add(signature); - } - - if (emitPrompts.length === 0) { - return { - action: "complete", - result: { - status: "ok", - summary: `검토 ${reviewObs.length}건 처리됨 (카드 생성 요청 없음)`, - review_count: reviewObs.length, - skipped_approved: cp.stats.skipped_already_approved, - skipped_quality_gate: cp.stats.skipped_quality_gate, - }, - checkpoint: cp, - }; - } - - // Emit one materialization prompt (first candidate, others handled on next ticks) - const { signature, candidate } = emitPrompts[0]; - const prompt = buildMaterializationPrompt(signature, candidate); - const previousSeen = cp.seen_candidates[signature]; - const emitCount = (previousSeen?.emit_count || 0) + 1; - cp.seen_candidates[signature] = { - first_seen_at: previousSeen?.first_seen_at || nowStr, - last_emitted_at: nowStr, - emit_count: emitCount, - status: "emitted", - }; - cp.stats.materialized++; - - return { - action: "agent", - prompt, - checkpoint: cp, - }; - }, -}); diff --git a/routines/monitoring/automation-candidate-executor.js b/routines/monitoring/automation-candidate-executor.js deleted file mode 100644 index 74c73962d0..0000000000 --- a/routines/monitoring/automation-candidate-executor.js +++ /dev/null @@ -1,320 +0,0 @@ -// Automation Candidate Executor -// -// Consumes kanban_ready observations (pipeline_stage_id='automation-candidate', status='ready') -// and drives an autoresearch-style iteration loop per card: -// -// ready → requested → in_progress → [metric-regression re-queue OR review → done] -// -// Re-queue on metric regression: current card → "review", new child card → "ready" -// (intermediate cards go to "review", NOT "done", to avoid premature kanban_dispatched suppression) -// -// Loop ends when MAX_ITERATIONS is reached or final gate triggers. -// LLM submits iteration results via POST /api/automation-candidates/{card_id}/iteration-result. -// Rust computes keep/discard verdict deterministically. - -const MAX_ITERATIONS = 10; -const DISPATCH_RETRY_MS = 30 * 60 * 1000; -const MAX_DISPATCH_RETRIES = 3; -const DISPATCHED_WINDOW_DAYS = 7; -const CHECKPOINT_VERSION = 2; -const AUTOMATION_CANDIDATE_CONTRACT = Object.freeze({ - pipelineStageId: "automation-candidate", - programKey: "program", - requiredProgramFields: Object.freeze([ - "repo_dir", - "allowed_write_paths", - "metric_name", - "metric_target", - ]), -}); - -// --- Checkpoint helpers --- - -function emptyCheckpoint() { - return { - version: CHECKPOINT_VERSION, - // card_id -> { dispatched_at, iteration, status } - dispatched: {}, - // card_id -> { attempt_count, last_attempted_at, first_attempted_at, status?, stalled_at? } - pending: {}, - stats: { ticks: 0, dispatched: 0, skipped: 0, max_iterations_reached: 0, stalled_candidates: 0 }, - }; -} - -function loadCheckpoint(raw) { - if (!raw || raw.version !== CHECKPOINT_VERSION) return emptyCheckpoint(); - const cp = Object.assign(emptyCheckpoint(), raw); - cp.dispatched = raw.dispatched || {}; - cp.pending = raw.pending || {}; - cp.stats = Object.assign(emptyCheckpoint().stats, raw.stats || {}); - return cp; -} - -function nowIso(now) { - return typeof now === "string" ? now : now.toISOString ? now.toISOString() : String(now); -} - -function isRecent(iso, nowStr, maxMs) { - const t = new Date(iso || "").getTime(); - return Number.isFinite(t) && new Date(nowStr).getTime() - t < maxMs; -} - -function pruneDispatched(cp, nowStr) { - const cutoff = new Date(nowStr).getTime() - DISPATCHED_WINDOW_DAYS * 86400 * 1000; - for (const [id, entry] of Object.entries(cp.dispatched)) { - if (new Date(entry.dispatched_at || 0).getTime() < cutoff) delete cp.dispatched[id]; - } - for (const [id, entry] of Object.entries(cp.pending)) { - if (new Date(entry.first_attempted_at || 0).getTime() < cutoff) delete cp.pending[id]; - } -} - -function hasCompleteProgramContract(program) { - const [repoDirField, allowedPathsField, metricNameField, metricTargetField] = - AUTOMATION_CANDIDATE_CONTRACT.requiredProgramFields; - return typeof program[repoDirField] === "string" - && program[repoDirField].trim().length > 0 - && Array.isArray(program[allowedPathsField]) - && program[allowedPathsField].length > 0 - && program[allowedPathsField].every((path) => typeof path === "string" && path.trim().length > 0) - && typeof program[metricNameField] === "string" - && program[metricNameField].trim().length > 0 - && program[metricTargetField] != null; -} - -function isAutomationCandidateCard(obs) { - const program = (obs.metadata || {})[AUTOMATION_CANDIDATE_CONTRACT.programKey] || {}; - return obs.pipeline_stage_id === AUTOMATION_CANDIDATE_CONTRACT.pipelineStageId - && hasCompleteProgramContract(program); -} - -function candidateIterationBudget(program) { - const value = Number(program.iteration_budget || MAX_ITERATIONS); - if (!Number.isFinite(value)) return MAX_ITERATIONS; - return Math.max(1, Math.min(MAX_ITERATIONS, Math.floor(value))); -} - -function previousIterationsFor(inventory, cardId) { - if (!inventory) return []; - if (Array.isArray(inventory)) { - return inventory.flatMap((item) => { - if (!item || typeof item !== "object") return []; - const itemCardId = item.card_id || item.kanban_card_id; - if (itemCardId !== cardId) return []; - if (Array.isArray(item.iterations)) return item.iterations; - return [item]; - }); - } - if (Array.isArray(inventory[cardId])) return inventory[cardId]; - if (inventory[cardId] && Array.isArray(inventory[cardId].iterations)) { - return inventory[cardId].iterations; - } - if (inventory.card_id === cardId && Array.isArray(inventory.iterations)) { - return inventory.iterations; - } - return []; -} - -// --- Build executor prompt (autoresearch-style: program contract + previous findings) --- - -function buildIterationPrompt(cardId, card, iteration, previousIterations) { - const program = (card.metadata && card.metadata.program) || {}; - const allowedPaths = (program.allowed_write_paths || []).join(", ") || "(not specified)"; - const metricName = program.metric_name || "improvement_score"; - const metricDirection = program.metric_direction || program.direction || "lower_is_better"; - const metricTarget = program.metric_target != null ? String(program.metric_target) : "(not specified)"; - const iterBudget = candidateIterationBudget(program); - const description = program.description || card.title || "(no description)"; - - const prevSummary = previousIterations.length === 0 - ? "(이전 반복 없음 — 첫 번째 시도입니다)" - : previousIterations.map((r, i) => - ` 반복 ${r.iteration}: ${r.status} | ${metricName}: ${r.metric_before} → ${r.metric_after} | ${r.description || ""}` - ).join("\n"); - - return [ - "## 자동화 후보 반복 실행 요청 (Automation Candidate Executor)", - "", - `**카드 ID**: \`${cardId}\``, - `**제목**: ${card.title || "(no title)"}`, - `**반복 번호**: ${iteration} / ${iterBudget}`, - "", - "### Program Contract", - `- **목표**: ${description}`, - `- **수정 허용 경로**: \`${allowedPaths}\``, - `- **지표명**: ${metricName}`, - `- **지표 방향**: ${metricDirection}`, - `- **목표값**: ${metricTarget}`, - "", - "### 이전 반복 결과", - prevSummary, - "", - "### 실행 지침", - "", - "1. **먼저 아래 API로 격리된 git worktree를 준비**하고, 응답의 `path`에서만 작업:", - "", - "```", - `POST /api/automation-candidates/${cardId}/prepare-worktree`, - "Content-Type: application/json", - "", - JSON.stringify({ iteration }, null, 2), - "```", - "", - "2. **allowed_write_paths 내에서만** 코드 수정", - "3. 지표 측정 (변경 전/후)", - "4. **반드시 아래 API 호출로 결과 제출** (다른 방법으로 상태 변경 금지):", - "", - "```", - `POST /api/automation-candidates/${cardId}/iteration-result`, - "Content-Type: application/json", - "", - JSON.stringify({ - iteration, - branch: `automation/${cardId}/iter-${iteration}`, - commit_hash: "<커밋 해시>", - metric_before: "<변경 전 수치>", - metric_after: "<변경 후 수치>", - is_simplification: false, - status: "keep", - description: "<변경 요약>", - allowed_write_paths_used: program.allowed_write_paths || [], - run_seconds: "<소요 초>", - crash_trace: null, - }, null, 2), - "```", - "", - "**주의**: `lower_is_better`에서는 `metric_after < metric_before`, `higher_is_better`에서는 `metric_after > metric_before`인 경우에만 Rust가 `keep` 판정합니다.", - "**주의**: allowed_write_paths 외 경로 수정 시 API가 403을 반환합니다.", - "**주의**: 이 API 호출 없이 카드 상태를 직접 변경하지 마세요.", - ].join("\n"); -} - -// --- Main tick --- - -agentdesk.routines.register({ - name: "Automation Candidate Executor", - - tick(ctx) { - const nowStr = nowIso(ctx.now); - const cp = loadCheckpoint(ctx.checkpoint); - const observations = ctx.observations || []; - - pruneDispatched(cp, nowStr); - - // Collect kanban_ready candidates (source 8 in store.rs) - const readyCards = observations - .filter((o) => o.source === "kanban_ready") - .filter((o) => { - const valid = isAutomationCandidateCard(o); - if (!valid) cp.stats.skipped++; - return valid; - }) - .map((o) => ({ cardId: o.card_id || o.evidence_ref?.replace("kanban_cards:", ""), obs: o })) - .filter((c) => c.cardId); - - // Collect kanban_dispatched to suppress already-completed cards (source 9) - const dispatchedCardIds = new Set( - observations - .filter((o) => o.source === "kanban_dispatched") - .map((o) => o.card_id || o.evidence_ref?.replace("kanban_cards:", "")) - .filter(Boolean) - ); - - cp.stats.ticks++; - - if (readyCards.length === 0) { - return { - action: "complete", - result: { status: "ok", summary: "실행 대기 중인 자동화 후보 없음" }, - checkpoint: cp, - }; - } - - // Filter candidates - const toProcess = []; - for (const { cardId, obs } of readyCards) { - if (cp.dispatched[cardId] || dispatchedCardIds.has(cardId)) { - cp.stats.skipped++; - continue; - } - const meta = obs.metadata || {}; - const program = meta.program || {}; - const maxIterations = candidateIterationBudget(program); - const iteration = (program.current_iteration || 0) + 1; - const pending = cp.pending[cardId]; - const pendingIteration = Number.isFinite(Number(pending?.iteration)) - ? Number(pending.iteration) - : iteration; - if (pending && pendingIteration !== iteration) { - delete cp.pending[cardId]; - } - const activePending = pending && pendingIteration === iteration ? pending : null; - const retryWindowOpen = activePending - && isRecent(activePending.last_attempted_at, nowStr, DISPATCH_RETRY_MS); - - if (activePending && (activePending.attempt_count || 0) >= MAX_DISPATCH_RETRIES) { - if (activePending.status !== "stalled" && retryWindowOpen) { - cp.stats.skipped++; - continue; - } - if (activePending.status !== "stalled") { - cp.pending[cardId] = Object.assign({}, activePending, { - status: "stalled", - stalled_at: nowStr, - }); - cp.stats.stalled_candidates = (cp.stats.stalled_candidates || 0) + 1; - } - continue; - } - if (retryWindowOpen) { - cp.stats.skipped++; - continue; - } - - if (iteration > maxIterations) { - cp.stats.max_iterations_reached++; - cp.dispatched[cardId] = { dispatched_at: nowStr, status: "max_iterations_reached", iteration }; - continue; - } - - toProcess.push({ cardId, obs, iteration, program }); - } - - if (toProcess.length === 0) { - return { - action: "complete", - result: { - status: "ok", - summary: `ready 후보 ${readyCards.length}건 모두 처리됨 또는 대기 중`, - skipped: cp.stats.skipped, - }, - checkpoint: cp, - }; - } - - // Process first candidate - const { cardId, obs, iteration } = toProcess[0]; - const card = { - title: obs.summary || "", - metadata: obs.metadata || {}, - }; - - // Read previous iteration results from ctx.automationInventory if available - const previousIterations = previousIterationsFor(ctx.automationInventory, cardId); - - const prevPending = cp.pending[cardId]; - cp.pending[cardId] = { - first_attempted_at: prevPending?.first_attempted_at || nowStr, - last_attempted_at: nowStr, - attempt_count: (prevPending?.attempt_count || 0) + 1, - iteration, - }; - cp.stats.dispatched++; - - return { - action: "agent", - prompt: buildIterationPrompt(cardId, card, iteration, previousIterations), - checkpoint: cp, - }; - }, -}); diff --git a/routines/monitoring/automation-candidate-recommender.js b/routines/monitoring/automation-candidate-recommender.js deleted file mode 100644 index 292e0fe0ef..0000000000 --- a/routines/monitoring/automation-candidate-recommender.js +++ /dev/null @@ -1,1336 +0,0 @@ -// Automation Candidate Recommender -// 매 tick마다 bounded observations를 checkpoint에 누적하고, -// 강한 근거(score >= 80)에서만 agent proposal을 생성한다. -// P0: read-only, no auto-implementation, proposal-only. - -const SCORE_THRESHOLD = 80; -const DAILY_CAP = 3; -const COOLDOWN_HOURS = 6; -const MAX_EXAMPLES_PER_CANDIDATE = 3; -const PROMPT_CAP_BYTES = 12288; -const CHECKPOINT_CAP_BYTES = 65536; -const CHECKPOINT_VERSION = 1; -const CANDIDATE_TTL_DAYS = 30; -const REPO_DIR_PLACEHOLDER = ""; - -// seen_evidence dedup: prevents the same evidence from incrementing score/evidence_count -// across multiple ticks. TTL covers the 24h observation query window with margin. -const SEEN_EVIDENCE_TTL_MS = 25 * 3600 * 1000; // 25 h -const SEEN_EVIDENCE_MAX_ENTRIES = 500; // LRU cap - -// Saturation detection & re-optimization (P0-E) -// EMA formula mirrors autoresearch train.py ema_beta=0.9 (karpathy/autoresearch). -// Fast-fail tier: analogous to autoresearch "if isnan(loss): exit(1)". -// Re-optimization (partial reset + diversity mode) is original design. -const EMA_BETA = 0.9; -const EMA_SATURATION_THRESHOLD = 0.3; // ema_scored below this → saturation_ticks++ -const SATURATION_TICKS = 5; // consecutive EMA-saturated ticks → reopt -const FAST_FAIL_TICKS = 2; // all-dedup consecutive ticks → immediate reopt -const REOPT_WINDOW_MS = 12 * 3600 * 1000; // partial reset: remove seen_evidence older than 12h -const DIVERSITY_BOOST_TICKS = 10; // ticks of diversity mode after each reopt -const DIVERSITY_LOOKBACK_TICKS = 5; // history window for underrepresented category detection - -const KNOWN_CATEGORIES = new Set([ - "routine-candidate", - "release-freshness", - "outbox-delivery", - "memento-hygiene", - "api-friction", - "kanban-flow", - "dispatch-retry", - "session-pattern", - "log-signal", - "automation-candidate", -]); - -const CATEGORY_GATES = Object.freeze({ - "routine-candidate": { minScore: SCORE_THRESHOLD, minEvidence: 5 }, - "release-freshness": { minScore: SCORE_THRESHOLD, minEvidence: 5 }, - "outbox-delivery": { minScore: 60, minEvidence: 3 }, - "memento-hygiene": { minScore: 60, minEvidence: 3 }, - "api-friction": { minScore: 60, minEvidence: 3 }, - "kanban-flow": { minScore: 60, minEvidence: 3 }, - "dispatch-retry": { minScore: 60, minEvidence: 3 }, - "session-pattern": { minScore: 60, minEvidence: 3 }, - "log-signal": { minScore: 60, minEvidence: 3 }, - "automation-candidate": { minScore: SCORE_THRESHOLD, minEvidence: 5 }, -}); - -const CATEGORY_SCORE_MULTIPLIERS = Object.freeze({ - "outbox-delivery": 1.25, - "api-friction": 1.2, - "memento-hygiene": 1.1, - "kanban-flow": 1.15, - "dispatch-retry": 1.2, - "session-pattern": 1.15, - "log-signal": 1.15, -}); - -// --- Scoring weights --- -const WEIGHT_BASE = 10; -const WEIGHT_RECENCY_BONUS = 10; // occurred in last 30 min -const WEIGHT_FIRST_SEEN = 5; // new candidate bonus - -// --- Checkpoint helpers --- - -function emptyCheckpoint() { - return { - version: CHECKPOINT_VERSION, - cursors: {}, - candidates: {}, - suppressions: {}, - seen_evidence: {}, // evidence_key -> { seen_at, occurrences } (legacy ISO string accepted) - recommendations: [], - last_tick_at: null, - // P0-E saturation tracking - ema_scored: 0, - saturation_ticks: 0, - fast_fail_ticks: 0, - reopt_count: 0, - diversity_mode_ticks_remaining: 0, - last_reopt_at: null, - stats: { - ticks: 0, - observations_seen: 0, - agent_escalations: 0, - recommendations_today: 0, - recommendation_day: null, - category_scored: {}, - category_scored_history: [], // [{cat: count, ...}, ...] last DIVERSITY_LOOKBACK_TICKS entries - }, - }; -} - -function loadCheckpoint(raw) { - if (!raw || typeof raw !== "object" || raw.version !== CHECKPOINT_VERSION) { - return emptyCheckpoint(); - } - const cp = Object.assign(emptyCheckpoint(), raw); - cp.candidates = raw.candidates || {}; - cp.suppressions = raw.suppressions || {}; - cp.seen_evidence = raw.seen_evidence || {}; - cp.stats = Object.assign(emptyCheckpoint().stats, raw.stats || {}); - cp.stats.category_scored = (raw.stats && raw.stats.category_scored) ? raw.stats.category_scored : {}; - cp.stats.category_scored_history = (raw.stats && Array.isArray(raw.stats.category_scored_history)) - ? raw.stats.category_scored_history : []; - // P0-E backward-compat defaults - if (typeof cp.ema_scored !== "number") cp.ema_scored = 0; - if (typeof cp.saturation_ticks !== "number") cp.saturation_ticks = 0; - if (typeof cp.fast_fail_ticks !== "number") cp.fast_fail_ticks = 0; - if (typeof cp.reopt_count !== "number") cp.reopt_count = 0; - if (typeof cp.diversity_mode_ticks_remaining !== "number") cp.diversity_mode_ticks_remaining = 0; - if (cp.last_reopt_at === undefined) cp.last_reopt_at = null; - return cp; -} - -function nowIso(now) { - return typeof now === "string" ? now : now.toISOString ? now.toISOString() : String(now); -} - -function dateOf(iso) { - return iso ? iso.slice(0, 10) : null; -} - -function addHours(isoStr, hours) { - const ms = new Date(isoStr).getTime() + hours * 3600 * 1000; - return new Date(ms).toISOString(); -} - -function simpleHash(str) { - let h = 0; - for (let i = 0; i < str.length; i++) { - h = ((h << 5) - h + str.charCodeAt(i)) | 0; - } - return (h >>> 0).toString(16); -} - -// --- seen_evidence dedup helpers --- - -function seenEvidenceTimestamp(entry) { - if (typeof entry === "string") return entry; - if (entry && typeof entry === "object") { - return entry.seen_at || entry.first_seen_at || null; - } - return null; -} - -function seenEvidenceOccurrences(entry) { - if (!entry || typeof entry !== "object") return 0; - const value = Number(entry.occurrences || 0); - if (!Number.isFinite(value) || value < 0) return 0; - return Math.floor(value); -} - -function isSeenEvidenceFresh(entry, nowStr) { - const ts = seenEvidenceTimestamp(entry); - if (!ts) return false; - return new Date(nowStr).getTime() - new Date(ts).getTime() < SEEN_EVIDENCE_TTL_MS; -} - -function evidenceKey(obs) { - if (obs.evidence_ref) return obs.evidence_ref; - return `${obs.source || ""}|${obs.category || ""}|${obs.signature || ""}|${simpleHash(String(obs.summary || ""))}`; -} - -function seenOccurrences(cp, key, nowStr) { - const entry = cp.seen_evidence[key]; - if (!isSeenEvidenceFresh(entry, nowStr)) return 0; - return seenEvidenceOccurrences(entry); -} - -function isEvidenceFullySeen(cp, key, nowStr, occurrences) { - const entry = cp.seen_evidence[key]; - if (!isSeenEvidenceFresh(entry, nowStr)) return false; - if (typeof entry === "string") return true; - return seenEvidenceOccurrences(entry) >= occurrences; -} - -function markEvidenceSeen(cp, key, nowStr, occurrences) { - const value = Number(occurrences || 0); - cp.seen_evidence[key] = { - seen_at: nowStr, - occurrences: Number.isFinite(value) && value > 0 ? Math.floor(value) : 0, - }; -} - -function pruneSeenEvidence(cp, nowStr) { - const cutoffMs = new Date(nowStr).getTime() - SEEN_EVIDENCE_TTL_MS; - // Expire TTL-exceeded entries - for (const key of Object.keys(cp.seen_evidence)) { - const seenAt = seenEvidenceTimestamp(cp.seen_evidence[key]); - if (!seenAt || new Date(seenAt).getTime() < cutoffMs) { - delete cp.seen_evidence[key]; - } - } - // LRU eviction: drop oldest entries if still over cap - const keys = Object.keys(cp.seen_evidence); - if (keys.length > SEEN_EVIDENCE_MAX_ENTRIES) { - keys.sort((a, b) => - new Date(seenEvidenceTimestamp(cp.seen_evidence[a])).getTime() - - new Date(seenEvidenceTimestamp(cp.seen_evidence[b])).getTime() - ); - for (const key of keys.slice(0, keys.length - SEEN_EVIDENCE_MAX_ENTRIES)) { - delete cp.seen_evidence[key]; - } - } -} - -// --- P0-E: Saturation detection & re-optimization helpers --- - -function updateEmaScored(cp, scoredThisTick) { - cp.ema_scored = EMA_BETA * cp.ema_scored + (1 - EMA_BETA) * scoredThisTick; -} - -function updateFastFailTicks(cp, scoringReport, obsCount) { - // Fast-fail: all observations were deduped (no new scoring signal at all) - const allDeduped = - scoringReport.scored === 0 && - scoringReport.deduped > 0 && - scoringReport.deduped === obsCount; - if (allDeduped) { - cp.fast_fail_ticks++; - } else { - cp.fast_fail_ticks = 0; - } -} - -function updateSaturationTicks(cp) { - if (cp.ema_scored < EMA_SATURATION_THRESHOLD) { - cp.saturation_ticks++; - } else { - cp.saturation_ticks = 0; - } -} - -function shouldTriggerReopt(cp) { - if (cp.fast_fail_ticks >= FAST_FAIL_TICKS) return { trigger: true, reason: "fast_fail" }; - if (cp.saturation_ticks >= SATURATION_TICKS) return { trigger: true, reason: "ema_saturation" }; - return { trigger: false, reason: null }; -} - -function partialResetSeenEvidence(cp, nowStr) { - const cutoff = new Date(new Date(nowStr).getTime() - REOPT_WINDOW_MS).getTime(); - for (const [key, entry] of Object.entries(cp.seen_evidence)) { - const seenAt = seenEvidenceTimestamp(entry); - if (!seenAt || new Date(seenAt).getTime() < cutoff) { - delete cp.seen_evidence[key]; - } - } -} - -function triggerReopt(cp, nowStr) { - partialResetSeenEvidence(cp, nowStr); - cp.diversity_mode_ticks_remaining = DIVERSITY_BOOST_TICKS; - cp.reopt_count++; - cp.saturation_ticks = 0; - cp.fast_fail_ticks = 0; - cp.last_reopt_at = nowStr; -} - -// Returns Set of underrepresented categories using last DIVERSITY_LOOKBACK_TICKS tick history. -// A category is underrepresented when its summed scored count is below the per-category average. -function underrepresentedCatsFromHistory(cp) { - const totals = {}; - for (const cat of KNOWN_CATEGORIES) totals[cat] = 0; - for (const snapshot of cp.stats.category_scored_history || []) { - for (const [cat, n] of Object.entries(snapshot)) { - totals[cat] = (totals[cat] || 0) + n; - } - } - const vals = Object.values(totals); - const avg = vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0; - return new Set(Object.entries(totals).filter(([, n]) => n < avg).map(([c]) => c)); -} - -function normalizeCategory(value) { - if (typeof value === "string" && KNOWN_CATEGORIES.has(value)) { - return value; - } - return "routine-candidate"; -} - -function categoryGate(category) { - return CATEGORY_GATES[normalizeCategory(category)] || CATEGORY_GATES["routine-candidate"]; -} - -function categoryScoreMultiplier(category) { - const value = CATEGORY_SCORE_MULTIPLIERS[normalizeCategory(category)] || 1; - return Math.min(1.5, Math.max(1, value)); -} - -function candidateGate(candidate) { - return categoryGate(candidate && candidate.category); -} - -function candidateMeetsGate(candidate) { - const gate = candidateGate(candidate); - return (candidate.score || 0) >= gate.minScore - && (candidate.evidence_count || 0) >= gate.minEvidence; -} - -function observationOccurrences(obs) { - const value = Number(obs.occurrences || obs.count || 1); - if (!Number.isFinite(value) || value < 1) { - return 1; - } - return Math.min(50, Math.floor(value)); -} - -function observationKey(obs) { - if (typeof obs.key === "string") return obs.key; - if (typeof obs.evidence_ref === "string") { - if (obs.evidence_ref.startsWith("kv_meta:routine_observation:")) { - return obs.evidence_ref.slice("kv_meta:".length); - } - if (obs.evidence_ref.startsWith("routine_observation:")) { - return obs.evidence_ref; - } - } - return ""; -} - -function compactText(value, maxChars) { - return String(value || "") - .replace(/\s+/g, " ") - .trim() - .slice(0, maxChars); -} - -function topEvidenceForCandidate(candidate, limit) { - return (candidate.examples || []) - .slice(-limit) - .reverse() - .map((example) => ({ - summary: compactText(example.summary, 140), - timestamp: example.timestamp || null, - evidence_ref: example.evidence_ref || null, - weight: example.weight || 1, - occurrences: example.occurrences || 1, - })); -} - -function topEvidenceSummaryForCandidate(candidate) { - const evidence = topEvidenceForCandidate(candidate, 2); - if (evidence.length === 0) return "핵심 근거 없음"; - return evidence - .map( - (item, index) => - `${index + 1}) ${item.summary || "요약 없음"} (occurrences=${item.occurrences}, weight=${item.weight})` - ) - .join(" / "); -} - -function topContributionSummary(map, limit) { - const entries = Object.entries(map || {}) - .sort(([, a], [, b]) => Number(b || 0) - Number(a || 0)) - .slice(0, limit) - .map(([key, count]) => `${key}=${count}`); - return entries.length ? entries.join(", ") : "none"; -} - -function candidateSourceSummary(candidate) { - return [ - `sources(${topContributionSummary(candidate.sources, 3)})`, - `categories(${topContributionSummary(candidate.source_categories, 3)})`, - ].join(" "); -} - -function topCandidateEvidence(cp, limit) { - return Object.entries(cp.candidates || {}) - .filter(([, candidate]) => candidate.state === "observing" || candidate.state === "recommended") - .sort(([, a], [, b]) => (b.score || 0) - (a.score || 0)) - .slice(0, limit) - .map(([patternId, candidate]) => ({ - pattern_id: patternId, - score: candidate.score || 0, - score_delta_last_tick: candidate.score_delta_last_tick || 0, - evidence_count: candidate.evidence_count || 0, - gate: candidateGate(candidate), - latest_evidence: topEvidenceSummaryForCandidate(candidate), - })); -} - -function topCandidateEvidenceSummary(cp) { - const top = topCandidateEvidence(cp, 3); - if (top.length === 0) return "관찰 중인 후보 없음"; - return top - .map( - (item, index) => - `${index + 1}) ${item.pattern_id}: score=${item.score}, delta=${item.score_delta_last_tick}, evidence=${item.evidence_count}, 근거=${item.latest_evidence}` - ) - .join(" / "); -} - -function suppressionSummary(suppressedObservations, droppedCandidates) { - const parts = []; - for (const item of (suppressedObservations || []).slice(0, 3)) { - parts.push(`${item.pattern_id}: ${item.reason}`); - } - for (const item of (droppedCandidates || []).slice(0, 3)) { - parts.push(`${item.pattern_id}: ${item.reason}`); - } - return parts.length ? parts.join(" / ") : "중복/억제 후보 없음"; -} - -function noEscalationReason(cp, nowStr) { - if (cp.stats.recommendations_today >= DAILY_CAP) { - return `보류 이유: 일일 추천 한도 ${DAILY_CAP}개에 도달했습니다.`; - } - const top = topCandidateEvidence(cp, 1)[0]; - if (!top) { - return "보류 이유: 관찰된 후보가 없습니다."; - } - const candidate = cp.candidates[top.pattern_id] || {}; - const gate = candidateGate(candidate); - if ((candidate.evidence_count || 0) < gate.minEvidence) { - return `보류 이유: 최상위 후보 ${top.pattern_id}의 근거가 ${candidate.evidence_count || 0}회로 category=${normalizeCategory(candidate.category)} 최소 ${gate.minEvidence}회 미만입니다.`; - } - if ((candidate.score || 0) < gate.minScore) { - return `보류 이유: 최상위 후보 ${top.pattern_id}의 점수 ${candidate.score || 0}가 category=${normalizeCategory(candidate.category)} 기준 ${gate.minScore} 미만입니다.`; - } - if (candidate.cooldown_until && candidate.cooldown_until > nowStr) { - return `보류 이유: 최상위 후보 ${top.pattern_id}가 ${candidate.cooldown_until}까지 쿨다운 중입니다.`; - } - return `보류 이유: 최상위 후보 ${top.pattern_id}는 중복 추천 해시 또는 게이트 조건으로 보류됐습니다.`; -} - -function utf8CharBytes(ch) { - const codePoint = ch.codePointAt(0); - if (codePoint <= 0x7f) return 1; - if (codePoint <= 0x7ff) return 2; - if (codePoint <= 0xffff) return 3; - return 4; -} - -function utf8ByteLength(value) { - let bytes = 0; - for (const ch of String(value || "")) { - bytes += utf8CharBytes(ch); - } - return bytes; -} - -function truncateUtf8(value, maxBytes) { - if (maxBytes <= 0) return ""; - let bytes = 0; - let out = ""; - for (const ch of String(value || "")) { - const nextBytes = utf8CharBytes(ch); - if (bytes + nextBytes > maxBytes) break; - out += ch; - bytes += nextBytes; - } - return out; -} - -// --- Daily cap reset --- - -function resetDailyCapIfNeeded(cp, nowStr) { - const today = dateOf(nowStr); - if (cp.stats.recommendation_day !== today) { - cp.stats.recommendations_today = 0; - cp.stats.recommendation_day = today; - } -} - -// --- Suppression / inventory filter --- - -function hasDurableAcceptance(entry) { - return Boolean(entry && (entry.automation_ref || entry.source_ref)); -} - -function buildSuppressedSet(cp, inventory, observations) { - const exact = new Map(); - const prefixes = []; - - function addPattern(patternId, reason) { - if (!patternId) return; - if (patternId.endsWith(":*")) { - prefixes.push({ prefix: patternId.slice(0, -1), reason }); - } else { - exact.set(patternId, reason); - } - } - - // From checkpoint suppressions - for (const [patternId, entry] of Object.entries(cp.suppressions || {})) { - const state = entry.state; - if ( - (state === "accepted" && hasDurableAcceptance(entry)) || - state === "implemented" || - state === "suppressed" || - state === "rejected" - ) { - addPattern(patternId, `체크포인트 억제 상태=${state}`); - } - } - - // From automation inventory (exact pattern_id match only) - for (const item of inventory || []) { - if ( - item.pattern_id && - ((item.status === "accepted" && hasDurableAcceptance(item)) || - item.status === "implemented" || - item.status === "suppressed" || - item.status === "rejected") - ) { - addPattern( - item.pattern_id, - `자동화 인벤토리 상태=${item.status}${item.source_ref ? ` ref=${item.source_ref}` : ""}` - ); - } - } - - // Candidate-level accepted/implemented/suppressed/rejected states - for (const [patternId, candidate] of Object.entries(cp.candidates || {})) { - const s = candidate.state; - if ( - (s === "accepted" && hasDurableAcceptance(candidate)) || - s === "implemented" || - s === "suppressed" || - s === "rejected" - ) { - addPattern(patternId, `후보 상태=${s}`); - } - } - - // Contract: executor/dispatched markers must suffix the same signature used by obs.signature. - // candidate_dispatched:* kv_meta observations → suppress re-recommendation (REQ-P1-004) - for (const obs of observations || []) { - const key = observationKey(obs); - if (key.startsWith("routine_observation:candidate_dispatched:")) { - const signature = key.replace("routine_observation:candidate_dispatched:", ""); - if (signature) addPattern(signature, "dispatched kv_meta 존재 (재추천 차단)"); - } - } - - return { - has(patternId) { - return exact.has(patternId) || prefixes.some((item) => patternId.startsWith(item.prefix)); - }, - reason(patternId) { - if (exact.has(patternId)) return exact.get(patternId); - const matched = prefixes.find((item) => patternId.startsWith(item.prefix)); - return matched ? matched.reason : null; - }, - }; -} - -function dropSuppressedCandidates(cp, suppressedSet) { - const dropped = []; - for (const [patternId, candidate] of Object.entries(cp.candidates || {})) { - if (suppressedSet.has(patternId)) { - const s = candidate.state; - if ( - (s === "accepted" && hasDurableAcceptance(candidate)) || - s === "implemented" || - s === "suppressed" || - s === "rejected" - ) { - continue; - } - dropped.push({ - pattern_id: patternId, - state: s, - reason: suppressedSet.reason(patternId) || "인벤토리/체크포인트 기준으로 억제", - }); - delete cp.candidates[patternId]; - } - } - - if (Array.isArray(cp.recommendations)) { - cp.recommendations = cp.recommendations.filter((item) => !suppressedSet.has(item.pattern_id)); - } - - return dropped.slice(0, 5); -} - -// --- Expired suppression cleanup --- - -function pruneExpiredSuppressions(cp, nowStr) { - for (const [patternId, entry] of Object.entries(cp.suppressions || {})) { - if (entry.expires_at && entry.expires_at < nowStr) { - delete cp.suppressions[patternId]; - if (cp.candidates[patternId]) { - cp.candidates[patternId].state = "observing"; - } - } - } -} - -function expireStaleCandidates(cp, nowStr) { - const cutoff = new Date(new Date(nowStr).getTime() - CANDIDATE_TTL_DAYS * 24 * 3600 * 1000).toISOString(); - for (const candidate of Object.values(cp.candidates || {})) { - if ( - candidate.last_seen_at && - candidate.last_seen_at < cutoff && - (candidate.state === "observing" || candidate.state === "recommended") - ) { - candidate.state = "expired"; - } - } -} - -// --- Score observations into candidates --- - -function scoreObservations(cp, observations, suppressedSet, nowStr, diversityMode) { - const thirtyMinAgo = new Date(new Date(nowStr).getTime() - 30 * 60 * 1000).toISOString(); - const report = { - scored: 0, - deduped: 0, - suppressed: [], - category_scored_this_tick: {}, - }; - - // Diversity mode: underrepresented categories from last DIVERSITY_LOOKBACK_TICKS tick history - const underrepresentedCats = diversityMode ? underrepresentedCatsFromHistory(cp) : null; - - for (const candidate of Object.values(cp.candidates || {})) { - candidate.score_delta_last_tick = 0; - candidate.scored_observations_last_tick = 0; - candidate.last_score_reason = null; - } - - for (const obs of observations) { - cp.stats.observations_seen++; - - const patternId = obs.signature; - if (!patternId) continue; - if (suppressedSet.has(patternId)) { - report.suppressed.push({ - pattern_id: patternId, - reason: suppressedSet.reason(patternId) || "인벤토리/체크포인트 기준으로 억제", - }); - continue; - } - - // Cross-tick dedup: skip evidence already scored in a previous tick, but - // score only the newly increased occurrence count for rolling grouped sources. - const eKey = evidenceKey(obs); - const totalOccurrences = observationOccurrences(obs); - if (isEvidenceFullySeen(cp, eKey, nowStr, totalOccurrences)) { - markEvidenceSeen(cp, eKey, nowStr, totalOccurrences); - report.deduped++; - continue; - } - const priorOccurrences = seenOccurrences(cp, eKey, nowStr); - const occurrences = Math.max(1, totalOccurrences - priorOccurrences); - markEvidenceSeen(cp, eKey, nowStr, totalOccurrences); - - const category = normalizeCategory(obs.category); - let candidate = cp.candidates[patternId]; - if (!candidate) { - candidate = { - category, - state: "observing", - score: WEIGHT_FIRST_SEEN, - evidence_count: 0, - first_seen_at: obs.timestamp || nowStr, - last_seen_at: null, - examples: [], - sources: {}, - source_categories: {}, - last_recommended_at: null, - last_recommendation_hash: null, - cooldown_until: null, - automation_ref: null, - has_error_evidence: false, - }; - cp.candidates[patternId] = candidate; - } else { - candidate.category = normalizeCategory(candidate.category || category); - } - - // Skip if already resolved - if (candidate.state === "accepted" && !hasDurableAcceptance(candidate)) { - candidate.state = "recommended"; - } - if ( - (candidate.state === "accepted" && hasDurableAcceptance(candidate)) || - candidate.state === "implemented" || - candidate.state === "suppressed" || - candidate.state === "rejected" - ) { - continue; - } - - candidate.evidence_count += occurrences; - candidate.last_seen_at = obs.timestamp || nowStr; - candidate.sources = candidate.sources || {}; - candidate.source_categories = candidate.source_categories || {}; - const source = String(obs.source || "unknown"); - candidate.sources[source] = (candidate.sources[source] || 0) + occurrences; - candidate.source_categories[category] = (candidate.source_categories[category] || 0) + occurrences; - - // Score delta - const weight = typeof obs.weight === "number" ? obs.weight : 1; - const scoredOccurrences = Math.min(occurrences, 5); - let delta = WEIGHT_BASE * weight * scoredOccurrences; - if (weight === 2) { - candidate.has_error_evidence = true; - } - - // Recency bonus - const recencyBonus = obs.timestamp && obs.timestamp >= thirtyMinAgo ? WEIGHT_RECENCY_BONUS : 0; - if (obs.timestamp && obs.timestamp >= thirtyMinAgo) { - delta += recencyBonus; - } - - // Diversity mode: 1.5× boost for underrepresented categories - const diversityBoost = (underrepresentedCats && underrepresentedCats.has(category)) ? 1.5 : 1; - const categoryMultiplier = categoryScoreMultiplier(category); - delta = delta * diversityBoost * categoryMultiplier; - - candidate.score = Math.min(100, candidate.score + delta); - candidate.score_delta_last_tick = (candidate.score_delta_last_tick || 0) + delta; - candidate.scored_observations_last_tick = - (candidate.scored_observations_last_tick || 0) + 1; - candidate.last_score_reason = - `weight=${weight}, occurrences=${occurrences}, recency_bonus=${recencyBonus}, category_multiplier=${categoryMultiplier}${diversityBoost > 1 ? ", diversity_boost=1.5" : ""}`; - candidate.last_scored_at = nowStr; - report.scored++; - - // Track per-category scoring stats (cumulative + per-tick for diversity history) - cp.stats.category_scored[category] = (cp.stats.category_scored[category] || 0) + 1; - report.category_scored_this_tick[category] = (report.category_scored_this_tick[category] || 0) + 1; - - // Keep up to 3 examples - if (candidate.examples.length < MAX_EXAMPLES_PER_CANDIDATE) { - candidate.examples.push({ - summary: obs.summary, - timestamp: obs.timestamp, - evidence_ref: obs.evidence_ref, - source: obs.source, - category, - weight, - occurrences, - }); - } - } - - return report; -} - -// --- Find best escalation candidate --- - -function findEscalationCandidate(cp, nowStr) { - if (cp.stats.recommendations_today >= DAILY_CAP) { - return null; - } - - let best = null; - let bestScore = -1; - - for (const [patternId, candidate] of Object.entries(cp.candidates || {})) { - if (candidate.state !== "observing" && candidate.state !== "recommended") { - continue; - } - if (!candidateMeetsGate(candidate)) { - continue; - } - if (candidate.score <= bestScore) { - continue; - } - if (candidate.cooldown_until && candidate.cooldown_until > nowStr) { - continue; - } - - // Dedupe: same hash within cooldown - const hash = simpleHash(patternId + ":" + candidate.evidence_count); - if (candidate.last_recommendation_hash === hash) { - continue; - } - - bestScore = candidate.score; - best = { patternId, candidate, hash }; - } - - return best; -} - -// --- Mark candidate as recommended --- - -function markRecommended(cp, escalation, nowStr) { - const { patternId, candidate, hash } = escalation; - const assessment = candidateAssessment(patternId, candidate); - const decisionSummary = candidateDecisionSummary(patternId, candidate, assessment); - const topEvidenceSummary = topEvidenceSummaryForCandidate(candidate); - candidate.state = "recommended"; - candidate.last_recommended_at = nowStr; - candidate.last_recommendation_hash = hash; - candidate.cooldown_until = addHours(nowStr, COOLDOWN_HOURS); - candidate.suggested_automation = assessment.suggestedAutomation; - candidate.recommended_execution = assessment.recommendedExecution; - candidate.outcome_summary = assessment.outcomeSummary; - candidate.before_after = assessment.beforeAfter; - candidate.expected_files = assessment.expectedFiles; - candidate.expected_side_effects = assessment.expectedSideEffects; - candidate.verification_method = assessment.verificationMethod; - candidate.gated_handoff = assessment.gatedHandoff; - candidate.materialize_request = assessment.materializeRequest; - candidate.decision_summary = decisionSummary; - candidate.top_evidence = topEvidenceForCandidate(candidate, 3); - candidate.top_evidence_summary = topEvidenceSummary; - cp.stats.recommendations_today++; - cp.stats.agent_escalations++; - cp.recommendations.push({ - pattern_id: patternId, - recommended_at: nowStr, - hash, - score: candidate.score, - score_delta_last_tick: candidate.score_delta_last_tick || 0, - evidence_count: candidate.evidence_count, - sources: candidate.sources || {}, - source_categories: candidate.source_categories || {}, - outcome_summary: assessment.outcomeSummary, - decision_summary: decisionSummary, - top_evidence_summary: topEvidenceSummary, - materialize_request: assessment.materializeRequest, - }); - // Keep recommendations list bounded - if (cp.recommendations.length > 50) { - cp.recommendations = cp.recommendations.slice(-50); - } -} - -// --- Agent prompt builder --- - -function buildOutcomeSummary(patternId, candidate, isErrorPattern, category) { - const latestExample = (candidate.examples || []).slice(-1)[0] || {}; - const latestSummary = String(latestExample.summary || patternId) - .replace(/\s+/g, " ") - .slice(0, 120); - const count = candidate.evidence_count || observationOccurrences(latestExample) || 0; - const categoryLabel = { - "routine-candidate": "루틴 반복", - "release-freshness": "릴리스 신선도", - "outbox-delivery": "메시지 발송", - "memento-hygiene": "메모리 위생", - "api-friction": "API 마찰", - "kanban-flow": "칸반 흐름", - "dispatch-retry": "디스패치 재시도", - "session-pattern": "세션 오류", - "log-signal": "운영 로그", - "automation-candidate": "자동화 후보", - }[category] || "루틴 후보"; - const prefix = isErrorPattern || category === "outbox-delivery" || category === "api-friction" - ? "실패 요약" - : "성공 요약"; - const action = prefix === "실패 요약" - ? "자동 복구나 알림 후보입니다" - : "수동 확인 없이 루틴화할 후보입니다"; - return `${prefix}: ${categoryLabel} 패턴이 ${count}회 반복되어 ${action}. 최근 근거: ${latestSummary}`; -} - -function candidateAssessment(patternId, candidate) { - const isErrorPattern = Boolean(candidate.has_error_evidence) || - (candidate.examples || []).some((example) => example.weight === 2); - const category = normalizeCategory(candidate.category); - const categoryProfiles = { - "routine-candidate": { - suggestedAutomation: isErrorPattern - ? "반복 실패 루틴에 대한 자동 재시도 또는 알림" - : "반복 패턴을 자동 처리하는 예약 루틴", - before: "반복 루틴 근거는 수동 로그 확인 후에만 보입니다.", - after: "제한된 루틴/규칙이 반복 패턴을 처리하거나 쿨다운을 두고 에스컬레이션합니다.", - files: ["routines/monitoring/*.js", "src/services/routines/*"], - sideEffects: "루틴 또는 규칙 경로가 추가될 수 있으므로 쿨다운, 중복 제거, Discord 노이즈를 검증해야 합니다.", - verification: "대상 루틴 로더 테스트를 실행하고 체크포인트 후보 필드를 확인합니다.", - }, - "release-freshness": { - suggestedAutomation: "오래된 배포, 버전, 생성 인벤토리 신호를 감지하는 릴리스 신선도 모니터", - before: "버전이나 생성 문서가 오래된 뒤에야 사람이 릴리스 드리프트를 발견합니다.", - after: "신선도 점검이 오래된 릴리스 상태가 누적되기 전에 업데이트 경로를 제안합니다.", - files: ["scripts/*release*", "src/cli/*", "docs/generated/worker-inventory.md"], - sideEffects: "읽기 전용 신선도 점검이 추가될 수 있으며 자동 게시, 태깅, 배포는 피해야 합니다.", - verification: "스크립트 검사와 릴리스 부작용이 없음을 증명하는 신선도 픽스처를 실행합니다.", - }, - "outbox-delivery": { - suggestedAutomation: "반복 전송 또는 큐 적재 실패를 감지하는 메시지 아웃박스 전달 모니터", - before: "전달 실패 반복 패턴을 찾으려면 DB/로그를 사람이 직접 확인해야 합니다.", - after: "반복 아웃박스 실패가 명확한 전달 수정 경로를 가진 제한된 제안으로 묶입니다.", - files: ["src/services/message_outbox.rs", "src/services/routines/discord_log.rs", "src/services/discord/*"], - sideEffects: "알림 재시도 또는 폴백 동작이 바뀔 수 있으므로 중복 제거와 전달 대상을 검증해야 합니다.", - verification: "아웃박스/루틴 대상 테스트를 실행하고 전달 실패 픽스처를 확인합니다.", - }, - "memento-hygiene": { - suggestedAutomation: "반복되는 메모리 품질 또는 라우팅 문제를 요약하는 Memento 위생 다이제스트 모니터", - before: "메모리 위생 문제는 원문 노트에 흩어져 있어 안전하게 조치하기 어렵습니다.", - after: "토픽/횟수/최신 예시 다이제스트만 제한된 제안으로 변환합니다.", - files: ["src/services/memory/*", "src/services/routines/store.rs", "routines/monitoring/*.js"], - sideEffects: "이 루틴은 원문 메모리 본문을 읽거나 쓰면 안 되며 다이제스트 절단을 검증해야 합니다.", - verification: "추천기 다이제스트 픽스처를 실행하고 프롬프트에 원문 메모리 본문이 없는지 확인합니다.", - }, - "api-friction": { - suggestedAutomation: "반복되는 문서 또는 엔드포인트 워크플로 붕괴를 감지하는 API 마찰 모니터", - before: "API 마찰이 에이전트 응답에서 반복되지만 통합 개선 제안으로 이어지지 않습니다.", - after: "반복 마찰 마커가 지문별로 묶이고 문서 및 검증 가이드가 함께 제안됩니다.", - files: ["src/services/api_friction.rs", "src/server/routes/*", "docs/*"], - sideEffects: "문서 또는 API 라우팅이 바뀔 수 있으며 DB 직접 우회가 도입되지 않았는지 검증해야 합니다.", - verification: "API 마찰 파싱 테스트와 대상 루틴 추천기 픽스처를 실행합니다.", - }, - "kanban-flow": { - suggestedAutomation: "정체되거나 차단된 칸반 흐름을 감지하고 다음 조치 후보를 생성하는 칸반 흐름 모니터", - before: "오래 멈춘 카드와 차단 사유를 사람이 수동으로 훑어야 합니다.", - after: "정체/차단 패턴이 에이전트별·상태별로 묶여 자동화 후보 카드로 올라옵니다.", - files: ["src/server/routes/kanban.rs", "src/db/kanban_cards/*", "routines/monitoring/*.js"], - sideEffects: "카드 상태 전이는 직접 수행하지 않고, 후보 카드와 검증 지시만 생성해야 합니다.", - verification: "칸반 observation fixture와 executor ready-card 회귀 테스트를 실행합니다.", - }, - "dispatch-retry": { - suggestedAutomation: "반복 재시도되는 디스패치 흐름을 감지하는 dispatch retry monitor", - before: "반복 재시도/실패 디스패치는 로그나 DB를 직접 확인해야 합니다.", - after: "from/to agent와 status별 반복 재시도 패턴이 후보로 묶입니다.", - files: ["src/server/routes/dispatches/*", "src/db/auto_queue/*", "policies/timeouts/*"], - sideEffects: "재시도 정책은 중복 디스패치와 알림 폭증을 만들 수 있으므로 dry-run 검증이 필요합니다.", - verification: "dispatch/outbox 관련 테스트와 retry fixture를 실행합니다.", - }, - "session-pattern": { - suggestedAutomation: "반복 에러가 발생하는 agent/session 패턴을 감지하고 개선 후보를 만드는 세션 패턴 모니터", - before: "같은 agent 오류가 대화 로그에 흩어져 후속 개선으로 이어지기 어렵습니다.", - after: "agent별 반복 오류가 bounded observation으로 점수화되어 개선 후보가 됩니다.", - files: ["src/db/session_transcripts.rs", "src/services/discord/*", "routines/monitoring/*.js"], - sideEffects: "세션 본문 원문을 후보에 과도하게 싣지 않고 집계와 최신 예시만 사용해야 합니다.", - verification: "session_transcripts observation과 recommender ROI gate 테스트를 실행합니다.", - }, - "log-signal": { - suggestedAutomation: "audit_logs와 kanban_audit_logs 반복 패턴을 감지하는 운영 로그 모니터", - before: "운영 로그의 반복 action/source 패턴은 analytics 화면에서만 보이고 자동화 후보로 이어지지 않습니다.", - after: "반복 로그 패턴이 category별 후보로 묶이고 ROI gate에 포함됩니다.", - files: ["src/services/analytics/*", "src/services/routines/store.rs", "src/kanban/audit.rs"], - sideEffects: "로그는 읽기 전용 근거로만 사용하고 audit log write path는 변경하지 않아야 합니다.", - verification: "store.rs observation 테스트와 recommender ROI gate 테스트를 실행합니다.", - }, - "automation-candidate": { - suggestedAutomation: "이미 준비된 automation-candidate 카드의 반복 실행 상태를 관리하는 후보 루프 모니터", - before: "준비된 자동화 후보 카드가 executor까지 이어졌는지 사람이 확인해야 합니다.", - after: "ready/done 후보 카드가 observation으로 들어와 executor/suppression 경로를 안정화합니다.", - files: ["src/services/automation_candidate_materializer.rs", "src/services/routines/store.rs", "routines/monitoring/automation-candidate-executor.js"], - sideEffects: "후보 실행은 allowed_write_paths와 iteration result API 계약 안에서만 진행해야 합니다.", - verification: "automation-candidate executor tests와 materializer tests를 실행합니다.", - }, - }; - const profile = categoryProfiles[category] || categoryProfiles["routine-candidate"]; - const recommendedExecution = category === "routine-candidate" && candidate.score < 90 - ? "rule" - : "agent"; - const title = patternId.replace(/\s+/g, " ").slice(0, 96); - const allowedWritePaths = candidateProgramAllowedPaths(profile.files); - return { - suggestedAutomation: profile.suggestedAutomation, - recommendedExecution, - outcomeSummary: buildOutcomeSummary(patternId, candidate, isErrorPattern, category), - beforeAfter: { - before: profile.before, - after: profile.after, - }, - expectedFiles: profile.files, - expectedSideEffects: profile.sideEffects, - verificationMethod: profile.verification, - gatedHandoff: { - status: "requires_human_approval", - kanban_card_draft: { - title: `[automation-candidate] ${title}`, - category, - acceptance: [ - "브랜치/카드 변경 전에 제안 승인이 완료되어야 합니다", - "루틴은 제한적이고 멱등적으로 유지되어야 합니다", - "검증 명령 또는 픽스처가 PR/카드에 기록되어야 합니다", - ], - }, - pr_draft: { - title: `자동화 후보 구현: ${title}`, - body_hint: "Before/After, 예상 파일, 부작용, 검증 근거를 포함합니다.", - }, - side_effects: "사람이 게이트된 핸드오프를 명시적으로 승인하기 전까지는 없음", - }, - materializeRequest: { - endpoint: "POST /api/automation-candidates", - body: { - title: `[automation-candidate] ${title}`, - source: "routine_recommender", - dedupe_key: patternId, - start_ready: false, - program: { - repo_dir: REPO_DIR_PLACEHOLDER, - allowed_write_paths: allowedWritePaths, - metric_name: candidateMetricName(category), - metric_target: 0, - metric_direction: "lower_is_better", - final_gate: "manual_review", - iteration_budget: 3, - }, - }, - needs_human_fields: ["program.repo_dir"], - }, - }; -} - -function candidateProgramAllowedPaths(files) { - const out = []; - for (const file of files || []) { - let path = String(file || "").trim(); - if (!path || path.startsWith("/") || path.includes("..")) continue; - const star = path.indexOf("*"); - if (star >= 0) path = path.slice(0, star); - path = path.replace(/\/+$/, ""); - if (!path) continue; - if (!out.includes(path)) out.push(path); - } - return out.length ? out : ["src"]; -} - -function candidateMetricName(category) { - return { - "routine-candidate": "manual_routine_touch_count", - "release-freshness": "release_drift_count", - "outbox-delivery": "outbox_delivery_failure_count", - "memento-hygiene": "memento_hygiene_issue_count", - "api-friction": "api_friction_count", - "kanban-flow": "kanban_stuck_or_blocked_count", - "dispatch-retry": "dispatch_retry_count", - "session-pattern": "session_error_pattern_count", - "log-signal": "audit_log_pattern_count", - "automation-candidate": "automation_candidate_backlog_count", - }[category] || "automation_friction_count"; -} - -function candidateDecisionSummary(patternId, candidate, assessment) { - const score = candidate.score || 0; - const delta = candidate.score_delta_last_tick || 0; - const evidenceCount = candidate.evidence_count || 0; - const gate = candidateGate(candidate); - const evidenceSummary = topEvidenceSummaryForCandidate(candidate); - return `선택 이유: ${patternId} 후보가 category=${normalizeCategory(candidate.category)}, score=${score}, delta=${delta}, evidence=${evidenceCount}, gate=${gate.minScore}/${gate.minEvidence}로 ROI 기준을 충족했습니다. ${candidateSourceSummary(candidate)}. ${assessment.outcomeSummary} 핵심 근거: ${evidenceSummary}`; -} - -function convergenceSummary(candidate) { - if (!candidate.last_recommended_at && !candidate.last_recommendation_hash) { - return "이 후보는 이전 추천 이력이 없습니다. 현재 tick의 새 근거를 중심으로 평가합니다."; - } - const parts = []; - if (candidate.last_recommended_at) { - parts.push(`이전 추천 시각=${candidate.last_recommended_at}`); - } - if (candidate.last_recommendation_hash) { - parts.push(`이전 추천 해시=${candidate.last_recommendation_hash}`); - } - if (candidate.cooldown_until) { - parts.push(`쿨다운 종료=${candidate.cooldown_until}`); - } - return `이 후보는 이전 추천/체크포인트 이력이 있습니다. ${parts.join(", ")}`; -} - -function buildPrompt(escalation) { - const { patternId, candidate } = escalation; - const evidenceLines = (candidate.examples || []) - .map((ex, i) => `${i + 1}. [${ex.timestamp || "?"}] ${ex.summary || ""} (occurrences=${ex.occurrences || 1})`) - .join("\n"); - - const { - suggestedAutomation, - recommendedExecution, - beforeAfter, - expectedFiles, - expectedSideEffects, - verificationMethod, - outcomeSummary, - gatedHandoff, - materializeRequest, - } = candidateAssessment(patternId, candidate); - const decisionSummary = candidateDecisionSummary(patternId, candidate, { - outcomeSummary, - }); - const handoffAcceptance = (gatedHandoff.kanban_card_draft.acceptance || []) - .map((item) => `- ${item}`) - .join("\n"); - - const raw = `# 자동화 후보 추천 - -패턴: ${patternId} -카테고리: ${normalizeCategory(candidate.category)} -점수: ${candidate.score}/100 -근거: ${candidate.evidence_count}회 발생 (최초: ${candidate.first_seen_at || "?"}, 최신: ${candidate.last_seen_at || "?"}) - -## 근거 예시 -${evidenceLines || "(기록 없음)"} - -## 성공/실패 한 줄 요약 -${outcomeSummary} - -## 선택 판단 근거 -${decisionSummary} - -## 루트 기반 JS 자동화 패턴 탐지 가이드 -- 이 제안은 runtime이 제공한 bounded observation, checkpoint, automation inventory만 근거로 판단합니다. -- 같은 증상이 아니라 같은 루트 원인 또는 같은 수동 작업이 반복되는지 pattern/category/count/first-last/example을 연결해 설명합니다. -- 단순 빈도만 보지 말고 최근성, 실패 weight, 운영 ROI, 부작용, 이미 구현/억제된 자동화와의 중복 가능성을 함께 평가합니다. -- 규칙으로 충분한 deterministic retry/check/threshold인지, 문맥 판단과 코드 변경 설계가 필요한 agent 주도 자동화인지 구분합니다. -- 근거가 부족하거나 오탐 가능성이 높으면 자동화 보류로 결론을 내립니다. - -## 이전 작업/체크포인트 수렴 대응 -${convergenceSummary(candidate)} -- 이전 추천, checkpoint, inventory를 보고 같은 결론에 수렴하더라도 "추가 개선 없음"으로만 끝내지 않습니다. -- 반복 제안이 되지 않게 새 근거, 새 실패 양상, 새 검증 경로, 기존 자동화의 누락 지점 중 하나를 반드시 확인합니다. -- 목표 달성을 위해 대체 탐색 경로를 최소 1개 제안합니다: observation 스키마 보강, 로그 수집 위치 변경, suppression/inventory 오탐 점검, rule 기반 작은 자동화, agent 주도 핸드오프 분리, 검증 metric 추가. -- 여전히 보류라면 다음 tick에서 어떤 근거를 더 수집해야 하는지 한 줄로 남깁니다. - -## 이미 자동화됨 판단 기준 -- automation inventory, checkpoint suppression, candidate state 중 하나가 같은 pattern_id 또는 wildcard prefix와 매칭되어야 합니다. -- status가 implemented/suppressed/rejected이면 이미 처리된 것으로 봅니다. -- status가 accepted인 경우에는 automation_ref 또는 source_ref 같은 지속 증거(durable proof)가 있을 때만 이미 자동화된 것으로 봅니다. -- 지속 증거가 없는 accepted 또는 단순 이전 추천 이력은 자동화 완료가 아니라 "이전 제안"으로만 봅니다. -- 이름이 비슷한 정도로 중복 처리하지 말고 pattern/category/evidence_ref가 같은 루트 원인이나 같은 반복 수동 작업을 가리키는지 확인합니다. - -## 자료 범위 및 검색 정책 -- 기본 판단 근거는 PostgreSQL-backed routine observation/checkpoint/inventory, 로그 기반 observation, memento-mcp digest observation입니다. -- 유저 프롬프트와 메모리는 runtime이 구조화 observation 또는 승인된 digest로 제공한 범위만 사용합니다. -- 외부 웹자료 검색은 기본 동작이 아닙니다. 최신 외부 문서/버전 확인이 꼭 필요하면 opt-in evidence provider 필요성을 제안만 합니다. -- 이 프롬프트 안에서 임의 웹 검색, 지속 크롤링, memento 쓰기, DB 직접 우회는 수행하지 않습니다. - -## Before / After -- Before: ${beforeAfter.before} -- After: ${beforeAfter.after} - -## 예상 구현 파일 -${expectedFiles.map((file) => `- ${file}`).join("\n")} - -## 판단 -- 제안 자동화: ${suggestedAutomation} -- 권장 실행 방식: ${recommendedExecution} (규칙 기반 vs 에이전트 주도) -- 예상 부작용: ${expectedSideEffects} - -## 검증 방법 -${verificationMethod} - -## 게이트된 핸드오프 초안 -- 상태: ${gatedHandoff.status} -- Kanban 제목: ${gatedHandoff.kanban_card_draft.title} -- PR 제목: ${gatedHandoff.pr_draft.title} -- 핸드오프 부작용: ${gatedHandoff.side_effects} -${handoffAcceptance} - -## 자동화 후보 카드 생성 초안 -아래 JSON은 탐지기(detector)가 자동으로 카드를 생성할 때 사용합니다. -\`program.repo_dir\`에 현재 워크스페이스의 실제 절대 경로를 채워 kv_meta를 기록해주세요. -${JSON.stringify(materializeRequest, null, 2)} - -## 지시사항 -에이전트가 도출한 내용은 반드시 한국어로 작성합니다. 이 자동화를 구현할 가치가 있는지 평가하고 다음을 제공합니다: -1. 자동화 여부(예 / 아니오 / 보류), 신뢰도, 그리고 이유 -2. 루트 원인 또는 반복 수동 작업 가설과 그 근거 -3. 구현한다면 제안 구현 방식, rule-vs-agent 선택 이유, 영향 파일/루틴 -4. 성공/실패에 대한 한 줄 요약 -5. 예상 부작용, 오탐/중복 억제 방법, 자동화 동작 검증 방법 -6. 이전 추천과 같은 결론이면 다른 탐색/진행 방식 또는 추가로 수집할 근거 - -## kv_meta 기록 지침 - -평가 완료 후 **자동화 가치가 있다(예 / 부분 예)** 고 판단한 경우에만 아래를 실행합니다. - -**key** (TTL: 48h): -\`routine_observation:candidate_review:${patternId}\` - -**value** (JSON): -\`\`\`json -{ - "score": ${candidate.score}, - "evidence_count": ${candidate.evidence_count || 0}, - "last_seen_at": "${candidate.last_seen_at || ""}", - "category": "${normalizeCategory(candidate.category)}", - "suggested_automation": "<평가에서 도출한 제안 자동화 내용>", - "outcome_summary": "<평가에서 도출한 한 줄 결과 요약>", - "materialize_request": <위 '자동화 후보 카드 생성 초안' JSON에서 program.repo_dir을 실제 절대 경로로 채운 버전> -} -\`\`\` - -\`program.repo_dir\`는 현재 워크스페이스에서 확인한 agentdesk 리포지터리 절대 경로로 채웁니다. -자동화 가치가 없거나 보류라면 kv_meta를 기록하지 않습니다. -구현, 파일 수정, 서비스 재시작, memento 쓰기, PR/카드/이슈 생성은 금지합니다.`; - - if (utf8ByteLength(raw) <= PROMPT_CAP_BYTES) { - return raw; - } - - // Trim examples first while preserving policy and decision sections. - const evidenceHeader = "## 근거 예시\n"; - const [header, restWithEvidence] = raw.split(evidenceHeader); - const evidenceBlock = evidenceLines || "(기록 없음)"; - const rest = restWithEvidence.slice(evidenceBlock.length); - const budget = - PROMPT_CAP_BYTES - - utf8ByteLength(header) - - utf8ByteLength(evidenceHeader) - - utf8ByteLength(rest) - - 20; - const trimmedEvidence = - budget > 32 - ? truncateUtf8(evidenceBlock, budget) - : "(근거 예시는 크기 제한으로 생략됨)"; - return header + evidenceHeader + trimmedEvidence + rest; -} - -// --- Checkpoint size guard --- - -function guardCheckpointSize(cp) { - const json = JSON.stringify(cp); - if (json.length <= CHECKPOINT_CAP_BYTES) return cp; - - // Prune least-recently-observed candidates first. - const entries = Object.entries(cp.candidates).sort( - ([, a], [, b]) => String(a.last_seen_at || "").localeCompare(String(b.last_seen_at || "")) - ); - let pruned = 0; - for (const [patternId] of entries) { - if (JSON.stringify(cp).length <= CHECKPOINT_CAP_BYTES) break; - if ( - cp.candidates[patternId].state === "observing" - ) { - delete cp.candidates[patternId]; - pruned++; - } - } - - // Trim examples on remaining candidates - for (const candidate of Object.values(cp.candidates)) { - if (candidate.examples && candidate.examples.length > 1) { - candidate.examples = candidate.examples.slice(-1); - } - } - - return cp; -} - -// --- Main tick --- - -agentdesk.routines.register({ - name: "Automation Candidate Recommender", - - tick(ctx) { - const nowStr = nowIso(ctx.now); - const cp = loadCheckpoint(ctx.checkpoint); - const observations = ctx.observations || []; - const inventory = ctx.automationInventory || []; - - resetDailyCapIfNeeded(cp, nowStr); - pruneExpiredSuppressions(cp, nowStr); - pruneSeenEvidence(cp, nowStr); - expireStaleCandidates(cp, nowStr); - - const suppressedSet = buildSuppressedSet(cp, inventory, observations); - const droppedCandidates = dropSuppressedCandidates(cp, suppressedSet); - const diversityMode = cp.diversity_mode_ticks_remaining > 0; - const scoringReport = scoreObservations(cp, observations, suppressedSet, nowStr, diversityMode); - - // Update category scored history for diversity mode (keep last DIVERSITY_LOOKBACK_TICKS entries) - cp.stats.category_scored_history.push(scoringReport.category_scored_this_tick); - if (cp.stats.category_scored_history.length > DIVERSITY_LOOKBACK_TICKS) { - cp.stats.category_scored_history.shift(); - } - - // P0-E: saturation detection & re-optimization - updateEmaScored(cp, scoringReport.scored); - updateFastFailTicks(cp, scoringReport, observations.length); - updateSaturationTicks(cp); - const reoptCheck = shouldTriggerReopt(cp); - if (reoptCheck.trigger) { - triggerReopt(cp, nowStr); - } else if (cp.diversity_mode_ticks_remaining > 0) { - cp.diversity_mode_ticks_remaining--; - } - - cp.stats.ticks++; - cp.last_tick_at = nowStr; - - const escalation = findEscalationCandidate(cp, nowStr); - - if (!escalation) { - const activeCandidates = Object.values(cp.candidates).filter( - (c) => c.state === "observing" || c.state === "recommended" - ).length; - const summary = `관찰=${observations.length}, 후보=${activeCandidates}, 오늘 추천=${cp.stats.recommendations_today}`; - const outcomeSummary = `성공 요약: 새 자동화 추천 후보 없음 (${summary})`; - const decisionSummary = noEscalationReason(cp, nowStr); - const topEvidenceSummary = topCandidateEvidenceSummary(cp); - const suppressedSummary = suppressionSummary(scoringReport.suppressed, droppedCandidates); - const scoringSummary = [ - `scored=${scoringReport.scored}`, - `deduped=${scoringReport.deduped}`, - `suppressed=${scoringReport.suppressed.length + droppedCandidates.length}`, - `ema_scored=${cp.ema_scored.toFixed(3)}`, - `saturation_ticks=${cp.saturation_ticks}`, - `fast_fail_ticks=${cp.fast_fail_ticks}`, - `reopt_count=${cp.reopt_count}`, - diversityMode ? `diversity_mode_remaining=${cp.diversity_mode_ticks_remaining}` : null, - reoptCheck.trigger ? `reopt_triggered=${reoptCheck.reason}` : null, - ].filter(Boolean).join(", "); - return { - action: "complete", - result: { - status: "ok", - summary, - outcome_summary: outcomeSummary, - decision_summary: decisionSummary, - top_evidence_summary: topEvidenceSummary, - suppression_summary: suppressedSummary, - scoring_summary: scoringSummary, - observation_count: observations.length, - active_candidate_count: activeCandidates, - recommendations_today: cp.stats.recommendations_today, - reopt_count: cp.reopt_count, - }, - checkpoint: guardCheckpointSize(cp), - lastResult: outcomeSummary, - }; - } - - const prompt = buildPrompt(escalation); - markRecommended(cp, escalation, nowStr); - - return { - action: "agent", - prompt, - checkpoint: guardCheckpointSize(cp), - }; - }, -}); diff --git a/routines/monitoring/automation-executor.js b/routines/monitoring/automation-executor.js deleted file mode 100644 index a5edd991d0..0000000000 --- a/routines/monitoring/automation-executor.js +++ /dev/null @@ -1,242 +0,0 @@ -// Automation Executor -// Reads candidate_approved:* observations and dispatches GitHub Issue + Kanban card -// creation prompts to the agent. Dedup via candidate_dispatched:* observations and -// checkpoint dispatched_signatures. A signature is marked dispatched only after -// durable candidate_dispatched kv_meta is observed. - -const DISPATCHED_TTL_DAYS = 7; -const DISPATCH_RETRY_MS = 60 * 60 * 1000; // re-emit at most once per hour per candidate -const MAX_DISPATCH_RETRIES = 5; // give up and mark stalled after this many no-shows -const CHECKPOINT_VERSION = 1; - -// --- Checkpoint helpers --- - -function emptyCheckpoint() { - return { - version: CHECKPOINT_VERSION, - dispatched_signatures: {}, // signature -> dispatched_at ISO string (TTL 7d) - pending_dispatches: {}, // signature -> { attempt_count, last_attempted_at, first_attempted_at } - stats: { - ticks: 0, - dispatched: 0, - skipped_already_dispatched: 0, - stalled_candidates: 0, - }, - }; -} - -function loadCheckpoint(raw) { - if (!raw || typeof raw !== "object" || raw.version !== CHECKPOINT_VERSION) { - return emptyCheckpoint(); - } - const cp = Object.assign(emptyCheckpoint(), raw); - cp.dispatched_signatures = raw.dispatched_signatures || {}; - cp.pending_dispatches = raw.pending_dispatches || {}; - cp.stats = Object.assign(emptyCheckpoint().stats, raw.stats || {}); - return cp; -} - -function nowIso(now) { - return typeof now === "string" ? now : now.toISOString ? now.toISOString() : String(now); -} - -function validIso(value) { - const timestamp = new Date(value || "").getTime(); - return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null; -} - -function observationKey(obs) { - if (typeof obs.key === "string") return obs.key; - if (typeof obs.evidence_ref === "string") { - if (obs.evidence_ref.startsWith("kv_meta:routine_observation:")) { - return obs.evidence_ref.slice("kv_meta:".length); - } - if (obs.evidence_ref.startsWith("routine_observation:")) { - return obs.evidence_ref; - } - } - return ""; -} - -function observationSignature(obs, prefix) { - const key = observationKey(obs); - return key.startsWith(prefix) ? key.slice(prefix.length) : null; -} - -function observationPayload(obs) { - return obs.value && typeof obs.value === "object" ? obs.value : obs; -} - -function isRecentIso(value, nowStr, maxAgeMs) { - const timestamp = new Date(value || "").getTime(); - if (!Number.isFinite(timestamp)) return false; - return new Date(nowStr).getTime() - timestamp < maxAgeMs; -} - -function observationDispatchedAt(obs, fallback) { - const payload = observationPayload(obs); - return ( - validIso(payload.dispatched_at) || - validIso(payload.timestamp) || - validIso(obs.timestamp) || - fallback - ); -} - -// --- Prune expired dispatched_signatures --- - -function pruneDispatched(cp, nowStr) { - const cutoffMs = DISPATCHED_TTL_DAYS * 24 * 3600 * 1000; - const cutoff = new Date(nowStr).getTime() - cutoffMs; - for (const [sig, dispatchedAt] of Object.entries(cp.dispatched_signatures)) { - if (new Date(dispatchedAt).getTime() < cutoff) { - delete cp.dispatched_signatures[sig]; - } - } - // Prune stale pending entries (same TTL as dispatched) - for (const [sig, entry] of Object.entries(cp.pending_dispatches)) { - if (new Date(entry.first_attempted_at || 0).getTime() < cutoff) { - delete cp.pending_dispatches[sig]; - } - } -} - -// --- Build dispatch prompt --- - -function buildDispatchPrompt(signature, candidate) { - const lines = [ - "승인된 자동화 후보에 대한 GitHub Issue 및 Kanban 카드 생성을 요청합니다.", - "", - `**후보 ID (signature)**: \`${signature}\``, - `**카테고리**: ${candidate.category || "routine-candidate"}`, - `**점수**: ${candidate.score || 0}`, - `**승인 시각**: ${candidate.approved_at || "(알 수 없음)"}`, - `**제안된 자동화**: ${candidate.suggested_automation || "(없음)"}`, - `**결과 요약**: ${candidate.outcome_summary || "(없음)"}`, - "", - "---", - "## 작업 지침", - "", - "1. **GitHub Issue 생성**: `kunkunGames/agentdesk` 저장소에 이슈를 생성해주세요.", - " - 제목: `[자동화 후보] " + (candidate.category || "routine-candidate") + ": " + signature + "`", - " - 레이블: `automation-candidate`", - " - 본문: 위 후보 정보 포함", - "", - "2. **Kanban 카드 생성**: kanban-writer 스킬을 사용해 카드를 생성해주세요.", - "", - "3. **완료 후 kv_meta 기록**:", - "```", - `routine_observation:candidate_dispatched:${signature}`, - "```", - "값(JSON): `{\"signature\":\"" + signature + "\",\"dispatched_at\":\"<현재시각ISO>\",\"category\":\"" + (candidate.category || "routine-candidate") + "\"}`", - "TTL: 7d (604800초)", - "", - "이 kv_meta 기록은 executor 중복 방지 및 recommender 재추천 억제에 사용됩니다.", - ]; - return lines.join("\n"); -} - -// --- Main tick --- - -agentdesk.routines.register({ - name: "Automation Executor", - - tick(ctx) { - const nowStr = nowIso(ctx.now); - const cp = loadCheckpoint(ctx.checkpoint); - const observations = ctx.observations || []; - - pruneDispatched(cp, nowStr); - - // Find candidate_approved observations - const approvedPrefix = "routine_observation:candidate_approved:"; - const dispatchedPrefix = "routine_observation:candidate_dispatched:"; - const approvedObs = observations.filter((obs) => observationSignature(obs, approvedPrefix)); - - // Find already-dispatched signatures (from kv_meta observations + checkpoint) - const dispatchedFromObs = new Map(); - for (const obs of observations) { - const signature = observationSignature(obs, dispatchedPrefix); - if (signature) { - dispatchedFromObs.set(signature, observationDispatchedAt(obs, nowStr)); - } - } - for (const [signature, dispatchedAt] of dispatchedFromObs.entries()) { - const current = cp.dispatched_signatures[signature]; - if (!current || new Date(dispatchedAt).getTime() < new Date(current).getTime()) { - cp.dispatched_signatures[signature] = dispatchedAt; - } - } - - cp.stats.ticks++; - - if (approvedObs.length === 0) { - return { - action: "complete", - result: { - status: "ok", - summary: "승인된 후보 없음", - approved_count: 0, - }, - checkpoint: cp, - }; - } - - // Filter to candidates not yet dispatched - const toDispatch = []; - for (const obs of approvedObs) { - const signature = observationSignature(obs, approvedPrefix); - const candidate = observationPayload(obs); - - if (dispatchedFromObs.has(signature) || cp.dispatched_signatures[signature]) { - cp.stats.skipped_already_dispatched++; - continue; - } - - // Give up if LLM has repeatedly failed to write the dispatched marker. - const pending = cp.pending_dispatches[signature]; - if (pending && (pending.attempt_count || 0) >= MAX_DISPATCH_RETRIES) { - cp.stats.stalled_candidates = (cp.stats.stalled_candidates || 0) + 1; - continue; - } - // Throttle: don't re-emit within cooldown window. - if (pending && isRecentIso(pending.last_attempted_at, nowStr, DISPATCH_RETRY_MS)) { - cp.stats.skipped_already_dispatched++; - continue; - } - - toDispatch.push({ signature, candidate }); - } - - if (toDispatch.length === 0) { - return { - action: "complete", - result: { - status: "ok", - summary: `승인 후보 ${approvedObs.length}건 모두 이미 처리됨`, - approved_count: approvedObs.length, - skipped: cp.stats.skipped_already_dispatched, - }, - checkpoint: cp, - }; - } - - // Dispatch first pending candidate; remaining handled on next ticks - const { signature, candidate } = toDispatch[0]; - const prevPending = cp.pending_dispatches[signature]; - cp.pending_dispatches[signature] = { - first_attempted_at: prevPending?.first_attempted_at || nowStr, - last_attempted_at: nowStr, - attempt_count: (prevPending?.attempt_count || 0) + 1, - }; - cp.stats.dispatched++; - - const prompt = buildDispatchPrompt(signature, candidate); - - return { - action: "agent", - prompt, - checkpoint: cp, - }; - }, -}); diff --git a/routines/monitoring/daily-log-digest.js b/routines/monitoring/daily-log-digest.js deleted file mode 100644 index b7f9b9c191..0000000000 --- a/routines/monitoring/daily-log-digest.js +++ /dev/null @@ -1,71 +0,0 @@ -// Daily dcserver Log Digest (#4263) -// -// QuickJS routines intentionally have no filesystem/network bridge. Match the -// existing monitoring frame by dispatching one fresh agent turn; the agent runs -// the deterministic sibling helper and its final response is posted through the -// routine Discord logger. - -const CHECKPOINT_VERSION = 1; - -function dayKey(now) { - const value = typeof now === "string" ? now : now.toISOString(); - const kst = new Date(new Date(value).getTime() + 9 * 60 * 60 * 1000); - return kst.toISOString().slice(0, 10); -} - -function loadCheckpoint(raw) { - if (!raw || raw.version !== CHECKPOINT_VERSION) { - return { version: CHECKPOINT_VERSION, last_dispatched_day: null }; - } - return { - version: CHECKPOINT_VERSION, - last_dispatched_day: raw.last_dispatched_day || null, - }; -} - -function buildPrompt(day) { - return [ - "# Daily dcserver log digest", - "", - `Digest day: ${day}`, - "", - "Run the repository-bundled deterministic helper:", - "```bash", - 'ROOT="${AGENTDESK_ROOT_DIR:-${ADK_REL:-$HOME/.adk/release}}"', - 'python3 "$ROOT/routines/monitoring/daily_log_digest.py"', - "```", - "", - "Return the helper stdout verbatim as your final response, with no preface or follow-up.", - "Do not call `gh issue create` directly. The helper writes pending drafts and its shared", - "gate permits posting only when a human has explicitly set", - "`AGENTDESK_LOG_DIGEST_CREATE_ISSUE=confirmed` and marked that specific draft", - "with an adjacent `.approved` file; the default is `off`.", - ].join("\n"); -} - -agentdesk.routines.register({ - name: "Daily dcserver Log Digest", - - tick(ctx) { - const day = dayKey(ctx.now); - const checkpoint = loadCheckpoint(ctx.checkpoint); - if (checkpoint.last_dispatched_day === day) { - return { - action: "complete", - result: { - status: "already_dispatched", - summary: `daily log digest already dispatched for ${day}`, - }, - checkpoint, - }; - } - - checkpoint.last_dispatched_day = day; - return { - action: "agent", - prompt: buildPrompt(day), - lastResult: `daily log digest dispatched for ${day}`, - checkpoint, - }; - }, -}); diff --git a/routines/monitoring/daily_log_digest.py b/routines/monitoring/daily_log_digest.py deleted file mode 100644 index f3def1fbf5..0000000000 --- a/routines/monitoring/daily_log_digest.py +++ /dev/null @@ -1,393 +0,0 @@ -#!/usr/bin/env python3 -"""Aggregate the last day of dcserver logs and emit one human-review digest.""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import os -import re -import subprocess -from datetime import datetime, timedelta, timezone -from pathlib import Path -from typing import BinaryIO, Iterable - -from log_digest_issue_drafts import ( - CONFIRMED_APPROVAL, - DEFAULT_DAILY_THRESHOLD, - IssueDraft, - OpenIssue, - aggregate_normalized_signatures, - decide_issue_drafts, - extract_severity, - format_daily_summary, - maybe_post_approved_drafts, - write_pending_drafts, -) - - -REPOSITORY = "itismyfield/AgentDesk" -OPEN_ISSUE_LIMIT = 1000 -UNDATED_CHECKPOINT_VERSION = 3 -UNDATED_HEAD_FINGERPRINT_CAP = 65_536 -_LINE_TIMESTAMP_RE = re.compile( - r"(? Path: - """Resolve the runtime root used by the release launcher.""" - - # src/config.rs owns AGENTDESK_ROOT_DIR as the canonical runtime override. - # ADK_REL remains a compatibility fallback because deploy-release.sh derives - # it from that override and the routine launcher may pass only ADK_REL. - configured = os.environ.get("AGENTDESK_ROOT_DIR") or os.environ.get("ADK_REL") - if configured: - return Path(configured).expanduser() - return Path.home() / ".adk" / "release" - - -def dcserver_log_paths(root: Path) -> list[Path]: - """Return internal stdout rotations plus the actual launchd stderr path.""" - - logs = root / "logs" - stdout = logs / "dcserver.stdout.log" - paths = [stdout] - paths.extend(logs / f"dcserver.stdout.log.{index}" for index in range(1, 11)) - paths.append(logs / "dcserver.launchd.stderr.log") - return paths - - -def _parse_line_timestamp(line: str) -> datetime | None: - match = _LINE_TIMESTAMP_RE.search(line) - if not match: - return None - value = match.group(1).replace(",", ".") - if value.endswith("Z"): - value = value[:-1] + "+00:00" - try: - parsed = datetime.fromisoformat(value) - except ValueError: - return None - if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=timezone.utc) - return parsed.astimezone(timezone.utc) - - -def _load_undated_offsets( - checkpoint_path: Path | None, -) -> tuple[dict[str, dict[str, int | str]], list[str]]: - if checkpoint_path is None or not checkpoint_path.is_file(): - return {}, [] - try: - payload = json.loads(checkpoint_path.read_text(encoding="utf-8")) - if not isinstance(payload, dict): - raise ValueError("unsupported checkpoint shape") - files = payload.get("files") - if payload.get("version") != UNDATED_CHECKPOINT_VERSION or not isinstance(files, dict): - raise ValueError("unsupported checkpoint shape") - offsets: dict[str, dict[str, int | str]] = {} - for path, entry in files.items(): - if not isinstance(path, str) or not isinstance(entry, dict): - raise ValueError("invalid checkpoint entry") - offsets[path] = { - "device": int(entry["device"]), - "inode": int(entry["inode"]), - "offset": int(entry["offset"]), - "head_hash": str(entry["head_hash"]), - "head_length": int(entry["head_length"]), - } - return offsets, [] - except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as error: - return {}, [f"could not load undated-line checkpoint {checkpoint_path}: {error}"] - - -def _save_undated_offsets( - checkpoint_path: Path | None, offsets: dict[str, dict[str, int | str]] -) -> list[str]: - if checkpoint_path is None: - return [] - try: - checkpoint_path.parent.mkdir(parents=True, exist_ok=True) - temporary = checkpoint_path.with_suffix(checkpoint_path.suffix + ".tmp") - temporary.write_text( - json.dumps( - {"version": UNDATED_CHECKPOINT_VERSION, "files": offsets}, - sort_keys=True, - ) - + "\n", - encoding="utf-8", - ) - temporary.replace(checkpoint_path) - except OSError as error: - return [f"could not save undated-line checkpoint {checkpoint_path}: {error}"] - return [] - - -def _head_fingerprint(stream: BinaryIO, offset: int) -> tuple[int, str]: - head_length = min(offset, UNDATED_HEAD_FINGERPRINT_CAP) - stream.seek(0) - digest = hashlib.sha256(stream.read(head_length)).hexdigest() - return head_length, digest - - -def _watermark_matches(stream: BinaryIO, previous: dict[str, int | str]) -> bool: - offset = int(previous["offset"]) - head_length = int(previous["head_length"]) - if head_length != min(offset, UNDATED_HEAD_FINGERPRINT_CAP): - return False - stream.seek(0) - current = hashlib.sha256(stream.read(head_length)).hexdigest() - return current == previous["head_hash"] - - -def recent_log_lines( - paths: Iterable[Path], - since: datetime, - now: datetime, - *, - undated_checkpoint: Path | None = None, -) -> tuple[list[str], list[str]]: - """Read the 24h window, baselining unseen undated launchd files at EOF.""" - - lines: list[str] = [] - undated_offsets, warnings = _load_undated_offsets(undated_checkpoint) - found_any = False - for path in paths: - if not path.is_file(): - continue - found_any = True - try: - stat = path.stat() - include_undated = ( - path.name == "dcserver.launchd.stderr.log" - and datetime.fromtimestamp(stat.st_mtime, timezone.utc) >= since - ) - checkpoint_key = str(path.resolve()) - previous = undated_offsets.get(checkpoint_key) - with path.open("rb") as stream: - previous_offset = 0 - if previous is None and undated_checkpoint is not None: - # The first persisted observation establishes a watermark; - # pre-existing undated history has no reliable 24h timestamp. - previous_offset = stat.st_size - elif ( - previous is not None - and previous["device"] == stat.st_dev - and previous["inode"] == stat.st_ino - and 0 <= int(previous["offset"]) <= stat.st_size - and _watermark_matches(stream, previous) - ): - previous_offset = int(previous["offset"]) - stream.seek(0) - while raw_bytes := stream.readline(): - line_end = stream.tell() - raw_line = raw_bytes.decode("utf-8", errors="replace") - if extract_severity(raw_line) is None: - continue - line = raw_line.rstrip("\n") - timestamp = _parse_line_timestamp(line) - if timestamp is not None: - if since <= timestamp <= now + timedelta(minutes=5): - lines.append(line) - elif include_undated and line_end > previous_offset: - # Identity, offset, and head fingerprint make appended - # ranges eligible once. Rotation or detected rewrite - # restarts at byte zero; first observation baselines EOF. - lines.append(line) - if path.name == "dcserver.launchd.stderr.log": - final_offset = stream.tell() - # A rewrite beyond the cap that reproduces the first 64 KiB - # cannot be distinguished from an append. For append-only - # launchd logs, reproducing that prefix (or a SHA collision) - # after truncate/regrow is considered operationally negligible. - head_length, head_hash = _head_fingerprint(stream, final_offset) - undated_offsets[checkpoint_key] = { - "device": stat.st_dev, - "inode": stat.st_ino, - "offset": final_offset, - "head_length": head_length, - "head_hash": head_hash, - } - except OSError as error: - warnings.append(f"could not read {path}: {error}") - if not found_any: - warnings.append("no dcserver stdout or launchd stderr log files were found") - warnings.extend(_save_undated_offsets(undated_checkpoint, undated_offsets)) - return lines, warnings - - -def load_open_issues(repo: str) -> tuple[list[OpenIssue], str | None]: - command = [ - "gh", - "issue", - "list", - "--repo", - repo, - "--state", - "open", - "--limit", - str(OPEN_ISSUE_LIMIT), - "--json", - "number,title,body,url", - ] - try: - completed = subprocess.run(command, check=False, capture_output=True, text=True, timeout=30) - except (OSError, subprocess.TimeoutExpired) as error: - return [], f"open-issue dedup unavailable ({error}); drafts suppressed" - if completed.returncode != 0: - detail = completed.stderr.strip() or f"gh exited {completed.returncode}" - return [], f"open-issue dedup unavailable ({detail}); drafts suppressed" - try: - payload = json.loads(completed.stdout) - issues = [ - OpenIssue( - number=int(item["number"]), - title=str(item.get("title") or ""), - body=str(item.get("body") or ""), - url=str(item.get("url") or ""), - ) - for item in payload - ] - except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error: - return [], f"open-issue dedup response invalid ({error}); drafts suppressed" - if len(issues) == OPEN_ISSUE_LIMIT: - return ( - issues, - f"open-issue dedup may be truncated at {OPEN_ISSUE_LIMIT} results; drafts suppressed", - ) - return issues, None - - -def create_github_issue(repo: str, draft: IssueDraft) -> str: - if draft.path is None: - raise ValueError("approved issue draft must be written before posting") - completed = subprocess.run( - [ - "gh", - "issue", - "create", - "--repo", - repo, - "--title", - draft.title, - "--body-file", - str(draft.path), - ], - check=True, - capture_output=True, - text=True, - timeout=30, - ) - return completed.stdout.strip() - - -def positive_int(value: str) -> int: - parsed = int(value) - if parsed <= 0: - raise argparse.ArgumentTypeError("must be greater than zero") - return parsed - - -def threshold_from_env(value: str | None) -> tuple[int, str | None]: - configured = value if value is not None else str(DEFAULT_DAILY_THRESHOLD) - try: - return positive_int(configured), None - except (ValueError, argparse.ArgumentTypeError): - return ( - DEFAULT_DAILY_THRESHOLD, - "invalid AGENTDESK_LOG_DIGEST_THRESHOLD " - f"value {configured!r}; using default {DEFAULT_DAILY_THRESHOLD}", - ) - - -def parse_args() -> argparse.Namespace: - env_threshold, threshold_warning = threshold_from_env( - os.environ.get("AGENTDESK_LOG_DIGEST_THRESHOLD") - ) - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--root", type=Path, default=runtime_root()) - parser.add_argument("--repo", default=os.environ.get("AGENTDESK_LOG_DIGEST_REPO", REPOSITORY)) - parser.add_argument( - "--threshold", - type=positive_int, - default=None, - ) - parser.add_argument("--now", help="RFC3339 test/diagnostic override") - args = parser.parse_args() - if args.threshold is None: - args.threshold = env_threshold - args.threshold_warning = threshold_warning - else: - args.threshold_warning = None - return args - - -def main() -> int: - args = parse_args() - now = datetime.fromisoformat(args.now.replace("Z", "+00:00")) if args.now else datetime.now(timezone.utc) - if now.tzinfo is None: - now = now.replace(tzinfo=timezone.utc) - now = now.astimezone(timezone.utc) - since = now - timedelta(days=1) - window_label = f"{since:%Y-%m-%d %H:%M}–{now:%Y-%m-%d %H:%M} UTC" - - lines, warnings = recent_log_lines( - dcserver_log_paths(args.root), - since, - now, - undated_checkpoint=args.root - / "runtime" - / "daily-log-digest" - / "undated-line-offsets.json", - ) - if args.threshold_warning: - warnings.append(args.threshold_warning) - patterns = aggregate_normalized_signatures(lines) - open_issues, dedup_warning = load_open_issues(args.repo) - if dedup_warning: - warnings.append(dedup_warning) - decisions = decide_issue_drafts( - patterns, - open_issues, - threshold=args.threshold, - window_label=window_label, - dedup_available=dedup_warning is None, - ) - pending_dir = args.root / "runtime" / "pending-issue-drafts" / "daily-log-digest" - drafts = write_pending_drafts( - [decision.draft for decision in decisions if decision.draft is not None], - pending_dir, - ) - - approval_mode = os.environ.get("AGENTDESK_LOG_DIGEST_CREATE_ISSUE", "off") - post = maybe_post_approved_drafts( - drafts, - approval_mode, - lambda draft: create_github_issue(args.repo, draft), - ) - if approval_mode not in {"off", CONFIRMED_APPROVAL}: - warnings.append( - "invalid AGENTDESK_LOG_DIGEST_CREATE_ISSUE value ignored; use literal 'confirmed' or 'off'" - ) - elif approval_mode == CONFIRMED_APPROVAL and not post.attempted: - warnings.append(post.reason) - if post.created_urls: - warnings.append("human-confirmed issues created: " + ", ".join(post.created_urls)) - - print( - format_daily_summary( - patterns, - decisions, - drafts, - threshold=args.threshold, - window_label=window_label, - warnings=warnings, - ) - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/routines/monitoring/local_worktree_inventory.js b/routines/monitoring/local_worktree_inventory.js deleted file mode 100644 index f414967f7e..0000000000 --- a/routines/monitoring/local_worktree_inventory.js +++ /dev/null @@ -1,341 +0,0 @@ -// Local agent worktree inventory helper (#4684) -// -// Deterministic, READ-ONLY inventory of `.claude/worktrees/agent-*` git -// worktrees. QuickJS routines have no filesystem bridge, so the scheduled -// routine (`routines/local-worktree-gc.js`) dispatches one agent turn that runs -// this Node helper and returns its JSON stdout verbatim. -// -// SAFETY BY CONSTRUCTION: this module performs zero destructive operations. It -// never removes worktrees, prunes refs, deletes branches, or unlinks files. -// Every child-process call is a read-only git/du subcommand. There is no code -// path that deletes anything — "never deletes" is provable by inspection, not by -// trusting an LLM to obey natural-language instructions. Disposition labels are -// advisory classifications for a human/future prune step; this helper only reads -// and emits a schema-validated report. The #4595 lesson is enforced here: -// dirty, locked, unmerged, and unknown worktrees are always marked PRESERVE, so -// the exact uncommitted work a naive GC could destroy is protected. - -const fs = require("node:fs"); -const path = require("node:path"); -const { execFileSync } = require("node:child_process"); - -const SCHEMA_VERSION = 1; -const DEFAULT_AGED_ORPHAN_SECONDS = 7 * 24 * 60 * 60; // 7 days (issue proposal #2) -const AGENT_WORKTREE_PREFIX = "agent-"; - -// --- Read-only signal collectors (dependency-injected for tests) --- - -// Parse `git worktree list --porcelain` into { : {head, branch, locked, bare} }. -function parseWorktreeList(porcelain) { - const registered = {}; - let current = null; - for (const rawLine of String(porcelain).split("\n")) { - const line = rawLine.trimEnd(); - if (line.startsWith("worktree ")) { - current = { path: line.slice("worktree ".length), head: null, branch: null, locked: false }; - registered[current.path] = current; - } else if (!current) { - continue; - } else if (line.startsWith("HEAD ")) { - current.head = line.slice("HEAD ".length); - } else if (line.startsWith("branch ")) { - current.branch = line.slice("branch ".length); - } else if (line === "locked" || line.startsWith("locked ")) { - current.locked = true; - } else if (line === "") { - current = null; - } - } - return registered; -} - -function defaultDeps(repoDir) { - const gitRO = (args) => - execFileSync("git", ["-C", repoDir, ...args], { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - maxBuffer: 32 * 1024 * 1024, - }); - return { - // Read-only: list registered worktrees. - worktreeList: () => parseWorktreeList(gitRO(["worktree", "list", "--porcelain"])), - // Read-only: porcelain status of a specific worktree; non-empty => dirty. - statusPorcelain: (wtPath) => - execFileSync("git", ["-C", wtPath, "status", "--porcelain"], { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - maxBuffer: 32 * 1024 * 1024, - }), - // Read-only: is an ancestor of the integration ref? merge-base exits 0/1. - isMerged: (head, baseRef) => { - try { - execFileSync("git", ["-C", repoDir, "merge-base", "--is-ancestor", head, baseRef], { - stdio: "ignore", - }); - return true; - } catch { - return false; - } - }, - // Read-only: apparent-size disk usage in KiB. `du` mutates nothing. - sizeKb: (wtPath) => { - const out = execFileSync("du", ["-sk", wtPath], { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }); - const kb = parseInt(String(out).trim().split(/\s+/)[0], 10); - return Number.isFinite(kb) ? kb : null; - }, - }; -} - -// --- Pure classification (no I/O) --- - -// Given objective signals, decide an advisory disposition. NEVER a deletion -// authority: positive_ownership_proof is always false and destructive_actions -// stays 0. Dirty/locked/unmerged-recent/unknown => PRESERVE (the #4595 lesson). -function classifyWorktree(signals, opts = {}) { - const agedSeconds = - typeof opts.agedOrphanSeconds === "number" ? opts.agedOrphanSeconds : DEFAULT_AGED_ORPHAN_SECONDS; - const base = { positive_ownership_proof: false }; - - if (signals.worktree_state === "unknown" || signals.locked) { - return { ...base, disposition: "PRESERVE", reason: signals.locked ? "locked worktree" : "inspection unknown" }; - } - if (signals.worktree_state === "missing") { - // Registered but directory absent: a `git worktree prune` concern, never ours to delete. - return { ...base, disposition: "PRESERVE", reason: "registered worktree with missing directory" }; - } - if (signals.dirty) { - return { ...base, disposition: "PRESERVE", reason: "uncommitted changes present" }; - } - if (signals.merged === false) { - const aged = typeof signals.age_seconds === "number" && signals.age_seconds > agedSeconds; - if (aged) { - // Proposal #2: aged unmerged orphan. Flag for human review + archive-ref - // backup BEFORE any future removal. This helper never removes it. - return { - ...base, - disposition: "AGED_ORPHAN_REVIEW", - reason: `clean but unmerged, mtime older than ${agedSeconds}s; archive branch tip before any prune`, - }; - } - return { ...base, disposition: "PRESERVE", reason: "clean but unmerged and recent (possibly live work)" }; - } - if (signals.merged === true && signals.registered && !signals.dirty) { - // Proposal #1: clean AND merged AND registered => the session-verified safe - // reclaim condition. Still report-only; surfaced as a candidate, not deleted. - return { ...base, disposition: "SAFE_MERGED_CANDIDATE", reason: "clean and merged into integration ref" }; - } - return { ...base, disposition: "PRESERVE", reason: "no positive ownership proof" }; -} - -// --- Enumeration + report assembly --- - -function listAgentWorktreeDirs(worktreesRoot) { - let entries; - try { - entries = fs.readdirSync(worktreesRoot, { withFileTypes: true }); - } catch (err) { - if (err && err.code === "ENOENT") return []; - throw err; - } - const dirs = []; - for (const entry of entries) { - if (!entry.name.startsWith(AGENT_WORKTREE_PREFIX)) continue; - const abs = path.join(worktreesRoot, entry.name); - // Do not follow symlinks: lstat, and only accept real directories. - let st; - try { - st = fs.lstatSync(abs); - } catch { - continue; - } - if (st.isSymbolicLink() || !st.isDirectory()) continue; - dirs.push({ name: entry.name, path: abs, mtimeMs: st.mtimeMs }); - } - return dirs; -} - -function runInventory(options = {}) { - const repoDir = options.repoDir || process.env.AGENTDESK_REPO_DIR || process.cwd(); - const worktreesRoot = options.worktreesRoot || path.join(repoDir, ".claude", "worktrees"); - const baseRef = options.baseRef || "origin/main"; - const nowMs = typeof options.nowMs === "number" ? options.nowMs : Date.now(); - const agedOrphanSeconds = - typeof options.agedOrphanSeconds === "number" ? options.agedOrphanSeconds : DEFAULT_AGED_ORPHAN_SECONDS; - const deps = options.deps || defaultDeps(repoDir); - - const inspectionErrors = []; - let registered = {}; - try { - registered = deps.worktreeList() || {}; - } catch (err) { - inspectionErrors.push({ path: repoDir, error: `worktree list failed: ${err.message || err}` }); - } - - const dirs = listAgentWorktreeDirs(worktreesRoot); - const seen = new Set(); - const worktrees = []; - - const collect = (name, absPath, mtimeMs) => { - seen.add(absPath); - const reg = registered[absPath]; - const isRegistered = Boolean(reg); - const locked = Boolean(reg && reg.locked); - const head = reg ? reg.head : null; - const branch = reg ? reg.branch : null; - - let worktreeState = "unknown"; - let dirty = null; - let merged = null; - const dirExists = mtimeMs !== null; - - if (!dirExists) { - worktreeState = "missing"; - } else { - try { - const status = deps.statusPorcelain(absPath); - dirty = String(status).trim().length > 0; - worktreeState = dirty ? "dirty" : "clean"; - } catch (err) { - worktreeState = "unknown"; - inspectionErrors.push({ path: absPath, error: `status failed: ${err.message || err}` }); - } - if (head && worktreeState !== "unknown") { - try { - merged = deps.isMerged(head, baseRef); - } catch (err) { - merged = null; - inspectionErrors.push({ path: absPath, error: `merge-base failed: ${err.message || err}` }); - } - } - } - - let sizeKb = null; - if (dirExists) { - try { - sizeKb = deps.sizeKb(absPath); - } catch (err) { - inspectionErrors.push({ path: absPath, error: `size failed: ${err.message || err}` }); - } - } - - const ageSeconds = dirExists ? Math.max(0, Math.floor((nowMs - mtimeMs) / 1000)) : null; - const signals = { - registered: isRegistered, - locked, - dirty: dirty === true, - merged, - age_seconds: ageSeconds, - worktree_state: worktreeState, - }; - const decision = classifyWorktree(signals, { agedOrphanSeconds }); - - worktrees.push({ - path: absPath, - name, - age_seconds: ageSeconds, - size_kb: sizeKb, - registered: isRegistered, - locked, - dirty, - merged, - head, - branch, - worktree_state: worktreeState, - disposition: decision.disposition, - positive_ownership_proof: false, - reason: decision.reason, - }); - }; - - for (const dir of dirs) { - collect(dir.name, dir.path, dir.mtimeMs); - } - // Registered agent-* worktrees whose directory is missing must still surface. - for (const [absPath, reg] of Object.entries(registered)) { - if (seen.has(absPath)) continue; - if (!path.basename(absPath).startsWith(AGENT_WORKTREE_PREFIX)) continue; - collect(path.basename(absPath), absPath, null); - void reg; - } - - worktrees.sort((a, b) => (b.size_kb || 0) - (a.size_kb || 0)); - - const totalSizeKb = worktrees.reduce((sum, w) => sum + (w.size_kb || 0), 0); - const orphanCount = worktrees.filter( - (w) => w.disposition === "AGED_ORPHAN_REVIEW" || w.disposition === "SAFE_MERGED_CANDIDATE", - ).length; - const preserveCount = worktrees.filter((w) => w.disposition === "PRESERVE").length; - - const report = { - schema_version: SCHEMA_VERSION, - generated_at: new Date(nowMs).toISOString(), - root: worktreesRoot, - base_ref: baseRef, - mode: "report_only", - destructive_actions: 0, - aged_orphan_seconds: agedOrphanSeconds, - totals: { - count: worktrees.length, - total_size_kb: totalSizeKb, - orphan_count: orphanCount, - preserve_count: preserveCount, - }, - worktrees, - inspection_errors: inspectionErrors, - }; - validateReport(report); - return report; -} - -// --- Schema validation (throws on violation) --- - -const VALID_DISPOSITIONS = new Set(["PRESERVE", "SAFE_MERGED_CANDIDATE", "AGED_ORPHAN_REVIEW"]); -const VALID_STATES = new Set(["clean", "dirty", "missing", "unknown"]); - -function validateReport(report) { - const fail = (msg) => { - throw new Error(`local-worktree-inventory schema violation: ${msg}`); - }; - if (!report || typeof report !== "object") fail("report is not an object"); - if (report.schema_version !== SCHEMA_VERSION) fail("schema_version mismatch"); - if (report.mode !== "report_only") fail("mode must be report_only"); - if (report.destructive_actions !== 0) fail("destructive_actions must be 0"); - if (!Array.isArray(report.worktrees)) fail("worktrees must be an array"); - if (!Array.isArray(report.inspection_errors)) fail("inspection_errors must be an array"); - if (!report.totals || typeof report.totals !== "object") fail("totals must be an object"); - for (const w of report.worktrees) { - if (typeof w.path !== "string" || !w.path) fail("worktree.path must be a non-empty string"); - if (!VALID_DISPOSITIONS.has(w.disposition)) fail(`invalid disposition '${w.disposition}' for ${w.path}`); - if (!VALID_STATES.has(w.worktree_state)) fail(`invalid worktree_state '${w.worktree_state}' for ${w.path}`); - if (w.positive_ownership_proof !== false) fail(`positive_ownership_proof must be false for ${w.path}`); - if (!(w.age_seconds === null || typeof w.age_seconds === "number")) fail("age_seconds must be number|null"); - if (!(w.size_kb === null || typeof w.size_kb === "number")) fail("size_kb must be number|null"); - // Data-loss invariant: anything dirty or locked MUST be PRESERVE. - if ((w.dirty === true || w.locked === true) && w.disposition !== "PRESERVE") { - fail(`dirty/locked worktree ${w.path} must be PRESERVE, got ${w.disposition}`); - } - } - return report; -} - -function main() { - const report = runInventory(); - process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); -} - -if (require.main === module) { - main(); -} - -module.exports = { - SCHEMA_VERSION, - DEFAULT_AGED_ORPHAN_SECONDS, - parseWorktreeList, - classifyWorktree, - listAgentWorktreeDirs, - runInventory, - validateReport, -}; diff --git a/routines/monitoring/log_digest_issue_drafts.py b/routines/monitoring/log_digest_issue_drafts.py deleted file mode 100644 index aecff35ca7..0000000000 --- a/routines/monitoring/log_digest_issue_drafts.py +++ /dev/null @@ -1,432 +0,0 @@ -#!/usr/bin/env python3 -"""Reusable log-signature aggregation and human-gated issue-draft helpers. - -The daily log digest and future audits (notably #4265) share this module so -signature normalization, open-issue deduplication, and the default-off issue -creation boundary do not drift between routines. -""" - -from __future__ import annotations - -import hashlib -import re -from collections import Counter -from dataclasses import dataclass -from pathlib import Path -from typing import Callable, Iterable, Sequence - - -DEFAULT_DAILY_THRESHOLD = 50 -CONFIRMED_APPROVAL = "confirmed" - -_ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") -_SEVERITY_RE = re.compile(r"\b(ERROR|WARN(?:ING)?)\b", re.IGNORECASE) -_TIMESTAMP_RE = re.compile( - r"(? str | None: - """Return canonical ERROR/WARN severity when the line contains one.""" - - match = _SEVERITY_RE.search(_ANSI_RE.sub("", line)) - if not match: - return None - return "ERROR" if match.group(1).upper() == "ERROR" else "WARN" - - -def normalize_signature(line: str) -> str: - """Collapse volatile log fields while retaining the semantic message. - - Timestamps, UUIDs, hashes, and known dynamic identifiers become stable - placeholders. Bare numbers are treated as volatile except HTTP/status codes - and values explicitly labelled as ports. This deliberately narrow heuristic - preserves the common semantic cases without pretending every number's role - can be inferred from free-form text; unlabeled semantic numbers may collapse. - """ - - normalized = _ANSI_RE.sub("", line).strip() - normalized = _TIMESTAMP_RE.sub("", normalized) - normalized = _SEVERITY_RE.sub("", normalized) - normalized = _UUID_RE.sub("", normalized) - normalized = _HASH_RE.sub("", normalized) - normalized = _EMBEDDED_ID_RE.sub(lambda match: f"{match.group(1)}", normalized) - normalized = _DYNAMIC_KEY_VALUE_RE.sub(lambda match: f"{match.group(1).lower()}=", normalized) - normalized = _MEASURED_NUMBER_RE.sub(_normalize_measured_number, normalized) - normalized = _NUMBER_RE.sub(_normalize_bare_number, normalized) - normalized = _WHITESPACE_RE.sub(" ", normalized).strip(" -:|\t") - return normalized.lower()[:500] - - -def _normalize_measured_number(match: re.Match[str]) -> str: - unit = match.group(1).lower() - if unit in {"ms", "us", "ns", "s", "m", "h"}: - return "" - if unit in {"b", "kb", "mb", "gb", "kib", "mib", "gib"}: - return "" - return "" - - -def _normalize_bare_number(match: re.Match[str]) -> str: - prefix = match.string[max(0, match.start() - 24) : match.start()] - if re.search(r"\b(?:http(?:\s+status)?|status|port)\s*$", prefix, re.IGNORECASE): - return match.group(0) - return "" - - -def aggregate_normalized_signatures(lines: Iterable[str]) -> list[SignatureCount]: - """Count ERROR/WARN lines by ``(severity, normalized signature)``.""" - - counts: Counter[tuple[str, str]] = Counter() - samples: dict[tuple[str, str], str] = {} - for raw_line in lines: - severity = extract_severity(raw_line) - if severity is None: - continue - signature = normalize_signature(raw_line) - if not signature: - continue - key = (severity, signature) - counts[key] += 1 - samples.setdefault(key, _WHITESPACE_RE.sub(" ", _ANSI_RE.sub("", raw_line)).strip()[:500]) - - return sorted( - ( - SignatureCount(severity=severity, signature=signature, count=count, sample=samples[key]) - for key, count in counts.items() - for severity, signature in [key] - ), - key=lambda pattern: (-pattern.count, pattern.severity, pattern.signature), - ) - - -def exceeds_threshold(count: int, threshold: int = DEFAULT_DAILY_THRESHOLD) -> bool: - """The issue contract says *exceeds*, so equality does not cross the gate.""" - - if threshold < 0: - raise ValueError("threshold must be non-negative") - return count > threshold - - -def _similarity_tokens_from_normalized(normalized: str) -> set[str]: - return { - token - for token in _TOKEN_RE.findall(normalized) - if token not in _STOP_WORDS and not token.startswith("timestamp") - } - - -def _normalized_issue_candidates(issue: OpenIssue) -> list[str]: - """Return bounded, issue-defining text rather than an unbounded epic body.""" - - first_body_line = next((line.strip() for line in issue.body.splitlines() if line.strip()), "") - return [normalize_signature(part) for part in (issue.title, first_body_line) if part] - - -def issue_matches_signature(signature: str, issue: OpenIssue) -> bool: - """Match against the issue title or first non-empty body line. - - Each bounded candidate must share at least three meaningful tokens and reach - 50% symmetric Jaccard similarity. Direct containment is accepted only when - the candidate is still signature-like (at most 3x the signature token count, - with a floor of 12 tokens). This avoids a three-token signature matching an - unrelated long epic that merely mentions those words. A duplicate described - only deep in a long issue body may therefore require human reconciliation. - """ - - normalized_signature = normalize_signature(signature) - signature_tokens = _similarity_tokens_from_normalized(normalized_signature) - if len(signature_tokens) < 3: - return False - for candidate in _normalized_issue_candidates(issue): - issue_tokens = _similarity_tokens_from_normalized(candidate) - overlap = signature_tokens & issue_tokens - if len(overlap) < 3: - continue - signature_like_limit = max(12, len(signature_tokens) * 3) - if normalized_signature in candidate and len(issue_tokens) <= signature_like_limit: - return True - union = signature_tokens | issue_tokens - if union and len(overlap) / len(union) >= 0.50: - return True - return False - - -def build_issue_draft(pattern: SignatureCount, window_label: str, threshold: int) -> IssueDraft: - signature_preview = pattern.signature[:120] - title = f"ops(log-digest): {pattern.severity} {signature_preview}" - safe_sample = normalize_signature(pattern.sample) or pattern.signature - body = "\n".join( - [ - "# Daily log-digest draft", - "", - "This is a pending draft generated for human review. It has not been posted to GitHub.", - "", - f"- Window: `{window_label}`", - f"- Severity: `{pattern.severity}`", - f"- Count: `{pattern.count}`", - f"- Draft threshold: `>{threshold}`", - f"- Normalized signature: `{pattern.signature}`", - "", - "## Representative sample", - "", - "```text", - f"{pattern.severity} {safe_sample}", - "```", - "", - "## Human review", - "", - "Confirm impact, reproduction, ownership, and labels before approving issue creation.", - ] - ) - return IssueDraft( - severity=pattern.severity, - signature=pattern.signature, - count=pattern.count, - title=title, - body=body, - ) - - -def decide_issue_drafts( - patterns: Sequence[SignatureCount], - open_issues: Sequence[OpenIssue], - *, - threshold: int = DEFAULT_DAILY_THRESHOLD, - window_label: str = "last 24 hours", - dedup_available: bool = True, -) -> list[DraftDecision]: - """Apply threshold and fail-closed open-issue deduplication. - - If the GitHub open-issue scan is unavailable, threshold crossings are - reported but no draft is emitted; this prevents duplicate drafts when the - dedup authority cannot be consulted. - """ - - decisions: list[DraftDecision] = [] - for pattern in patterns: - if not exceeds_threshold(pattern.count, threshold): - continue - matching_issue = next( - (issue for issue in open_issues if issue_matches_signature(pattern.signature, issue)), - None, - ) - draft = None - if dedup_available and matching_issue is None: - draft = build_issue_draft(pattern, window_label, threshold) - decisions.append(DraftDecision(pattern=pattern, draft=draft, matching_issue=matching_issue)) - return decisions - - -def stable_draft_filename(draft: IssueDraft) -> str: - digest = hashlib.sha256(f"{draft.severity}\0{draft.signature}".encode()).hexdigest()[:16] - return f"{draft.severity.lower()}-{digest}.md" - - -def write_pending_drafts(drafts: Iterable[IssueDraft], pending_dir: Path) -> list[IssueDraft]: - pending_dir.mkdir(parents=True, exist_ok=True) - written: list[IssueDraft] = [] - for draft in drafts: - path = pending_dir / stable_draft_filename(draft) - path.write_text(draft.body + "\n", encoding="utf-8") - written.append( - IssueDraft( - severity=draft.severity, - signature=draft.signature, - count=draft.count, - title=draft.title, - body=draft.body, - path=path, - ) - ) - return written - - -def maybe_post_approved_drafts( - drafts: Sequence[IssueDraft], - approval_mode: str, - create_issue: Callable[[IssueDraft], str], -) -> PostDecision: - """Post only after the operator supplies the literal ``confirmed`` gate. - - This check is intentionally in the shared helper (not only its CLI caller), - so every future consumer inherits the same default-off safety boundary. - """ - - if approval_mode != CONFIRMED_APPROVAL: - return PostDecision( - attempted=False, - created_urls=(), - reason="issue creation disabled; set approval mode to literal 'confirmed' after human review", - ) - - approved = [ - draft - for draft in drafts - if draft.path is not None and Path(f"{draft.path}.approved").is_file() - ] - if not approved: - return PostDecision( - attempted=False, - created_urls=(), - reason="confirmation enabled, but no human-reviewed .approved draft markers exist", - ) - - created_urls = tuple(create_issue(draft) for draft in approved) - return PostDecision(attempted=True, created_urls=created_urls, reason="human-confirmed issue creation") - - -def _compact(text: str, limit: int = 100) -> str: - return text if len(text) <= limit else text[: limit - 1] + "…" - - -def format_daily_summary( - patterns: Sequence[SignatureCount], - decisions: Sequence[DraftDecision], - drafts: Sequence[IssueDraft], - *, - threshold: int, - window_label: str, - warnings: Sequence[str] = (), - top_per_severity: int = 3, -) -> str: - """Format the single routine-channel digest for one daily run.""" - - lines = [f"📊 dcserver daily log digest — {window_label}"] - for severity in ("ERROR", "WARN"): - top = [pattern for pattern in patterns if pattern.severity == severity][:top_per_severity] - if top: - lines.append(f"{severity} top: " + " | ".join( - f"{pattern.count}× {_compact(pattern.signature, 90)}" for pattern in top - )) - else: - lines.append(f"{severity} top: none") - lines.append("ℹ best-effort signatures; verify top patterns manually") - - crossed = [decision for decision in decisions] - lines.append(f"Threshold >{threshold}: {len(crossed)} crossed") - if crossed: - lines.append( - "Crossed: " - + " | ".join( - f"{decision.pattern.count}× {decision.pattern.severity} " - f"{_compact(decision.pattern.signature, 75)}" - for decision in crossed[:10] - ) - ) - if len(crossed) > 10: - lines.append(f"Crossed: +{len(crossed) - 10} more") - matched = [decision for decision in crossed if decision.matching_issue is not None] - if matched: - references = ", ".join(f"#{decision.matching_issue.number}" for decision in matched) - lines.append(f"Open-issue dedup: {len(matched)} matched ({references})") - if drafts: - lines.append("Pending drafts: " + ", ".join(str(draft.path or draft.title) for draft in drafts)) - else: - lines.append("Pending drafts: none") - lines.extend(f"⚠ {warning}" for warning in warnings) - return "\n".join(lines) diff --git a/routines/monitoring/weekly_churn_audit.py b/routines/monitoring/weekly_churn_audit.py deleted file mode 100644 index 73f9adf633..0000000000 --- a/routines/monitoring/weekly_churn_audit.py +++ /dev/null @@ -1,556 +0,0 @@ -#!/usr/bin/env python3 -"""Audit one week of repeated fix churn and flag redesign candidates.""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import os -import re -import subprocess -import sys -import urllib.error -import urllib.request -from collections import Counter -from dataclasses import dataclass -from pathlib import Path, PurePosixPath -from typing import Callable, Mapping, Sequence - -from daily_log_digest import ( - REPOSITORY, - create_github_issue, - load_open_issues, - runtime_root, -) -from log_digest_issue_drafts import ( - CONFIRMED_APPROVAL, - IssueDraft, - OpenIssue, - PostDecision, - maybe_post_approved_drafts, - write_pending_drafts, -) - - -DEFAULT_THRESHOLD = 3 -DEFAULT_SINCE = "7 days" -DEFAULT_API_URL = "http://127.0.0.1:8791/api/discord/send" -LINEAGE_PATH_STATE_LIMIT = 10_000 -_FIX_SUBJECT_RE = re.compile(r"^fix(?:\([^)\r\n]+\))?!?:") -_ISSUE_REFERENCE_RE = re.compile(r"(? int: - return len(self.issues) - - -@dataclass(frozen=True) -class ChurnAudit: - since: str - threshold: int - fix_commits: tuple[GitCommit, ...] - file_counts: Mapping[str, int] - module_counts: Mapping[str, int] - candidates: tuple[ChurnCandidate, ...] - lineages: tuple[IssueLineage, ...] - - -def is_fix_commit_subject(subject: str) -> bool: - """Recognize conventional fix subjects, including breaking-change markers.""" - - return _FIX_SUBJECT_RE.match(subject) is not None - - -def issue_references(subject: str, body: str = "") -> tuple[int, ...]: - """Return genuine issue references in text order, excluding squash PR suffixes.""" - - ordered: list[int] = [] - seen: set[int] = set() - issue_text = f"{_SQUASH_PR_SUFFIX_RE.sub('', subject)}\n{body}" - for match in _ISSUE_REFERENCE_RE.finditer(issue_text): - number = int(match.group(1)) - if number not in seen: - seen.add(number) - ordered.append(number) - return tuple(ordered) - - -def module_for_file(file: str) -> str: - """Map a file to its containing repo-relative module directory.""" - - path = PurePosixPath(file) - if path.name == "mod.rs": - return str(path.parent) - if str(path.parent) != ".": - return str(path.parent) - return path.stem - - -def log(message: str) -> None: - print(f"weekly-churn-audit: {message}", file=sys.stderr) - - -def _git(repo_root: Path, args: Sequence[str]) -> subprocess.CompletedProcess[str]: - return subprocess.run( - ["git", "-C", str(repo_root), *args], - check=False, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - - -def _require_git(result: subprocess.CompletedProcess[str], operation: str) -> str: - if result.returncode != 0: - detail = result.stderr.strip() or f"git exited {result.returncode}" - raise RuntimeError(f"could not {operation}: {detail}") - return result.stdout - - -def collect_git_commits(repo_root: Path, since: str = DEFAULT_SINCE) -> list[GitCommit]: - """Collect commit text and changed paths from the requested local git window.""" - - log_output = _require_git( - _git(repo_root, ["log", f"--since={since}", "--format=%H"]), - "read weekly git log", - ) - commits: list[GitCommit] = [] - for sha in (line.strip() for line in log_output.splitlines() if line.strip()): - text = _require_git( - _git(repo_root, ["show", "-s", "--format=%s%x00%b", sha]), - f"read commit text for {sha}", - ) - subject, separator, body = text.rstrip("\n").partition("\x00") - if not separator: - raise RuntimeError(f"could not parse commit text for {sha}") - if not is_fix_commit_subject(subject): - commits.append(GitCommit(sha=sha, subject=subject, body=body.strip(), files=())) - continue - changed = _require_git( - _git( - repo_root, - ["diff-tree", "--root", "--no-commit-id", "--name-only", "-r", sha], - ), - f"read changed files for {sha}", - ) - files = tuple(sorted({line for line in changed.splitlines() if line})) - commits.append(GitCommit(sha=sha, subject=subject, body=body.strip(), files=files)) - return commits - - -def _longest_lineage( - component: set[int], edges: Mapping[int, set[int]] -) -> tuple[int, ...]: - best: tuple[int, ...] = () - starts = sorted(component) - pending = [(node, ()) for node in reversed(starts[:LINEAGE_PATH_STATE_LIMIT])] - scheduled = len(pending) - truncated = len(starts) > LINEAGE_PATH_STATE_LIMIT - while pending: - node, path = pending.pop() - extended = (*path, node) - if len(extended) > len(best) or (len(extended) == len(best) and extended < best): - best = extended - for child in reversed(sorted(edges.get(node, set()))): - if child in extended: - continue - if scheduled >= LINEAGE_PATH_STATE_LIMIT: - truncated = True - break - pending.append((child, extended)) - scheduled += 1 - if truncated: - log( - "issue-lineage search truncated at " - f"{LINEAGE_PATH_STATE_LIMIT} path states for a {len(component)}-issue component" - ) - return best - - -def compute_issue_lineages(commits: Sequence[GitCommit]) -> tuple[IssueLineage, ...]: - """Merge commit-text #A→#B edges and report the longest chain per component.""" - - nodes: set[int] = set() - edges: dict[int, set[int]] = {} - neighbours: dict[int, set[int]] = {} - for commit in commits: - references = issue_references(commit.subject, commit.body) - nodes.update(references) - for parent, child in zip(references, references[1:]): - edges.setdefault(parent, set()).add(child) - neighbours.setdefault(parent, set()).add(child) - neighbours.setdefault(child, set()).add(parent) - - lineages: list[IssueLineage] = [] - unseen = set(nodes) - while unseen: - first = min(unseen) - component: set[int] = set() - pending = [first] - while pending: - node = pending.pop() - if node in component: - continue - component.add(node) - pending.extend(neighbours.get(node, set()) - component) - unseen -= component - lineages.append(IssueLineage(_longest_lineage(component, edges))) - return tuple(sorted(lineages, key=lambda item: (-item.generations, item.issues))) - - -def analyze_churn( - commits: Sequence[GitCommit], - *, - since: str = DEFAULT_SINCE, - threshold: int = DEFAULT_THRESHOLD, -) -> ChurnAudit: - if threshold <= 0: - raise ValueError("threshold must be positive") - - fix_commits = tuple(commit for commit in commits if is_fix_commit_subject(commit.subject)) - file_counts: Counter[str] = Counter() - module_counts: Counter[str] = Counter() - commits_by_file: dict[str, list[GitCommit]] = {} - for commit in fix_commits: - unique_files = set(commit.files) - file_counts.update(unique_files) - module_counts.update({module_for_file(file) for file in unique_files}) - for file in unique_files: - commits_by_file.setdefault(file, []).append(commit) - - candidates = tuple( - ChurnCandidate(file=file, count=count, commits=tuple(commits_by_file[file])) - for file, count in sorted(file_counts.items(), key=lambda item: (-item[1], item[0])) - if count >= threshold - ) - return ChurnAudit( - since=since, - threshold=threshold, - fix_commits=fix_commits, - file_counts=dict(file_counts), - module_counts=dict(module_counts), - candidates=candidates, - lineages=compute_issue_lineages(commits), - ) - - -def _candidate_marker(candidate: ChurnCandidate) -> str: - return f"" - - -def build_candidate_draft(candidate: ChurnCandidate, since: str, threshold: int) -> IssueDraft: - evidence = [ - f"- `{commit.sha[:12]}` {commit.subject}" for commit in candidate.commits - ] - body = "\n".join( - [ - _candidate_marker(candidate), - "# 주간 회귀 churn 재설계 후보", - "", - "This is a pending draft generated for human review. It has not been posted to GitHub.", - "", - f"- File: `{candidate.file}`", - f"- Window: `git log --since={since!r}`", - f"- Fix commits: `{candidate.count}`", - f"- Candidate threshold: `>={threshold}`", - "", - "## Fix lineage evidence", - "", - *evidence, - "", - "## Human review", - "", - "Confirm the recurring failure model, intended ownership, and redesign boundary before approval.", - ] - ) - return IssueDraft( - severity="CHURN", - signature=_candidate_marker(candidate), - count=candidate.count, - title=f"ops(process): redesign candidate for {candidate.file}", - body=body, - ) - - -def candidate_drafts( - candidates: Sequence[ChurnCandidate], - open_issues: Sequence[OpenIssue], - *, - since: str, - threshold: int, - dedup_available: bool = True, -) -> tuple[list[IssueDraft], list[tuple[ChurnCandidate, OpenIssue]]]: - """Build drafts only when the complete open-issue dedup authority is available.""" - - drafts: list[IssueDraft] = [] - matches: list[tuple[ChurnCandidate, OpenIssue]] = [] - for candidate in candidates: - matching = next( - ( - issue - for issue in open_issues - if _candidate_marker(candidate) in issue.body - ), - None, - ) - if matching is not None: - matches.append((candidate, matching)) - elif dedup_available: - drafts.append(build_candidate_draft(candidate, since, threshold)) - return drafts, matches - - -def format_report(audit: ChurnAudit, notes: Sequence[str] = ()) -> str: - lines = [ - f"📈 AgentDesk 주간 회귀 churn 감사 — git log --since={audit.since!r}", - f"Fix commits: {len(audit.fix_commits)} | 재설계 후보 threshold: >={audit.threshold}", - "", - f"재설계 후보 ({len(audit.candidates)}):", - ] - if audit.candidates: - lines.extend(f"- {item.count}× `{item.file}`" for item in audit.candidates) - else: - lines.append("- none") - - lines.extend(["", f"파일별 fix-commit tally ({len(audit.file_counts)}):"]) - if audit.file_counts: - lines.extend( - f"- {count}× `{file}`" - for file, count in sorted( - audit.file_counts.items(), key=lambda item: (-item[1], item[0]) - ) - ) - else: - lines.append("- none") - - lines.extend(["", f"모듈별 fix-commit tally ({len(audit.module_counts)}):"]) - if audit.module_counts: - lines.extend( - f"- {count}× `{module}`" - for module, count in sorted( - audit.module_counts.items(), key=lambda item: (-item[1], item[0]) - ) - ) - else: - lines.append("- none") - - lines.extend(["", f"Issue-reference lineages ({len(audit.lineages)}):"]) - if audit.lineages: - lines.extend( - f"- generations={lineage.generations}: " - + "→".join(f"#{issue}" for issue in lineage.issues) - for lineage in audit.lineages - ) - else: - lines.append("- none") - lines.extend(["", *(f"⚠ {note}" for note in notes)]) - return "\n".join(lines).rstrip() - - -def _post_report(api_url: str, channel_id: str, report: str) -> None: - payload = json.dumps( - { - "target": f"channel:{channel_id}", - "content": report, - "source": "weekly-churn-audit", - "bot": "notify", - } - ).encode() - request = urllib.request.Request( - api_url, - data=payload, - headers={"Content-Type": "application/json"}, - method="POST", - ) - with urllib.request.urlopen(request, timeout=30) as response: - response_payload = json.loads(response.read().decode()) - if not isinstance(response_payload, dict) or response_payload.get("ok") is not True: - raise RuntimeError(f"AgentDesk channel post rejected: {response_payload!r}") - - -def maybe_post_weekly_channel( - report: str, - approval_mode: str, - channel_id: str | None, - state_path: Path, - post_report: Callable[[str], None], -) -> tuple[bool, str]: - """Post once per report fingerprint, and only behind literal confirmation.""" - - if approval_mode != CONFIRMED_APPROVAL: - return False, "weekly ops channel post disabled" - if not channel_id: - return False, "channel post confirmed but AGENTDESK_CHURN_AUDIT_CHANNEL_ID is unset" - - fingerprint = hashlib.sha256(report.encode()).hexdigest() - try: - prior = json.loads(state_path.read_text(encoding="utf-8")) if state_path.is_file() else {} - except (OSError, json.JSONDecodeError): - prior = {} - if isinstance(prior, dict) and prior.get("fingerprint") == fingerprint: - return False, "identical weekly report already posted" - - post_report(report) - state_path.parent.mkdir(parents=True, exist_ok=True) - temporary = state_path.with_suffix(".tmp") - temporary.write_text(json.dumps({"fingerprint": fingerprint}) + "\n", encoding="utf-8") - temporary.replace(state_path) - return True, "weekly ops channel report posted" - - -def positive_int(value: str) -> int: - parsed = int(value) - if parsed <= 0: - raise argparse.ArgumentTypeError("must be greater than zero") - return parsed - - -def threshold_from_env(value: str | None) -> tuple[int, str | None]: - configured = value if value is not None else str(DEFAULT_THRESHOLD) - try: - return positive_int(configured), None - except (ValueError, argparse.ArgumentTypeError): - return ( - DEFAULT_THRESHOLD, - "invalid AGENTDESK_CHURN_AUDIT_THRESHOLD " - f"value {configured!r}; using default {DEFAULT_THRESHOLD}", - ) - - -def parse_args() -> argparse.Namespace: - script_repo_root = Path(__file__).resolve().parents[2] - env_threshold, threshold_warning = threshold_from_env( - os.environ.get("AGENTDESK_CHURN_AUDIT_THRESHOLD") - ) - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--repo-root", type=Path, default=script_repo_root) - parser.add_argument("--runtime-root", type=Path, default=runtime_root()) - parser.add_argument( - "--repo", default=os.environ.get("AGENTDESK_CHURN_AUDIT_REPO", REPOSITORY) - ) - parser.add_argument( - "--since", default=os.environ.get("AGENTDESK_CHURN_AUDIT_SINCE", DEFAULT_SINCE) - ) - parser.add_argument( - "--threshold", - type=positive_int, - default=None, - ) - args = parser.parse_args() - if args.threshold is None: - args.threshold = env_threshold - if threshold_warning: - log(threshold_warning) - return args - - -def main() -> int: - args = parse_args() - audit = analyze_churn( - collect_git_commits(args.repo_root, args.since), - since=args.since, - threshold=args.threshold, - ) - notes: list[str] = [] - - issue_mode = os.environ.get("AGENTDESK_CHURN_AUDIT_CREATE_ISSUE", "off") - if issue_mode == CONFIRMED_APPROVAL: - open_issues, dedup_warning = load_open_issues(args.repo) - if dedup_warning: - notes.append(dedup_warning) - proposed, matches = candidate_drafts( - audit.candidates, - open_issues, - since=args.since, - threshold=args.threshold, - dedup_available=dedup_warning is None, - ) - pending_dir = ( - args.runtime_root / "runtime" / "pending-issue-drafts" / "weekly-churn-audit" - ) - drafts = write_pending_drafts(proposed, pending_dir) - if matches: - notes.append( - "open-issue dedup matched: " - + ", ".join(f"{item.file}→#{issue.number}" for item, issue in matches) - ) - post: PostDecision = maybe_post_approved_drafts( - drafts, - issue_mode, - lambda draft: create_github_issue(args.repo, draft), - ) - notes.append( - "pending drafts: " - + (", ".join(str(draft.path) for draft in drafts) if drafts else "none") - ) - if not post.attempted: - notes.append(post.reason) - elif post.created_urls: - notes.append("human-confirmed issues created: " + ", ".join(post.created_urls)) - elif issue_mode == "off": - notes.append( - "issue drafts dry-run only; set AGENTDESK_CHURN_AUDIT_CREATE_ISSUE=confirmed " - "to dedup and emit human-review drafts" - ) - else: - notes.append( - "invalid AGENTDESK_CHURN_AUDIT_CREATE_ISSUE value ignored; " - "use literal 'confirmed' or 'off'" - ) - - report = format_report(audit, notes) - channel_mode = os.environ.get("AGENTDESK_CHURN_AUDIT_POST_CHANNEL", "off") - try: - posted, channel_note = maybe_post_weekly_channel( - report, - channel_mode, - os.environ.get("AGENTDESK_CHURN_AUDIT_CHANNEL_ID"), - args.runtime_root / "runtime" / "weekly-churn-audit" / "post-state.json", - lambda content: _post_report( - os.environ.get("AGENTDESK_CHURN_AUDIT_API", DEFAULT_API_URL), - os.environ.get("AGENTDESK_CHURN_AUDIT_CHANNEL_ID", ""), - content, - ), - ) - except (OSError, RuntimeError, ValueError, urllib.error.URLError) as error: - posted, channel_note = False, f"weekly ops channel post failed: {error}" - if channel_mode not in {"off", CONFIRMED_APPROVAL}: - channel_note = ( - "invalid AGENTDESK_CHURN_AUDIT_POST_CHANNEL value ignored; " - "use literal 'confirmed' or 'off'" - ) - if posted or channel_mode != "off": - report = f"{report}\n⚠ {channel_note}" - - print(report) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/ci-script-checks.sh b/scripts/ci-script-checks.sh index 98e547a98a..38e52a7081 100755 --- a/scripts/ci-script-checks.sh +++ b/scripts/ci-script-checks.sh @@ -185,12 +185,12 @@ node --test policies/__tests__/merge-automation.test.js echo "=== Timeout shadow aggregation gate tests (#3950) ===" node --test scripts/__tests__/timeout-shadow-gate.test.mjs -echo "=== Daily log-digest routine tests (#4263) ===" -node --test policies/__tests__/daily-log-digest.test.js -"$PYTHON" -m unittest tests.test_daily_log_digest - -echo "=== Weekly regression-churn audit tests (#4265) ===" -"$PYTHON" -m unittest tests.test_weekly_churn_audit +echo "=== Operator routine scripts must stay out of git (docs/source-of-truth.md) ===" +if tracked_routines="$(git ls-files routines)" && [ -n "$tracked_routines" ]; then + echo "✗ routines/ is operator-private and must not be tracked; found:" >&2 + printf ' %s\n' $tracked_routines >&2 + exit 1 +fi echo "=== External toolchain draft/approval/smoke tests (#4555) ===" "$PYTHON" -m unittest tests.test_toolchain_update diff --git a/tests/test_daily_log_digest.py b/tests/test_daily_log_digest.py deleted file mode 100644 index 48cd2057f9..0000000000 --- a/tests/test_daily_log_digest.py +++ /dev/null @@ -1,612 +0,0 @@ -#!/usr/bin/env python3 -"""Focused tests for the reusable daily log-digest draft pipeline (#4263).""" - -from __future__ import annotations - -import json -import os -import subprocess -import sys -import tempfile -import unittest -from contextlib import redirect_stdout -from datetime import datetime, timedelta, timezone -from io import StringIO -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -ROUTINE_DIR = ROOT / "routines" / "monitoring" -sys.path.insert(0, str(ROUTINE_DIR)) - -from log_digest_issue_drafts import ( # noqa: E402 - _MEASURED_NUMBER_RE, - IssueDraft, - OpenIssue, - SignatureCount, - aggregate_normalized_signatures, - decide_issue_drafts, - exceeds_threshold, - format_daily_summary, - issue_matches_signature, - maybe_post_approved_drafts, - normalize_signature, - write_pending_drafts, -) -import daily_log_digest # noqa: E402 -from daily_log_digest import ( # noqa: E402 - OPEN_ISSUE_LIMIT, - dcserver_log_paths, - load_open_issues, - recent_log_lines, - runtime_root, -) - - -class SignatureNormalizationTests(unittest.TestCase): - def test_runtime_root_keeps_canonical_override_before_deploy_fallback(self) -> None: - with patch.dict( - os.environ, - {"AGENTDESK_ROOT_DIR": "/canonical", "ADK_REL": "/deploy-fallback"}, - clear=False, - ): - self.assertEqual(runtime_root(), Path("/canonical")) - with patch.dict(os.environ, {"ADK_REL": "/deploy-fallback"}, clear=True): - self.assertEqual(runtime_root(), Path("/deploy-fallback")) - - def test_runtime_log_paths_include_internal_stdout_and_launchd_stderr(self) -> None: - paths = dcserver_log_paths(Path("/srv/agentdesk")) - self.assertIn(Path("/srv/agentdesk/logs/dcserver.stdout.log"), paths) - self.assertIn(Path("/srv/agentdesk/logs/dcserver.stdout.log.1"), paths) - self.assertIn(Path("/srv/agentdesk/logs/dcserver.launchd.stderr.log"), paths) - - def test_recent_window_filters_old_and_undated_stdout_lines(self) -> None: - now = datetime(2026, 7, 14, 0, 0, tzinfo=timezone.utc) - since = now - timedelta(days=1) - with tempfile.TemporaryDirectory() as temp: - logs = Path(temp) - stdout = logs / "dcserver.stdout.log" - launchd_stderr = logs / "dcserver.launchd.stderr.log" - stdout.write_text( - "2026-07-12T23:59:00Z ERROR stale failure id=1\n" - "2026-07-13T12:00:00Z WARN recent timeout id=2\n" - "ERROR undated stale stdout line\n", - encoding="utf-8", - ) - launchd_stderr.write_text("WARN undated recent launchd bootstrap\n", encoding="utf-8") - timestamp = now.timestamp() - stdout.touch() - launchd_stderr.touch() - # touch() uses wall clock; explicitly pin both mtimes to the test window. - os.utime(stdout, (timestamp, timestamp)) - os.utime(launchd_stderr, (timestamp, timestamp)) - - lines, warnings = recent_log_lines([stdout, launchd_stderr], since, now) - - self.assertEqual(warnings, []) - self.assertEqual( - lines, - [ - "2026-07-13T12:00:00Z WARN recent timeout id=2", - "WARN undated recent launchd bootstrap", - ], - ) - - def test_first_undated_observation_baselines_then_counts_only_append(self) -> None: - now = datetime(2026, 7, 14, 0, 0, tzinfo=timezone.utc) - since = now - timedelta(days=1) - with tempfile.TemporaryDirectory() as temp: - root = Path(temp) - launchd_stderr = root / "dcserver.launchd.stderr.log" - checkpoint = root / "runtime" / "undated-offsets.json" - launchd_stderr.write_text( - "".join(f"ERROR panic stale-{index}\n" for index in range(60)), - encoding="utf-8", - ) - old_run = now - timedelta(days=3) - os.utime(launchd_stderr, (old_run.timestamp(), old_run.timestamp())) - - baseline, baseline_warnings = recent_log_lines( - [launchd_stderr], - old_run - timedelta(days=1), - old_run, - undated_checkpoint=checkpoint, - ) - with launchd_stderr.open("a", encoding="utf-8") as stream: - stream.write("WARN fresh append today\n") - os.utime(launchd_stderr, (now.timestamp(), now.timestamp())) - fresh, fresh_warnings = recent_log_lines( - [launchd_stderr], since, now, undated_checkpoint=checkpoint - ) - repeated, repeated_warnings = recent_log_lines( - [launchd_stderr], - now, - now + timedelta(days=1), - undated_checkpoint=checkpoint, - ) - - self.assertEqual(baseline, []) - self.assertEqual(fresh, ["WARN fresh append today"]) - self.assertEqual(repeated, []) - self.assertEqual(baseline_warnings + fresh_warnings + repeated_warnings, []) - - def test_corrupt_checkpoint_also_baselines_undated_history(self) -> None: - now = datetime(2026, 7, 14, 0, 0, tzinfo=timezone.utc) - with tempfile.TemporaryDirectory() as temp: - root = Path(temp) - launchd_stderr = root / "dcserver.launchd.stderr.log" - checkpoint = root / "runtime" / "undated-offsets.json" - launchd_stderr.write_text("ERROR old undated history\n" * 60, encoding="utf-8") - os.utime(launchd_stderr, (now.timestamp(), now.timestamp())) - checkpoint.parent.mkdir(parents=True) - checkpoint.write_text("{broken", encoding="utf-8") - - baseline, warnings = recent_log_lines( - [launchd_stderr], - now - timedelta(days=1), - now, - undated_checkpoint=checkpoint, - ) - - self.assertEqual(baseline, []) - self.assertEqual(len(warnings), 1) - self.assertIn("could not load undated-line checkpoint", warnings[0]) - - def test_truncate_then_regrow_past_offset_resets_same_inode_watermark(self) -> None: - now = datetime(2026, 7, 14, 0, 0, tzinfo=timezone.utc) - since = now - timedelta(days=1) - with tempfile.TemporaryDirectory() as temp: - root = Path(temp) - launchd_stderr = root / "dcserver.launchd.stderr.log" - checkpoint = root / "runtime" / "undated-offsets.json" - launchd_stderr.write_text("ERROR old history\n" * 8, encoding="utf-8") - os.utime(launchd_stderr, (now.timestamp(), now.timestamp())) - baseline, _ = recent_log_lines( - [launchd_stderr], since, now, undated_checkpoint=checkpoint - ) - old_offset = launchd_stderr.stat().st_size - old_inode = launchd_stderr.stat().st_ino - - with launchd_stderr.open("w", encoding="utf-8") as stream: - stream.write("ERROR new content after truncation\n" * 10) - os.utime(launchd_stderr, (now.timestamp(), now.timestamp())) - fresh, warnings = recent_log_lines( - [launchd_stderr], since, now, undated_checkpoint=checkpoint - ) - new_offset = launchd_stderr.stat().st_size - new_inode = launchd_stderr.stat().st_ino - - self.assertEqual(baseline, []) - self.assertEqual(new_inode, old_inode) - self.assertGreater(new_offset, old_offset) - self.assertEqual(fresh, ["ERROR new content after truncation"] * 10) - self.assertEqual(warnings, []) - - def test_truncate_regrow_with_same_tail_resets_then_plain_append_resumes(self) -> None: - now = datetime(2026, 7, 14, 0, 0, tzinfo=timezone.utc) - since = now - timedelta(days=1) - tail = "ERROR tail unchanged marker\n" * 8 - with tempfile.TemporaryDirectory() as temp: - root = Path(temp) - launchd_stderr = root / "dcserver.launchd.stderr.log" - checkpoint = root / "runtime" / "undated-offsets.json" - launchd_stderr.write_text("ERROR old prelude\n" + tail, encoding="utf-8") - os.utime(launchd_stderr, (now.timestamp(), now.timestamp())) - baseline, baseline_warnings = recent_log_lines( - [launchd_stderr], since, now, undated_checkpoint=checkpoint - ) - old_inode = launchd_stderr.stat().st_ino - old_offset = launchd_stderr.stat().st_size - - rewritten = ( - "ERROR new prelude\n" - + tail - + "ERROR appended beyond old offset\n" - ) - with launchd_stderr.open("w", encoding="utf-8") as stream: - stream.write(rewritten) - os.utime(launchd_stderr, (now.timestamp(), now.timestamp())) - new_inode = launchd_stderr.stat().st_ino - fresh, fresh_warnings = recent_log_lines( - [launchd_stderr], since, now, undated_checkpoint=checkpoint - ) - - with launchd_stderr.open("a", encoding="utf-8") as stream: - stream.write("ERROR plain append after rewrite\n") - appended, append_warnings = recent_log_lines( - [launchd_stderr], since, now, undated_checkpoint=checkpoint - ) - - self.assertEqual(baseline, []) - self.assertEqual(new_inode, old_inode) - self.assertGreater(len(rewritten.encode()), old_offset) - self.assertEqual( - fresh, - ["ERROR new prelude"] - + ["ERROR tail unchanged marker"] * 8 - + ["ERROR appended beyond old offset"], - ) - self.assertEqual(appended, ["ERROR plain append after rewrite"]) - self.assertEqual(baseline_warnings + fresh_warnings + append_warnings, []) - - def test_different_ids_hashes_and_timestamps_collapse(self) -> None: - first = ( - "2026-07-13T01:02:03.123Z WARN sqlx pool timed out while acquiring " - "id=123 request_id=req-a9f3 token=secret-one commit=deadbeef" - ) - second = ( - "2026-07-14T04:05:06.987Z WARN sqlx pool timed out while acquiring " - "id=456 request_id=req-b7d1 token=secret-two commit=cafebabe" - ) - - self.assertEqual(normalize_signature(first), normalize_signature(second)) - patterns = aggregate_normalized_signatures([first, second]) - self.assertEqual(len(patterns), 1) - self.assertEqual(patterns[0].severity, "WARN") - self.assertEqual(patterns[0].count, 2) - - def test_semantically_distinct_patterns_stay_distinct(self) -> None: - patterns = aggregate_normalized_signatures( - [ - "2026-07-14T01:00:00Z ERROR postgres pool timed out id=123", - "2026-07-14T01:00:01Z ERROR discord gateway connection refused id=456", - ] - ) - - self.assertEqual(len(patterns), 2) - self.assertNotEqual(patterns[0].signature, patterns[1].signature) - - def test_short_embedded_request_ids_collapse_and_cross_threshold(self) -> None: - ids = ("4f2a", "8c1d", "0b7e", "9d3c") - lines = [ - f"ERROR failed to open /tmp/req-{ids[index % len(ids)]}/data" - for index in range(100) - ] - patterns = aggregate_normalized_signatures(lines) - - self.assertEqual(len(patterns), 1) - self.assertEqual(patterns[0].count, 100) - self.assertIn("req-", patterns[0].signature) - self.assertTrue(exceeds_threshold(patterns[0].count, 50)) - - def test_unit_suffixed_numbers_collapse_without_touching_identifiers(self) -> None: - duration_patterns = aggregate_normalized_signatures( - ["ERROR request took 123ms", "ERROR request took 456ms"] - ) - size_patterns = aggregate_normalized_signatures( - ["WARN payload reached 512kb", "WARN payload reached 1mb"] - ) - - self.assertEqual(len(duration_patterns), 1) - self.assertEqual(duration_patterns[0].count, 2) - self.assertIn("", duration_patterns[0].signature) - self.assertEqual(len(size_patterns), 1) - self.assertEqual(size_patterns[0].count, 2) - self.assertIn("", size_patterns[0].signature) - for value, placeholder in ( - ("1.5s", ""), - ("20us", ""), - ("8gib", ""), - ("99%", ""), - ("20/s", ""), - ("30req/s", ""), - ): - with self.subTest(value=value): - self.assertIn(placeholder, normalize_signature(f"ERROR measured {value}")) - self.assertEqual( - normalize_signature("ERROR error500handler failed"), - "error500handler failed", - ) - - def test_measured_numbers_require_standalone_token_boundaries(self) -> None: - for first, second in ( - ("cache_1mb_loader", "cache_2mb_loader"), - ("cache-1mb-loader", "cache-2mb-loader"), - ("cache.1mb.loader", "cache.2mb.loader"), - ): - with self.subTest(first=first, second=second): - patterns = aggregate_normalized_signatures( - [f"ERROR {first} failed", f"ERROR {second} failed"] - ) - self.assertEqual(len(patterns), 2) - self.assertNotEqual(patterns[0].signature, patterns[1].signature) - - for first, second, expected_signature in ( - ("request took 123ms.", "request took 456ms.", "request took ."), - ("size 512kb.", "size 1mb.", "size ."), - ("request took 123ms,", "request took 456ms,", "request took ,"), - ("request took (123ms)", "request took (456ms)", "request took ()"), - ("request took 123ms;", "request took 456ms;", "request took ;"), - ("request took 123ms:", "request took 456ms:", "request took "), - ): - with self.subTest(first=first, second=second): - patterns = aggregate_normalized_signatures( - [f"ERROR {first}", f"ERROR {second}"] - ) - self.assertEqual(len(patterns), 1) - self.assertEqual(patterns[0].count, 2) - self.assertEqual(patterns[0].signature, expected_signature) - - for first, second, placeholder in ( - ("took 123ms", "took 456ms", ""), - ("512kb", "1mb", ""), - ("took 1.5s", "took 2.5s", ""), - ): - with self.subTest(first=first, second=second): - patterns = aggregate_normalized_signatures( - [f"ERROR {first}", f"ERROR {second}"] - ) - self.assertEqual(len(patterns), 1) - self.assertEqual(patterns[0].count, 2) - self.assertIn(placeholder, patterns[0].signature) - - self.assertIsNone(_MEASURED_NUMBER_RE.search("0.144.1")) - - def test_http_status_codes_remain_distinct(self) -> None: - self.assertNotEqual( - normalize_signature("ERROR HTTP 500 upstream error"), - normalize_signature("ERROR HTTP 404 upstream error"), - ) - - def test_port_numbers_remain_distinct(self) -> None: - self.assertNotEqual( - normalize_signature("ERROR connection to port 5432 refused"), - normalize_signature("ERROR connection to port 6379 refused"), - ) - - -class DraftDecisionTests(unittest.TestCase): - def setUp(self) -> None: - self.pattern = SignatureCount( - severity="ERROR", - signature="postgres pool timed out while acquiring connection id=", - count=51, - sample="ERROR postgres pool timed out while acquiring connection id=9234", - ) - - def test_threshold_crosses_only_above_named_limit(self) -> None: - self.assertFalse(exceeds_threshold(49, 50)) - self.assertFalse(exceeds_threshold(50, 50)) - self.assertTrue(exceeds_threshold(51, 50)) - - below = SignatureCount(**{**self.pattern.__dict__, "count": 50}) - self.assertEqual(decide_issue_drafts([below], [], threshold=50), []) - crossed = decide_issue_drafts([self.pattern], [], threshold=50) - self.assertEqual(len(crossed), 1) - self.assertIsNotNone(crossed[0].draft) - self.assertNotIn("9234", crossed[0].draft.body) - self.assertIn("id=", crossed[0].draft.body) - - def test_matching_open_issue_suppresses_draft(self) -> None: - issue = OpenIssue( - number=4249, - title="fix(db): postgres pool timed out while acquiring connection", - body="Repeated pool acquisition timeouts are visible in dcserver.", - url="https://github.com/itismyfield/AgentDesk/issues/4249", - ) - - self.assertTrue(issue_matches_signature(self.pattern.signature, issue)) - decisions = decide_issue_drafts([self.pattern], [issue], threshold=50) - self.assertEqual(len(decisions), 1) - self.assertEqual(decisions[0].matching_issue, issue) - self.assertIsNone(decisions[0].draft) - - def test_unrelated_long_issue_body_does_not_suppress_short_signature(self) -> None: - pattern = SignatureCount( - severity="WARN", - signature="worker lease expired", - count=60, - sample="WARN worker lease expired", - ) - issue = OpenIssue( - number=4250, - title="epic: routine runtime hardening", - body=( - "This broad epic discusses workers, scheduling, and resilience.\n" - + "unrelated planning context " * 80 - + "worker ownership and lease cleanup after expired sessions" - ), - ) - - self.assertFalse(issue_matches_signature(pattern.signature, issue)) - decisions = decide_issue_drafts([pattern], [issue], threshold=50) - self.assertIsNotNone(decisions[0].draft) - - def test_genuine_short_signature_duplicate_is_suppressed(self) -> None: - pattern = SignatureCount( - severity="WARN", - signature="worker lease expired", - count=60, - sample="WARN worker lease expired", - ) - issue = OpenIssue(number=4251, title="fix(runtime): worker lease expired") - - self.assertTrue(issue_matches_signature(pattern.signature, issue)) - decisions = decide_issue_drafts([pattern], [issue], threshold=50) - self.assertIsNone(decisions[0].draft) - - def test_unavailable_dedup_fails_closed_without_draft(self) -> None: - decisions = decide_issue_drafts( - [self.pattern], - [], - threshold=50, - dedup_available=False, - ) - self.assertEqual(len(decisions), 1) - self.assertIsNone(decisions[0].draft) - - def test_issue_creation_is_default_off_mutation_guard(self) -> None: - """Removing the shared approval check makes this mutation-style test fail.""" - - calls: list[str] = [] - draft = IssueDraft( - severity=self.pattern.severity, - signature=self.pattern.signature, - count=self.pattern.count, - title="draft title", - body="draft body", - ) - - with tempfile.TemporaryDirectory() as temp: - written = write_pending_drafts([draft], Path(temp))[0] - Path(f"{written.path}.approved").touch() - - with patch.dict(os.environ, {}, clear=True): - unset_mode = os.environ.get("AGENTDESK_LOG_DIGEST_CREATE_ISSUE", "off") - for mode in (unset_mode, "off", "invalid"): - disabled = maybe_post_approved_drafts( - [written], - mode, - lambda item: calls.append(item.title) or "https://example.test/1", - ) - self.assertFalse(disabled.attempted) - self.assertEqual(calls, [], f"mode {mode!r} must suppress an approved draft") - - Path(f"{written.path}.approved").unlink() - unreviewed = maybe_post_approved_drafts( - [written], - "confirmed", - lambda item: calls.append(item.title) or "https://example.test/1", - ) - self.assertFalse(unreviewed.attempted) - self.assertEqual(calls, [], "confirmation alone cannot bypass per-draft review") - - Path(f"{written.path}.approved").touch() - confirmed = maybe_post_approved_drafts( - [written], - "confirmed", - lambda item: calls.append(item.title) or "https://example.test/1", - ) - self.assertTrue(confirmed.attempted) - self.assertEqual(calls, ["draft title"]) - - def test_daily_summary_lists_top_patterns_crossings_and_drafts(self) -> None: - decisions = decide_issue_drafts([self.pattern], [], threshold=50) - with tempfile.TemporaryDirectory() as temp: - drafts = write_pending_drafts( - [decision.draft for decision in decisions if decision.draft], - Path(temp), - ) - summary = format_daily_summary( - [self.pattern], - decisions, - drafts, - threshold=50, - window_label="2026-07-13 00:00–2026-07-14 00:00 UTC", - ) - - self.assertIn("ERROR top: 51× postgres pool timed out", summary) - self.assertIn("WARN top: none", summary) - self.assertIn("best-effort signatures; verify top patterns manually", summary) - self.assertIn("Threshold >50: 1 crossed", summary) - self.assertIn("Crossed: 51× ERROR postgres pool timed out", summary) - self.assertIn("Pending drafts:", summary) - self.assertNotIn("Pending drafts: none", summary) - - -class DailyDigestIntegrationTests(unittest.TestCase): - def _write_threshold_crossing(self, root: Path) -> None: - logs = root / "logs" - logs.mkdir(parents=True) - (logs / "dcserver.stdout.log").write_text( - "".join( - f"2026-07-13T12:00:{index % 60:02d}Z ERROR worker lease expired id={index}\n" - for index in range(51) - ), - encoding="utf-8", - ) - - def test_main_gh_failure_wires_dedup_unavailable_fail_closed(self) -> None: - with tempfile.TemporaryDirectory() as temp: - root = Path(temp) - self._write_threshold_crossing(root) - completed = subprocess.CompletedProcess( - args=["gh"], returncode=1, stdout="", stderr="simulated gh failure" - ) - output = StringIO() - with ( - patch.object( - sys, - "argv", - [ - "daily_log_digest.py", - "--root", - str(root), - "--now", - "2026-07-14T00:00:00Z", - ], - ), - patch.object(daily_log_digest.subprocess, "run", return_value=completed), - patch.dict(os.environ, {"AGENTDESK_LOG_DIGEST_CREATE_ISSUE": "off"}, clear=False), - redirect_stdout(output), - ): - rc = daily_log_digest.main() - - drafts = list( - (root / "runtime" / "pending-issue-drafts" / "daily-log-digest").glob("*.md") - ) - - self.assertEqual(rc, 0) - self.assertEqual(drafts, []) - self.assertIn("dedup unavailable", output.getvalue()) - self.assertIn("drafts suppressed", output.getvalue()) - - def test_invalid_threshold_env_warns_and_falls_back_to_50(self) -> None: - for invalid in ("0", "-4", "not-a-number"): - with self.subTest(invalid=invalid), tempfile.TemporaryDirectory() as temp: - root = Path(temp) - self._write_threshold_crossing(root) - output = StringIO() - with ( - patch.object( - sys, - "argv", - [ - "daily_log_digest.py", - "--root", - str(root), - "--now", - "2026-07-14T00:00:00Z", - ], - ), - patch.object(daily_log_digest, "load_open_issues", return_value=([], None)), - patch.dict( - os.environ, - { - "AGENTDESK_LOG_DIGEST_THRESHOLD": invalid, - "AGENTDESK_LOG_DIGEST_CREATE_ISSUE": "off", - }, - clear=False, - ), - redirect_stdout(output), - ): - rc = daily_log_digest.main() - - self.assertEqual(rc, 0) - self.assertIn("invalid AGENTDESK_LOG_DIGEST_THRESHOLD", output.getvalue()) - self.assertIn("using default 50", output.getvalue()) - self.assertIn("Threshold >50: 1 crossed", output.getvalue()) - - def test_open_issue_cap_is_fail_closed_with_warning(self) -> None: - payload = [ - {"number": index, "title": f"issue {index}", "body": "", "url": ""} - for index in range(1, OPEN_ISSUE_LIMIT + 1) - ] - completed = SimpleNamespace(returncode=0, stdout=json.dumps(payload), stderr="") - with patch.object(daily_log_digest.subprocess, "run", return_value=completed): - issues, warning = load_open_issues("owner/repo") - - self.assertEqual(len(issues), OPEN_ISSUE_LIMIT) - self.assertIsNotNone(warning) - self.assertIn("may be truncated", warning) - pattern = SignatureCount("ERROR", "brand new failure pattern", 51, "ERROR sample") - decisions = decide_issue_drafts( - [pattern], issues, threshold=50, dedup_available=warning is None - ) - self.assertIsNone(decisions[0].draft) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_weekly_churn_audit.py b/tests/test_weekly_churn_audit.py deleted file mode 100644 index 186f84a651..0000000000 --- a/tests/test_weekly_churn_audit.py +++ /dev/null @@ -1,391 +0,0 @@ -#!/usr/bin/env python3 -"""Focused tests for the weekly regression-churn audit (#4265).""" - -from __future__ import annotations - -import os -import subprocess -import sys -import tempfile -import unittest -from contextlib import redirect_stdout -from io import StringIO -from pathlib import Path -from unittest.mock import patch - - -ROOT = Path(__file__).resolve().parents[1] -ROUTINE_DIR = ROOT / "routines" / "monitoring" -sys.path.insert(0, str(ROUTINE_DIR)) - -import weekly_churn_audit # noqa: E402 -from log_digest_issue_drafts import OpenIssue, stable_draft_filename # noqa: E402 -from weekly_churn_audit import ( # noqa: E402 - GitCommit, - analyze_churn, - candidate_drafts, - compute_issue_lineages, - is_fix_commit_subject, - issue_references, - maybe_post_weekly_channel, -) - - -def commit( - subject: str, - files: tuple[str, ...] = ("src/services/discord/example.rs",), - *, - sha: str = "a" * 40, - body: str = "", -) -> GitCommit: - return GitCommit(sha=sha, subject=subject, body=body, files=files) - - -class WeeklyChurnAuditTests(unittest.TestCase): - def test_fix_commit_classifier_is_precise(self) -> None: - for subject in ( - "fix: stop duplicate relay", - "fix(discord): stop duplicate relay", - "fix!: stop breaking duplicate relay", - "fix(discord)!: stop breaking duplicate relay", - ): - with self.subTest(subject=subject): - self.assertTrue(is_fix_commit_subject(subject)) - - for subject in ( - "chore: stop duplicate relay", - "feat(discord): stop duplicate relay", - "refactor: stop duplicate relay", - "docs: explain duplicate relay", - "test: reproduce duplicate relay", - "prefix: fix: embedded text is not a fix subject", - ): - with self.subTest(subject=subject): - self.assertFalse(is_fix_commit_subject(subject)) - - def test_threshold_includes_n_but_not_n_minus_one(self) -> None: - repeated = "src/services/discord/repeated.rs" - below = "src/services/discord/below.rs" - commits = [ - commit("fix: first", (repeated, below), sha="1" * 40), - commit("fix(scope): second", (repeated, below), sha="2" * 40), - commit("fix: third", (repeated,), sha="3" * 40), - commit("feat: not counted", (below,), sha="4" * 40), - ] - - audit = analyze_churn(commits, threshold=3) - - self.assertEqual(audit.file_counts[repeated], 3) - self.assertEqual(audit.file_counts[below], 2) - self.assertEqual([candidate.file for candidate in audit.candidates], [repeated]) - self.assertEqual(audit.module_counts["src/services/discord"], 3) - - def test_squash_pr_suffixes_do_not_create_issue_generations(self) -> None: - subject = "fix(deploy): #4262 post-deploy scope (#4511) (#4523)" - - self.assertTrue(is_fix_commit_subject(subject)) - self.assertEqual(issue_references(subject), (4262,)) - self.assertEqual(compute_issue_lineages([commit(subject)])[0].issues, (4262,)) - self.assertEqual(issue_references("fix(deploy): #100 (#101) (#102)"), (100,)) - self.assertEqual( - issue_references("fix: #100 see #99", "Regression-of: #98"), - (100, 99, 98), - ) - - def test_issue_lineage_generation_count_spans_commit_text_edges(self) -> None: - commits = [ - commit("fix: #100 first regression", body="Regression-of cross-reference: #200"), - commit("fix: #200 follow-up", body="Regression-of cross-reference: #300"), - commit("fix: independent #900"), - ] - - lineages = compute_issue_lineages(commits) - - self.assertEqual(lineages[0].issues, (100, 200, 300)) - self.assertEqual(lineages[0].generations, 3) - self.assertIn((900,), [lineage.issues for lineage in lineages]) - - def test_open_issue_dedup_suppresses_matching_candidate_draft(self) -> None: - candidate = analyze_churn( - [ - commit(f"fix: regression {index}", sha=str(index) * 40) - for index in range(1, 4) - ], - threshold=3, - ).candidates[0] - created = weekly_churn_audit.build_candidate_draft(candidate, "7 days", 3) - matching = OpenIssue(number=4265, title=created.title, body=created.body) - - drafts, matches = candidate_drafts( - [candidate], [matching], since="7 days", threshold=3 - ) - - self.assertEqual(drafts, []) - self.assertEqual(matches, [(candidate, matching)]) - - def test_created_issue_marker_prevents_duplicate_on_second_run(self) -> None: - audit_commits = [ - commit(f"fix: repeat {index}", ("justfile",), sha=str(index) * 40) - for index in range(1, 4) - ] - candidate = analyze_churn(audit_commits, threshold=3).candidates[0] - draft = weekly_churn_audit.build_candidate_draft(candidate, "7 days", 3) - open_issues: list[OpenIssue] = [] - - def load_open(_repo: str) -> tuple[list[OpenIssue], None]: - return list(open_issues), None - - def create_issue(_repo: str, approved) -> str: - open_issues.append( - OpenIssue(number=4265, title=approved.title, body=approved.body) - ) - return "https://example.test/issues/4265" - - with tempfile.TemporaryDirectory() as temp: - pending = ( - Path(temp) - / "runtime" - / "pending-issue-drafts" - / "weekly-churn-audit" - ) - pending.mkdir(parents=True) - draft_name = stable_draft_filename(draft) - Path(f"{pending / draft_name}.approved").touch() - argv = [ - "weekly_churn_audit.py", - "--repo-root", - str(ROOT), - "--runtime-root", - temp, - ] - with ( - patch.object(sys, "argv", argv), - patch.dict( - os.environ, - {"AGENTDESK_CHURN_AUDIT_CREATE_ISSUE": "confirmed"}, - clear=True, - ), - patch.object( - weekly_churn_audit, - "collect_git_commits", - return_value=audit_commits, - ), - patch.object( - weekly_churn_audit, "load_open_issues", side_effect=load_open - ), - patch.object( - weekly_churn_audit, "create_github_issue", side_effect=create_issue - ) as create, - redirect_stdout(StringIO()), - ): - self.assertEqual(weekly_churn_audit.main(), 0) - self.assertEqual(weekly_churn_audit.main(), 0) - - create.assert_called_once() - - def test_git_replaces_non_utf8_commit_text(self) -> None: - with tempfile.TemporaryDirectory() as temp: - repo = Path(temp) - subprocess.run(["git", "init", "-q", str(repo)], check=True) - subprocess.run( - ["git", "-C", str(repo), "config", "user.name", "Audit Test"], - check=True, - ) - subprocess.run( - ["git", "-C", str(repo), "config", "user.email", "audit@example.test"], - check=True, - ) - subprocess.run( - [ - "git", - "-C", - str(repo), - "config", - "i18n.commitEncoding", - "ISO-8859-1", - ], - check=True, - ) - subprocess.run( - [ - "git", - "-C", - str(repo), - "config", - "i18n.logOutputEncoding", - "ISO-8859-1", - ], - check=True, - ) - (repo / "sample.txt").write_text("sample\n", encoding="utf-8") - subprocess.run(["git", "-C", str(repo), "add", "sample.txt"], check=True) - subprocess.run( - ["git", "-C", str(repo), "commit", "-q", "-F", "-"], - input=b"fix: latin-1 \xff message\n", - check=True, - ) - - commits = weekly_churn_audit.collect_git_commits(repo, "7 days") - - self.assertEqual(len(commits), 1) - self.assertIn("\ufffd", commits[0].subject) - self.assertEqual(commits[0].files, ("sample.txt",)) - - def test_invalid_env_thresholds_fall_back_and_log(self) -> None: - for value in ("abc", "0", "-1"): - with ( - self.subTest(value=value), - patch.object(sys, "argv", ["weekly_churn_audit.py"]), - patch.dict( - os.environ, - {"AGENTDESK_CHURN_AUDIT_THRESHOLD": value}, - clear=True, - ), - patch.object(weekly_churn_audit, "log") as audit_log, - ): - args = weekly_churn_audit.parse_args() - - self.assertEqual(args.threshold, weekly_churn_audit.DEFAULT_THRESHOLD) - audit_log.assert_called_once() - self.assertIn(value, audit_log.call_args.args[0]) - - def test_dense_cyclic_lineage_search_is_bounded_and_logged(self) -> None: - component = set(range(1, 8)) - edges = {node: component - {node} for node in component} - with ( - patch.object(weekly_churn_audit, "LINEAGE_PATH_STATE_LIMIT", 25), - patch.object(weekly_churn_audit, "log") as audit_log, - ): - lineage = weekly_churn_audit._longest_lineage(component, edges) - - self.assertTrue(lineage) - self.assertLessEqual(len(lineage), len(component)) - audit_log.assert_called_once() - self.assertIn("truncated at 25 path states", audit_log.call_args.args[0]) - - def test_channel_post_gate_is_default_off_and_confirmed_is_idempotent(self) -> None: - calls: list[str] = [] - with tempfile.TemporaryDirectory() as temp: - state = Path(temp) / "post-state.json" - disabled = maybe_post_weekly_channel( - "report", - "off", - "123", - state, - calls.append, - ) - first = maybe_post_weekly_channel( - "report", - "confirmed", - "123", - state, - calls.append, - ) - repeated = maybe_post_weekly_channel( - "report", - "confirmed", - "123", - state, - calls.append, - ) - - self.assertEqual(disabled, (False, "weekly ops channel post disabled")) - self.assertEqual(first, (True, "weekly ops channel report posted")) - self.assertEqual(repeated, (False, "identical weekly report already posted")) - self.assertEqual(calls, ["report"]) - - def test_main_default_off_has_no_issue_or_channel_side_effect(self) -> None: - audit_commits = [ - commit(f"fix: repeat {index}", sha=str(index) * 40) - for index in range(1, 4) - ] - with tempfile.TemporaryDirectory() as temp: - output = StringIO() - with ( - patch.object( - sys, - "argv", - [ - "weekly_churn_audit.py", - "--repo-root", - str(ROOT), - "--runtime-root", - temp, - ], - ), - patch.dict(os.environ, {}, clear=True), - patch.object( - weekly_churn_audit, - "collect_git_commits", - return_value=audit_commits, - ), - patch.object(weekly_churn_audit, "load_open_issues") as load_open, - patch.object(weekly_churn_audit, "write_pending_drafts") as write_drafts, - patch.object( - weekly_churn_audit, "maybe_post_approved_drafts" - ) as create_issues, - patch.object(weekly_churn_audit, "_post_report") as post_channel, - redirect_stdout(output), - ): - rc = weekly_churn_audit.main() - - runtime_files = list(Path(temp).rglob("*")) - - self.assertEqual(rc, 0) - load_open.assert_not_called() - write_drafts.assert_not_called() - create_issues.assert_not_called() - post_channel.assert_not_called() - self.assertEqual(runtime_files, []) - self.assertIn("재설계 후보 (1)", output.getvalue()) - self.assertIn("issue drafts dry-run only", output.getvalue()) - - def test_confirmed_issue_gate_still_requires_per_draft_approval(self) -> None: - audit_commits = [ - commit(f"fix: repeat {index}", sha=str(index) * 40) - for index in range(1, 4) - ] - with tempfile.TemporaryDirectory() as temp: - argv = [ - "weekly_churn_audit.py", - "--repo-root", - str(ROOT), - "--runtime-root", - temp, - ] - with ( - patch.object(sys, "argv", argv), - patch.dict( - os.environ, - {"AGENTDESK_CHURN_AUDIT_CREATE_ISSUE": "confirmed"}, - clear=True, - ), - patch.object( - weekly_churn_audit, - "collect_git_commits", - return_value=audit_commits, - ), - patch.object( - weekly_churn_audit, "load_open_issues", return_value=([], None) - ) as load_open, - patch.object( - weekly_churn_audit, - "create_github_issue", - return_value="https://example.test/issues/1", - ) as create_issue, - redirect_stdout(StringIO()), - ): - self.assertEqual(weekly_churn_audit.main(), 0) - draft = next( - (Path(temp) / "runtime" / "pending-issue-drafts").rglob("*.md") - ) - create_issue.assert_not_called() - Path(f"{draft}.approved").touch() - self.assertEqual(weekly_churn_audit.main(), 0) - - self.assertEqual(load_open.call_count, 2) - create_issue.assert_called_once() - - -if __name__ == "__main__": - unittest.main()