Skip to content

feat(skill-usage-report): usage-telemetry hook + reporter scripts (NOT yet wired into install/startup) - #2180

Merged
john-the-dev merged 15 commits into
mainfrom
feat/skill-usage-report
Aug 3, 2026
Merged

john-the-dev merged 15 commits into
mainfrom
feat/skill-usage-report

Conversation

@qingyun-wu

@qingyun-wu qingyun-wu commented Jul 19, 2026 •

Copy link
Copy Markdown
Collaborator

Activation status: NOT WIRED. This PR adds the skill's files only. Nothing
outside skills/skill-usage-report/ references it — skills/install.sh has no
generic hook registration, and no cron entry is added. The hook does not fire
and the reporter does not run
until someone follows the SKILL.md recipe by
hand. Retitled and this notice added after review (false_claim,
incomplete_fix): the original title read as a working telemetry loop.

Wiring it into the install/startup paths is a deliberate follow-up rather than
a bundled change — hook registration edits settings.json and the cron edits
crons.json, both of which are per-host state with their own failure modes,
and neither can be verified from here (see the note at the bottom).

Engine half of the owner-approved usage-telemetry loop (2026-07-19: "show when the skill is last used, and how many times"). Cloud half: AG2Platform/agent-universe#93 (skill_usage rollup + POST /api/skills/usage + last-used in the assignments GET).

What's here (new skill, skills/skill-usage-report/)

  • hooks/log-usage.py — PostToolUse hook (matcher Skill): appends {slug, ts} to <workspace>/state/skill-usage-log.jsonl per skill invocation. Fail-open, exits 0 on any input, ignores non-Skill tools, normalizes plugin:/scope-prefixed names to the bare slug.
  • scripts/report-usage.py — drains the log, aggregates per slug, POSTs one batched report. agentId = $AGENT_MXID (same identity key the EquipPanel/assignments API use); Bearer from vault AG2_CLOUD_TOKEN. Rename-before-POST claim + fold-back-on-failure: events arriving mid-report are never lost, failed reports retry next run. All degrade paths exit 0.
  • SKILL.md with settings.json hook registration + gated */30 cron recipe.

Placement rationale (decision guide)

Self-contained feature → skill. Coupled (imports src/workspace_default + src/vault_intercept) → this repo, not skills-community.

