diff --git a/.scheduler-memory/session-mlnw868c/retrieve.json b/.scheduler-memory/session-mlnw868c/retrieve.json new file mode 100644 index 0000000..09e7c9c --- /dev/null +++ b/.scheduler-memory/session-mlnw868c/retrieve.json @@ -0,0 +1,18 @@ +{ + "cadence": "weekly", + "operation": "retrieve", + "runId": "session-mlnw868c", + "servicePath": "src/services/memory/index.js#getRelevantMemories", + "inputs": { + "agentId": "race-condition-agent", + "promptPath": "src/prompts/weekly/race-condition-agent.md", + "promptIntent": "## Required startup + artifacts + memory + issue capture", + "query": "scheduler | weekly | agent | race-condition-agent | intent | Required startup + artifacts + memory + issue capture", + "events": 1 + }, + "outputs": { + "ingestedCount": 1, + "retrievedCount": 1 + }, + "status": "ok" +} \ No newline at end of file diff --git a/.scheduler-memory/session-mlnw868c/retrieve.ok b/.scheduler-memory/session-mlnw868c/retrieve.ok new file mode 100644 index 0000000..84b1719 --- /dev/null +++ b/.scheduler-memory/session-mlnw868c/retrieve.ok @@ -0,0 +1 @@ +MEMORY_RETRIEVED diff --git a/.scheduler-memory/session-mlnwbg1h/store.json b/.scheduler-memory/session-mlnwbg1h/store.json new file mode 100644 index 0000000..99fce4b --- /dev/null +++ b/.scheduler-memory/session-mlnwbg1h/store.json @@ -0,0 +1,19 @@ +{ + "cadence": "weekly", + "operation": "store", + "runId": "session-mlnwbg1h", + "servicePath": "src/services/memory/index.js#ingestEvents", + "inputs": { + "agentId": "race-condition-agent", + "promptPath": "src/prompts/weekly/race-condition-agent.md", + "promptIntent": "## Required startup + artifacts + memory + issue capture", + "events": 2 + }, + "outputs": { + "storedCount": 1, + "summaries": [ + "Store memory event A for weekly :: ## Required startup + artifacts + memory + issue capture Store memory event B for weekly :: ## Required startup + artifacts + memory + issue capture" + ] + }, + "status": "ok" +} \ No newline at end of file diff --git a/.scheduler-memory/session-mlnwbg1h/store.ok b/.scheduler-memory/session-mlnwbg1h/store.ok new file mode 100644 index 0000000..1e40bb6 --- /dev/null +++ b/.scheduler-memory/session-mlnwbg1h/store.ok @@ -0,0 +1 @@ +MEMORY_STORED diff --git a/artifacts/weekly-race-condition-report-2026-02-15.md b/artifacts/weekly-race-condition-report-2026-02-15.md new file mode 100644 index 0000000..f27f8c1 --- /dev/null +++ b/artifacts/weekly-race-condition-report-2026-02-15.md @@ -0,0 +1,23 @@ +# Weekly Race Condition Report: 2026-02-15 + +## Focus Areas Audited +- Shared Client and Relay Pool (`src/lib.mjs`, `src/lock-ops.mjs`) + +## Findings + +### 1. Non-deterministic Lock Sorting in `cmdLock` (High Severity) +- **Description**: The `cmdLock` function in `src/lib.mjs` sorts contending lock events by `createdAt` timestamp only. Since Nostr `created_at` has 1-second resolution, two agents creating lock events in the same second could be sorted differently by different clients depending on the order in which relays return them (or local array sort stability if not handled). +- **Interleaving**: + 1. Agent A creates lock event E1 at T=100. + 2. Agent B creates lock event E2 at T=100. + 3. Agent A queries relays, receives [E1, E2]. Sorts by time (equal). If stable sort or random order puts E1 first, A thinks it won. + 4. Agent B queries relays, receives [E2, E1]. Sorts by time (equal). If stable sort or random order puts E2 first, B thinks it won. + 5. Both agents proceed to execute the task, violating the mutual exclusion property. +- **Fix**: Implemented a deterministic tie-breaker using `eventId` (lexicographical comparison). + ```javascript + .sort((a, b) => (a.createdAt - b.createdAt) || String(a.eventId).localeCompare(String(b.eventId))); + ``` +- **Risk**: Low (behavior preserving, standard distributed system practice). + +## Next Week Focus +- App initialization and login flow. diff --git a/src/context/CONTEXT_2026-02-15T15-22-42Z.md b/src/context/CONTEXT_2026-02-15T15-22-42Z.md new file mode 100644 index 0000000..571c5e3 --- /dev/null +++ b/src/context/CONTEXT_2026-02-15T15-22-42Z.md @@ -0,0 +1,15 @@ +--- +agent: race-condition-agent +cadence: weekly +run-start: 2026-02-15T15:22:42Z +prompt: src/prompts/weekly/race-condition-agent.md +--- +Goal: Audit for race conditions and fix one. + +Scope: +- Shared Client and Relay Pool (`src/lib.mjs`, `src/lock-ops.mjs`) +- Lock acquisition logic + +Constraints: +- Non-breaking changes +- One fix per run diff --git a/src/decisions/DECISIONS_2026-02-15T15-22-42Z.md b/src/decisions/DECISIONS_2026-02-15T15-22-42Z.md new file mode 100644 index 0000000..c86c390 --- /dev/null +++ b/src/decisions/DECISIONS_2026-02-15T15-22-42Z.md @@ -0,0 +1,8 @@ +--- +agent: race-condition-agent +cadence: weekly +run-start: 2026-02-15T15:22:42Z +prompt: src/prompts/weekly/race-condition-agent.md +--- +Decision: Use `eventId` (hex string) lexicographical sort as tie-breaker. +Rationale: Event IDs are unique and random (SHA256). Sorting by ID ensures that all clients agree on the order of events with identical timestamps, preventing split-brain scenarios where multiple agents think they won the lock. diff --git a/src/lib.mjs b/src/lib.mjs index f9e8ade..411b86e 100644 --- a/src/lib.mjs +++ b/src/lib.mjs @@ -292,7 +292,7 @@ export async function cmdLock(agent, cadence, dryRun = false, deps = {}) { const postLocks = await queryLocks(relays, cadence, dateStr, namespace); const racingLocks = postLocks .filter((l) => l.agent === agent) - .sort((a, b) => a.createdAt - b.createdAt); + .sort((a, b) => (a.createdAt - b.createdAt) || String(a.eventId).localeCompare(String(b.eventId))); if (racingLocks.length > 1 && racingLocks[0].eventId !== event.id) { const winner = racingLocks[0]; diff --git a/src/test_logs/TEST_LOG_2026-02-15T15-22-42Z.md b/src/test_logs/TEST_LOG_2026-02-15T15-22-42Z.md new file mode 100644 index 0000000..9c7b40b --- /dev/null +++ b/src/test_logs/TEST_LOG_2026-02-15T15-22-42Z.md @@ -0,0 +1,64 @@ +--- +agent: race-condition-agent +cadence: weekly +run-start: 2026-02-15T15:22:42Z +prompt: src/prompts/weekly/race-condition-agent.md +--- +Command: node --test test/lib.test.mjs +Result: +TAP version 13 +# Subtest: src/lib.mjs + # Subtest: cmdCheck + # Subtest: returns correct structure with empty locks + ok 1 - returns correct structure with empty locks + # Subtest: identifies locked agents + ok 2 - identifies locked agents + # Subtest: excludes paused agents + ok 3 - excludes paused agents + # Subtest: identifies unknown locked agents + ok 4 - identifies unknown locked agents + 1..4 + ok 1 - cmdCheck + # Subtest: cmdLock + # Subtest: successfully locks an available agent + ok 1 - successfully locks an available agent + # Subtest: fails if agent is not in roster + ok 2 - fails if agent is not in roster + # Subtest: fails if agent is already locked + ok 3 - fails if agent is already locked + # Subtest: fails if agent is already completed + ok 4 - fails if agent is already completed + # Subtest: fails if race check is lost + ok 5 - fails if race check is lost + # Subtest: handles race condition with identical timestamps using eventId tie-breaker (we win) + ok 6 - handles race condition with identical timestamps using eventId tie-breaker (we win) + # Subtest: handles race condition with identical timestamps using eventId tie-breaker (we lose) + ok 7 - handles race condition with identical timestamps using eventId tie-breaker (we lose) + # Subtest: dry run does not publish + ok 8 - dry run does not publish + 1..8 + ok 2 - cmdLock + # Subtest: cmdList + # Subtest: lists active locks + ok 1 - lists active locks + # Subtest: warns about unknown agents + ok 2 - warns about unknown agents + # Subtest: handles no locks + ok 3 - handles no locks + 1..3 + ok 3 - cmdList + # Subtest: cmdComplete + # Subtest: successfully completes an active lock + ok 1 - successfully completes an active lock + # Subtest: fails if no active lock found + ok 2 - fails if no active lock found + # Subtest: detects already completed task + ok 3 - detects already completed task + 1..3 + ok 4 - cmdComplete + 1..4 +ok 1 - src/lib.mjs +1..1 +# tests 18 +# pass 18 +# not-passed 0 diff --git a/src/todo/TODO_2026-02-15T15-22-42Z.md b/src/todo/TODO_2026-02-15T15-22-42Z.md new file mode 100644 index 0000000..bc7f30c --- /dev/null +++ b/src/todo/TODO_2026-02-15T15-22-42Z.md @@ -0,0 +1,17 @@ +--- +agent: race-condition-agent +cadence: weekly +run-start: 2026-02-15T15:22:42Z +prompt: src/prompts/weekly/race-condition-agent.md +--- +## Completed Tasks +- [x] Audit lock logic. +- [x] Implement fix in `src/lib.mjs`. +- [x] Add regression test in `test/lib.test.mjs`. +- [x] Verify tests pass. +- [x] Create weekly report. +- [x] Complete memory storage. + +## Pending Tasks +- [ ] Verify artifacts. +- [ ] Complete lock. diff --git a/task-logs/weekly/2026-02-15T15-22-42Z__race-condition-agent__completed.md b/task-logs/weekly/2026-02-15T15-22-42Z__race-condition-agent__completed.md new file mode 100644 index 0000000..79fcafa --- /dev/null +++ b/task-logs/weekly/2026-02-15T15-22-42Z__race-condition-agent__completed.md @@ -0,0 +1,7 @@ +--- +agent: race-condition-agent +cadence: weekly +status: completed +platform: linux +--- +Fixed race condition in lock acquisition logic (`src/lib.mjs`) by adding `eventId` tie-breaker. Verified with regression tests. diff --git a/test/lib.test.mjs b/test/lib.test.mjs index 1acf464..32ca64c 100644 --- a/test/lib.test.mjs +++ b/test/lib.test.mjs @@ -199,6 +199,94 @@ describe('src/lib.mjs', () => { assert.match(logs.join('\n'), /LOCK_STATUS=race_lost/); }); + it('handles race condition with identical timestamps using eventId tie-breaker (we win)', async () => { + let callCount = 0; + const deps = { + ...baseDeps, + queryLocks: async () => { + callCount++; + if (callCount === 1) return []; + return [{ + agent: 'agent1', + eventId: 'aaaaa', // competitor (higher ID) + createdAt: 100, + createdAtIso: 'time' + }, { + agent: 'agent1', + eventId: 'mock-id', // our ID (we need to ensure mockFinalizeEvent returns this or we control comparison) + // The test mockFinalizeEvent returns 'mock-id'. + // 'mock-id' > 'aaaaa'. So we would lose if competitor is 'aaaaa'. + // To win, competitor must be 'zzzzz'. + // Let's re-check the logic. + // sort((a, b) => ... || a.id.localeCompare(b.id)) + // If we are 'mock-id', and competitor is 'zzzzz'. + // 'mock-id'.localeCompare('zzzzz') is negative. So we come first. + // racingLocks[0] is us. We win. + createdAt: 100, + }]; + }, + publishLock: async () => {}, + generateSecretKey: mockGenerateSecretKey, + getPublicKey: mockGetPublicKey, + finalizeEvent: (t) => ({ ...t, id: 'mock-id' }), + raceCheckDelayMs: 1, + }; + + // Competitor is 'aaaaa'. We are 'mock-id'. 'aaaaa' comes first. We lose. + // Wait, I want a test where we WIN. + // So competitor should be 'zzzzz'. + deps.queryLocks = async () => { + callCount++; + if (callCount === 1) return []; + return [{ + agent: 'agent1', + eventId: 'zzzzz', // competitor (higher ID) + createdAt: 100, + createdAtIso: 'time' + }, { + agent: 'agent1', + eventId: 'mock-id', // our ID + createdAt: 100, + }]; + }; + + const result = await cmdLock('agent1', 'daily', false, deps); + assert.strictEqual(result.status, 'ok'); + assert.match(logs.join('\n'), /LOCK_STATUS=ok/); + }); + + it('handles race condition with identical timestamps using eventId tie-breaker (we lose)', async () => { + let callCount = 0; + const deps = { + ...baseDeps, + queryLocks: async () => { + callCount++; + if (callCount === 1) return []; + return [{ + agent: 'agent1', + eventId: 'aaaaa', // competitor (lower ID) + createdAt: 100, + createdAtIso: 'time' + }, { + agent: 'agent1', + eventId: 'mock-id', // our ID ('m' > 'a') + createdAt: 100, + }]; + }, + publishLock: async () => {}, + generateSecretKey: mockGenerateSecretKey, + getPublicKey: mockGetPublicKey, + finalizeEvent: (t) => ({ ...t, id: 'mock-id' }), + raceCheckDelayMs: 1, + }; + + await assert.rejects( + async () => await cmdLock('agent1', 'daily', false, deps), + (err) => err.code === 3 && err.message === 'Race check lost' + ); + assert.match(logs.join('\n'), /LOCK_STATUS=race_lost/); + }); + it('dry run does not publish', async () => { let published = false; const deps = {