Posts tagged with “dev”

我的笔记现在会自动发到 X —— 靠一个只读 Redis 旁观者

我有一个手动发推的 CLI(Playwright 驱动登录状态的 Firefox 网页,所以不需要 X API key),还有一个多用户笔记应用(HappyNotes)把新笔记推入 Redis 队列同步到 Mastodon。想让它俩连起来:笔记一发,自动发推。但不想动共享后端、毕竟这是一个很私人的hack,也没法子支持其他用户。

演进三步

1. CLI → 微服务。 把发推核心抽成库,Node 内置 http 起一个服务,只监听 Tailscale IP(100.x.y.z:8090)——内网即鉴权,零依赖、零鉴权代码。任何 tailnet 机器都能入队:

curl -X POST http://100.x.y.z:8090/add \
  -H 'content-type: application/json' \
  -d '{"text":"hello","post_at":"2026-09-07T09:00:00+12:00"}'

2. 图片走 data URL。 别的机器没有你这台的文件系统,multipart 又要新依赖——所以图片在 JSON body 里以 data:image/png;base64,... 传输,Playwright setInputFiles 喂内存 buffer,最多 4 张。

3. 只读 Redis 旁观者。 这是关键设计:不 LPOP(会偷走 Mastodon 消费者的消息),只每秒 LRANGE queue + ZRANGEBYSCORE processing,用 redis-cli 子进程(还是零新依赖)。然后三道过滤:

userId == OWNER_USER_ID  &&  action == "CREATE"  &&  isPrivate == false

task.id 落盘去重(先写 seen 再发布,失败绝不重试——重试 = 重复推文),冷启动基线首轮只记已见不发布,防止积压刷屏。

代价:一个有意的竞态窗口

队列项最多活 5 秒(消费者 LPOP 后成功后 ZREM,删了就没了,没有 deleted 队列可拣漏)。所以旁观者"几乎不漏但不保证"——如果任务在两次轮询之间被消费完,就错过了。要修复的话要动HappyNotes后端(加一个 synced 队列,成功时 LPUSH + 1 小时 TTL),但只为我一个人需求改全站代码不太值,先接受这个妥协看看。

Oracle `FOR UPDATE`: the timeout belongs to the waiter, not the lock holder

You'll run into this shape in PL/SQL procedures that need to serialize work per row:

SELECT clientid
  INTO l_locked_client_id
  FROM dataela.clients
 WHERE clientid = p_client_id
 FOR UPDATE;

The selected value is never used — the variable name admits it. The query exists purely to take a row-level exclusive lock, turning that client row into a mutex so a read-modify-write sequence can't be clobbered by a concurrent session. It raises NO_DATA_FOUND for free if the client doesn't exist.

Two things about that lock are easy to get wrong.

The lock outlives the procedure. A PL/SQL block is not a transaction boundary. When the procedure returns, the lock is still held — it belongs to the caller's transaction and is released only at COMMIT or ROLLBACK (or when the session dies, or on the implicit commit from a stray DDL statement mid-transaction, which drops it silently). So hold time is decided by the caller, not by the procedure that took the lock. If the caller runs for five minutes, the row is locked for five minutes. Worth a comment on the procedure saying exactly that.

The WAIT clause is the waiter's patience, not the holder's timeout.

FOR UPDATE              -- wait forever (default)
FOR UPDATE NOWAIT       -- fail immediately, ORA-00054
FOR UPDATE WAIT 5       -- give up after 5s, ORA-30006
FOR UPDATE SKIP LOCKED  -- skip locked rows (queue consumers)

Each clause constrains only the statement it's attached to, so whatever the holder wrote has no effect on how long anyone else waits. Oracle has no holder-side "release after N seconds" and no global lock timeout to protect you — if you don't want connections piling up behind a lock, every call site that might wait needs its own NOWAIT or WAIT n plus a handler that retries or returns "try again later". Breaking a lock from outside means ALTER SYSTEM KILL SESSION.

That default infinite wait is also what makes the pattern deadlock-prone: two procedures locking several client rows in opposite orders will wait on each other until ORA-00060. Lock in a consistent order, or use NOWAIT with a retry.

One thing you don't have to worry about: a plain SELECT never blocks on any of this. Oracle rebuilds the pre-change version of the row from undo and reads that, so reporting queries pass straight through a locked row — none of the WITH (NOLOCK) reflex SQL Server teaches. Only FOR UPDATE, UPDATE, and DELETE against the same row queue up behind you, and only that row.

Adding a rule to an agent's system prompt? Delete one in the same breath

Agent system prompts rot the same way every time: each fix tacks on a sentence, nothing ever comes off, and the prompt grows forever. Two things break as a result — the prompt drifts past whatever size budget you have, and (the subtle one) the prompt's prose is the agent's prose. A verbose, redundant constitution teaches a verbose, redundant writing voice.

So make every addition carry a matching subtraction. When you add a rule, find the existing rule it makes redundant and cut it. In practice the new rule almost always overlaps something — a preamble about "only ask when a decision genuinely needs the human" makes a stale "no confirmation requests for obvious next steps" bullet redundant; fold one into the other.

