Skip to content

Commit fb3d03d

Browse files
deepcoldyclaude
andcommitted
fix(trigger): 幂等键 crash-atomicity 收口(收 codex #776 三轮 6 blocker,全部 fail-closed)
codex 三轮(review 4878310684)确认 v3 方向对、happy-path 已证;剩 6 组崩溃/IO 故障 原子性 blocker,全部按 fail-closed 修 + 补故障注入测试: 1. reserved 清理非 snapshot CAS(旧快照能删掉已推进到 attempting 的 fence)→ 新增 compareAndRemoveByPath(fp, expect):锁内重读 + 完整 identity/revision/state CAS,只删仍 匹配快照的记录;不匹配保留。reconcile 改用它(替代无条件 removeByPathLocked)。 测试:advancedState=attempting 后旧快照 remove → fence 仍在。 2. compareAndRemove 把 EIO/EROFS/EACCES 吞成 already-gone→返 true → 新增 strictUnlink: 仅 ENOENT 当已删,其余 throw。调用方(barrier 前释放)检查返回。 3. durable failed 直接 continue 不 quarantine(崩在写 failed 后 close 前→下轮 restore 重注册) → reconcile 对 failed 也每次 quarantine + 重试 closeSession。 4. reconcile per-record / corrupt / daemon 外层错误被吞后继续 bind(注释写 fail readiness、 行为 fail-open)→ reconcile 收集硬失败并在 sweep 后 throw;listAll({throwOnCorrupt}) corrupt lease 直接抛(不 silent skip);daemon.ts 改为 reconcile 抛错则 throw 中止该 bot 启动(不再 log-and-continue bind)。 5. barrier 后 recordFailedStrict+close 双失败仍返回 state:failed(磁盘故障下轮询永久 running) → 只有 recordFailedStrict 成功(terminal 真 durable)才返回 state:failed;写也失败则返 5xx trigger_failed(诚实硬错,lease 留 attempting 交下轮 reconcile)。测试:async 目标路径 预置为目录使 strict 写失败 → 断言非 phantom failed、errorCode trigger_failed。 6. recordFailedStrict 写 strict 但读用 soft load()(corrupt/EIO/invalid 当空文件覆盖,可能抹掉 completed/owner 证据)→ 新增 loadStrict:仅 ENOENT 当 absent,其余 throw;覆盖前校验 owner 不匹配则 throw。测试:corrupt 文件不被覆盖、completed-wins、late-completed-wins、 owner-proof、EIO throw。 验证:pnpm build 绿;affected+shared 10 套件 311/311 绿(store 17 / async-store 24 含 7 故障注入 / trigger-session-idempotency 12 / e2e 5 含双故障 / trigger-api / api-only readiness 序 / …)。 docs 主路径契约不变(崩溃语义 caller-visible 不变)。普通 trigger/webhook 零行为变化。 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent d53f207 commit fb3d03d

7 files changed

Lines changed: 210 additions & 44 deletions

src/core/trigger-session.ts

Lines changed: 64 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -233,34 +233,56 @@ export async function reconcileIdempotencyLeasesOnBoot(
233233
): Promise<Set<string>> {
234234
const now = Date.now();
235235
const quarantined = new Set<string>();
236-
for (const { file, record } of idempotencyStore.listAll()) {
236+
// Fail-closed: any lease we cannot PROVE converged (terminal not durable, or a
237+
// corrupt/unreadable lease we can't reason about) makes the whole reconcile
238+
// throw — the daemon must then abort this bot's startup rather than bind and
239+
// let a poller hang `running` or an orphan re-attach. We finish the sweep to
240+
// converge everything we can, but remember the first hard failure and rethrow.
241+
let hardFailure: Error | undefined;
242+
const leases = idempotencyStore.listAll({ throwOnCorrupt: true }); // corrupt → throw (unprovable)
243+
for (const { file, record } of leases) {
244+
// Owner scoping (fail-closed) + skip current boot's own in-flight leases.
245+
if (record.ownerLarkAppId !== ownerLarkAppId) continue;
246+
if (record.ownerBootId === currentBootId) continue;
237247
try {
238-
// Owner scoping (fail-closed) + skip current boot's own in-flight leases.
239-
if (record.ownerLarkAppId !== ownerLarkAppId) continue;
240-
if (record.ownerBootId === currentBootId) continue;
241248
const outcome = asyncTriggerStore.lookup(record.sessionId, record.triggerId)?.result.status;
242-
if (outcome === 'completed' || outcome === 'failed') continue; // already converged
249+
if (outcome === 'completed') continue; // converged good; retry reuses + polls
250+
if (outcome === 'failed') {
251+
// Already durable-failed, but a PREVIOUS boot may have crashed after
252+
// writing failed and before closing → always re-quarantine and re-attempt
253+
// close, so restore never re-attaches a session the caller already saw failed.
254+
quarantined.add(record.sessionId);
255+
if (getSession(record.sessionId)) await closeSession(record.sessionId);
256+
continue;
257+
}
243258
if (record.state === 'attempting') {
244-
// Authoritative terminal FIRST (throws on failure → surfaced to caller,
245-
// which fails boot readiness rather than silently leaving `running`).
259+
// Write the authoritative terminal FIRST (throws on I/O failure), THEN
260+
// quarantine + close. Quarantine happens regardless of close success.
246261
asyncTriggerStore.recordFailedStrict(record.sessionId, record.triggerId, now, ownerLarkAppId, 'dispatch_unknown');
247262
quarantined.add(record.sessionId);
248-
if (getSession(record.sessionId)) {
249-
try { await closeSession(record.sessionId); } catch (e) { logger.warn(`[idempotency] reconcile close ${record.sessionId} failed (terminal already durable): ${(e as Error).message}`); }
250-
}
263+
if (getSession(record.sessionId)) await closeSession(record.sessionId);
251264
continue;
252265
}
253-
// reserved: provably never dispatched → drop the lease (by enumerated path,
254-
// under its own lock) + close the empty session.
255-
idempotencyStore.removeByPathLocked(file);
266+
// reserved: provably never dispatched → CAS-remove by path (only if the
267+
// on-disk record is still this exact reserved snapshot — never delete a
268+
// fence that advanced to attempting), + close the empty session.
269+
idempotencyStore.compareAndRemoveByPath(file, record);
256270
quarantined.add(record.sessionId);
257-
if (getSession(record.sessionId)) {
258-
try { await closeSession(record.sessionId); } catch (e) { logger.warn(`[idempotency] reconcile close ${record.sessionId} failed: ${(e as Error).message}`); }
259-
}
271+
if (getSession(record.sessionId)) await closeSession(record.sessionId);
260272
} catch (err) {
261-
logger.warn(`[idempotency] boot reconcile skipped a lease: ${(err as Error).message}`);
273+
// This lease could not be converged (strict-failed write threw, CAS-remove
274+
// threw on EIO, or close threw). Do NOT skip-and-continue as "handled":
275+
// record it and keep the session quarantined so restore can't revive it,
276+
// then fail the whole reconcile after the sweep.
277+
quarantined.add(record.sessionId);
278+
const e = err as Error;
279+
logger.error(`[idempotency] reconcile could not converge lease for ${record.sessionId}: ${e.message}`);
280+
if (!hardFailure) hardFailure = e;
262281
}
263282
}
283+
if (hardFailure) {
284+
throw new Error(`idempotency boot reconcile failed to converge at least one lease: ${hardFailure.message}`);
285+
}
264286
return quarantined;
265287
}
266288

@@ -1027,11 +1049,33 @@ export async function triggerSessionTurn(
10271049
? triggerId
10281050
: { turnId: triggerId, dispatchAttempt });
10291051
} catch (err) {
1052+
// The ONLY thing that lets us honestly report a terminal `failed` is a
1053+
// DURABLE failed record (that is what trigger-result reads). If the strict
1054+
// write itself fails (disk full/EIO), we must NOT claim `state:failed` —
1055+
// the caller could never observe it and would see `running` forever. In
1056+
// that double-failure case return a 5xx so the caller treats it as an
1057+
// unknown hard error (and the next boot's reconcile will converge the
1058+
// still-`attempting` lease). Only on a successful durable write do we
1059+
// return the terminal failed. (finding: double storage failure must be a
1060+
// fail-closed 5xx, not a phantom `failed`.)
1061+
let terminalDurable = false;
10301062
if (idempotencyKey) {
1031-
try { asyncTriggerStore.recordFailedStrict(session.sessionId, triggerId, Date.now(), larkAppId, 'dispatch_unknown'); }
1032-
catch (e) { logger.error(`[idempotency] failed to record dispatch_unknown after dispatch throw: ${(e as Error).message}`); }
1063+
try {
1064+
asyncTriggerStore.recordFailedStrict(session.sessionId, triggerId, Date.now(), larkAppId, 'dispatch_unknown');
1065+
terminalDurable = true;
1066+
} catch (e) {
1067+
logger.error(`[idempotency] dispatch threw AND recordFailedStrict failed — lease stays attempting for next-boot reconcile: ${(e as Error).message}`);
1068+
}
1069+
}
1070+
try { await closeSession(session.sessionId); } catch { /* best-effort; terminal already durable if terminalDurable */ }
1071+
if (idempotencyKey && !terminalDurable) {
1072+
return {
1073+
ok: false, errorCode: 'trigger_failed',
1074+
error: `dispatch failed and terminal outcome could not be persisted: ${(err as Error).message}`,
1075+
target: { kind: 'turn', sessionId: session.sessionId, chatId },
1076+
idempotencyKey,
1077+
};
10331078
}
1034-
try { await closeSession(session.sessionId); } catch { /* best-effort */ }
10351079
return {
10361080
ok: false, state: 'failed', triggerId,
10371081
errorCode: 'no_output', error: `dispatch failed with unknown outcome: ${(err as Error).message}`,

src/daemon.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18971,18 +18971,23 @@ export async function startDaemon(botIndex?: number): Promise<void> {
1897118971
// interleave with the sweep and have its fresh lease mistaken for stale) and is
1897218972
// scoped to THIS bot (the dataDir is shared across bots). Converges leases left
1897318973
// by a previous boot: `attempting` → durable failed(dispatch_unknown) + close;
18974-
// `reserved` → drop + close. Returns the sessionIds it terminalized/closed so
18975-
// restoreActiveSessions can quarantine them from re-attach (else a session the
18974+
// `reserved` → CAS-remove + close. Returns the sessionIds it terminalized/closed
18975+
// so restoreActiveSessions can quarantine them from re-attach (else a session the
1897618976
// poller now sees `failed` could be reattached and keep running — state/exec
1897718977
// divergence). sessionStore is init'd + worker pool is up by here.
18978-
let idempotencyQuarantinedSessionIds = new Set<string>();
18978+
//
18979+
// FAIL-CLOSED: if reconcile throws (a lease it could not prove converged — a
18980+
// strict-failed write that failed, a corrupt lease, an unlink/close that
18981+
// errored), we must NOT bind the IPC server and restore sessions as if
18982+
// everything converged — that is exactly the "poller hangs running / orphan
18983+
// re-attach" this feature exists to prevent. Abort this bot's startup so an
18984+
// operator/supervisor sees it, rather than fail-open into an inconsistent state.
18985+
let idempotencyQuarantinedSessionIds: Set<string>;
1897918986
try {
1898018987
idempotencyQuarantinedSessionIds = await reconcileIdempotencyLeasesOnBoot(cfg.larkAppId, getDaemonBootId());
1898118988
} catch (err) {
18982-
// A failed reconcile means an ambiguous turn might still poll `running` — do
18983-
// not proceed as if converged; surface loudly. (recordFailedStrict throwing
18984-
// is the main way this happens.)
18985-
logger.error(`[idempotency] boot reconcile failed — some leases may be unconverged: ${err instanceof Error ? err.message : err}`);
18989+
logger.error(`[idempotency] boot reconcile failed to converge — aborting bot startup (fail-closed): ${err instanceof Error ? err.message : err}`);
18990+
throw err instanceof Error ? err : new Error(String(err));
1898618991
}
1898718992
// Seed dashboard IPC botName with the custom displayName (falling back to the
1898818993
// bot's config id); the friendly name from /bot/v3/info is wired into the

src/services/async-trigger-store.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,26 @@ function load(sessionId: string): AsyncTriggerFile {
8585
}
8686
}
8787

88+
/** STRICT loader for the authoritative failed-evidence RMW: ONLY a genuinely
89+
* absent file (ENOENT) is treated as empty. A present-but-unreadable file
90+
* (EIO/EACCES), corrupt JSON, or invalid shape THROWS — the soft `load()` would
91+
* fold these into `{results:{}}`, and recordFailedStrict would then durably
92+
* OVERWRITE a file that might hold a `completed` proof or another owner's data
93+
* (finding: strict write over a soft read defeats completed-wins/owner-proof). */
94+
function loadStrict(sessionId: string): AsyncTriggerFile {
95+
const fp = getFilePath(sessionId);
96+
try { readFileSync(fp, 'utf-8'); }
97+
catch (err: any) {
98+
if (err?.code === 'ENOENT') return { results: {} };
99+
throw err; // EIO/EACCES/… — do NOT treat as empty
100+
}
101+
const data = JSON.parse(readFileSync(fp, 'utf-8')) as AsyncTriggerFile; // corrupt → throw
102+
if (!data || typeof data !== 'object' || typeof data.results !== 'object') {
103+
throw new Error(`corrupt async-trigger file (invalid shape): ${fp}`);
104+
}
105+
return { ownerLarkAppId: data.ownerLarkAppId, latestTriggerId: data.latestTriggerId, results: data.results ?? {} };
106+
}
107+
88108
function save(sessionId: string, file: AsyncTriggerFile): void {
89109
ensureDir();
90110
const fp = getFilePath(sessionId);
@@ -178,7 +198,12 @@ export function recordFailedStrict(
178198
if (!ownerLarkAppId) throw new Error('recordFailedStrict requires ownerLarkAppId');
179199
ensureDir();
180200
withFileLockSync(getFilePath(sessionId), () => {
181-
const file = load(sessionId);
201+
const file = loadStrict(sessionId); // ONLY ENOENT is empty; corrupt/EIO throws
202+
// Owner proof: never overwrite another bot's file (a hash/path mixup or a
203+
// cross-bot mistake must fail-closed, not clobber their evidence).
204+
if (file.ownerLarkAppId && file.ownerLarkAppId !== ownerLarkAppId) {
205+
throw new Error(`recordFailedStrict owner mismatch: file owned by ${file.ownerLarkAppId}, caller ${ownerLarkAppId}`);
206+
}
182207
const prev = file.results[triggerId];
183208
if (prev?.status === 'completed') return; // completed is stronger — keep it
184209
file.ownerLarkAppId = ownerLarkAppId;

src/services/idempotency-store.ts

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -230,9 +230,21 @@ export function transition(
230230
});
231231
}
232232

233+
/** Delete a file, treating ONLY ENOENT as "already gone". EIO/EROFS/EACCES etc.
234+
* mean the file may still exist — the caller must NOT proceed as if released,
235+
* so we throw (finding: a swallowed unlink error left a reserved lease stuck to
236+
* a closed session). */
237+
function strictUnlink(fp: string): void {
238+
try { unlinkSync(fp); }
239+
catch (err: any) { if (err?.code !== 'ENOENT') throw err; }
240+
}
241+
233242
/** Compare-and-remove: delete the lease ONLY if it still matches `expect`
234243
* (identity + revision + state) under the lock. Used to release a `reserved`
235-
* lease we created but abandoned before dispatch. Returns true if removed. */
244+
* lease we created but abandoned before dispatch. Returns true if removed,
245+
* false if the on-disk record changed (someone advanced it) or was already
246+
* gone. THROWS on an ambiguous unlink error (EIO/EROFS/…): the caller must be
247+
* able to trust that `true` means the lease is truly released. */
236248
export function compareAndRemove(ownerLarkAppId: string, key: string, expect: IdempotencyRecord): boolean {
237249
const fp = fileFor(ownerLarkAppId, key);
238250
return withKeyLock(fp, () => {
@@ -241,14 +253,17 @@ export function compareAndRemove(ownerLarkAppId: string, key: string, expect: Id
241253
if (current.revision !== expect.revision || current.state !== expect.state || !sameIdentity(current, expect)) {
242254
return false;
243255
}
244-
try { unlinkSync(fp); } catch { /* already gone */ }
256+
strictUnlink(fp); // throws on EIO/EROFS/… (never a silent success)
245257
return true;
246258
});
247259
}
248260

249-
/** Enumerate every stored lease (boot reconcile). Best-effort per file: a
250-
* corrupt file is logged + skipped so it can't abort the sweep. */
251-
export function listAll(): Array<{ file: string; record: IdempotencyRecord }> {
261+
/** Enumerate every stored lease (boot reconcile). By default a corrupt file is
262+
* logged + skipped. With `throwOnCorrupt`, a corrupt lease THROWS instead — the
263+
* reconcile can't prove such a lease converged, so it must fail-closed rather
264+
* than silently skip (a skipped corrupt lease could hide an unconverged
265+
* attempting fence). */
266+
export function listAll(opts: { throwOnCorrupt?: boolean } = {}): Array<{ file: string; record: IdempotencyRecord }> {
252267
const dir = getDir();
253268
if (!existsSync(dir)) return [];
254269
const out: Array<{ file: string; record: IdempotencyRecord }> = [];
@@ -259,16 +274,30 @@ export function listAll(): Array<{ file: string; record: IdempotencyRecord }> {
259274
const rec = readRecord(fp);
260275
if (rec) out.push({ file: fp, record: rec });
261276
} catch (err) {
277+
if (opts.throwOnCorrupt) throw new Error(`unreadable idempotency lease ${fp}: ${(err as Error).message}`);
262278
logger.warn(`[idempotency] skipping unreadable lease ${fp}: ${err}`);
263279
}
264280
}
265281
return out;
266282
}
267283

268-
/** Reconcile-only remove by path, under a lock keyed on that path. Used by boot
269-
* reconcile to drop a pre-dispatch `reserved` lease. Best-effort. */
270-
export function removeByPathLocked(fp: string): void {
271-
withKeyLock(fp, () => {
272-
try { if (existsSync(fp)) unlinkSync(fp); } catch { /* ignore */ }
284+
/** Reconcile-only compare-and-remove BY PATH (reconcile enumerated the file via
285+
* listAll and holds a snapshot record; the plaintext key isn't recoverable from
286+
* the hashed filename). Re-reads under the lock and removes ONLY if the on-disk
287+
* record still matches the snapshot's full identity + revision + state — so a
288+
* stale reserved snapshot can NOT delete a fence that has since advanced to
289+
* `attempting` (finding: old sweep erasing a crossed commit-unknown barrier).
290+
* Returns true iff removed. THROWS on an ambiguous unlink error. */
291+
export function compareAndRemoveByPath(fp: string, expect: IdempotencyRecord): boolean {
292+
return withKeyLock(fp, () => {
293+
let current: IdempotencyRecord | undefined;
294+
try { current = readRecord(fp); }
295+
catch { return false; } // corrupt now → leave it for a human, never blind-delete
296+
if (!current) return false;
297+
if (current.revision !== expect.revision || current.state !== expect.state || !sameIdentity(current, expect)) {
298+
return false; // advanced/changed under us — keep it
299+
}
300+
strictUnlink(fp);
301+
return true;
273302
});
274303
}

test/async-trigger-store.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ vi.mock('../src/utils/logger.js', () => ({
3030
import {
3131
recordPending,
3232
recordCompleted,
33+
recordFailedStrict,
3334
lookup,
3435
deleteResults,
3536
} from '../src/services/async-trigger-store.js';
@@ -180,3 +181,46 @@ describe('robustness', () => {
180181
expect(lookup(sid)?.result.content).toBe('ok');
181182
});
182183
});
184+
185+
describe('recordFailedStrict (authoritative dispatch_unknown terminal)', () => {
186+
it('writes a durable failed(dispatch_unknown) that lookup surfaces', () => {
187+
recordFailedStrict('sessF', 'trg_f', 7000, 'cli_test', 'dispatch_unknown');
188+
const r = lookup('sessF', 'trg_f')?.result;
189+
expect(r?.status).toBe('failed');
190+
expect(r?.errorCode).toBe('no_output');
191+
expect(r?.reason).toBe('dispatch_unknown');
192+
});
193+
194+
it('COMPLETED WINS: does not overwrite an existing completed result', () => {
195+
recordCompleted('sessC', 'trg_c', 'the answer', 5000, 'cli_test');
196+
recordFailedStrict('sessC', 'trg_c', 6000, 'cli_test'); // must be a no-op
197+
expect(lookup('sessC', 'trg_c')?.result.status).toBe('completed');
198+
expect(lookup('sessC', 'trg_c')?.result.content).toBe('the answer');
199+
});
200+
201+
it('LATE COMPLETED WINS: a completed arriving after failed overwrites it', () => {
202+
recordFailedStrict('sessL', 'trg_l', 6000, 'cli_test');
203+
expect(lookup('sessL', 'trg_l')?.result.status).toBe('failed');
204+
recordCompleted('sessL', 'trg_l', 'done late', 7000, 'cli_test');
205+
expect(lookup('sessL', 'trg_l')?.result.status).toBe('completed');
206+
});
207+
208+
it('STRICT READ: throws on a corrupt existing file (never overwrites it as empty)', () => {
209+
mkdirSync(join(tempDir, 'async-triggers'), { recursive: true });
210+
writeFileSync(join(tempDir, 'async-triggers', 'sessCorrupt.json'), '{ not json', 'utf-8');
211+
expect(() => recordFailedStrict('sessCorrupt', 'trg_x', 8000, 'cli_test')).toThrow();
212+
// The corrupt file is left intact for a human — not silently replaced.
213+
expect(existsSync(join(tempDir, 'async-triggers', 'sessCorrupt.json'))).toBe(true);
214+
});
215+
216+
it('OWNER PROOF: refuses to overwrite a file owned by a different bot', () => {
217+
recordCompleted('sessO', 'trg_o', 'x', 5000, 'cli_ownerA');
218+
expect(() => recordFailedStrict('sessO', 'trg_o', 6000, 'cli_ownerB')).toThrow(/owner mismatch/);
219+
// ownerA's data intact.
220+
expect(lookup('sessO', 'trg_o')?.result.status).toBe('completed');
221+
});
222+
223+
it('requires ownerLarkAppId', () => {
224+
expect(() => recordFailedStrict('sessN', 'trg_n', 1, '')).toThrow(/ownerLarkAppId/);
225+
});
226+
});

test/idempotency-store.test.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ vi.mock('../src/utils/logger.js', () => ({
2323
}));
2424

2525
import {
26-
claim, transition, takeover, lookup, compareAndRemove, listAll, removeByPathLocked,
26+
claim, transition, takeover, lookup, compareAndRemove, listAll, compareAndRemoveByPath,
2727
IdempotencyConflictError,
2828
type IdempotencyRecord,
2929
} from '../src/services/idempotency-store.js';
@@ -154,12 +154,17 @@ describe('reconcile enumeration', () => {
154154
expect(all.every(a => a.file.endsWith('.json'))).toBe(true);
155155
});
156156

157-
it('removeByPathLocked drops a lease by its enumerated path', () => {
157+
it('compareAndRemoveByPath drops a lease only if the on-disk record still matches the snapshot', () => {
158158
claim(base());
159-
const { file } = listAll()[0];
160-
removeByPathLocked(file);
159+
const { file, record } = listAll()[0];
160+
// Stale snapshot (advanced to attempting under us) → must NOT delete the fence.
161+
transition('cli_a', 'k1', record, { state: 'attempting', now: 5000 }); // rev2 attempting
162+
expect(compareAndRemoveByPath(file, record)).toBe(false); // record is the rev1 reserved snapshot
163+
expect(lookup('cli_a', 'k1')?.state).toBe('attempting'); // fence preserved (codex repro)
164+
// Exact match → removed.
165+
const current = lookup('cli_a', 'k1')!;
166+
expect(compareAndRemoveByPath(file, current)).toBe(true);
161167
expect(lookup('cli_a', 'k1')).toBeUndefined();
162-
expect(() => removeByPathLocked(file)).not.toThrow(); // idempotent
163168
});
164169

165170
it('listAll skips (does not throw on) a corrupt file', () => {

0 commit comments

Comments
 (0)