Skip to content

Commit f973581

Browse files
committed
fix(usage): renew visible notice before faster delivery
Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
1 parent b32fdba commit f973581

9 files changed

Lines changed: 136 additions & 15 deletions

File tree

‎apps/presentation/dashboard/src/features/personal-workspace/usage-statistics-notice.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ export function UsageStatisticsNotice({ onDetails }: { onDetails: () => void })
7575
<p>{zh
7676
? "用于改进平台支持与使用体验。发送随机安装标识和环境信息,以及另行汇总的 CLI 使用次数、结果、耗时和 Goal 时长区间;不采集对话、代码、路径或命令参数。可随时关闭。"
7777
: "Helps improve platform support and usage. Sends a random installation ID and environment information, plus separate CLI usage, result, timing and Goal duration summaries. No conversations, code, paths or command arguments. You can turn it off at any time."}</p>
78+
<p>{zh
79+
? "首个已测量的 CLI 结果立即上报,后续由使用活动触发,至少间隔 15 分钟发送一批。CLI 汇总不含安装标识;更频繁的请求仍可能让网络服务通过 IP 和请求时间关联活动。"
80+
: "The first measured CLI result is sent immediately; later activity sends buffered counts at most once every 15 minutes. CLI summaries contain no installation ID; more frequent requests may still let network services correlate activity using IP addresses and request timing."}</p>
7881
<p className="personal-usage-recipient">{zh ? "接收方:" : "Recipient: "}{state.endpoint}</p>
7982
{error ? <p role="alert">{zh ? "设置未能保存,请打开详情重试。" : "Could not save this setting. Open details to retry."}</p> : null}
8083
</div>

‎docs/reference/usage-ping.md‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,9 @@ Additional payload fields and invalid enum combinations are rejected.
7575

7676
Interactive CLI, unattended scripts/agents and the App use the same
7777
**first disclosure → automatic activation → subsequent measurement** policy.
78-
The first ordinary CLI command prints the recipient, fields, purpose and
79-
both disable mechanisms to stderr, records the disclosure, and sends nothing.
78+
The first ordinary CLI command prints the recipient, fields, purpose, CLI delivery
79+
cadence, network timing correlation boundary and both disable mechanisms to
80+
stderr, records the disclosure, and sends nothing.
8081
This also applies to captured stderr in scripts and Agent tool calls; JSON
8182
stdout is unaffected. Discarded stderr (the null device) or a failed write
8283
cannot acknowledge a notice. Background `chat`/`serve-status` services defer
@@ -139,7 +140,12 @@ interactive and unattended CLI lanes; Goal-duration snapshots remain daily.
139140

140141
Counts are capped at 128 distinct rows and 10,000 per row. Buffered counts expire
141142
after seven UTC days measured from the oldest buffered day. Existing daily
142-
buffers remain readable on upgrade. Each batch is removed and its attempt time
143+
buffer shapes remain readable. Notice revision 4 renews disclosure before the
144+
faster cadence takes effect: old notice state cannot send or consume buffers.
145+
Acknowledging the renewed notice discards old-scope counters and fences queued
146+
observations with a new generation; only subsequent measurements can send.
147+
Explicit disable remains disabled, and acknowledgment cannot replace explicit
148+
enable under `consent_required`. Each batch is removed and its attempt time
143149
persisted before the request starts under the same short lock; network waiting
144150
happens after release. Attempts are spaced even on failure or clock rollback.
145151
There are no immediate retries, background timers or durable network queues.

‎docs/reference/usage-ping.zh-CN.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ ID 随机生成,不绑定账号、不从硬件派生,但能跨天关联,
6666
## 告知、设置与升级
6767

6868
交互式 CLI、后台脚本/Agent 和 App 统一采用 **首次告知 → 自动开启 → 后续采集**。
69-
首次普通 CLI 命令向 stderr 显示接收方、字段、用途和关闭方法,然后记录告知;
69+
首次普通 CLI 命令向 stderr 显示接收方、字段、用途、CLI 发送频率、网络时序关联
70+
边界和关闭方法,然后记录告知;
7071
这一轮不计数、不发送。脚本和 Agent 工具调用捕获的 stderr 也适用,JSON stdout
7172
保持不变。stderr 指向空设备或输出失败时不记录告知。后台 `chat`/`serve-status`
7273
服务把首次告知交给 App,不以服务日志代替 App 界面。
@@ -110,8 +111,10 @@ authority provider 备份、公共投影。普通命令只读取很小的本地
110111
原有的次日发送机制;Goal 时长快照仍按天发送。
111112