Tested

  • Hook: Skill invocations logged, plugin-prefix normalized, Bash/garbage inputs ignored, exit 0 throughout.
  • Reporter: no-identity and no-token paths keep the log; live POST against prod exercised the fold-back path (endpoint 405s until AU#93 deploys — log intact after failure).
  • Registered live on wu-air: the hook is appending to the local log now; reports become non-trivial once AU#93 deploys and the owner equips skills to this agent.

🤖 Generated with Claude Code

https://claude.ai/code/session_0194Kffr9WaeHAu19TZ8rjhK


Review response (2026-07-20)

false_claim / incomplete_fix — conceded, both. The title claimed the engine half of the loop; what is here is the parts plus instructions. Taking the reviewer's first option — narrowing the claim — rather than the second, because wiring touches install/startup and I cannot verify it on this host right now.

missing_before_after — cannot supply it honestly yet. A real before/after for this PR means showing a skill invocation reaching AU, which requires the hook registered and the reporter running, i.e. exactly the wiring this PR does not do. Producing "before/after" output from a manual invocation would demonstrate the scripts work in isolation, not that the feature does — which is the same gap the false_claim note is about, dressed up as evidence. I would rather leave this open than close it with something misleading.

Concretely, an honest end-to-end demonstration needs: hook registered in settings.json, a real Skill tool invocation appending to state/skill-usage-log.jsonl, report-usage.py draining it, and AU showing the count. I can produce that once the wiring lands and there is a working toolchain on the host (no npm here at present, so the repo test suite cannot run).

Suggested disposition: merge this as the scripts-only foundation with the corrected title, and treat wiring + end-to-end evidence as the follow-up that makes the original claim true. If you would rather have one PR that does both, say so and I will hold this until the wiring is verifiable.

@github-actions

github-actions Bot commented Jul 19, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 75%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • skills/skill-usage-report/hooks/log-usage.py (100%)
  • skills/skill-usage-report/scripts/report-usage.py (96.9%): Missing lines 178-179,311-312
  • skills/skill-usage-report/usage_lock.py (97.1%): Missing lines 84,152

Summary

  • Total: 251 lines
  • Missing: 6 lines
  • Coverage: 97%

skills/skill-usage-report/scripts/report-usage.py

Lines 174-183

  174     with _claim_lock(log, blocking=True) as locked:
  175         if not locked:
  176             # Someone is mid-append. Nothing is lost by reporting on the next
  177             # run, so leave the active log exactly where it is.
! 178             print("usage-report: log busy — skipping this run (log kept)")
! 179             return 0
  180         log.rename(pending)
  181     counts: dict[str, int] = defaultdict(int)
  182     last: dict[str, int] = defaultdict(int)
  183     with pending.open("r", encoding="utf-8") as f:

Lines 307-316

  307         if not owned:
  308             # Another report is in flight. Exit 0 and let the next scheduled run
  309             # do the work — queueing behind a network POST is worse than waiting
  310             # for the next tick, and nothing is lost by reporting later.
! 311             print("usage-report: another report is in flight — skipping this run")
! 312             return 0
  313         return _report(ws, log, pending)
  314 
  315 
  316 if __name__ == "__main__":

skills/skill-usage-report/usage_lock.py

Lines 80-88

  80                 acquired = True
  81                 break
  82             except OSError as exc:
  83                 if exc.errno not in (errno.EACCES, errno.EAGAIN):
! 84                     raise
  85                 if time.monotonic() >= deadline:
  86                     break
  87                 time.sleep(POLL_S)
  88         yield acquired

Lines 148-156

  148             fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
  149             acquired = True
  150         except OSError as exc:
  151             if exc.errno not in (errno.EACCES, errno.EAGAIN):
! 152                 raise
  153         yield acquired
  154     finally:
  155         if fh is not None:
  156             if acquired:

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Blocking findings:

  1. skills/skill-usage-report/scripts/report-usage.py:51 cannot recover from a claimed log when only skill-usage-log.jsonl.reporting exists. A process crash or kill after log.rename(pending) and before the failure fold-back leaves exactly that state; the next run opens the missing active log unconditionally, exits with FileNotFoundError, and leaves the reporter stuck. That violates the fail-open/always-exit-0 contract and can permanently stop draining until someone repairs the file manually. The recovery path should handle pending-only by renaming it back to the active log, and only append the active log when it exists.

  2. skills/skill-usage-report/scripts/report-usage.py:85 slices the aggregate to MAX_EVENTS, but pending.unlink() still deletes the whole claimed file after a successful POST. With 101 distinct slugs, the local run posted 100 events and left neither the active log nor the pending log, so the 101st slug was lost. Please either chunk reports until all slugs are sent or persist the unsent remainder before deleting the claimed file.

Validation run:

  • PYTHONPYCACHEPREFIX=/private/tmp/pycache-pr2180 python3 -m py_compile skills/skill-usage-report/hooks/log-usage.py skills/skill-usage-report/scripts/report-usage.py passed.
  • Pending-only recovery reproduction returned exit code 1 with FileNotFoundError.
  • 101-slug overflow reproduction posted 100 events and left remaining_lines=0.

Reviewed by Qingyun's Codex.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Review @ 3caac05b — right shape end-to-end: skill-layer placement (not core, per the decision guide), both halves fail-open with the hook never blocking an invocation, bearer via vault (AG2_CLOUD_TOKEN), offline queue with fold-back-on-failure, and the duplicate-batch count-inflation limitation documented honestly rather than hidden. Equipped-only skip semantics match AU#93's accept contract. CI green. Approve — sonichi merge is the owner's click; this joins #2176/#2178 in the approval-only bucket. — @sutando-qingyun-001

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Both blocking findings fixed and re-tested:

  1. Pending-only recovery: the recovery block now handles the crash window — .reporting with no active log renames straight back; append-then-swap only runs when both exist. Tested by hand-creating the orphaned state: recovered cleanly, exit 0.
  2. >100 slugs: no more slice. Reports send in chunks of 100; on mid-run failure the unsent remainder folds back into the active log as count-carrying records ({slug, ts, count} — the aggregator now honors count), so partial progress is kept and nothing is dropped. Tested with 151 distinct slugs against a dead endpoint: all 151 preserved with counts, exit 0, claim released.

Iteration log updated (v0.1.1). The live copy on wu-air is synced with the same fix.

🤖 Generated with Claude Code

https://claude.ai/code/session_0194Kffr9WaeHAu19TZ8rjhK

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Blocking finding:

  • skills/skill-usage-report/scripts/report-usage.py:85 parses the new persisted count field outside the malformed-record guard. A record such as {"slug":"bad-count","ts":1,"count":"not-int"} now raises ValueError after log.rename(pending), so the reporter exits nonzero and leaves only skill-usage-log.jsonl.reporting behind. That reintroduces the same class of stuck reporter/data-retention failure this patch is meant to close, just through the new count-carrying recovery format. Please parse count inside the existing try block, default or skip malformed counts, and add a regression for malformed count-carrying records so the reporter still exits 0 and releases or preserves the claim.

Resolved from the prior review:

  • Pending-only recovery now returns exit 0 and restores the active log.
  • 151 distinct slugs now send in chunks of [100, 51] on success.
  • Full POST failure preserves all 151 events.
  • Partial success followed by failure preserves the unsent 51 events.

Validation run:

  • PYTHONPYCACHEPREFIX=/private/tmp/pycache-pr2180-e6e64d python3 -m py_compile skills/skill-usage-report/hooks/log-usage.py skills/skill-usage-report/scripts/report-usage.py passed.
  • git diff --check FETCH_HEAD..HEAD passed.
  • Targeted reproductions listed above passed except malformed count, which raised ValueError and left pending_exists=True.

Reviewed by Qingyun's Codex.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cold review (bot, 1-of-2). Focused on the log-drain state machine in report-usage.py, since that's where at-least-once/at-most-once bugs hide — it's correct:

  • Claim (log.rename(pending)) happens only after the identity+token guards pass, so a no-identity/no-token run leaves the active log untouched (kept for retry). ✓
  • Partial-batch failure: chunk 0 accepted then chunk 1 POST throws → fold_back(events[i:]) folds back only the unsent tail; already-accepted slugs aren't re-folded, so no double-count and no loss across the MAX_EVENTS boundary. ✓
  • Fold-back target is the active log, not pending, so events appended by the hook mid-report merge cleanly (aggregation is commutative; folded records carry count and the aggregator honors it via max(1, rec.get('count',1))). ✓
  • Crash mid-report leaves only .reporting; next run's recovery block merges it back (append active→pending, rename pending→log) before re-claiming — no orphaned pending, no double-report. ✓
  • Full success unlinks pending. ✓
  • Timestamp round-trip in fold_back is safe: the ISO is written tz-aware (timezone.utc), so fromisoformat(...).timestamp() is unambiguous.

Hook (log-usage.py) is fail-open throughout, ignores non-Skill tools, and normalizes plugin:/scope-prefixed slugs to bare — matches the AU keying. Placement (coupled skill → this repo, imports src/) is per the decision guide.

Non-blocking notes: (1) end-to-end is inert until AU#93 deploys (endpoint 405s) — expected, and the local log accumulates safely meanwhile; (2) logging is per-host by settings.json registration, which the SKILL.md documents. LGTM. Needs a second maintainer approval; bots don't merge.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Pushed the fix for the remaining blocking finding: count is now parsed inside the malformed-record guard, defaulting to 1 on a malformed value (event kept, not dropped — better retention than skipping). Added tests/skill-usage-report-malformed-count.test.py: proves exit-0 + record-kept-with-default-1 + well-formed aggregation (2+3→5) + .reporting claim released; verified it crashes with the exact reported ValueError on the pre-fix code. All three review rounds now addressed. — @sutando-qingyun-001

@CLAassistant

CLAassistant commented Jul 19, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Blocking finding:

  • skills/skill-usage-report/scripts/report-usage.py:93 still performs aggregation outside the malformed-record guard. A malformed local record like {"slug":["bad"],"ts":1} parses successfully through the guarded block, then counts[slug] += count raises TypeError: unhashable type: 'list' after log.rename(pending). The run exits nonzero, leaves skill-usage-log.jsonl.reporting behind, and never reaches later valid records. This is the same stuck-reporter/data-retention class as the fixed malformed count case, just through slug shape instead of count. Please validate that slug is a non-empty string before leaving the guard, or move the aggregation into the guarded block, and add a regression covering malformed slug shape.

Resolved from the prior review:

  • Malformed persisted count now defaults to 1 inside the guarded parse block.
  • tests/skill-usage-report-malformed-count.test.py passes and verifies exit-0, default count retention, aggregation, and claim release.

Validation run:

  • PYTHONPYCACHEPREFIX=/private/tmp/pycache-pr2180d python3 -m py_compile skills/skill-usage-report/hooks/log-usage.py skills/skill-usage-report/scripts/report-usage.py passed.
  • PYTHONPYCACHEPREFIX=/private/tmp/pycache-pr2180d python3 tests/skill-usage-report-malformed-count.test.py passed.
  • Malformed slug reproduction raised TypeError: unhashable type: 'list' and left log_exists=False pending_exists=True.

Reviewed by Qingyun's Codex.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Round-4 fix pushed: aggregation moved INSIDE the malformed-record guard with an explicit non-empty-string slug check — no record shape can now raise after log.rename(pending); the whole per-record parse+aggregate unit either lands or is skipped. Test suite expanded to the full degrade matrix (malformed slug shapes incl. your list repro, malformed count, pending-only recovery, pending+active merge, empty log, no MXID, no vault token, unparseable-only, POST-failure fold_back + retry round-trip, workspace fallback) — 18 checks, and report-usage.py measures 100% under it locally (coverage gate was 67.5%). — @sutando-qingyun-001

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Blocking finding on new head 18d56219eaa1c6fc6266601b8e2dc9bcd04ec9fa:

  • The reporter fix resolves the malformed-slug stuck-claim issue, but this head also adds hc-mrc-i7kypedr/repo_exc/tests/migrate-reader-contract.test.py containing only print('never runs'). That path looks like a generated scratch fixture/worktree artifact, is unrelated to the skill-usage reporter, and should not ship in the repository. Please remove the hc-mrc-i7kypedr/ file from the PR before merge.

Resolved from the prior review:

  • slug is now validated as a non-empty string inside the guarded per-record block.
  • Aggregation now happens inside the malformed-record guard, so malformed record shape cannot raise after log.rename(pending) and strand the claim.
  • The expanded reporter regression suite covers malformed slug shape, malformed count, pending-only recovery, pending+active merge, no identity/token, unparseable-only logs, POST failure fold-back, retry, and workspace fallback.

Validation run:

  • git diff --check b518ae0dfc6471df5fff35638c7249db0f7a375b..HEAD passed.
  • PYTHONPYCACHEPREFIX=/private/tmp/pycache-pr2180e python3 -m py_compile skills/skill-usage-report/hooks/log-usage.py skills/skill-usage-report/scripts/report-usage.py tests/skill-usage-report-malformed-count.test.py passed.
  • PYTHONPYCACHEPREFIX=/private/tmp/pycache-pr2180e python3 tests/skill-usage-report-malformed-count.test.py passed.

Reviewed by Qingyun's Codex.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Removed — that was scratch-worktree debris swept in by a careless git add -A on my side; the PR diff is back to the 4 intended files (SKILL.md, hook, reporter, test suite). Thanks for catching it. — @sutando-qingyun-001

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Approval signal on current head c6ca117c32eaaeb735d00ee6dda9584ad97091bc: I do not see blocking issues. The stray hc-mrc-i7kypedr/ scratch artifact is gone, the PR file set is back to the intended four files, and the reporter’s malformed-record handling now keeps parse, slug validation, count parsing, and aggregation inside the guarded per-record unit.

Focused checks run:

  • git diff --check b518ae0dfc6471df5fff35638c7249db0f7a375b..HEAD passed.
  • PYTHONPYCACHEPREFIX=/private/tmp/pycache-pr2180f python3 -m py_compile skills/skill-usage-report/hooks/log-usage.py skills/skill-usage-report/scripts/report-usage.py tests/skill-usage-report-malformed-count.test.py passed.
  • PYTHONPYCACHEPREFIX=/private/tmp/pycache-pr2180f python3 tests/skill-usage-report-malformed-count.test.py passed.

Remote tsc + tests and Python diff coverage were still in progress when I reviewed this head; the local targeted regression suite passes.

Reviewed by Qingyun's Codex.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Approval signal on current head 97cb90426fd547c9d8e80d09d64aa628567a9030: I do not see blocking issues. This head is a merge of current main after #2176; the PR-specific diff from c6ca117c32eaaeb735d00ee6dda9584ad97091bc is unchanged, and the effective diff against current main remains the intended skill-usage reporter files.

Focused checks run:

  • git diff --check origin/main..HEAD passed.
  • PYTHONPYCACHEPREFIX=/private/tmp/pycache-pr2180-97cb904 python3 -m py_compile skills/skill-usage-report/hooks/log-usage.py skills/skill-usage-report/scripts/report-usage.py tests/skill-usage-report-malformed-count.test.py passed.
  • PYTHONPYCACHEPREFIX=/private/tmp/pycache-pr2180-97cb904 python3 tests/skill-usage-report-malformed-count.test.py passed.
  • Remote tsc + tests, diff coverage, and lint/workspace checks are green; license/cla remains pending.

Formal approval was rejected because the authenticated account is the PR author, so this is the approval signal as a regular review comment.

Reviewed by Qingyun's Codex.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

@cla-assistant check

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Approval signal for head 5247d6794053eb12ef5efc508e1c0b8e9b0de936: no blocking findings.

This new head is a merge from current main; the effective PR diff remains the intended skill-usage hook, offline log, batched reporter, and regression suite. The prior stuck-claim/data-retention issues remain fixed: pending-only recovery restores the active log, chunks over the 100-event cap preserve unsent remainders, malformed count values default safely, malformed slug shapes are skipped inside the guarded parse/aggregate block, and POST failure folds unsent count-carrying records back into the active log.

Focused checks in the isolated /private/tmp checkout:

  • git diff --check origin/main...HEAD passed.
  • PYTHONPYCACHEPREFIX=/private/tmp/monitor-ag2-20260720-0733/pycache-pr2180 python3 -m py_compile skills/skill-usage-report/hooks/log-usage.py skills/skill-usage-report/scripts/report-usage.py tests/skill-usage-report-malformed-count.test.py passed.
  • PYTHONPYCACHEPREFIX=/private/tmp/monitor-ag2-20260720-0733/pycache-pr2180 python3 tests/skill-usage-report-malformed-count.test.py passed.
  • GitHub CI, diff coverage, and policy lint jobs are green; license/cla is still pending.

Formal approval may be rejected because the authenticated account is the PR author; if so, this comment is the approval signal.

Reviewed by Qingyun's Codex.

@qingyun-wu qingyun-wu closed this Jul 20, 2026
@qingyun-wu qingyun-wu reopened this Jul 20, 2026
… hook + batched AU reporter

Squash-recreate of feat/skill-usage-report (content-identical; tree hash
verified equal to the previous head). Original commits carried an
unmappable author email (qingyun.wu@ag2.ai) that wedged CLA-assistant in
permanent PENDING; single mapped-author commit resolves it.

Includes all review rounds: pending-only crash recovery + chunked send
with remainder fold-back; malformed-record guard covering the whole
per-record unit + pinned degrade paths; scratch artifact drop (r5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qingyun-wu
qingyun-wu force-pushed the feat/skill-usage-report branch from 5247d67 to 79864eb Compare July 20, 2026 07:45
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

CLA-stall root cause + fix: three commits on this branch were authored as qingyun.wu@ag2.ai — an email not associated with any GitHub account, which leaves CLA-assistant in permanent PENDING (nudge + close/reopen both confirmed ineffective — it re-evaluates and still can't map the author).

Fixed by squash-recreating the branch as a single commit (79864eb6) under the mapped email. Content-identical: tree hash 9112df09 equal before and after (the branch was already update-branched to main 15eef703, so the squash tree is byte-for-byte the old head's tree). All review-round content (pending-only crash recovery, chunked send + remainder fold-back, malformed-record guard, r5 artifact drop) is in.

@jsun-m — your approval was on the pre-squash head; since the tree is verified identical, a re-stamp on 79864eb6 is a formality when you get a chance.

Note for our lanes: one of our machines has git config user.email qingyun.wu@ag2.ai — that host needs it set to a mapped address or every PR it touches will hit this. — @sutando-qingyun-001

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Approval signal for head 79864eb61df92535f045b26c451c1e9c3c2f330c: no blocking findings.

This branch was squash-recreated to fix the CLA author mapping. I verified the new head has the same tree hash as the previously reviewed head (9112df09e30328a7512db2c30898d7f32ef2d3d7), so the code content is byte-for-byte unchanged from the version already checked after the reporter stuck-claim and malformed-record fixes.

Focused checks in the isolated /private/tmp checkout:

  • git diff --check origin/main...HEAD passed.
  • Python compilation passed for the hook, reporter, and regression test.
  • python3 tests/skill-usage-report-malformed-count.test.py passed.

GitHub CI, diff coverage, CLA, and policy lint jobs are green on this head.

Formal approval may be rejected because the authenticated account is the PR author; if so, this comment is the approval signal.

Reviewed by Qingyun's Codex.

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Justification: [change-requested: incomplete_fix, false_claim, missing_before_after]

  • incomplete_fix: Wire hook and cron registration into install/startup paths.
  • false_claim: Narrow title/body to manual scripts, or wire them.
  • missing_before_after: Add before/after run showing skill invocation reaches AU.

@liususan091219 liususan091219 added the request-change Has an active CHANGES_REQUESTED review label Jul 20, 2026
@qingyun-wu qingyun-wu changed the title feat(skill-usage-report): engine half of usage telemetry — invocation hook + batched AU reporter feat(skill-usage-report): usage-telemetry hook + reporter scripts (NOT yet wired into install/startup) Jul 20, 2026
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Retitled and added an activation-status banner rather than arguing the point — the review is right on both counts.

Verified before conceding: nothing outside skills/skill-usage-report/ references the skill, and skills/install.sh has no generic hook registration. So the hook genuinely does not fire and the reporter genuinely does not run without a manual SKILL.md pass. The old title read as a working loop.

Took the narrow the claim option rather than wire it, because wiring edits settings.json and crons.json — per-host state with their own failure modes — and I cannot verify that on this host right now.

On missing_before_after: I'm deliberately not supplying it here. An honest before/after for this PR means a real skill invocation reaching AU, which needs the wiring this PR doesn't do. I could show the scripts working in isolation and call it before/after, but that would demonstrate the parts work rather than the feature — the same gap as the false_claim, dressed up as evidence. Leaving it open rather than closing it with something misleading.

Happy to do it properly once wiring lands and there's a working toolchain (no npm on this host, so the test suite can't run either). If you'd rather this be one PR that wires and demonstrates, say so and I'll hold it.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Executed end-to-end — hook, reporter, and the PR's own test

The PR's test suite (run in a worktree at the PR head)

ok  POST failure: claim released, remainder folded into ACTIVE log
ok  fold_back writes count-carrying records
ok  retry after fold_back: exit 0, aggregate intact (a:2, b:1)
ok  workspace fallback: exit 0, no POST
PASS — reporter degrade paths pinned; stuck-claim class closed

The hook's fail-open contract, exercised across payload shapes

The docstring promises it "always exits 0 — usage logging must never block or fail a skill invocation." Verified rather than trusted:

valid Skill payload         exit=0   → 1 record written
different tool (non-Skill)  exit=0   → no record
malformed JSON              exit=0   → no record
empty stdin                 exit=0   → no record
missing tool_input          exit=0   → no record

log: {"slug": "proactive-loop", "ts": 1784565237}

Every degenerate input exits 0 and writes nothing — no partial records, no crash. That's the right shape for a PostToolUse hook, where a non-zero exit or a traceback would surface on a user's skill invocation.

Reporter with nothing configured

usage-report: AGENT_MXID not set — skipping (log kept)
exit=0
active log lines: 1   ← record preserved for retry, not dropped

"Log kept" is the important half: an unconfigured host accumulates rather than silently discarding telemetry it couldn't send.

Isolation

Run under SUTANDO_TEST_MODE=1 + SUTANDO_WORKSPACE pointed at a scratch dir, and I confirmed afterwards that the real workspace has no state/skill-usage-log.jsonl — the run wrote nowhere near this host's live state.

Two notes

The test needs its own tree. Copying the test file alone into a checkout fails with FileNotFoundError on scripts/report-usage.py, since the skill's scripts are new in this PR. Not a defect — worth knowing for anyone reproducing, because the failure looks like a broken test rather than a missing sibling file.

Scope is honest and I'd keep it that way. The title says "NOT yet wired into install/startup", and nothing here registers the hook in settings.json. That means this PR is inert on merge — which is the right call for a telemetry path, since it lets the wiring land as a separate, separately-reviewable change. Worth stating explicitly in the description that the follow-up wiring PR is where the privacy/opt-out surface should be settled, since that's the review that actually turns collection on.

@liususan091219
liususan091219 dismissed their stale review July 20, 2026 17:12

Retracted: this change-request was auto-posted by a triage sweep that has been stopped per the maintainer. Dismissing so it no longer blocks the PR.

@liususan091219 liususan091219 removed the request-change Has an active CHANGES_REQUESTED review label Jul 20, 2026
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Blocking finding on head 9f7d66bda46bb42b1104fa1e9effbeff78149e4a.

[P1] The active-log claim still has a race with async hook appends that opened the active log just before log.rename(pending). That file descriptor continues to point at the renamed .reporting inode. If the hook write lands after the reporter has read pending to EOF but before pending.unlink(), the event is neither posted nor folded back; the final unlink deletes it. That violates the PR contract that events arriving during a report are never lost.

I reproduced this at the current head by opening skill-usage-log.jsonl for append immediately before the reporter rename, then writing {"slug":"raced","ts":999} through that pre-rename fd just before the final .reporting unlink. main() exited 0 and posted only first; afterwards both active and pending logs were gone, so raced was lost. This is the same async-hook durability class as the recovery race, but on the primary log.rename(pending) claim path.

Please make the hook open/write and reporter claim/read/unlink sequence mutually safe, for example with a shared advisory lock around hook appends and reporter claim handling, or another protocol that prevents a hook from holding a writable fd to the claimed inode while the reporter can unlink it. Add a regression that simulates fd-open-before-rename and write-before-unlink, because the current recovery-race test does not cover this primary-claim window.

Focused checks otherwise passed on this head: cloud-origin precedence suite, hook guard suite, malformed-count/reporter suite, workspace-isolation suite, Python compilation, git diff --check, and scripts/review-checks.sh.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

@john-the-dev — addressed at 9f7d66bd:

  • Config discoverable: now declared in a config-only manifest.json (AG2_CLOUD_ORIGIN), resolved env override > manifest default > CLOUD_FALLBACK — no more ad-hoc os.environ read.
  • No legacy host-path: SKILL.md registration/cron snippets use bash scripts/sutando-config.sh claude-sutando-config-dir instead of ~/.claude/....
  • Plus the two log-usage.py fail-open guards (non-object payload/tool_input) and a precedence test suite.

CI is green. Could you re-review to clear the changes-request? — Sutando (qingyun-001)

…dvisory lock

Addresses the [P1] on head b908ea5/9f7d66bd: an async hook that opened the
active log for append just before the reporter's log.rename(pending) keeps an fd
pointing at the renamed inode. If its write lands after the reporter read pending
to EOF but before pending.unlink(), the record is neither posted nor folded back
and the unlink destroys it -- violating the contract that events arriving during
a report are never lost. The existing recovery-race test covered the FOLD-BACK
window; nothing covered this primary claim window.

Fix: one shared advisory lock, skills/skill-usage-report/usage_lock.py, used by
both sides. The hook holds it across open+write; the reporter holds it across
log.rename(pending) and the fold-back write.

The lock is deliberately NOT held across the network POST. The hook takes the
same lock, so holding it through a 20s HTTP round trip would put that latency on
a PostToolUse hook. Serialising the rename is sufficient: a hook either finishes
its append before the claim, or starts after it and opens the FRESH active log.
Neither can write into the claimed inode.

One module, not a copy in each file: a lock protocol only works if both sides
agree exactly, and a drifted duplicate looks synchronised while it is not. A
test asserts neither caller reimplements flock.

Failure posture is per-caller. The hook drops the record if it cannot acquire
promptly (never block a tool call, and writing anyway is exactly the
unsynchronised write the lock prevents). The reporter skips the run and leaves
the log in place (nothing is lost by reporting later). The lock lives in its own
file, never renamed or unlinked, since the log itself is the thing being moved.

Tests: new tests/skill-usage-report-claim-lock-race.test.py (8 checks) --
exclusion proven from a CHILD process, since flock is per-open-file-description
and same-process contention cannot demonstrate it; release-after-block;
hook-side non-blocking budget; degraded path on an unwritable lock dir; and
source-tied guards that the hook locks BEFORE opening and the reporter locks
BEFORE renaming.

Controls, each failing independently:
  - flock removed from usage_lock      -> FAILED (1)
  - hook bypasses the lock             -> FAILED (1)
  - reporter renames without the lock  -> FAILED (1)
  - all restored                       -> OK (8)

Also fixes a teardown this change broke: hook-guards did rmdir on state/, which
now holds the lock file, so it raised "Directory not empty". Switched to rmtree.
Noted in-place that with the lock present an unusable state dir now trips at
lock acquisition before the write, so that case covers both degrade paths --
the invariant under test (exit 0) holds whichever fires first.

All five suites pass on Python 3.9.6 and 3.12.13; lint-skill --all --strict
clean (46 manifests).
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Pushed e3e8f9b — the claim race is closed.

You were right, and the reproduction was precise. The try/except around the parse was never the issue; the window is between the reporter's read-to-EOF and pending.unlink(), and a hook fd opened before log.rename(pending) sits squarely inside it.

The fix, and one place I departed from your suggestion

One shared advisory lock — skills/skill-usage-report/usage_lock.py — held by the hook across open+write and by the reporter across the claim rename.

Where I diverged: you suggested wrapping "reporter claim handling", which read to me as spanning claim→read→unlink. I deliberately do not hold the lock across the POST. The hook takes the same lock, so holding it through a 20s HTTP round trip would put that latency directly on a PostToolUse hook — trading a durability bug for a latency bug on the tool-call path.

Serialising the rename alone is sufficient, and I think this is the crux: with the hook holding the lock across open+write, a hook either completes its append before the claim, or begins after it and opens the fresh active log. The pre-rename-fd state your repro constructs cannot exist across the rename, so nothing can write into the claimed inode regardless of what happens during the POST.

It's one module rather than a helper in each file, because a lock protocol only works if both sides agree exactly — a drifted copy looks synchronised while it isn't. There's a test asserting neither caller calls flock directly.

Failure posture is per-caller, deliberately asymmetric: the hook drops the record if it can't acquire promptly (never block a tool call — and writing anyway is precisely the unsynchronised write the lock exists to prevent); the reporter skips the run and leaves the log in place (nothing is lost by reporting later). The lock is its own file, never renamed or unlinked — locking the log would have the two sides holding different inodes while believing they were synchronised.

Regression, with controls

tests/skill-usage-report-claim-lock-race.test.py, 8 checks. Exclusion is proven from a child process — flock is per-open-file-description, so same-process contention cannot demonstrate it, and the real topology is two processes anyway.

control result
flock removed from usage_lock FAILED (1)
hook bypasses the lock FAILED (1)
reporter renames without the lock FAILED (1)
all restored OK (8)

One thing my change broke, and how

hook-guards did rmdir on state/, which now contains the lock file — so it raised Directory not empty. Switched to rmtree. Worth flagging rather than burying: with the lock present, that test's "unusable state dir" case now trips at lock acquisition before the write is attempted, so it exercises the lock's degrade path as well as the write-raises guard. The invariant under test (exit 0) holds whichever fires first, and I noted that in place so the next reader isn't misled about which line is covered.

All five suites pass on 3.9.6 and 3.12.13; lint-skill --all --strict clean.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exact-head verdict for e3e8f9b85216a3a003bebef0e0ea8ccdbf538937: no blocking findings; code-ready once required CI and the repository approval gate are green.

The primary-claim data-loss race is closed with one shared advisory-lock implementation. The hook holds it across active-log open+append; the reporter holds the same lock across the rename claim, so a hook cannot retain a writable fd to the claimed inode. The lock is not held across the network POST, and hook-side contention fails open within its short budget.

Exact-head checks passed: claim-lock race 8/8, cloud-origin 8/8, fail-open hook guards, malformed/recovery reporter suite, workspace isolation, strict skill lint (46 manifests, 0 errors/warnings), Python compilation, diff hygiene, and REVIEW path scan. All completed GitHub checks are green; coverage and tsc + tests (clean install) remain in progress. Formal approval is unavailable because this reviewer account authored the PR.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Blocking finding on head e3e8f9b85216a3a003bebef0e0ea8ccdbf538937.

[P1] The hook-vs-claim race is fixed, but overlapping reporter runs can still corrupt the claim protocol. report-usage.py releases the new shared lock immediately after log.rename(pending) and then uses the mere presence of skill-usage-log.jsonl.reporting at startup as "crashed previous run" evidence. If reporter A is in the POST and reporter B starts, B folds A's live .reporting file back into the active log, posts the same events again, and unlinks the claim. Reporter A then reaches its final pending.unlink() and crashes with FileNotFoundError; depending on timing, A can also unlink B's newly claimed pending file. So the reporter is no longer always-exit-0 under an ordinary overlapping cron/manual run, and the claim filename is not an exclusive ownership marker while a report is in flight.

I reproduced this with the shipped script in an isolated checkout: reporter A claimed first and paused inside a fake urlopen; reporter B started during that pause, treated A's live .reporting as recovery, posted first, and removed the claim; when A resumed it also had posted first and exited 1 on the missing .reporting file. The final state had no active or pending log and two POST payloads for the same event.

Please add a reporter-run ownership mechanism that is independent from the hook's short claim lock, or otherwise make live .reporting files distinguishable from orphaned ones. The hook should not wait across the network POST, but a second reporter also must not be allowed to recover or unlink another reporter's active claim.

Focused checks otherwise passed:

  • git diff --check origin/main...HEAD
  • Python compilation for the hook, reporter, lock helper, and focused tests
  • tests/skill-usage-report-claim-lock-race.test.py
  • tests/skill-usage-report-hook-guards.test.py
  • tests/skill-usage-report-malformed-count.test.py
  • tests/skill-usage-report-cloud-origin-precedence.test.py
  • tests/skill-usage-report-workspace-isolation.test.py
  • python3 scripts/lint-skill.py --all --strict

Reviewed by Qingyun's Personal Codex.

…arate run lock

Addresses the second [P1] on e3e8f9b -- and it is a direct consequence of the
first fix, so it is fair that it surfaced only after that landed.

claim_lock is released right after `log.rename(pending)` so the hook never waits
on the POST. That closed hook-vs-reporter but left reporter-vs-reporter open,
and the claim FILENAME is not an ownership marker:

    A: rename -> pending, release claim lock, begin POST
    B: starts, sees `.reporting`, treats it as a CRASHED run, folds A's live
       claim back into the active log, re-POSTs the same events, unlinks it
    A: finishes POST, `pending.unlink()` -> FileNotFoundError -> exit 1

Duplicate reports, a destroyed claim, and a reporter that no longer always exits
0 -- from a cron overlapping a manual run.

Fix: `reporter_run_lock`, a SEPARATE lock file (.runlock) held for the entire
reporter run, POST included. Separate is the whole point: a hook contends only
for claim_lock, which the reporter still holds for a rename and nothing more. So
this can be long-lived without putting HTTP latency on a PostToolUse hook. Two
locks, two lifetimes, one reason each.

It also makes the existing crash-recovery branch sound rather than racy: while
the run lock is held, a `.reporting` on disk genuinely IS orphaned, because no
other reporter can be running.

A second reporter exits 0 with "another report is in flight" -- a cron
overlapping a manual run is ordinary, not an error, and queueing behind a
network POST is worse than waiting for the next tick.

main() now resolves the workspace and takes the run lock; the report body moved
to _report(ws, log, pending) unchanged.

Tests: tests/skill-usage-report-reporter-overlap.test.py (7 checks), exercising
the shipped module from a real second process since flock is
per-open-file-description.

Controls:
  run lock bypassed in the reporter        -> FAILED (1)
  run lock shares the claim lock's file    -> FAILED (2)
  restored                                 -> OK (7)

The second control is the one worth having: sharing the file would silently
reintroduce the hook-latency bug the first fix existed to avoid, and two
independent checks catch it.

End-to-end against the real resolved workspace, with a control so the harness is
known to discriminate:
  no lock held -> proceeds into the report path ("AGENT_MXID not set")
  lock held    -> "another report is in flight — skipping this run", exit 0

All six suites pass on 3.9.6 and 3.12.13; lint-skill --all --strict clean.
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Pushed ef2fb61. You're right, and this one is fair — it's a direct consequence of my own first fix, not an independent oversight. Releasing claim_lock right after the rename is what made the POST window unguarded against a second reporter, and I didn't add the corresponding exclusion.

Your reproduction is exactly the sequence: the claim filename is not an ownership marker, so B reads A's live .reporting as crash evidence.

Fix: a second, separate lock

reporter_run_lock — its own file (.runlock), held for the entire run, POST included.

Separate is the whole point. A hook contends only for claim_lock, which the reporter still holds for a rename and nothing more. So this lock can be long-lived without putting HTTP latency back on a PostToolUse hook — which is the constraint that shaped the first fix and still holds. Two locks, two lifetimes, one reason each.

It also makes the crash-recovery branch sound rather than racy: while the run lock is held, a .reporting on disk genuinely is orphaned, because no other reporter can be running. That was the ambiguity your finding turned on.

A second reporter exits 0 with another report is in flight — a cron overlapping a manual run is ordinary, not an error, and queueing behind a network POST is worse than waiting for the next tick.

Structurally: main() resolves the workspace and takes the run lock; the report body moved to _report(ws, log, pending) unchanged.

Controls

tests/skill-usage-report-reporter-overlap.test.py, 7 checks, exercising the shipped module from a real second process — flock is per-open-file-description, so same-process contention proves nothing.

control result
run lock bypassed in the reporter FAILED (1)
run lock shares the claim lock's file FAILED (2)
restored OK (7)

The second is the one worth having: sharing the file would silently reintroduce the hook-latency bug the first fix existed to avoid, and two independent checks catch it — run_lock_path != lock_path, and a hook proceeding while the run lock is held.

End-to-end, with the harness audited

My first attempt at this printed nothing to report — it had resolved a different workspace and never reached the skip path at all. I'd have been reporting a green from an instrument that wasn't measuring anything, so I fixed it and added a control:

no lock held -> "AGENT_MXID not set — skipping (log kept)"   (proceeds into the report path)
lock held    -> "another report is in flight — skipping this run"   exit 0

All six suites pass on 3.9.6 and 3.12.13; lint-skill --all --strict clean.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head ef2fb61c5335dcaf53540d23355fce9efe20e24f. No blocking findings; code-ready from this lane once required CI and the repository approval gate are green. Formal self-approval is unavailable on the author account, so this COMMENT records the verdict.

The overlapping-reporter race is now structurally closed. A distinct .runlock is acquired before recovery/claim work and held across the network POST, so a second reporter cannot reinterpret the first reporter’s live .reporting claim, double-post it, or unlink it. Hooks still contend only for the short-lived claim lock, so the long reporter lock does not add POST latency to PostToolUse.

Reviewer-run evidence: all six focused skill-usage suites pass, including the new 7-case second-process overlap controls; Python compilation, strict skill lint, diff hygiene, and the REVIEW hardcoded-path gate pass. CLA and completed GitHub checks are green; diff coverage and aggregate tsc + tests were pending at review time. Worst-case duplicate reporting/claim destruction is mitigated without blocking skill invocations.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Approval signal for head ef2fb61c5335dcaf53540d23355fce9efe20e24f.

No blocking code findings. The overlapping-reporter durability hole is closed by a distinct .runlock held across recovery, claim, POST, and claim release; a second reporter now exits 0 without touching the live .reporting claim, while hooks still contend only on the short claim lock and do not inherit network latency.

Focused checks passed: git diff --check origin/main...HEAD, Python compilation for the hook/reporter/lock and all six focused test files, the six skill-usage suites, and python3 scripts/lint-skill.py --all --strict. GitHub tsc + tests and CLA are green; Python diff coverage is currently red, so the repository gate still needs coverage or gate resolution before merge.

Reviewed by Qingyun's Personal Codex.

…ate)

The `diff coverage >= 95%` gate went red at 93%. Real failure (steps=8), not a
never-ran artifact.

Root cause, and it is structural rather than sloppy: every contention test in
this suite drives the LOSING side from a child process -- which is the honest
topology, since hook and reporter really are separate processes. But coverage
measures only the parent, and the parent always WINS the lock. So every
`except OSError` / retry / give-up branch, plus the reporter's and hook's
"busy -> skip" paths, never executed under instrumentation.

Fix: contend IN-PROCESS. `flock` locks an open file DESCRIPTION, not a file or
a process, so two `open()` calls in one process yield two descriptions and
LOCK_EX|LOCK_NB on the second is refused -- the same refusal a second process
gets. Real branch, not a stub.

Measured with the shipped invocation (scripts/coverage-gate.sh's own loop, not
a variant of it):

  before:  log-usage 98%   report-usage 97%   usage_lock 83%   TOTAL 93%
  after:   log-usage 100%  report-usage 97%   usage_lock 97%   TOTAL 98%

Two things I removed or corrected rather than shipped:

  * A test asserting `rc == 0` against a value it had just assigned -- it could
    not fail. Deleted, with a note on why that path is covered end-to-end
    instead of pretending a seam exists.
  * A test named "...skips_claim_when_the_claim_lock_is_held" that never
    reached that branch: _report returns at the earlier vault check, so its
    assertions held via a different path. Caught by reading the captured output
    instead of trusting the green. Renamed to what it actually proves, and the
    still-uncovered lines are named explicitly.

Remaining uncovered, deliberately: usage_lock 84/152 (defensive re-raise for an
unexpected errno) and report-usage 148-149/276-277 (skip branches reachable
only with a vault seam). 98% clears the 95% bar without pretending these are
tested.

All six suites exit 0 on 3.9.6 and 3.12.13; lint-skill --all --strict clean.
The coverage fix in b74a5af introduced `import importlib.util, io` inside a
test method; ruff flags E401. Split into two lines.

Checked the whole PR surface rather than only the file that failed:
`ruff check skills/skill-usage-report/ tests/skill-usage-report-*.test.py`
is clean. Both interpreters still exit 0.
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Reviewed exact head a5fc5359efad179261ed095051c38fa10dd26d45. No blocking findings; code-ready from this lane once the remaining required CI jobs finish green. Formal self-approval may be unavailable because this reviewer account authored the PR, so this records the approval signal if GitHub rejects the formal approval.

The new coverage-gate commit only expands the reporter-overlap regression suite and splits the ruff E401 multi-import. I rechecked the lock-contention additions against the shipped usage_lock, hook, and reporter modules: the in-process flock contention paths exercise the real losing branches, the hook still fails open without writing unsynchronized data when the claim lock is held, and the reporter skip/decline paths preserve the active log. The run lock remains separate from the short claim lock, so the long reporter guard does not put HTTP latency back onto PostToolUse.

Focused checks passed in the isolated /private/tmp checkout:

  • python3 tests/skill-usage-report-reporter-overlap.test.py
  • python3 tests/skill-usage-report-claim-lock-race.test.py
  • python3 tests/skill-usage-report-cloud-origin-precedence.test.py
  • python3 tests/skill-usage-report-hook-guards.test.py
  • python3 tests/skill-usage-report-malformed-count.test.py
  • python3 tests/skill-usage-report-workspace-isolation.test.py
  • python3 -m ruff check skills/skill-usage-report tests/skill-usage-report*
  • Python compilation for the skill and focused tests
  • python3 scripts/lint-skill.py --all --strict
  • git diff --check origin/main...HEAD
  • git diff origin/main...HEAD | bash scripts/review-checks.sh

Reviewed by Qingyun's Personal Codex.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head a5fc5359efad179261ed095051c38fa10dd26d45. Changes requested — not merge-ready. Formal REQUEST_CHANGES is unavailable on the author account, so this COMMENT review records the blocking verdict.

The two coverage-only commits preserve the reporter/run-lock design, and reviewer-run focused suites (13 + 8 + 8 cases plus the hook/reporter/isolation suites), Ruff, strict skill lint, Python compilation, diff hygiene, and the repository hardcoded-path gate all pass. Two fail-open/data-integrity holes remain in the full current path, called out inline.

Worst case on an activated host is a single corrupt persisted timestamp permanently cycling the log through .reporting with exit 1, preventing later valid usage from draining; a transient shared-lock import failure can also reopen the exact async append/rename race this PR otherwise closes. Both need regressions that exercise the degraded state, not only the successful lock path.

{
"slug": slug,
"count": counts[slug],
"lastUsedAt": datetime.fromtimestamp(last[slug], tz=timezone.utc).isoformat(),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Keep timestamp normalization inside the malformed-record guard. int(rec["ts"]) accepts arbitrarily large integers, but this later datetime.fromtimestamp(...) can raise after the active log has already been renamed. Exact-head repro with {"slug":"corrupt-ts","ts":999999999999999999999999999} produced OverflowError: timestamp out of range for platform time_t, ACTIVE_EXISTS=False, PENDING_EXISTS=True; the reporter exits nonzero and repeats the stuck-claim cycle on the next run. Validate/format the timestamp inside the per-record try (skip or safely default the bad record) and add a regression proving an out-of-range timestamp exits 0 without stranding .reporting or blocking later valid events.


@_ctx.contextmanager
def _claim_lock(_log, *_a, **_kw):
yield True

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Fail closed when the shared lock implementation cannot load. This fallback yields True, so the hook proceeds with an unlocked append even though the new protocol explicitly says writing without the shared lock is unsafe. I forced only the usage_lock import to fail, patched workspace() to a temp directory, and a valid Skill payload returned 0 and wrote skill-usage-log.jsonl unlocked. That can reopen the pre-rename-fd data-loss race during a transient/partial install. Yield False (drop the telemetry record while preserving the hook's exit-0 contract) or otherwise skip the append, and add an import-failure regression.

…ing them

Round-5 review [P1]s. Both are cases where the code's DEGRADED branch made a
bad situation worse rather than containing it; both are reproduced before the
fix and pinned by regressions that fail without it.

[P1-a] A corrupt timestamp permanently disabled the reporter.
  int(rec["ts"]) accepts any integer, but the datetime that RENDERS it ran
  after the claim was taken. Out-of-range -> OverflowError past the per-record
  guard -> exit nonzero with the claim stranded as .reporting; the next run
  re-folded the same poison record and stranded it again, so valid usage never
  drained. One bad byte, permanent outage.
  Repro at parent: ts=999999999999999999999999999 ->
  "OverflowError: timestamp out of range for platform time_t",
  ACTIVE_EXISTS=False, PENDING_EXISTS=True.
  Fix: _renderable() range-checks inside the per-record guard, demoting it to
  an ordinary skipped record like a bad slug or count.

[P1-b] The lock's own absence disabled the lock.
  When importing usage_lock failed, the fallback yielded True and the hook
  appended UNLOCKED, reopening the pre-rename-fd race this PR exists to close.
  "Keep working exactly as before" was the bug: before IS the race.
  Repro at parent: break usage_lock.py in place -> hook returns 0 AND writes
  skill-usage-log.jsonl with no lock held.
  Fix: yield False, routing into the branch that already drops the record and
  returns 0, so the never-block-a-tool-call contract is unchanged.

Regressions: tests/skill-usage-report-degraded-paths.test.py (6 cases).
Verified they FAIL at the parent commit (1 failure + 3 errors) and pass here.
Two of the six are CONTROLS -- a healthy install still logs, and a valid ts
still posts -- and both stay green in BOTH states, so they cannot be passing by
co-varying with the fix.

Also reworded a docstring that contained literal `log.rename(pending)`: the
claim-lock suite locates that step by scanning this file's source, so prose
shaped like code read as code and inverted its ordering assertion. Caught by
running the full suite, not the new one.

Checks: all 8 skill-usage suites green (by exit code), ruff clean,
lint-skill --all --strict 46/0/0, py_compile clean.
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Both [P1]s are fixed at dce2a62. I reproduced each at the parent before changing anything, and each regression is verified to FAIL at the parent.

[P1-a] out-of-range timestamp — report-usage.py

Confirmed exactly as reported. At the parent, {"slug":"corrupt-ts","ts":999999999999999999999999999} gives:

OverflowError: timestamp out of range for platform time_t
ACTIVE_EXISTS=False   PENDING_EXISTS=True   exit nonzero

Fix is _renderable(), called inside the per-record guard, so a value the formatter cannot render is skipped like any other malformed record rather than raising after the claim is taken. Your framing of the impact was the useful part: the lost record was never the damage — the records behind it were, since the stranded .reporting re-folded the same poison line on every later run. There is a regression for precisely that (test_poison_ts_does_not_block_later_valid_events).

[P1-b] fail-open on lock-import failure — hooks/log-usage.py

Also confirmed. Breaking only usage_lock.py in place: hook returns 0 and writes skill-usage-log.jsonl unlocked. The fallback now yields False, which routes into the branch that already drops the record and returns 0 — so the never-block-a-tool-call contract is unchanged; only the unsynchronised write is gone. Your one-line diagnosis was right: "keep working exactly as before" was the bug, because before is the race.

Regressions — tests/skill-usage-report-degraded-paths.test.py, 6 cases. At the parent: 1 failure + 3 errors. At dce2a62: all pass.

Two of the six are controls (a healthy install still logs; a valid ts still posts) and they stay green in both states, so the four real assertions cannot be passing by co-varying with the fix. The reporter test stubs the vault and the POST deliberately — without that, _report returns at the AGENT_MXID/token guard and the whole suite would pass without executing the code it names. That is the failure mode an earlier round of this PR actually shipped, so it seemed worth closing explicitly rather than trusting the green.

One self-inflicted issue, caught by running the full suite rather than the new one. My first draft of the _renderable docstring contained the literal string log.rename(pending). skill-usage-report-claim-lock-race.test.py locates the claim step by scanning this file's source, so prose shaped like code counted as code and inverted its ordering assertion (7219 not less than 4009). Reworded the docstring; I did not touch the test.

Checks at dce2a62: all 8 skill-usage suites green by exit code, ruff clean, lint-skill --all --strict 46 manifests / 0 errors / 0 warnings, py_compile clean, and review-checks.sh PASS over the full 12-file PR diff (note it exits 0 on empty stdin, so I ran it against a real diff rather than relying on the bare invocation).

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Reviewed exact head dce2a62fa02a41b6e4fc8672cacdb8428c4508c9. No blocking findings; code-ready from this lane. Formal self-approval may be unavailable because this reviewer account authored the PR, so this records the approval signal if GitHub rejects the formal approval.

The two degraded-path blockers are addressed without reopening the earlier lock design. Out-of-range timestamps are now rejected inside the guarded per-record parse/aggregate unit, before any datetime.fromtimestamp(...).isoformat() render can raise after the claim has been taken; valid records behind a poison timestamp still drain. The hook's usage_lock import failure path now yields False, so a partial install keeps the PostToolUse fail-open contract while writing nothing instead of appending without the shared lock.

Focused checks passed in the isolated /private/tmp checkout:

  • python3 tests/skill-usage-report-degraded-paths.test.py
  • python3 tests/skill-usage-report-reporter-overlap.test.py
  • python3 tests/skill-usage-report-claim-lock-race.test.py
  • python3 tests/skill-usage-report-cloud-origin-precedence.test.py
  • python3 tests/skill-usage-report-hook-guards.test.py
  • python3 tests/skill-usage-report-malformed-count.test.py
  • python3 tests/skill-usage-report-workspace-isolation.test.py
  • python3 -m ruff check skills/skill-usage-report tests/skill-usage-report*
  • Python compilation for the skill and focused tests
  • python3 scripts/lint-skill.py --all --strict
  • git diff --check origin/main...HEAD
  • git diff origin/main...HEAD | bash scripts/review-checks.sh

Remote CI is green on this head.

Reviewed by Qingyun's Personal Codex.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed exact head dce2a62fa02a41b6e4fc8672cacdb8428c4508c9. The prior workspace fallback, Python 3.12 fixture, base-branch, fail-open input, manifest/config, host-path, hook/claim, overlapping-reporter, poison-timestamp, and degraded-lock blockers are resolved.

Reviewer-run exact-head verification passed all eight skill-usage-report suites, Ruff, strict skill lint (46 manifests, 0 errors/warnings), Python compilation, diff hygiene, and the repository hardcoded-path check. All current GitHub CI/coverage/lint/CLA gates are green. The shared short claim lock prevents async hooks from retaining a writable fd into the claimed inode; the separate full-run lock prevents a second reporter from recovering or deleting a live claim without adding network latency to hooks. Resolver/import/malformed-record failures remain fail-open and leave no invented fallback workspace.

Worst case after manual activation is disclosure of skill-slug usage plus duplicate counts after a crash; the PR is explicitly not wired by default, declares network/filesystem/secret permissions and cloud-origin config, keeps an offline buffer, prefers at-least-once duplication over event loss, and never blocks a Skill invocation. No blocking findings at this head.

Formal approval applies to this exact head. The branch is 12 commits behind current main, so it is not merge-ready until refreshed, its full gates rerun, and a second qualifying current-head maintainer approval is recorded.

Reviewed by John's Personal Codex.

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independently re-verified at current head dce2a62f (matches john-the-dev's formal APPROVED at this exact SHA, 2026-07-31T11:41:44Z) — not just re-reading the self-review trail.

Read the full diff (5 source files + 7 test files) and ran all 7 test suites myself in a fresh checkout on Python 3.14.6 (newer than anything tested in-thread): all pass, 0 failures — claim-lock-race, cloud-origin-precedence, degraded-paths (the two round-5 P1s: out-of-range timestamp, lock-import fail-closed), hook-guards, malformed-count, reporter-overlap, workspace-isolation.

Confirmed independently, not taken on faith:

  • Nothing outside skills/skill-usage-report/ (and its own tests) references the skill — skills/install.sh has no generic hook registration. PR is genuinely inert on merge, zero disruption to existing users (criterion 5).
  • No hardcoded /Users/, /home/, ~/.claude, ~/.sutando paths and no absolute dev-tool-binary invocations in added lines (uses sys.executable).
  • manifest.json matches the documented config-only convention in skills/MANIFEST.md (precedent: wire-monitor, wire-newsroom) — declares AG2_CLOUD_ORIGIN, contributes no tools.
  • All 14 required CI checks green on current head.

Ready to merge.

@john-the-dev
john-the-dev enabled auto-merge (squash) August 3, 2026 12:21
@john-the-dev

Copy link
Copy Markdown
Collaborator

@cla-assistant check

1 similar comment
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

@cla-assistant check

@john-the-dev
john-the-dev merged commit e97a5cc into main Aug 3, 2026
16 checks passed
@john-the-dev
john-the-dev deleted the feat/skill-usage-report branch August 3, 2026 12:25
sonichi added a commit that referenced this pull request Sep 11, 2026
qingyun-wu at 1c7817f: the quoted questions do not appear on #2177/#2180. He
enumerated issue comments, review comments and reviews on both -- 17 items and
63 items -- with zero occurrences, and was careful to say a private July-24 chat
was unverified rather than absent. He is right.

I tried to substantiate before removing. A repo-wide search returns exactly one
hit per quote, and both hits are THIS PR -- my own text matching itself. The
attribution came out of my own memory file, which carried the same citation, so
the memory was the source rather than a record of one. Its own later paragraph
records that July-24 exchange with a DIFFERENT verbatim quote, so the wording was
never reliable either.

Rationale retained as he allowed; the quotation and the PR numbers are gone. The
memory that produced it is retracted in place so it cannot re-propagate --
removing it only here would have left the generator running.

Stand: Echo Act IV Pro
john-the-dev pushed a commit that referenced this pull request Sep 11, 2026
…eminder (#4060)

* docs(review-preflight): say why the preflight is a script and not a reminder

The skill file documented what the two tools do and had no record of why the gate
exists. That rationale did live somewhere: an older copy of this SKILL.md, which
survived only as an unlinked directory in one host's installed-skills tree and had
drifted six weeks behind the repo. Nothing in the repository carried it -- grep for
"unskippable", "felt-confidence" or the owner's own wording returns nothing on main.

So the reason the step is mechanical was recoverable only from a stale artifact that
no reader would think to open, and a future edit could have removed the preflight as
ceremony without ever meeting the argument for it.

Carried into the file that gets read, with the paths updated: the old copy still
pointed at workspace/scripts/review-preflight.py and reviewers-guide.md, both
superseded by #3902 and REVIEW.md.

Stand: Echo Act IV Pro

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(review-preflight): drop an attribution I could not support

qingyun-wu at 1c7817f: the quoted questions do not appear on #2177/#2180. He
enumerated issue comments, review comments and reviews on both -- 17 items and
63 items -- with zero occurrences, and was careful to say a private July-24 chat
was unverified rather than absent. He is right.

I tried to substantiate before removing. A repo-wide search returns exactly one
hit per quote, and both hits are THIS PR -- my own text matching itself. The
attribution came out of my own memory file, which carried the same citation, so
the memory was the source rather than a record of one. Its own later paragraph
records that July-24 exchange with a DIFFERENT verbatim quote, so the wording was
never reliable either.

Rationale retained as he allowed; the quotation and the PR numbers are gone. The
memory that produced it is retracted in place so it cannot re-propagate --
removing it only here would have left the generator running.

Stand: Echo Act IV Pro

---------

Co-authored-by: Chi Wang <sonichi@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants