山友在訊號不穩的地方多按一次,志工稍晚才看到通知,原本那張單還算不算數?Day 25 預告今天要用兩個 LINE 視窗跑完同一張單,這件事還沒做到:Day 19 的收件匣和 Day 22 的停止開關仍是隔離範例,線上服務的單據則在 Firestore。今天先把乾淨容器與雙視窗要收齊的證據寫成嚴謹的驗收規格;真機紀錄預計在 10/12 示範凍結前完成,再回補本篇。
This chapter defines two separate acceptance paths: reproducing the pinned LOCAL code in a clean environment, and tracing one service request across two real LINE accounts. It connects user confirmation, notification, pause controls, atomic claiming, and authorized status retrieval. Existing offline evidence is retained; the new container run and dual-window demonstration remain pending until their own logs and device observations are collected.
現場一句話:鄉親已經按了送出,換一個人、另一支手機接手,還能找到同一張單嗎?
只准後端決定的規則:確認才建立請求;暫停阻擋新操作,已確認單據仍可授權查回。
Google AI 用到/刻意不用:Gemini 理解詢問、ADK 安排工具;認領、授權與完成判定交由後端。
五分鐘入口:有 Docker 執行第三節的 bash cleanroom.sh;無 Docker 則切至 9f1d98e 安裝 examples/day25/requirements-core.txt,執行 python3 -m unittest examples.day25.test_ingestion -v。
這篇不能證明:離線測試通過,不代表 LINE 已送出;LINE 接受推播,不代表志工手機已看到;志工看到卡片,也不代表已有人認領。
Day 25 已完成五題 Gemini 抽取、47 項離線測試與三筆候選採用(測試店甲與兩版測試店乙),並非三間真實店家;店家讀取工具尚未接上 LINE 服務。[1]
| 本篇驗收對象 | 已有依據 | 本次新增紀錄 |
|---|---|---|
| 安裝與離線契約 | Day 24 的 62 項、Day 25 的 47 項歷史測試 | 乾淨容器:NOT_RUN |
| 真人接手 | Day 19 收件匣、Day 22 停止與認領契約 | 雙 LINE 視窗:NOT_RUN |
| 操作友善度 | 既有地方服務的操作經驗 | 本篇整理設計理由,非新增訪談 |
今天要學會的,是把「另一個人接得起來」寫成能實際核對的條件。 同一個單號走到哪裡,就只說明到哪裡。
我的電腦記得很多事:安裝過的套件、終端機裡的變數,甚至早就存在的測試資料。這些方便,交給別人時可能全部消失。程式沒改,結果卻不同;原來被省略的,正是第一次使用的人最需要知道的部分。
原本規劃請 3 位外部開發者試用,現改為可自行驗證的乾淨環境。容器讓安裝條件受控,文件是否好懂則是另一個問題;既有營運經驗作為介面依據,不把容器當成真人報告。
無干預重現是預先寫好命令,執行中不補套件、不改程式、不借私人檔案;失敗時保留現場,修正後另開一輪。這是一套查明版本與失敗點的交付紀律,非對所有機器作無條件承諾。
容器比雲端 CI 多證明了:斷網測試確認無隱蔽外部呼叫、從公開網址取得程式確認公開 Repo 完整可用、換至純淨基底驗證安裝相依、且任何人在本機皆可重現。CI 驗明已提交程式與測試清單,新容器留存安裝日誌,雙手機則提供通道與真人依據。
乾淨環境並非毫無依賴。主機仍需 Docker 與 Bash,建置需要網路;Python、Git 與相依套件在映像內準備。「五分鐘入口」指操作起點,下載時間另計。
以下固定 Day 25 工程基線 9f1d98e,其歷史 CI 為 Run 37897811593;不是本次容器已通過的證明。後續只改文件的版本與程式測試基線分開記錄,避免每次修一句話都換掉實驗對象。[1]
在專案根目錄將下段存成 cleanroom.sh,以 bash cleanroom.sh 執行。它從公開 Repo 重新取得程式,沒有掛入既有程式資料夾、私人金鑰或套件快取;唯一主機掛載是新建的報告目錄。[2]
set -euo pipefail
RUN=${1:-out/day26/cleanroom-$(date +%Y%m%d-%H%M%S)}
REV=9f1d98e967b6a804b85df3b2aacd92bde3d60835
mkdir -p out/day26
mkdir "$RUN"
docker pull python:3.13-slim
BASE=$(docker image inspect python:3.13-slim --format '{{index .RepoDigests 0}}')
echo "$BASE" > "$RUN/base.txt"
docker build --pull --no-cache --build-arg BASE="$BASE" --build-arg REV="$REV" \
--iidfile "$RUN/image.id" - <<'DOCKERFILE' 2>&1 | tee "$RUN/build.log"
ARG BASE
FROM ${BASE}
RUN apt-get update && apt-get install -y --no-install-recommends \
git ca-certificates && rm -rf /var/lib/apt/lists/*
ARG REV
WORKDIR /work
RUN git clone https://github.com/jarsing/local-service-agent-for-line.git repo \
&& cd repo && git checkout --detach "$REV" \
&& test "$(git rev-parse HEAD)" = "$REV"
WORKDIR /work/repo
ENV PYTHONDONTWRITEBYTECODE=1 PIP_NO_CACHE_DIR=1
RUN python -m pip install -r examples/day25/requirements-core.txt \
&& python -m pip check
DOCKERFILE
IMAGE=$(cat "$RUN/image.id")
docker image inspect "$IMAGE" > "$RUN/image.json"
docker run --rm --network none \
--mount "type=bind,source=$(cd "$RUN" && pwd),target=/evidence" \
"$IMAGE" bash -euo pipefail -c '
git rev-parse HEAD > /evidence/commit.txt
python -VV > /evidence/python.txt 2>&1
python -m pip freeze > /evidence/packages.txt
python -m unittest examples.day25.test_ingestion -v \
> /evidence/day25.log 2>&1
python -m unittest examples.day24.test_evidence -v \
> /evidence/day24.log 2>&1
python -m unittest examples.day19.test_inbox_contract examples.day22.test_security -v \
> /evidence/day19_day22.log 2>&1
python -m examples.day25.demo --out out/day26/demo-01 \
> /evidence/demo.log 2>&1
cp out/day26/demo-01/report.json /evidence/demo.json
git status --porcelain --untracked-files=all > /evidence/worktree.txt
test ! -s /evidence/worktree.txt
'
安裝階段允許下載,測試階段以 --network none 強制斷網以防暗中依賴。set -euo pipefail 即時中斷異常,日誌各自寫入;重跑另建新目錄以保存現場。[2]
預期應看見 Day 25 的 47 項、Day 24 的 62 項、以及 Day 19 與 Day 22 的 46 項測試通過,Demo 顯示合成輸入、外部呼叫 0、重送同號。這是驗收條件,不能把預期輸出貼成執行結果。
還有一個容易漏掉的細節:Python 3.11 起,unittest 探索不再支援命名空間套件。Repo 的 examples/ 沒有 __init__.py,所以不能用 -s examples 一次掃完;逐日探索時要加 -t . 指定根目錄,Day 25 的相對匯入才找得到所屬套件。不是找到 test_*.py 就代表能正確匯入。[3]
python -m unittest discover -s examples/day25 -t . -p 'test_*.py' -v
python:3.13-slim 是會更新的標籤,這次取得的是哪一版,以 base.txt 的映像摘要和 python.txt 為準。容器只測得到有執行的程式,所以另外用 git grep 掃過公開 Repo:Day 8 的 run.py 沒有設定金鑰時,原本會去讀開發電腦固定位置的 .env,證據也預設寫到 Repo 外。commit 662c0ad 已改成先讀環境變數,沒有時只找目前目錄或範例目錄裡的 .env(都列在 .gitignore),輸出預設寫到 out/day08/;ci.yml Run 38044471367 通過。

圖 1:乾淨環境驗收設計。公開程式版本 → 相依安裝 → 斷網測試 → 獨立報告;47 項、62 項與 46 項是固定基線的預期測試數,容器結果另行記錄。
兩支手機分別登入使用者與志工帳號,才有跨角色的可見證據。同一人可持有兩個測試帳號,但截圖應說明是受控演練,不稱為外部人士的使用回饋。
待測問句為:「我想帶吃全素的長輩吃爌肉飯,可以幫我詢問店家嗎?」模型先釐清範圍,提議轉接時出示摘要,待確認才由後端建單;想問店家和同意送出,是兩個不同動作。
Day 19 收件匣與 Day 22 停止開關均為隔離範例,尚未串接真實 LINE 與 Cloud Run。實機前須定出驗收規格:確認步驟、通知派送、認領與查回全數綁定同一權威 request_id,不可兩端各自建單拼湊。[4]
接線前已知有兩處要先處理:權威單據是線上服務 Firestore 的 requests 文件,單號是 req- 加日期與 16 碼 16 進位,不是 Day 22 範例的 SQLite 與 UUID;Day 11 的查回與 Day 13 的回條畫面目前只接受 pending_human_review,志工一認領,原本的查回就會判定回條不符。接線時要把不會變的綁定欄位和會前進的狀態分開核對。
驗收順序如下,表內列的是應取得的證據,尚非手機實錄:
| 時序 | 操作與應觀察結果 | 對帳依據 |
|---|---|---|
| A 確認 | 使用者核對摘要並確認送出 | 同意綁定的內容、操作識別 |
| 後端建立 | 單據與待通知紀錄寫入資料庫 | 資料版本、通知識別與原單號 |
| B 收到 | 志工視窗看見待處理卡片 | 真實截圖及通知接受回條 |
| 暫停 | 管理者停止新操作,B 嘗試認領受阻 | 控制狀態、拒絕原因、單據未變 |
| 恢復 | B 重新取得目前狀態並認領 | 同一單號及認領版本更新 |
| A 查回 | 使用者查到 human_claimed |
授權查詢、資料庫狀態與可見回覆 |
已確認通道接受的通知不重派;狀態未知時依原 retry key 處理,晚到回條不得倒退已提交狀態。LINE Messaging API 接受推播(HTTP 200)與手機真機收到是不同證據;重試鍵能避免通道重複收單,但非送達保證。通知逾時先對帳,不可換鍵盲目重送。[5]
志工按鈕送回的角色欄位不是授權。先驗 Webhook 簽章,再用事件身分核對志工名單;群組有看到卡片,也不代表群內每個人都能接單。公開截圖遮蔽個資,內部原始記錄保留可比對關係。[6]
human_claimed 表示已有人接手,還不是餐點有貨或店家已同意預約。介面文案可寫「已有志工受理,正在確認」,這是顯示規格,正式文字要由實際狀態決定。

圖 2:雙 LINE 視窗驗收時序設計。同一個 request_id 串起確認、通知、PAUSED 攔截、SERVING 認領與查回;架構箭頭不是實機成功證據。
暫停服務時,鄉親已經確認的事仍然存在。停止開關應阻擋新的建單與認領,保留本人查回原單的能力;恢復後也不能把已派送通知再發一次。
Day 22 有兩個不同的版本數字。epoch 是控制世代編號(控制版本),用來拒絕停止/恢復前取得的舊建單許可;claim_version 是這張單的認領版本。現有 claim() 在同一交易讀取停止狀態,再做條件更新,並沒有接收 expected_epoch。接線時必須保留這個區分。[4]
CAS(Compare-and-Swap)指「資料符合原讀取條件才准修改」。不是先查沒人認領、回到 Python 再無條件寫入,而是在更新條件中同時檢查版本、狀態與尚無接手者。欄位沿用 state、claimed_by、claim_version。
下面是沿用 SecurityStore 的本機教學片段。它使用合成身分與新的 SQLite,檢查暫停、恢復與依序的第二次認領;沒有 LINE sender,也不是兩位真人同時點擊的紀錄。在固定版本 Repo 根目錄存為 stop_gate_example.py,再執行 python3 stop_gate_example.py。[4]
import json
from pathlib import Path
from examples.day22.circuit_breaker import SecurityStore, Paused, demo_policy
root = Path("out/day26/claim-01")
root.mkdir(parents=True, exist_ok=False)
store = SecurityStore(root / "claims.sqlite3", demo_policy())
store.set_mode("sample-operator", "SERVING", "demo")
epoch = store.admit("sample-user")
request = store.confirm("sample-user", "sample-operation", "meal_inquiry", epoch)
rid, version = request["request_id"], request["claim_version"]
store.set_mode("sample-operator", "PAUSED", "incident")
try:
store.claim("sample-volunteer", rid, version)
except Paused as exc:
if str(exc) != "SERVICE_PAUSED":
raise AssertionError(f"UNEXPECTED_EXCEPTION: {exc}")
else:
raise AssertionError("PAUSED_CLAIM_ACCEPTED")
if store.read("sample-user", rid) != request:
raise AssertionError("REQUEST_MUTATED_WHILE_PAUSED")
store.set_mode("sample-operator", "SERVING", "recovered")
if not store.claim("sample-volunteer", rid, version):
raise AssertionError("FIRST_CLAIM_REJECTED")
if store.claim("sample-second-volunteer", rid, version):
raise AssertionError("SECOND_CLAIM_ACCEPTED")
current = store.read("sample-user", rid)
if current["state"] != "human_claimed" or current["claimed_by"] != "sample-volunteer":
raise AssertionError("CLAIM_STATE_MISMATCH")
if current["claim_version"] != version + 1:
raise AssertionError("VERSION_NOT_INCREMENTED")
report = {
"scope": "LOCAL_SQLITE_SYNTHETIC_IDENTITIES",
"request_id": rid, "state": current["state"],
"external_calls": 0, "line_device_test": "NOT_RUN",
}
(root / "report.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
print(json.dumps(report, indent=2))
請注意,第二次 False 代表更新未成功,不一定只是另一位志工接手,也可能是卡片版本過期;呈現回覆前仍要授權查回。至於多連線並行的互斥保證,Day 22 已有 test_two_volunteers_claim_one_version 單元測試(兩條執行緒、兩條連線,結果一真一假),不可將上述依序呼叫當成並行測試。[4]
SQLite 交易裡成功,也不代表原本的 Firestore 已具備同一條接線。真機驗收要確認權威資料存在哪裡,將停止狀態與認領條件放在同一筆原子交易;跨兩個資料庫各自成功,仍可能留下不一致。
我在奇步應用做地方服務,曾承作彰化縣政府官方 LINE「愛玩彰化」的集章系統,包含卦山大縱走與彰化蔬食節。奇步經營的「彰化旅行+」,則與白色方塊工作室、旅庫彰化共同推出《爌肉之城》等內容;LOCAL 是個人開源教學專案,不是公部門或民間帳號的功能,兩邊的資料與服務責任各自獨立。
既有營運經驗顯示:戶外訊號不穩、螢幕反光或久候連按,不同年齡的使用者皆會遇到。保留設計餘裕是體貼情境,而非特定群體標籤。
重複點擊要保護的是已確認內容。同一操作識別與相同內容查回原單;內容變更則重新確認。重試若順手產生新識別,再周全的重複檢查也會把它當新操作。
舊卡片則是另一種時間差。按鈕還在聊天記錄裡,業務狀態卻已改變;後端核對期限、版本與狀態,給出明確引導,不讓鄉親自行承擔誤觸結果。
| 現場困擾 | 工程處理 | 應觀察的使用者結果 |
|---|---|---|
| 訊號慢,連按送出 | 同操作重試,內容變更重新確認 | 找回原單,不要求再填一次 |
| 翻回舊訊息按認領 | 身分、版本與狀態一起核對 | 提示查最新狀態,保留已完成的資料 |
| 查不到店家 | 區分無結果、暫不可用、不支援 | 說清楚缺什麼,以及下一個可用動作 |
Day 17 已把三種查不到分開為無結果(no_data)、暫不可用(query_unavailable)與不支援(unsupported)。介面先回答有依據的部分,再指出待查之處;能留詢問時提供確認入口,無電話或班表時不拿客氣話補上。志工應看見待查資訊,避免將推測轉述為承諾。

圖 3:既有營運經驗與防呆設計對照。重複操作、舊卡片與查無結果各自對應不同驗收;這是設計整理,非本日新增訪談或成效統計。
Agentic 在此指依意圖選擇工具並安排下一步。Gemini 辨識詢問、ADK 安排呼叫,但模型建議不可替代使用者同意與資料庫狀態。
MCP 提供跨系統工具通訊介面。但在單一應用內部,直接呼叫原生 Python 函式更精簡且免去多餘抽象階層。若將 Google Cloud 官方 Firestore MCP 寫入工具直接交由模型,將繞過 admit() 與停止閘門;工具公開到哪裡,授權與副作用檢查就必須守到哪裡。[7]
三分鐘展示先定節奏,時間是劇本配額,不是實測延遲:
| 時間 | 畫面與動作 | 要說清楚的判斷 |
|---|---|---|
| 0~30 秒 | 指出固定版本與乾淨環境報告 | 別人的電腦需要哪些準備 |
| 30~75 秒 | A 確認詢問,B 收到同號通知 | 接受通知和真人接手分開 |
| 75~120 秒 | 暫停認領受阻,恢復後成功認領 | 已確認單據保留,狀態由後端決定 |
| 120~180 秒 | A 查回同號,展示舊按鈕處理 | 真人受理仍不等於店家答覆完成 |
Day 26 的新容器與雙視窗驗收需各自真實紀錄,完成前維持待驗收。實機後收斂為同版程式、部署版本、原單號與日誌對照;乾淨重現以 Commit 9f1d98e 與 CI Run 37897811593 為基線,本機片段以 report.json 保存,只依真實產物填入。
下一篇 Day 27|上台前要鎖住什麼?Demo 版本與備援錄影,要固定展示版本、DEMO.md 與備援錄影。雙視窗真機驗收預計在 10/12 示範凍結前完成,完成後回補本篇;備援錄影只播放真正錄過的操作,並向觀眾說明。
走出自己的房間,是把自己早就知道的事情重新交代清楚。鄉親少填一次、志工少猜一次,背後都要有能查回的依據。 模型可以建議下一步,但不能代替完成判定。
[1] Day 25 已發布內容、Day 24 已發布內容;本篇前置重現固定 9f1d98e 工程基線。
[2] Docker:空建置上下文、None 網路驅動、容器執行與網路。
[3] Python:unittest 測試探索。
[4] 固定基線的 Day 19 收件匣、Day 22 停止與認領實作。本篇本機片段使用這份 API,非新增線上處理器。
[5] LINE:重試鍵與送達界線。
[6] LINE:Webhook 簽章驗證。
[7] MCP:規格索引與工具架構(2026-07-28);Google Cloud:Firestore 與 MCP 工具箱。