112113
本地汇总最多 128 种计数组合,每项封顶 10,000;以最早积压日期计算,超过七个 UTC
113-
日的计数丢弃。升级可继续读取旧的按日缓冲。每批在发送前持久化认领时间、移除对应
114-
计数,并在同一短锁内发起请求,网络等待不占锁。失败或时钟回拨也不能绕过间隔。
114+
日的计数丢弃。旧按日缓冲格式仍可读取。告知版本 4 要求在新频率生效前重新告知:
115+
确认前不发送、不消费缓存;确认后丢弃旧范围计数并更新 generation,阻止旧排队
116+
观测补发,后续新测量才可发送。明确关闭继续生效,`consent_required` 仍须明确启用。
117+
每批在发送前持久化认领时间、移除对应计数,并在同一短锁内发起请求,网络等待不占锁。失败或时钟回拨也不能绕过间隔。
115118
不会立即重试,不增加后台定时器或持久网络队列。间隔内停止使用仍可能丢失未发送的
116119
尾部计数;优化减少对次日回访的依赖,但不承诺完整采集。锁竞争、进程退出和网络
117120
故障也可能丢数。这是有损诊断,不能当账单或审计日志。

‎loopx/control_plane/runtime/usage_statistics.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import type { CycleObservation } from "./usage_statistics_cycles.ts";
1616

