Runbook · autoSubscriLab
How the scheduled task claims the measurement-room slots on 實驗室場所借用表單: the weekend straight away, Tuesday and Friday only after asking the lab whether anyone needs them. It also fills in the equipment and room, and takes a slot back when somebody overwrites it.
The task books the measurement room for the coming week. Saturday and Sunday are claimed at once under 馬嘉鴻. Tuesday and Friday are asked about first: everyone on the lab list gets a mail, and for each day the first answer decides it: a "no" books the day at once under that person's name, a "yes" keeps it free. With no "no" within three days, the day isn't booked.
The task starts from cron once a week, but it doesn't run only once. It keeps checking every ten minutes for the whole week. A slot held by somebody else today can be free tomorrow, the week tab it needs often doesn't exist yet when the task first fires, and the Tuesday/Friday answers come in over three days.
Everything below describes the system as it actually stands, verified against the live sheet and mail server rather than inferred from the code.
Read it top to bottom. Each ten-minute pass runs the weekend block and the Tuesday/Friday column, then folds their results into one exit code. Blue blocks are outside the machine. The ask goes out once, the decision is made once, and only the writes repeat until every slot is settled. The sections below go through each block in detail.
| Requirement | How it is implemented | Lives in |
|---|---|---|
| Run at 7pm every Friday | cron entry starts the retry wrapper | crontab → book_week.sh |
Book 六 and 日, both 0800~1200 and 1200~1600 | --latest --only Sat,Sun under 馬嘉鴻, four cells | book_week.sh |
| Ask everyone before booking 二 and 五 | one mail per person on the list, tagged [lab-slot <Monday>], sent once per week | ask_slots.py ask |
| Don't book until someone says they won't use it, within 3 days | replies read over IMAP every pass; the first answer for a day decides it, and a "no" books it at once | ask_slots.py decide |
| A late "no" doesn't book | a day's window ends after 3 days or when its 08:00 slot starts, whichever is first; after that the day is skipped | ask_slots.py |
| Tell me when a day is booked | one mail when the day is decided, another once the write has landed | book_week.sh |
| Always select the room option containing 7012 | room cell four rows below the name, matched against its own dropdown | ROOM_WANTED |
| Always select every equipment option | equipment cell two rows below the name, all 12 options joined | EQUIP_OFFSET |
| Detect somebody overriding the booking | cells previously seen holding the booked name are recorded in a claims file | claims.json |
| Override it back | a claimed cell showing another name is rewritten | plan_week.py |
| Email on run start / override / reclaim / Tue-Fri decision | four event mails plus the end-of-run summary | book_week.sh |
On its first attempt each run, the wrapper has ask_slots.py mail every
person in people.json, one mail each. The ask is recorded in
asks.json, keyed by the week's Monday, so retries never send it twice. If
the send fails, nothing is recorded and the next attempt tries again.
It is written to be polite and to say plainly what a reply leads to: a day answered "no" may be booked under that person's name, the slot is actually used by Carlos Ma (馬嘉鴻), that the first answer for a day decides it, and that not replying keeps their name off. Each day shows its own reply deadline. It also says the system is still being tested, and asks forgiveness for any booking it gets wrong. Chinese first, the same in English below. Dates and deadline are filled in per week; this is the text as the code generates it.
Subject: [lab-slot 〈Monday〉] 想請問下週 〈Tue〉(二)、〈Fri〉(五) 實驗室時段的使用意願 / Lab slots next week
〈name〉 你好,
不好意思打擾了。想請問你下週是否會用到實驗室的以下時段(0800~1200 與 1200~1600):
〈Tue〉(二) 請於 〈deadline〉 前回覆
〈Fri〉(五) 請於 〈deadline〉 前回覆
方便的話,請直接回覆這封信,例如:
二: 要
五: 不要
說明一下這些回覆會怎麼使用(每一天各自以最先收到的回覆為準):
- 如果最先收到的是「要」,那天就會保留下來,請需要的人自行登記。
- 如果最先收到的是你的「不要」,系統會立刻以你的名字登記這個時段,實際上這個時段會由馬嘉鴻 (Carlos Ma) 使用。
- 在期限前都沒有人回覆「不要」的話,那天就不會登記。沒有回覆不列入計算,也不會用你的名字登記;所以如果不希望用你的名字登記,不回覆就可以了。
另外,這套自動登記系統目前還在測試階段,如果出了差錯而登記錯誤,還請多多包涵,也歡迎直接告訴我,我會儘快修正。
謝謝你撥空回覆!
馬嘉鴻
---
Hi 〈name〉,
Sorry to bother you. Could you let me know whether you will need the lab
next week on these days (0800~1200 and 1200~1600)?
Tue 〈Tue〉 please reply by 〈deadline〉
Fri 〈Fri〉 please reply by 〈deadline〉
If you can, please just reply to this mail, e.g.
Tue: yes
Fri: no
How the replies are used (for each day, the first answer received
decides it):
- If the first answer is yes, that day is kept free for them to book.
- If the first answer is your no, the system books that slot under
your name right away, and the slot will actually be used by
Carlos Ma (馬嘉鴻).
- If nobody answers no by the deadline, the day is not booked. No
reply is not counted, and your name will not be used. So if you
would rather not have your name on a booking, simply don't reply.
Please note this booking system is still being tested. If something
goes wrong and a slot is booked by mistake, please forgive me, and do
let me know so I can put it right.
Thank you for taking the time!
Carlos (馬嘉鴻)
From then on, every pass reads the replies and looks at each undecided day on its own. The first reply that answers a day decides it, at once and for good. Each day has its own window: three days from the ask, but never past 08:00 on that day, since a slot that has started isn't worth asking about any more. While a day is undecided it counts as not yet booked (exit 6), which keeps the retry loop alive.
| For that day | Action | Booked under |
|---|---|---|
| the first answer is yes | skip | — left for them to book themselves; a later "no" changes nothing |
| the first answer is no | book | that person, at once; a later "yes" is too late |
| no answer before the window ends | skip | — no reply is not counted, and a "no" after the window is too late |
Booked days go through the same planner as the weekend, with --ref set to
the run's start date so they land on the week that was asked about, never whichever tab
happens to be newest. The name written is the person who said no first. If the cell's
dropdown doesn't contain that name (exit 5), the write fails on every pass and the day
stays unbooked, so names in people.json must match the sheet's.
| Rule | Detail |
|---|---|
| Read without touching | IMAPS to mail.akiragather.com.tw:993, INBOX opened read-only, bodies fetched with PEEK so nothing is marked read |
| Who is answering | a reply to a person's own ask mail (each has its own Message-ID) counts as theirs, from whatever address or alias it comes; otherwise the sender must be an asked address and the subject must carry lab-slot <Monday> |
| Inside the window | server arrival time between the ask and that day's deadline; a late reply is ignored |
| First answer wins | replies are taken in arrival order; the first one that answers a day decides it, whoever sent it |
| Own words only | quoted text (> lines, On … wrote:, 寫道, forwarded headers) is cut off before parsing, and so is the signature, from a sign-off like best regards, -- or 謝謝 onwards |
| No beats yes | 不要 / 不用 / no is checked before 要 / 會 / yes, so 不要 never reads as a yes |
| Day per line | a line naming 二 / 週二 / Tue / the date answers that day; a short line with no day (no thanks, 不用,謝謝) answers both |
| Unclear = no answer | a reply that gives no yes or no for a day counts as no reply for that day, and so is not counted |
Booking under someone else's name. A "no" means that person won't use the slot, not that their name may go on it. Their name, not 馬嘉鴻, is what the rest of the lab sees on the sheet. People who don't reply are never used. The "decided" and "booked" mails say exactly whose name each day went under.
Almost nothing about the grid is hardcoded, and that is deliberate: the sheet is edited by a couple of dozen people and its shape moves. Only the spreadsheet id is fixed. Everything else is located by reading the sheet each run.
Each week lives on its own tab, titled with its date range, like 11/2~11/8.
Within a tab, column E carries the time-slot labels and row 3 carries the day headers.
A slot occupies a twelve-row block holding two independent booking groups of six rows
each, and inside a group the rows have fixed roles:
| Row | Holds | Dropdown source | What gets written |
|---|---|---|---|
| N | the person booking | '<week tab>'!B3:B100 | 馬嘉鴻, or the chosen Tue/Fri name |
| N + 2 | equipment | '函式運算庫'!A17:A28 | all 12 options, joined with ", " |
| N + 4 | room | '函式運算庫'!D17:D20 | 7012 |
The day columns are not in calendar order. On the current tab they run Mon, Tue,
Wed, Fri, Sat, Sun across F to K, with a 特殊時段 column at L and Thursday
stranded out at M. Counting columns would land on the wrong day silently and book
somebody else's afternoon. So the script finds a day by searching row 3 for the
(二), (五), (六) or (日) marker instead.
| Column | F | G | H | I | J | K | L | M |
|---|---|---|---|---|---|---|---|---|
| Header | 11/2(一) | 11/3(二) | 11/4(三) | 11/6(五) | 11/7(六) | 11/8(日) | 特殊時段 | 11/5(四) |
The same reasoning applies to values. Every value written is read back out of the
target cell's own dropdown before use, so a near-miss like 馬家鴻 for
馬嘉鴻 fails loudly instead of being written and silently ignored by
whoever reads the sheet. The dropdown holds sheet names, which aren't always the names
on the mail list: it has Chinu and MD, for instance.
Every pass considers the four weekend cells and, once decided, the booked Tuesday/Friday cells. The rule that decides each one is deliberately narrower than a blanket overwrite: a slot is only taken from somebody if it was ours first.
| Cell currently shows | Recorded in claims? | Action | Result |
|---|---|---|---|
No one | — | claim it, record the claim | write |
| the name being booked (馬嘉鴻) | — | leave it, record the claim, fix equipment and room | no-op |
| the name being booked (Tue/Fri proxy) | no | that person's own booking: counts as done, nothing touched, no claim | theirs |
| somebody else | yes | take it back, then mail | reclaim |
| somebody else | no | leave alone, retry in 10 min | skip |
| blank, or 碩一預留 | no | leave alone — not demonstrably free | skip |
The proxy row exists so that a person who booked a slot themselves before the
decision doesn't have their equipment selection rewritten, or their slot "taken back"
in their own name later. The wrapper turns it on with BOOK_WEEK_PROXY=1
for Tuesday/Friday calls only.
A cell that holds neither the booked name nor No one is never assumed to
be available. A blank cell in particular is ambiguous: it may be a slot nobody has
touched, or one somebody cleared on their way to filling it in. So it is left alone.
The --force flag overrides all of this, and the scheduled task never passes
it.
| # | Step | Detail | On failure |
|---|---|---|---|
| 1 | Weekend | set_week.sh --latest --only Sat,Sun under 馬嘉鴻 | see exit codes |
| 2 | Ask | ask_slots.py ask: mails the list the first time, a no-op after | retry next pass |
| 3 | Decide | ask_slots.py decide: one line per day, book, skip or pending; a failed IMAP read leaves undecided days pending | pending → 6 |
| 4 | Tue/Fri | skip, or set_week.sh --ref <run date> --only Tue <name>; mail you once it is written | see exit codes |
| 5 | Combine | any real failure → that code; else any still-taken → 6; else 0 | — |
| 6 | Loop | 0 → stop and mail OK. Otherwise sleep 600s and go again. | — |
Each set_week.sh call is itself the same pipeline as before:
service-account token, choose the tab, fetch A1:M100 plus the library tab
with dropdown rules, plan, one batchUpdate, and promote the claims only
after the write returns 200.
| Code | Meaning | Wrapper's response |
|---|---|---|
| 0 | every target slot is done | stop, mail the summary |
| 6 | a slot is still somebody else's, or a Tue/Fri day is still waiting for an answer | keep retrying — not an error |
| 2 | no matching week tab (e.g. next week's not created yet) | keep retrying |
| 3 | two tabs claim the same week, refusing to guess | keep retrying; pin one with --gid |
| 5 | a name is not in the cell's dropdown | keep retrying; needs a human (fix the name in people.json) |
| 1 | auth, network, API or mail failure | keep retrying |
Exit 6 deserves emphasis: it is the normal state, not a fault. It means the task did everything it could this minute and intends to come back. Two days of exit 6 while the lab answers, followed by a single exit 0, is the system working exactly as designed.
Telling "somebody took my slot" apart from "this slot was never mine" needs memory,
and claims.json is it. It records, per tab, the cells that have been
observed holding the booked name:
{
"11/2~11/8": [ "G16", "I16", "I28", "J16", "J28", "K16", "K28" ]
}
asks.json is the memory for the Tuesday/Friday question. It holds one
entry per week: when the ask went out, the deadline, who was asked, the Message-IDs
replies are matched against, and, once made, the parsed answers and the decision.
Both it and people.json hold personal email addresses and are gitignored.
{
"2026-11-09": {
"ref": "2026-11-06", "sent_at": …, "deadline": …,
"asked": [ … ], "message_ids": [ … ],
"decision": {
"Tue": {"action": "book", "names": ["鍾毓星"], "answered": "no",
"at": "2026-11-07 09:12:40"},
"Fri": {"action": "skip", "names": [], "answered": null,
"at": "2026-11-09 19:01:00"} }
}
}
| Rule | Prevents |
|---|---|
| Claims are promoted only after the write returns HTTP 200 | a failed write leaving a claim we do not hold, which would evict somebody on a later pass |
Dry runs never write the claims file, and ask --dry-run sends nothing | a rehearsal quietly changing live state |
| Claims merge rather than replace | an --only Sun run making the system forget Saturday |
| The ask is recorded only once the mails are sent | a failed send leaving the week marked asked with nobody asked |
| Each day's decision is frozen once made | a later reply flipping a day that is already booked or left free |
| Someone's own booking is left untouched | rewriting the equipment of a person who booked a slot for themselves |
| A steal is mailed once per episode, not once per retry | a slot that cannot be reclaimed mailing every ten minutes for a week |
| Mail failures are logged, never fatal | a mail outage stopping the booking itself |
The weekend uses --latest, the newest week tab. Next week's tab is often
created only a few days ahead, so the current week keeps being checked until it appears,
and then the retries move onto it without anyone intervening.
Tuesday and Friday use --ref instead: the week starting the Monday after
the run began, the same week the mail asked about. If that tab doesn't exist yet the
pass exits 2 and retries. A decision about 11/10 can never be written onto 11/3.
| Subject | To | Sent when | Carries |
|---|---|---|---|
| [book_week] run starting | you | the wrapper starts — Friday 19:01, or a manual run | retry interval and the deadline |
| [lab-slot <Monday>] 想請問下週 … 實驗室時段的使用意願 / Lab slots next week | each person on the list | first attempt of the run | the two dates, how to reply and by when, and what a yes, a no or no reply leads to |
| [book_week] Tue decided: … | you | the pass that first sees a day decided, once per day | book under whom (who said no first), or skip and why |
| [book_week] Tue booked under … | you | the write for that day has landed (HTTP 200), once per day | time, day, name, and the cells written |
| [book_week] someone overrode your booking | you | a claimed cell turns up in another name | the cell, day, slot, and who holds it |
| [book_week] took your booking back | you | that cell is rewritten and the write confirms | the same detail lines |
| [book_week] OK / INCOMPLETE / FAIL | you | the retry loop finally exits | the full output of the last attempt |
| When | What happens |
|---|---|
| Fri 19:01 | cron starts the wrapper; "run starting" mail; weekend claimed; ask mails go out for next Tue/Fri |
| every 10 min | weekend re-checked (claim, reclaim, or wait); replies read: a first "no" books that day at once, a first "yes" keeps it free; you get a mail either way |
| Mon ~19:01 | 3-day window closes (earlier for a day whose 08:00 slot comes first); any day still unanswered is skipped |
| every 10 min | all booked slots re-checked until every one is done |
| Fri 18:56 | the loop stops five minutes early, so two instances never overlap |
| Fri 19:01 | cron starts the next one |
The first run with the ask step is Fri 6 Nov 19:01, asking about Tue 11/10 and Fri 11/13, with replies counted until Mon 9 Nov 19:01. The run started 30 Oct still uses the old flow, booking Tue/Fri under 馬嘉鴻, until it ends 6 Nov 18:56.
| Intent | Command |
|---|---|
| See what a pass would do, change nothing | ./set_week.sh --dry-run --latest |
| Preview the ask mail, send nothing | python3 ask_slots.py ask $(date +%F) --dry-run |
| See this week's Tue/Fri decision | source smtp_credentials.sh; python3 ask_slots.py decide <run date> |
| Run one pass now | ./set_week.sh --latest |
| Work on one specific tab | ./set_week.sh --gid 1790236012 |
| Only one day | ./set_week.sh --latest --only Sun |
| Take a slot that was never ours | ./set_week.sh --latest --force |
| Change who gets asked | edit people.json (name, email) |
| Watch it work | tail -f book_week.log |
| Stop the retry loop | kill <pid of book_week.sh> |
--dry-run is safe in the strongest sense: it prints the exact request
body it would have posted, and touches neither the sheet nor the claims file. Passed to
book_week.sh, it reaches every set_week.sh call, but not the
ask mail. Preview that with ask --dry-run.
| Slot | Name | Equipment | Room | State |
|---|---|---|---|---|
| Tue 0800~1200 | G16 馬嘉鴻 | G18 all 12 | G20 7012 | held |
| Tue 1200~1600 | G28 林振宇 | — | — | not ours |
| Fri 0800~1200 | I16 馬嘉鴻 | I18 all 12 | I20 7012 | held |
| Fri 1200~1600 | I28 馬嘉鴻 | I30 all 12 | I32 7012 | held |
| Sat 0800~1200 | J16 馬嘉鴻 | J18 all 12 | J20 7012 | held |
| Sat 1200~1600 | J28 馬嘉鴻 | J30 all 12 | J32 7012 | held |
| Sun 0800~1200 | K16 馬嘉鴻 | K18 all 12 | K20 7012 | held |
| Sun 1200~1600 | K28 馬嘉鴻 | K30 all 12 | K32 7012 | held |
This week was booked by the old flow. G28 is the case the reclaim rule deliberately does not cover: it has never been ours, so it is left alone, and the run keeps exiting 6 on it until the week ends.
Run from a sandbox copy with 馬嘉鴻 as the only person asked, so the real
asks.json and the Friday run were untouched.
| Reply | Read as | Fix it led to | |
|---|---|---|---|
| first wording | yes i will use tuesday 08-12, sent from chiahungma@mail.akiragather.com.tw | at first: ignored; then Tue yes, Fri no | the ask went to chma@akiragather.com.tw, so the reply was dropped: replies are now matched by the ask mail they answer. The stray "no" came from "No. 70" in the signature: signatures are now cut, and a no with no day only counts on a short line |
| polite wording, before the Carlos Ma line | — (superseded) | — | — |
| polite wording, with the Carlos Ma line | 我不會使用週五0800-1200 and 1200-1600, from the alias address | Tue skip no answer · Fri book said no | none: matched by Message-ID, signature cut, 不會 + 週五 read as Friday no |
Two things to keep in view. The retry loop wakes every ten minutes, so reclaiming is effectively instant and repeats indefinitely. If a slot changes hands by agreement rather than by accident, stop the loop rather than let it fight.
The sheet's own instructions ask for one slot at a time (two for doctoral students) and say time exchanges need both parties to agree. Holding seven half-day slots, or slots in other people's names, sits outside that.
| Item | Status | Consequence |
|---|---|---|
| SMTP login | resolved | SMTP_HOST is mail.akiragather.com.tw, the name on the STARTTLS certificate; start, steal, reclaim and summary mails all tested delivered |
| IMAP for replies | resolved | port 993 presents the mail.akiragather.com.tw certificate, so IMAP_HOST is set to that; login verified, and a mail to the sending address confirmed to land in the INBOX that is read |
| Chinmaya Tripathy → Chinu | resolved | renamed to the sheet's Chinu in people.json and in the 31 Oct ask record, so a first "no" from Chinu books under a name the dropdown accepts (the mail already sent still greets them as Chinmaya Tripathy). M.D has no email and isn't asked; 馬嘉鴻 isn't asked either |
| Reply parsing | tested live | unit-tested on typical Chinese and English replies, and tested end to end on 31 Oct (see section 11). A free-form reply it can't read counts as no reply, and is not counted, so check the decision mail the first few weeks |
| Credentials committed to git | exposed | the service-account key, OAuth client secret and SMTP file have been in the repository history since 29 Jul and are on the remote; needs untracking plus rotation |
| Chrome profile for the browser helper | unverified | still set to the default profile; worth confirming the account it pins is signed in there |
| File | Role |
|---|---|
| book_week.sh | the scheduled entry point: retry loop, weekend + Tue/Fri passes, logging, all status mail |
| ask_slots.py | ask mails the list; decide reads replies over IMAP and prints each day's decision |
| set_week.sh | one pass — token, tab, fetch, plan, write |
| plan_week.py | all the cell decisions: tab choice, targets, dropdowns, claims, request body |
| sa_token.sh | service-account JWT → access token |
| send_mail.py | SMTP sender over verified STARTTLS; CLI with --html and --attach FILE, plus a send() used by ask_slots.py |
| smtp_credentials.sh | SMTP and IMAP settings, sourced by the wrapper (gitignored, chmod 600) |
| people.json | who is asked, in priority order (gitignored) |
| asks.json | per-week ask record and each day's frozen decision (gitignored) |
| claims.json | cells seen holding the booked name, per tab |
| book_week.log | every attempt, appended |
| set_cell.sh | standalone single-cell writer, kept for manual use |
| authorize.sh, access_token.sh | the user-OAuth path — present but unused |