Skip to content

RFC-0010: which states may reach FAILED + ประกาศ transition ลง manifest (#14) - #16

Merged
monthop-gmail merged 2 commits into
mainfrom
rfc/0010-failable-states
Aug 19, 2026
Merged

RFC-0010: which states may reach FAILED + ประกาศ transition ลง manifest (#14)#16
monthop-gmail merged 2 commits into
mainfrom
rfc/0010-failable-states

Conversation

@monthop-gmail

@monthop-gmail monthop-gmail commented Aug 19, 2026

Copy link
Copy Markdown
Owner

ปิด #14 · ไม่มีการเปลี่ยนพฤติกรรม — ยืนยันสิ่งที่ states.FAILABLE ทำอยู่แล้ว แล้วเติมเอกสาร
กับ manifest ให้ตรงกัน · pytest 302 passed

ทำไม

PR #13 ส่ง states.FAILABLE ขึ้น main พร้อมหมายเหตุว่ายังต้องยืนยันด้วย RFC ซึ่ง
CONTRIBUTING.md บังคับสำหรับการเปลี่ยน lifecycle → ตอนนี้บน main มีการตัดสินใจเชิงสถาปัตยกรรม
ที่ไม่มี RFC รองรับ และ contract-semantics.yaml ที่ agent-platform อ้างอิงได้ก็ไม่ได้ประกาศกฎนั้น

RFC-0010 ตัดสินอะไร

Decision 1FAILED ไปถึงได้จาก 5 state หลัง approval เท่านั้น
(TASK_PLANNING IN_PROGRESS AWAITING_APPROVAL VALIDATING DEPLOYABLE)
ยืนยันตามที่โค้ดเลือก ไม่แก้โค้ด · เหตุผลยืนได้เองโดยไม่ต้องอ้างว่า "โค้ดทำแบบนี้อยู่แล้ว":
ก่อน APPROVED ไม่มี execution ให้ล้ม และการใช้ FAILED แทนจะทำให้ REJECTED / CANCELLED
/ TIMED_OUT ยุบรวมกัน ซึ่งเป็นเหตุผลเดียวกับที่ RFC-0007 แยกสองตัวหลังออกมาตั้งแต่แรก

Decision 2GOVERNANCE_ANALYSIS ที่ล้มแบบ error (ไม่ใช่คำตัดสิน) ลงเอยที่ TIMED_OUT
เพราะ REJECTED/CANCELLED/FAILED ใช้ไม่ได้ทั้งหมด · ยอมรับโดยตั้งใจ ต้นทุนคือ job
ที่พังทันทีต้องรอ timeout แลกกับการที่ FAILED คงความหมายว่า "งานที่อนุมัติแล้วไม่สำเร็จ"
ซึ่งเป็นสิ่งที่ recovery ผ่าน supersedes_job_id ของ RFC-0007 ตั้งอยู่บนสมมติฐานนั้น
— บันทึกไว้ตรง ๆ เพื่อไม่ให้คนอ่านรอบหน้าคิดว่าลืม

ช่องว่างใน manifest กว้างกว่าแค่ failable

states.py ใช้ 4 อย่าง สร้างตาราง manifest ไม่ประกาศไว้สักอย่าง — แต่ 3 ใน 4 มี RFC รองรับแล้ว
ขาดแค่ยังไม่ได้บันทึก
จึงลงให้ครบรอบเดียว ไม่งั้นปิด #14 แล้ว manifest ก็ยังไม่ครบ

ใน states.py เพิ่มเป็น ที่มา
_PROGRESSION progression RFC-0001
TIMEOUTABLE timeoutable RFC-0007
CANCELLED จากทุก non-terminal cancellable RFC-0007
FAILABLE failable RFC-0010 (ใหม่)

บวก awaiting_from ที่ประกาศเป็น กฎ ไม่ใช่ edge เพราะ resolve ต่อ job ตอน runtime
เขียนเป็นตาราง static ไม่ได้ — ถ้าปล่อย progression.AWAITING_APPROVAL: [] ไว้เฉย ๆ
consumer จะสรุปว่าเป็น dead end ซึ่งผิด

ตรวจแล้วว่า manifest ตรงกับ states.py ทุกชุด (failable timeoutable terminal states
progression เฉพาะ non-terminal) — สคริปต์ที่ใช้ตรวจอยู่ท้าย PR

ไม่ bump semantics_version — และทำไม

agent-platform เทียบ derived_from.semantics_version กับค่า ระดับไฟล์ ของ manifest นี้
(conformance/drift_check.pycheck_derived) การ bump จะทำให้ drift check ของเขาแดงทันที
จนกว่าจะ re-pin ทุก contract — เพื่อการเปลี่ยนแปลงในบล็อกที่เขาไม่ได้ derive จากมันด้วยซ้ำ

frozen scope ไม่ถูกแตะ จึงไม่ bump · แต่ RFC ตั้งเป็น Open Question ไว้ว่า
การเปลี่ยนใน not_derived ไม่มีสัญญาณบอก consumer เลย ซึ่งเป็นช่องว่างจริงของกติกาปัจจุบัน

สองข้อที่ RFC ตั้งเป็น Open Question ไม่ได้ตัดสินใน PR นี้

  1. APPROVED ค้างได้โดยไม่มีทางออกอัตโนมัติ — ไม่อยู่ทั้งใน FAILABLE และ TIMEOUTABLE
    ทางออกมีแค่ TASK_PLANNING กับ CANCELLED ถ้า orchestration ตายหลังอนุมัติแต่ก่อนวางแผน
    job จะค้างตลอดไปจนกว่าคนจะยกเลิก · เสนอให้เพิ่มเข้า TIMEOUTABLE แต่ไม่ตัดสินที่นี่
    เพราะเป็นคำถามของ TIMED_OUT ไม่ใช่ FAILED และจะกลายเป็นการแก้โค้ด ขณะที่ทั้ง PR นี้เป็นการยืนยัน
    → ขอเปิด issue แยก
  2. not_derived ไม่มี version signal — ตามข้างบน

ไฟล์ที่แตะ

ไฟล์ ทำอะไร
rfcs/0010-failable-states.md ใหม่
contract-semantics.yaml + progression awaiting_from failable timeoutable cancellable + invariant + source pointer
packages/core/state-machine.md ระบุ edge ของ FAILED ให้ครบ จากเดิมมีเส้นเดียว
packages/core/devfactory_core/job.py · states.py · packages/core/README.md ลบ "Open question" → ชี้ไป RFC-0010
rfcs/0001-job-state-machine.md + amendment pointer

Follow-up ที่แนะนำ

เพิ่ม conformance check ว่า manifest ยังตรงกับ states.py เพื่อกัน drift แบบเดียวกับที่เกิดระหว่าง
PR #13 กับ RFC นี้ — ตรรกะที่ผมใช้ตรวจ PR นี้ยกไปใช้ได้เลย:

m = yaml.safe_load(open("contract-semantics.yaml"))["not_derived"]["job_state_machine"]
assert sorted(m["failable"])    == sorted(s.value for s in states.FAILABLE)
assert sorted(m["timeoutable"]) == sorted(s.value for s in states.TIMEOUTABLE)
assert sorted(m["terminal"])    == sorted(s.value for s in states.TERMINAL)
assert sorted(m["states"])      == sorted(s.value for s in states.JobState)
assert {k: sorted(v) for k, v in m["progression"].items()} == {
    s.value: sorted(t.value for t in ts)
    for s, ts in states._PROGRESSION.items() if s not in states.TERMINAL
}

✅ สองคำถามที่ค้าง — ตอบแล้ว 2026-08-19

เหตุผลเต็มอยู่ในคอมเมนต์นี้
· cancellable แก้เป็น list แล้วตามที่ agent-platform ติงไว้

Closes #14

🤖 Generated with Claude Code

PR #13 ส่ง states.FAILABLE ขึ้น main พร้อมหมายเหตุว่ายังต้องยืนยันด้วย RFC
ซึ่ง CONTRIBUTING.md บังคับสำหรับการเปลี่ยน lifecycle — RFC-0010 ยืนยัน
สิ่งที่โค้ดเลือกไว้แล้วโดยไม่แก้พฤติกรรม

พร้อมกันนี้ contract-semantics.yaml ประกาศ job_state_machine ให้
agent-platform อ้างอิงได้ แต่มีแค่ states/terminal/invariants/layering
ไม่มี transition เลยสักอย่าง ทั้งที่ states.py ใช้สี่อย่างสร้างตาราง
สามในสี่มี RFC-0001/0007 รองรับอยู่แล้ว ขาดแค่ยังไม่ได้บันทึก จึงลงให้ครบ
รอบเดียว: progression, awaiting_from, failable, timeoutable, cancellable

RFC บันทึกสองข้อที่การ implement ทำให้เห็นและยังไม่ตัดสินในนี้
- GOVERNANCE_ANALYSIS ที่ล้มแบบ error ลงเอยที่ TIMED_OUT — ยอมรับโดยตั้งใจ
  เพื่อให้ FAILED คงความหมายว่า "งานที่อนุมัติแล้วไม่สำเร็จ"
- APPROVED ไม่อยู่ทั้งใน FAILABLE และ TIMEOUTABLE จึงค้างได้โดยไม่มี
  ทางออกอัตโนมัติ — เสนอให้เพิ่มเข้า TIMEOUTABLE ใน issue แยก

ไม่ bump semantics_version เพราะ frozen scope ไม่ถูกแตะ และการ bump จะทำให้
drift check ของ agent-platform แดงทันทีจนกว่าจะ re-pin

ยืนยันแล้วว่า manifest ตรงกับ states.py ทุกชุด · pytest 302 passed

Closes #14

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@monthop-gmail
monthop-gmail marked this pull request as ready for review August 19, 2026 08:43
@monthop-gmail

Copy link
Copy Markdown
Owner Author

จากทีม agent-platform — ตอบ #14 ที่เรา comment ไว้

✅ ตรวจแล้ว ไม่กระทบฝั่งเรา ไม่ต้องทำอะไร

ตรวจ ผล
semantics_version ยัง 1.1 — ไม่ขยับ
แตะ frozen block ไหม ไม่แตะเลย — การเพิ่มทั้งหมดอยู่ใน not_derived
derived_from pin ของเรา approval/v1 และ event/v1 ยังตรง ไม่ต้องแก้

not_derived อยู่นอกขอบเขตที่ derived_from pin ไว้ตามออกแบบ — นี่คือกรณีที่แสดงว่าเส้นแบ่งของ RFC-0005 ทำงานถูก คุณเพิ่มข้อมูล job semantics ได้เต็มที่โดยไม่ปลุก drift ที่ฝั่ง consumer ซึ่งเป็นสิ่งที่ควรเกิด เพราะไม่มี consumer รายไหนพึ่งพา job state machine ผ่าน wire schema

ให้มากกว่าที่ขอ

เราขอแค่ failable แต่ได้ progression · timeoutable · cancellable · awaiting_from มาด้วย

จุดที่มีค่าที่สุดคือคำเตือนใต้ progression:

AWAITING_APPROVAL: [] # ทางออกเป็น awaiting_from — ดูด้านล่าง ไม่ใช่ dead end
consumer ที่เห็น progression.AWAITING_APPROVAL = [] ต้องไม่สรุปว่าเป็น terminal

ถ้าไม่มีบรรทัดนี้ คนที่เขียน validator จากตารางนี้จะได้ตรรกะที่ผิดแบบเงียบ ๆ — [] อ่านได้ตรงตัวว่า terminal และ terminal list ก็ไม่มี AWAITING_APPROVAL อยู่ด้วย จึงไม่มีอะไรมาขัดการอ่านผิดนั้นเลยถ้าไม่เขียนกำกับ

เหตุผลของ Decision 1 ก็ตรงกับที่เรา comment ไว้ที่ #14 — การปล่อยให้ FAILED เกิดก่อน APPROVED จะทำให้ REJECTED / CANCELLED / TIMED_OUT ยุบรวมกัน ซึ่งเป็นเหตุผลเดียวกับที่ RFC-0007 เพิ่มสองตัวหลังเข้ามาแต่แรก

ข้อสังเกตเล็ก ๆ ข้อเดียว — cancellable เป็น string ส่วนอีกสองตัวเป็น list

failable:     [TASK_PLANNING, IN_PROGRESS, ...]        # list
timeoutable:  [GOVERNANCE_ANALYSIS, TASK_PLANNING, ...] # list
cancellable:  ทุก state ที่ไม่อยู่ใน terminal            # string

สามคีย์นี้ตอบคำถามชนิดเดียวกัน (state ไหนไปทางออกนี้ได้) แต่ parse ต่างกัน — เครื่องที่อ่านสองตัวแรกได้ จะอ่านตัวที่สามไม่ได้ และคนที่เขียน checker จะต้อง special-case มันหรือไม่ก็ข้ามไปเงียบ ๆ

ค่าของมันอนุมานได้จาก states ลบ terminal อยู่แล้ว — จะเขียนเป็น list ตรง ๆ หรือคงเป็น prose ก็ได้ทั้งคู่ เป็นการตัดสินของคุณ เราแค่ยกมาเพราะถ้าวันหนึ่งมี consumer เขียน validator จาก manifest นี้ ความไม่สม่ำเสมอตรงนี้จะเป็นจุดที่สะดุด

ไม่ใช่ blocker และไม่ต้องแก้เพื่อเรา

หลัง merge

ฝั่งเราไม่มีอะไรต้องทำ — execution/v1 อ้าง job semantics ในระดับที่ manifest ให้อยู่แล้ว และ drift check ของเรารันรายวันอยู่ ถ้าวันไหน frozen ขยับจริงเราจะรู้เอง

ขอบคุณที่บันทึกลง manifest ด้วย ไม่ใช่แค่แก้ code — นี่คือส่วนที่ทำให้สิ่งที่เราอ้างได้ตรงกับสิ่งที่ระบบทำจริง ซึ่งเป็นเรื่องที่ #14 เปิดมาเพื่อแก้พอดี

Decision 2 — ยืนตามเดิมว่า GOVERNANCE_ANALYSIS ที่ error ลงเอยที่ TIMED_OUT
แต่เพิ่มข้อกำหนดว่า reason ต้องแยก sla_exceeded / analysis_error ให้ได้
ไม่งั้น audit อ่านไม่ออกว่าช้าหรือพัง · และย้ำว่า retry เป็นของ orchestration
ตาม RFC-0004 + RFC-0007 D1 — TIMED_OUT จึงเป็นคำตอบที่ถูกด้วยความหมาย
ไม่ใช่เพราะเหลือตัวเลือกเดียว

Open Question APPROVED — เคาะแล้วว่าควรเพิ่มเข้า TIMEOUTABLE เหตุผลหลักคือ
การอนุมัติต้องมีวันหมดอายุ ซึ่งตรงกับที่ D1 ห้ามชุบชีวิต job ที่ FAILED
(stale APPROVED) · เป็นการเปลี่ยนพฤติกรรมและเป็นกฎของ RFC-0007
จึงแยกไปทำที่ #17 ไม่ทำใน PR นี้

cancellable เปลี่ยนจาก prose เป็น list ให้ parse ได้เหมือน failable/timeoutable
ตามที่ทีม agent-platform ติงไว้ · ตรวจแล้วว่าตรงกับ states ลบ terminal

pytest 302 passed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@monthop-gmail

Copy link
Copy Markdown
Owner Author

ตอบสองข้อที่ค้าง — เคาะแล้ว

อ่าน states.py บน main กับ RFC-0007 ประกอบก่อนตอบ ไม่ได้ตอบจากตัว RFC-0010 อย่างเดียว

ข้อ 1 · Decision 2 — ยืนตามที่เสนอ (GOVERNANCE_ANALYSIS ที่ error ลงเอยที่ TIMED_OUT) พร้อมเงื่อนไขหนึ่งข้อ

เหตุผลที่ ไม่ควร เพิ่ม FAILED เป็นทางออกของ GOVERNANCE_ANALYSIS:

  • RFC-0007 Decision 1 ผูก FAILED ไว้กับ "งานที่อนุมัติแล้วไม่สำเร็จ" และวาง recovery เป็น job ใหม่
    ที่ถือ supersedes_job_id · ถ้า analyzer พังแล้วลง FAILED ตัวเลข FAILED จะปนกันระหว่าง
    งานพัง กับ เครื่องมือตรวจพัง ซึ่งต้องการการตอบสนองคนละแบบ
  • REJECTED ยิ่งใช้ไม่ได้ — มันแปลว่า "ถูกปฏิเสธ" และวิ่งกลับ DRAFT การบันทึก crash เป็น REJECTED
    คือ บันทึกธรรมาภิบาลเท็จ: ไม่มีใครปฏิเสธคำขอนั้น

และเหตุผลเชิงหลักการที่ทำให้ TIMED_OUT ถูกจริง ไม่ใช่แค่ "เหลือตัวเลือกเดียว":

RFC-0004 + Decision 1 กำหนดว่า retry เป็นของ orchestration และ "a job does not enter FAILED
because one execution failed"
· analyzer พังหนึ่งรอบจึงไม่ควรขยับสถานะ job เลย — orchestration
ต้อง retry ก่อน · ถ้า retry หมดหรือ orchestration เองตาย สิ่งที่เกิดขึ้นจริงคือ
"ไม่มีใครขยับ job นี้ภายในเวลาที่กำหนด" ซึ่งคือความหมายของ TIMED_OUT เป๊ะ ๆ ไม่ใช่การยืมสถานะ

เงื่อนไขที่ขอเพิ่ม: reason ของ TIMED_OUT ต้องแยกสาเหตุได้ อย่างน้อย sla_exceeded
กับ analysis_error (พร้อม error ต้นทาง) — ไม่งั้น audit อ่านไม่ออกว่า ช้า หรือ พัง ซึ่งเป็น
ความต่างที่ทั้ง RFC-0007 และ RFC-0001 อุตส่าห์รักษาไว้ · ไม่ต้องเพิ่ม state ไม่ต้องแก้ตาราง
ใช้ metadata ที่บังคับอยู่แล้วตามกฎ "terminal ต้องมี reason"

ต้นทุนที่ยอมรับพร้อมข้อเสนอแนะ: job ที่พังตั้งแต่วินาทีแรกต้องรอจน timeout จริง
→ ควรตั้ง timeout ของ GOVERNANCE_ANALYSIS สั้นกว่าสถานะอื่นอย่างมีนัย (ค่าจริงอยู่นอกขอบเขต
RFC นี้ตาม Non-Goals แต่ควรเขียนเป็นคำแนะนำไว้)

ข้อ 2 · APPROVED ควรเข้า TIMEOUTABLEใช่ แต่ไม่ใช่ใน PR นี้

เหตุผลหลักไม่ใช่เรื่อง ops แต่เป็นธรรมาภิบาล: การอนุมัติต้องมีวันหมดอายุ
ถ้า APPROVED ค้างได้ไม่จำกัดเวลา งานอาจเริ่มเดินอีกสัปดาห์ถัดมาบน approval ที่ประเมินจากบริบทเก่า
ซึ่งเป็นเหตุผล เดียวกันเป๊ะ กับที่ Decision 1 ห้ามชุบชีวิต job ที่ FAILED
"execution continuing on a stale APPROVED" · ตอนนี้ RFC ปิดประตูนั้นไว้ด้านหนึ่ง
แต่เปิดค้างอีกด้านโดยไม่ได้ตั้งใจ

หลักฐานเชิงโครงสร้าง — ไล่จาก TRANSITIONS ที่ _build() สร้างจริง:

state ทางออกทั้งหมด ใครเป็นเจ้าของการเดินต่อ
DRAFT GOVERNANCE_ANALYSIS · CANCELLED คน (ผู้ยื่น) — ค้างได้ ไม่ใช่ความผิดปกติ
REJECTED DRAFT · CANCELLED คน (ผู้แก้) — เช่นกัน
APPROVED TASK_PLANNING · CANCELLED ระบบ — แต่ไม่มีทางออกอัตโนมัติเลย
DEPLOYABLE COMPLETED · AWAITING_APPROVAL · FAILED · CANCELLED ระบบ — มี FAILED รองรับอยู่

APPROVED เป็น state เดียวในทั้งเครื่อง ที่ระบบเป็นเจ้าของการเดินต่อ แต่ถ้าระบบไม่เดิน
ก็ไม่มีอะไรพามันออกจากตรงนั้นได้เลยนอกจากคนมากด cancel — และไม่มีใครเฝ้า APPROVED
เพราะหน้าตามันเหมือนความสำเร็จ

แต่ไม่ควรทำใน PR นี้ — PR #16 ประกาศตัวชัดว่าไม่เปลี่ยนพฤติกรรม การเพิ่ม APPROVED
เข้า TIMEOUTABLE เป็นการเปลี่ยนตารางจริง ควรเป็น issue แยก + amendment ของ RFC-0007
(เจ้าของกฎ TIMEOUTABLE) ไม่ใช่ RFC-0010 ซึ่งเป็นเรื่อง FAILED

ทำลงไปแล้วใน fb4d2d9

ทำอะไร ที่ไหน
Decision 2 + ข้อกำหนดว่า reason ต้องแยก sla_exceeded / analysis_error และย้ำว่า retry เป็นของ orchestration rfcs/0010-failable-states.md
Open Question APPROVEDDecided พร้อมเหตุผลเรื่อง approval หมดอายุ เดิม
cancellable prose → list ตามที่ agent-platform ติงไว้ contract-semantics.yaml

pytest 302 passed · ตรวจแล้วว่า cancellable ตรงกับ states ลบ terminal เป๊ะ
และอีกสามคีย์ยังตรงกับ states.py เหมือนเดิม

การเพิ่ม APPROVED เข้า TIMEOUTABLE ไม่ได้ทำใน PR นี้ — แยกไปที่ #17 เพราะเป็น
การเปลี่ยนพฤติกรรม และเป็นกฎของ RFC-0007 ไม่ใช่ RFC-0010

PR นี้พร้อม review/merge แล้วครับ 🙏

@monthop-gmail
monthop-gmail merged commit 8196d9f into main Aug 19, 2026
4 checks passed
@monthop-gmail
monthop-gmail deleted the rfc/0010-failable-states branch August 19, 2026 11:13
monthop-gmail added a commit that referenced this pull request Aug 19, 2026
…) (#20)

RFC-0010 decided this and deliberately left it unimplemented: TIMEOUTABLE is
RFC-0007's rule, so the amendment belongs there. RFC-0007 Amendment 1 adds
APPROVED to the "entered from" list for TIMED_OUT, and states.py follows.

The argument is governance, not liveness. A job that can sit in APPROVED forever
may start executing a week later under a verdict formed in a context that no
longer holds — the same stale APPROVED that RFC-0007 Decision 1 refuses when it
keeps FAILED terminal. That door was shut on one side and left open on the other.

It is also a conformance gap rather than a proposal. approval/v1 has carried
expires_at since it was written and says what it means: "approval ที่หมดอายุแล้ว
ใช้เดินงานไม่ได้ ต้องขอใหม่ · งานที่ค้างรออนุมัติจนเลยกำหนดควรเข้าสถานะ timeout
ไม่ใช่รอตลอดไป". So:

* Decision carries expires_at and renders it under the contract's own name;
  approve()/decide() take it. Optional there and optional here — an approval with
  no deadline never expires, and existing jobs are untouched.
* The engine refuses to enter any POST_APPROVAL state under a lapsed approval
  (ExpiredApproval), including the way back out of a pause. Both halves of the
  direction lock now guard the same set, so there is no state reachable without a
  valid approval but not without any approval.
* Replay refuses a trail that records it happening anyway (ExecutionAfterExpiry).
  The deadline and the moment are both in the log, so the claim is checkable by a
  reader who was not there — the same reason UnauditedExecution exists.
* conformance/payload_check.py validates a real approval payload carrying
  expires_at, and probes both refusals; simulation gains approval_expired, whose
  job settles at TIMED_OUT rather than at a failure.

contract-semantics.yaml records timeoutable and the enforced field. No
semantics_version bump: frozen is untouched, exactly as in #16.

Not done, and reported instead of guessed: nothing here sets expires_at or fires
a timeout — the policy values stay out of scope in RFC-0007 and RFC-0010 alike —
and the lifecycle still has no edge for "ขอใหม่", so a job whose approval lapsed
settles at TIMED_OUT and a re-request is a new job with nothing linking it back.

Co-authored-by: monthop-gmail <monthop-gmail@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RFC needed: which states may reach FAILED

2 participants