The part people skip: don't eyeball whether the wording "fits" — measure it. Render the prompt with all includes expanded, byte-count it, and compare against a budget:

# render each role's full prompt and check it against a per-role ceiling
for src in agents/*.md; do
  role=$(basename "$src" .md)
  _mat_render_prompt "$src" "/tmp/$role.md"
  size=$(wc -c < "/tmp/$role.md")
  ceiling=$(jq -r --arg r "$role" '.[$r]' ceilings.json)
  echo "$role: $size / $ceiling  (headroom $((ceiling-size)))"
done

A checked-in per-role ceiling turns this into a ratchet: adding a line either fits under existing headroom, or forces you to bump the ceiling in the same diff — which makes the growth visible and reviewable instead of silent.

Measuring mattered more than expected. The fattest candidate wording netted +161 bytes; the tightest role had exactly 163 bytes of headroom. Two bytes of slack — and a longer {{USERNAME}} at render time would have blown it. Invisible by eye, obvious once counted. We trimmed the wording to net +135 and left every ceiling untouched.

And run the ratchet both ways. When you remove fat, lower the ceiling to the new size × 1.10 in the same change — otherwise the slack you just created quietly refills.

Three-agent caucus before opening a design ticket

A solo design pick ships a brittle ticket. A small jury forces you to surface the hard rule, spot when "labeling problem" is really a data gap, and converge to ready instead of refining.

When to convene

Caucus is for design questions with real divergence, where the goal is a ready ticket. Skip it for one-line facts (one agent), pure execution (just do it), or where there's only one sane option.

Composition: two advocates + one skeptic

Two advocates pick majority-wins and call it consensus. Add an impartial chair/skeptic whose job is failure modes and a decision criterion. Three is convention, not doctrine — two advocates + one chair works; one advocate + one skeptic works; three advocates doesn't.

The three prompts share one SCENARIO

Each agent gets the same verified facts (IDs, code-line refs, observed outcomes). Letting each agent self-research drifts the facts and you can't synthesize. Then each gets a distinct lens and a boundary:

SCENARIO (verified): ...
CONTEXT (read these files, these are the related issues, decision-maker's steer): ...
YOUR LENS: [extend-X | separate-track | skeptic/chair]
BOUNDARY: read-only, no code, ≤500 words, return VERDICT line.

Use VERDICT as the last line so synthesis can grep it. Pick lens names that frame the choice (extend-#122 advocate, separate-track advocate, skeptic/chair) — the framing shapes the output.

Run the three concurrently in background; wait for all three before synthesizing. Sequential loses the wall-clock and the parallel disagreement.

Synthesize

  1. Consensus → hard rule. Whatever all three agree on goes in as a non-negotiable constraint, not a soft preference.
  2. Divergence → chair's criterion + compatibility points. The skeptic's job is to name when each advocate breaks. Often the advocates are compatible once you apply the criterion (e.g. fallback rule + quarantine path, gated by data-first verification).
  3. Lock → ready. The whole point of the caucus is to converge. If you converged, open ready and release the lock. Don't re-refine.

Traps

  • Treating caucus as solution. Three agents return positions; you synthesize. Letting them agree with each other is a fragile consensus.
  • No skeptic. Two advocates ship majority-wins.
  • No shared SCENARIO. Facts diverge, synthesis collapses.
  • No VERDICT line. Agent writes an essay, you can't grep.
  • Caucus for execution. Locating a bug, writing a doc, picking a flag value — none of these need a jury.
  • "Three" as dogma. Two advocates + one chair is the minimum useful shape. Three advocates is the maximum useless shape.

The synthesis rule that matters most: when the chair says "this is a data problem disguised as a labeling problem," that's the reframe — verify the data first, then design the fallback. Without the skeptic, you'd have built a clever rule on top of an incomplete export and shipped proxy labels.

Reviewing a branch: `git diff` wants three dots, `git log` wants two

Same task — "show me what this branch changed" — but the two tools take opposite dot conventions. Get it backwards and your review fills with commits the author never touched.

The diff: use three dots

git diff origin/main...HEAD

Three-dot diff is git diff $(git merge-base origin/main HEAD) HEAD — it diffs against the branch point, not the tip of main. That's exactly what you want: the author's delta and nothing else.

The nice property: it's immune to origin/main moving forward. New commits landing on main after the branch point aren't ancestors of HEAD, so they don't shift the merge-base. The diff stays clean.

The trap is a stale base, not a newer one. If your local main is older than the branch point, the merge-base slides back to an older ancestor and the diff swallows unrelated upstream commits — making the author look like they changed far more than they did. So fetch first:

git fetch origin
git diff origin/main...HEAD

Want to see the branch point itself? git merge-base origin/main HEAD.

The log: use two dots

git log origin/main..HEAD

Two-dot log = commits reachable from HEAD but not from origin/main = the branch's own commits, exactly.

Don't reach for three dots here out of habit — git log A...B is the symmetric difference, so it also lists the commits main picked up that HEAD doesn't have. That's the noise you were trying to avoid.

So: diff three-dot, log two-dot. Different tools, opposite defaults, same job.