1717
export const STATE_SCHEMA = "loopx_usage_ping_state_v1";
1818
export const DEFAULT_ENDPOINT = "https://loopx-usage-collector.huangrt01.workers.dev/v1/ping";
19-
export const NOTICE_VERSION = 3;
19+
export const NOTICE_VERSION = 4;
2020
const AGGREGATE_INTERVAL_MS = 15 * 60 * 1000;
2121
export type Env = Record<string, string | undefined>;
2222
export type Context = { env: Env; version: string; python: string; channel: string; now?: Date };
@@ -105,7 +105,7 @@ export async function inspect(path: string, ctx: Context) {
105105
aggregate_preview: state.consent === "disabled" || !state.counters?.length ? null : { schema: AGGREGATE_SCHEMA, counters: state.counters },
106106
goal_preview: state.consent === "disabled" ? null : await goalPreview(path + ".goals", state.generation).catch(() => null),
107107
aggregate_day: state.day ?? null,
108-
disclosure: "LoopX basic usage statistics are on by default after this notice. Daily heartbeats send a random installation ID, version, OS, CPU architecture, Python version and install channel to the configured LoopX collector (Cloudflare). Fixed CLI feature/result/duration/error counts are sent separately without an ID. Goal span/duration buckets and fixed Host labels are aggregated without Goal or installation IDs. Common quota-to-spend cycles cover every Host using the quota CLI; bound Codex tasks add local timing-event reads; managed Turns and regular owner Goal chat add direct Host-call timing. These overlapping measurements are separate, partial and not completion or billing evidence. Raw session content is never uploaded. No prompts, code, paths, arguments, Goal contents or raw errors. Disable all with loopx usage-ping disable or LOOPX_USAGE_PING=0; inspect with loopx usage-ping status. Consent-required distributions wait for explicit enable. Recipient: " + (endpoint(ctx.env) || "not configured") };
108+
disclosure: "LoopX basic usage statistics are on by default after this notice. Daily heartbeats send a random installation ID, version, OS, CPU architecture, Python version and install channel to the configured LoopX collector (Cloudflare). Fixed CLI feature/result/duration/error counts are sent separately without an ID. The first measured CLI result is sent immediately; later activity sends buffered counts at most once every 15 minutes. More frequent requests can make network timing correlation easier: network services may observe IP addresses and request times even though CLI summaries have no installation ID. Goal span/duration buckets and fixed Host labels are aggregated without Goal or installation IDs. Common quota-to-spend cycles cover every Host using the quota CLI; bound Codex tasks add local timing-event reads; managed Turns and regular owner Goal chat add direct Host-call timing. These overlapping measurements are separate, partial and not completion or billing evidence. Raw session content is never uploaded. No prompts, code, paths, arguments, Goal contents or raw errors. Disable all with loopx usage-ping disable or LOOPX_USAGE_PING=0; inspect with loopx usage-ping status. Consent-required distributions wait for explicit enable. Recipient: " + (endpoint(ctx.env) || "not configured") };
109109
}
110110
export async function configure(path: string, ctx: Context, action: "enable" | "disable" | "acknowledge", expectedNotice?: unknown) {
111111
await withFileMutationLock(path, async () => {

‎loopx/usage_ping.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
from .paths import DEFAULT_RUNTIME_ROOT
1616

1717
STATE_FILENAME = "usage-ping.json"
18+
# Scheduling hint only; keep aligned with the TypeScript notice revision.
19+
_NOTICE_VERSION = 4
1820
_ENTRY = Path(__file__).parent / "control_plane/runtime/usage_statistics_cli.ts"
1921

2022

@@ -71,7 +73,7 @@ def begin(command: str) -> tuple[str, float] | None:
7173
state = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
7274
if state.get("consent") == "disabled":
7375
return None
74-
if (state.get("notice") or {}).get("version") != 3:
76+
if (state.get("notice") or {}).get("version") != _NOTICE_VERSION:
7577
# App services defer disclosure to the visible frontend. Ordinary
7678
# script/Agent calls disclose on stderr too; JSON stdout stays clean.
7779
stream = sys.stderr

‎tests/control_plane/test_source_cli_entrypoint.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ def test_source_first_usage_disclosure_keeps_json_pure_and_does_not_send(tmp_pat
311311
assert json.loads(result.stdout)["ok"] is True
312312
assert "random installation ID" in result.stderr
313313
stored = json.loads((state / "usage-ping.json").read_text())
314-
assert stored["notice"]["version"] == 3
314+
assert stored["notice"]["version"] == 4
315315
assert "last_attempt_day" not in stored and "counters" not in stored
316316

317317

‎tests/control_plane_ts/usage_statistics_delivery.test.ts‎

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ test("midnight and high call volume cannot bypass 15-minute spacing; each batch
5454
assert.equal(batches.length, 3, "no empty periodic requests");
5555
});
5656

57-
test("upgrade flushes retained legacy counts; expired counts are dropped before a new result", async t => {
57+
test("current-notice legacy buffer shape remains readable; expired counts are dropped before a new result", async t => {
5858
for (const [bufferDay, count] of [["2026-09-27", 8], ["2026-09-20", 1]] as const) {
5959
const { path, ctx, state, generation, batches, post } = await fixture(t);
6060
await writeFile(path, JSON.stringify({ ...await state(), day: bufferDay, counters: [{ ...row, count: 7 }], last_attempt_day: "2026-09-28" }));
@@ -131,3 +131,60 @@ test("a malformed legacy buffer cannot bypass outgoing aggregate validation", as
131131
await observe(path, ctx, generation, null, post);
132132
assert.deepEqual(batches, [], "duplicate counter keys are not a valid wire payload");
133133
});
134+
135+
test("v3 upgrade waits for renewed notice, preserves pre-ack state and fences old observations", async t => {
136+
for (const consent of ["default", "enabled"] as const) {
137+
const { path, ctx, state, generation, batches, post } = await fixture(t);
138+
await writeFile(path, JSON.stringify({ ...await state(), consent,
139+
notice: { version: 3, endpoint: ctx.env.LOOPX_USAGE_PING_ENDPOINT, policy: "opt_out" },
140+
day: "2026-09-28", counters: [{ ...row, count: 7 }] }));
141+
const before = await readFile(path, "utf8");
142+
const status = await inspect(path, ctx);
143+
assert.equal(status.notice.version, 4);
144+
assert.equal(status.blocked_by, "notice_required");
145+
assert.equal(status.automatic_notice_required, true);
146+
let attempts = 0;
147+
for (const counter of [null, row]) {
148+
await observe(path, ctx, generation, counter, async () => { attempts++; return 204; });
149+
}
150+
assert.equal(attempts, 0, "neither heartbeat nor aggregate can start before renewed notice");
151+
assert.equal(await readFile(path, "utf8"), before, "unacknowledged buffers remain untouched");
152+
await assert.rejects(configure(path, ctx, "acknowledge", { ...status.notice, version: 3 }), /usage_notice_changed/);
153+
await configure(path, ctx, "acknowledge", status.notice);
154+
const renewed = await state();
155+
assert.notEqual(renewed.generation, generation);
156+
assert.deepEqual(renewed.counters, [], "existing notice migration discards old-scope counts");
157+
await observe(path, ctx, generation, row, post);
158+
assert.deepEqual(batches, [], "queued old-generation work cannot send after acknowledgment");
159+
await observe(path, ctx, renewed.generation, row, post);
160+
assert.deepEqual(batches, [{ schema: AGGREGATE_SCHEMA, counters: [row] }]);
161+
}
162+
});
163+
164+
test("v3 renewal cannot undo disable or replace explicit consent", async t => {
165+
for (const [consent, policy, reason] of [
166+
["disabled", "opt_out", "disabled"],
167+
["default", "consent_required", "consent_required"],
168+
] as const) {
169+
const { path, ctx, state, generation } = await fixture(t);
170+
const restricted = { ...ctx, env: { ...ctx.env, LOOPX_USAGE_POLICY: policy } };
171+
await writeFile(path, JSON.stringify({ ...await state(), consent,
172+
notice: { version: 3, endpoint: ctx.env.LOOPX_USAGE_PING_ENDPOINT, policy },
173+
day: "2026-09-28", counters: [{ ...row, count: 7 }] }));
174+
const before = await readFile(path, "utf8");
175+
const status = await inspect(path, restricted);
176+
assert.equal(status.blocked_by, reason);
177+
assert.equal(status.automatic_notice_required, false);
178+
await configure(path, restricted, "acknowledge", status.notice);
179+
let attempts = 0;
180+
await observe(path, restricted, generation, row, async () => { attempts++; return 204; });
181+
assert.equal(attempts, 0);
182+
assert.equal(await readFile(path, "utf8"), before);
183+
assert.equal((await inspect(path, restricted)).sending, false);
184+
if (policy === "consent_required") {
185+
await configure(path, restricted, "enable");
186+
await observe(path, restricted, (await state()).generation, row, async () => { attempts++; return 204; });
187+
assert.ok(attempts > 0, "only explicit enable authorizes consent-required collection");
188+
}
189+
}
190+
});

‎tests/control_plane_ts/usage_statistics_sources.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ test("scope expansion renews disclosure and fences old observations without undo
163163
assert.equal((await inspect(path,ctx)).blocked_by,"notice_required");
164164
await observe(path,ctx,prior.generation,null,async()=>{throw new Error("unexpected send");},undefined,cycle("start",at));
165165
await assert.rejects(readFile(path+".cycles"),/ENOENT/);
166-
const enabled=await configure(path,ctx,"enable"); assert.equal(enabled.notice.version,3);
166+
const enabled=await configure(path,ctx,"enable"); assert.equal(enabled.notice.version,4);
167167
const current=JSON.parse(await readFile(path,"utf8")); assert.notEqual(current.generation,prior.generation);
168168
await configure(path,ctx,"disable");
169169
assert.equal((await inspect(path,ctx)).consent,"disabled");

‎tests/test_usage_ping.py‎

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ def test_absent_stderr_keeps_real_cli_json_pure_until_a_stream_discloses(isolate
141141
assert main(['version', '--format', 'json']) == 0
142142
assert 'random installation ID' in stderr.getvalue()
143143
assert json.loads(capsys.readouterr().out)['ok'] is True
144-
assert json.loads(usage_ping.state_path().read_text())['notice']['version'] == 3
144+
assert json.loads(usage_ping.state_path().read_text())['notice']['version'] == 4
145145

146146

147147
@pytest.mark.parametrize('setting,value', [
@@ -257,7 +257,15 @@ def test_detached_sender_honors_proxy_and_no_proxy(isolated, collector, monkeypa
257257
release.set()
258258

259259

260-
def test_real_chat_settings_share_cli_choice_and_reject_cross_origin(isolated):
260+
@pytest.mark.parametrize('upgrade', [False, True])
261+
def test_real_chat_settings_share_cli_choice_and_reject_cross_origin(isolated, upgrade):
262+
if upgrade:
263+
usage_ping.control('enable')
264+
prior = json.loads(usage_ping.state_path().read_text())
265+
prior['consent'] = 'default'
266+
prior['notice']['version'] = 3
267+
usage_ping.state_path().write_text(json.dumps(prior))
268+
before = usage_ping.state_path().read_bytes()
261269
import http.client
262270
from loopx.chat_server import ChatHTTPServer, ChatRequestHandler
263271
server = ChatHTTPServer(('127.0.0.1', 0), ChatRequestHandler)
@@ -270,7 +278,11 @@ def test_real_chat_settings_share_cli_choice_and_reject_cross_origin(isolated):
270278
response = connection.getresponse()
271279
initial = json.loads(response.read())
272280
assert response.status == 200 and initial['automatic_notice_required']
273-
assert not usage_ping.state_path().exists()
281+
if upgrade:
282+
assert usage_ping.state_path().read_bytes() == before
283+
else:
284+
assert not usage_ping.state_path().exists()
285+
assert initial['notice']['version'] == 4
274286
connection.request('POST', path, json.dumps({'notice': initial['notice']}), {'Content-Type': 'application/json'})
275287
response = connection.getresponse()
276288
acknowledged = json.loads(response.read())
@@ -320,3 +332,41 @@ def test_goal_observer_does_not_wait_for_unresponsive_collector(isolated, collec
320332
assert not release.is_set(), 'host returned before collector released HTTP response'
321333
usage_ping.control('disable')
322334
release.set()
335+
336+
337+
def test_v3_cli_upgrade_requires_visible_renewal_before_real_http(isolated, collector, monkeypatch):
338+
endpoint, received, accepted, release = collector
339+
release.set()
340+
monkeypatch.setenv('LOOPX_USAGE_PING_ENDPOINT', endpoint)
341+
usage_ping.control('enable')
342+
path = usage_ping.state_path()
343+
old = json.loads(path.read_text())
344+
old.update(consent='default', day='2026-09-28', counters=[{
345+
'feature': 'todo', 'outcome': 'ok', 'duration': 'lt_1s', 'error': 'none', 'count': 7,
346+
}])
347+
old['notice']['version'] = 3
348+
path.write_text(json.dumps(old))
349+
before = path.read_bytes()
350+
setup = 'import sys; from pathlib import Path; from loopx import usage_ping; usage_ping.DEFAULT_RUNTIME_ROOT=Path(sys.argv[1]); from loopx.cli_runtime import main; '
351+
command = [sys.executable, '-c', setup + 'raise SystemExit(main(["version", "--format", "json"]))', str(isolated)]
352+
hidden = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=30)
353+
assert hidden.returncode == 0 and json.loads(hidden.stdout)['ok']
354+
assert path.read_bytes() == before
355+
assert not accepted.wait(0.3) and received == []
356+
visible = subprocess.run(command, capture_output=True, text=True, timeout=30)
357+
assert visible.returncode == 0 and json.loads(visible.stdout)['ok']
358+
assert 'first measured CLI result' in visible.stderr and '15 minutes' in visible.stderr
359+
assert 'network timing' in visible.stderr
360+
current = json.loads(path.read_text())
361+
assert current['notice']['version'] == 4
362+
assert current['generation'] != old['generation']
363+
assert current['counters'] == []
364+
assert not accepted.wait(0.3) and received == []
365+
subsequent = subprocess.run(command, capture_output=True, text=True, timeout=30)
366+
assert subsequent.returncode == 0 and subsequent.stderr == ''
367+
assert accepted.wait(5)
368+
deadline = time.monotonic() + 5
369+
while not any(item.get('schema') == 'loopx_usage_aggregate_v1' for item in received) and time.monotonic() < deadline:
370+
time.sleep(0.02)
371+
batches = [item for item in received if item.get('schema') == 'loopx_usage_aggregate_v1']
372+
assert len(batches) == 1 and sum(row['count'] for row in batches[0]['counters']) == 1

0 commit comments

Comments
 (0)