Process ยังไม่ตาย ≠ งานยังเดินหน้า: liveness ที่เชื่อได้ต้องหลอมหลาย signal
UI ที่รายงานว่า "running" จาก flag เดียวจะโกหก user อย่างมั่นใจ — บทเรียนการ supervise long-running agent/process ว่าทำไม 'process alive' เป็น proxy ที่แย่ของ 'งานคืบหน้า' และวิธีหลอมหลาย signal ให้ liveness เชื่อได้จริง
อ่าน ~8 นาที
Thesis: process-alive ≠ work-progressing
ผมเคยสร้างระบบ supervisor ที่คอย monitor long-running agent — พวก worker ที่รันงานยาวหลายนาทีถึงหลายชั่วโมง แล้วรายงานสถานะกลับขึ้น dashboard ให้ user ดู ตอนแรกผมทำแบบที่ทุกคนทำ: เช็คว่า process ยังอยู่ไหม ถ้า PID ยังตอบก็ขึ้นป้ายเขียว "running" จบ.
ปัญหาคือมันโกหก. มีหลายครั้งที่ process ยังไม่ตาย แต่ตัวงานค้างสนิท — deadlock บน lock ตัวหนึ่ง, รอ input ที่ไม่มีวันมา, หรือ loop อยู่กับ retry ที่ fail ทุกครั้ง. UI ยังโชว์เขียวมั่นใจ ในขณะที่ความจริงคืองานตายไปแล้ว 20 นาที. บทเรียนแรกและสำคัญที่สุด: "process ยังไม่ตาย" เป็น proxy ที่แย่มากของ "งานยังเดินหน้า". flag เดียวไม่พอ และ flag ที่ผิดจะทำให้ user เชื่อผิดแบบมั่นใจ ซึ่งอันตรายกว่าไม่รู้อะไรเลย.
แยก 4 ความจริงออกจากกัน
รากของปัญหาคือเราชอบยุบหลายคำถามให้เหลือ boolean เดียว. จริงๆ แล้วสถานะของงานหนึ่งประกอบด้วยความจริงอย่างน้อย 4 ชั้นที่ independent กัน:
- process — OS ยังมี PID นี้อยู่ไหม (alive/dead). ตอบได้ง่ายสุด แต่ informative น้อยสุด
- activity — มันกำลังทำอะไรอยู่จริงไหม หรือแค่ค้าง. เป็นชั้นที่คนมองข้ามบ่อยสุด
- transport — connection/channel ที่ใช้คุยกับมัน healthy ไหม. process อาจดีแต่ socket ตายไปแล้ว
- gate — งานผ่านด่าน review/verify ที่เราตั้งไว้หรือยัง. "รันเสร็จ" ≠ "ผ่าน"
ทั้ง 4 ต้องเป็น field แยกกันใน state model ไม่ใช่ยุบเป็น isRunning: true. เพราะ combination ที่แปลกๆ คือของจริง: process alive + activity idle = ค้าง; process alive + transport dead = zombie ที่คุยด้วยไม่ได้; activity busy + gate failed = ทำงานเสร็จแต่ผลใช้ไม่ได้. ถ้าคุณ collapse มันตั้งแต่แรก คุณจะ debug ไม่เจอเลยว่าปัญหาอยู่ชั้นไหน.
Screen-hash idle detection เปราะทั้งสองทาง
ตอนพยายามตอบคำถาม "activity" ผมลองวิธีที่ดูฉลาด: hash หน้าจอ/output ของ process เป็นช่วงๆ ถ้า hash ไม่เปลี่ยน = idle. มันพังทั้งสองทาง:
- false idle — เวลา model กำลังคิดหนัก (reasoning, รอ API, ประมวลผลในหัว) จะไม่มี screen change เลย ทั้งที่งานเดินอยู่. supervisor เห็น hash นิ่ง เลยไป kill/restart ทิ้งงานที่กำลังจะเสร็จ
- false alive — spinner หมุน, log timestamp เดิน, progress bar ที่ค้างแต่ยัง redraw — hash เปลี่ยนตลอดทั้งที่งานตายแล้ว
ทางออกไม่ใช่หา signal เดียวที่ดีกว่า แต่คือ หลอมหลาย signal พร้อม confidence. ผมเลิกคืน boolean แล้วเปลี่ยนเป็นคืน evidence array + confidence + expiry:
type Evidence = { source: string; fresh: boolean; detail: string };
function assessLiveness(w: Worker): {
state: "progressing" | "idle" | "stalled" | "dead" | "unknown";
confidence: number; // 0..1
evidence: Evidence[];
expiresAt: number; // liveness verdict มีวันหมดอายุ
} {
const now = Date.now();
const ev: Evidence[] = [
{ source: "pid", fresh: isPidAlive(w.pid), detail: `pid ${w.pid}` },
{ source: "cpu", fresh: cpuPercent(w.pid) > 0.5, detail: "cpu>0.5%" },
{ source: "output_mtime", fresh: now - statMtime(w.logFile) < 30_000, detail: "log written <30s" },
{ source: "heartbeat", fresh: now - w.lastHeartbeat < 15_000, detail: "hb <15s" },
{ source: "git_head", fresh: gitHead(w.repo) !== w.lastGitHead, detail: "HEAD moved" },
{ source: "file_change", fresh: anyFileChangedSince(w.dir, w.lastScan), detail: "fs mutated" },
];
const pidAlive = ev[0].fresh;
const fresh = ev.filter(e => e.fresh).length;
const activityFresh = ev.slice(1).filter(e => e.fresh).length; // สัญญาณงานจริง ไม่นับ pid
const confidence = fresh / ev.length;
let state: "progressing" | "idle" | "stalled" | "dead" | "unknown";
if (!pidAlive) state = "dead"; // ไม่มี PID = process จบไปแล้ว
else if (activityFresh >= 2) state = "progressing"; // หลายแหล่งยืนยันว่างานเดิน
else if (activityFresh === 1) state = "idle"; // มีสัญญาณเบาๆ อาจกำลังรอ input
else state = "stalled"; // pid ยังอยู่ แต่ไม่มีสัญญาณงานเลย = ค้าง
return { state, confidence, evidence: ev, expiresAt: now + 20_000 };
}
จุดสำคัญคือ คืน evidence array กลับไปด้วย ไม่ใช่แค่ verdict — เพราะเวลามันบอกว่า "stalled" user (และ log) จะเห็นเลยว่าเพราะ pid ยังอยู่แต่ log ไม่ถูกเขียนมา 30 วินาที + git HEAD ไม่ขยับ. verdict ที่อธิบายตัวเองได้ debug ได้จริง. และ verdict ทุกอันมี expiresAt เพราะ liveness เป็นข้อมูลที่เน่าเร็ว — สถานะเมื่อ 20 วินาทีที่แล้วห้ามเอามาโชว์เป็นปัจจุบัน.
Timeout ต้องแยกตามชนิดงาน
ความผิดพลาดคลาสสิกอีกอันคือตั้ง timeout เดียวทั้งระบบ. งาน machine-to-machine (เรียก internal API) ควร timeout ในไม่กี่วินาที — เกินนั้นคือมีอะไรผิด. แต่งาน human-in-the-loop ต่างกันคนละโลก: ให้คนสแกน QR, ยืนยัน biometric, กรอก OTP, หรืออนุมัติอะไรบางอย่าง — คนอาจวางมือถือ เดินไปเข้าห้องน้ำ แล้วกลับมาทำต่อในอีก 3 นาที. ถ้าคุณเอา timeout ของ M2M ไปครอบงาน human-in-the-loop คุณจะ abort งานที่ปกติดีทิ้งตลอดเวลา.
const TIMEOUTS = {
m2m_internal: 5_000, // เรียก service ภายใน
m2m_external: 30_000, // 3rd-party API
human_confirm: 180_000, // สแกน QR / OTP / กดอนุมัติ
human_biometric: 300_000, // ต้องขยับตัว, จัดแสง, ลองใหม่
};
หลักคิด: timeout คือ assertion เรื่อง "งานชนิดนี้ปกติควรใช้เวลาเท่าไหร่" ไม่ใช่ตัวเลขมั่วๆ ตัวเดียว. งานที่มีมนุษย์อยู่ใน loop ต้องเผื่อ think-time ของมนุษย์เสมอ และควรแยก timeout ของ "รอ input" ออกจาก timeout ของ "ประมวลผลหลังได้ input".
Pause/resume ข้าม restart ต้องมี generation fence
ตอนเพิ่มความสามารถ pause/resume ให้ worker ผมเจอบั๊กที่เจ็บที่สุดในเรื่องนี้. flow คือ: worker กำลังรัน → user กด pause → เรา restart supervisor → user กด resume. ปัญหาคือ callback หรือ event ที่ถูก schedule ไว้ก่อน pause บางตัวเพิ่งเดินทางมาถึงหลัง resume แล้วเอา state เก่ามาทับ state สดที่เพิ่ง resume — งานที่ user เพิ่งสั่งต่อโดนย้อนกลับไปจุดเดิมเงียบๆ.
ทางแก้คือ generation fence: ทุก state มี generation counter, ทุก async callback ต้อง capture generation ตอนถูกสร้าง แล้วตอนจะ apply ให้เทียบ generation ก่อน (optimistic concurrency) ถ้าไม่ตรง = callback นี้มาจากยุคก่อน ทิ้งทันที.
let generation = 0; // เพิ่มทุกครั้งที่ pause/resume/restart
function onPause() { generation++; }
function onResume() { generation++; }
function scheduleWork(task: Task) {
const g = generation; // capture ยุคปัจจุบัน
runAsync(task).then(result => {
// generation check: apply ได้เฉพาะถ้ายังอยู่ยุคเดิม
if (g !== generation) {
log.warn(`stale callback dropped (gen ${g} != ${generation})`);
return; // fence กันทับ state สด
}
applyResult(result);
});
}
บทเรียนที่ generalize ได้: recording generation ≠ enforcing generation. หลายระบบเก็บ version/generation ไว้ใน state แต่ไม่เคยเอามาเทียบตอน write — นั่นคือแค่ metadata สวยๆ ที่ไม่กันอะไรเลย. fence จะทำงานก็ต่อเมื่อคุณปฏิเสธ write ที่ generation ไม่ตรงจริงๆ. เรื่องนี้เหมือน optimistic locking ใน DB — เก็บ version column เฉยๆ ไม่พอ ต้อง WHERE version = ? ตอน update ด้วย.
Health check ต้องวัด output จริง ไม่ใช่ pid alive
สุดท้าย เมื่อคุณต่อ auto-restart เข้ากับ health check ต้องระวังให้มาก. health check ที่ดีต้องวัด data age — "output ล่าสุดเก่ากว่า threshold ไหม" — ไม่ใช่แค่ "pid ยังอยู่ไหม" เพราะ pid alive คือ signal ที่เราเพิ่งพิสูจน์ว่าโกหกได้. และ auto-restart ทุกตัวต้องมีเกราะ 3 ชั้น ไม่งั้นมันกลายเป็น crash loop ที่ถล่มระบบตัวเอง:
- reentrancy guard — กันไม่ให้ restart ซ้อน restart (health check ยิงถี่ + restart ยังไม่เสร็จ = restart หลายตัวพร้อมกัน)
- backoff — restart แล้วตายอีก ให้เว้นระยะนานขึ้นเรื่อยๆ (exponential) อย่ากระหน่ำทันที
- rate limit — เกิน N ครั้งใน window เดียว = หยุด แล้วส่ง alert ให้คน แทนที่จะ restart ต่อไปเรื่อยๆ
let restarting = false; // reentrancy guard
let attempts = 0;
const MAX = 5, WINDOW = 5 * 60_000;
let windowStart = Date.now(); // ต้อง let เพราะต้อง reset เมื่อ window หมุน
async function maybeRestart(w: Worker) {
if (restarting) return; // 1) กัน reentrancy
if (Date.now() - windowStart > WINDOW) { // window หมดรอบ → เริ่มนับใหม่
attempts = 0;
windowStart = Date.now();
}
if (attempts >= MAX) return alertHuman(w); // 3) rate limit → คน
const dataAge = Date.now() - statMtime(w.logFile);
if (dataAge < 30_000) return; // output สด = ไม่ต้อง restart
restarting = true;
try {
await sleep(Math.min(1000 * 2 ** attempts, 30_000)); // 2) backoff
await restart(w);
attempts++;
} finally {
restarting = false;
}
}
สรุป
- process-alive ≠ work-progressing — flag เดียวจะทำให้ UI โกหก user แบบมั่นใจ ซึ่งแย่กว่าไม่รู้
- แยก 4 ความจริง — process / activity / transport / gate เป็น field อิสระ อย่ายุบเป็น isRunning เดียว
- screen-hash idle detection เปราะทั้งสองทาง — model คิดหนัก = false idle, spinner หมุน = false alive; ให้หลอม PID/CPU/mtime/heartbeat/git HEAD/file change แล้วคืน evidence array + confidence + expiry
- timeout แยกตามชนิดงาน — human-in-the-loop (QR, biometric) เผื่อ think-time มนุษย์ ยาวกว่า M2M มาก; อย่าใช้ timeout เดียวทั้งระบบ
- pause/resume ต้องมี generation fence — callback ยุคก่อนจะทับ state สด; ต้องเทียบ generation แล้วปฏิเสธ write ที่ไม่ตรง — recording ≠ enforcing
- health check วัด output จริง (data age) ไม่ใช่ pid alive; auto-restart ต้องมี reentrancy guard + backoff + rate limit เสมอ ไม่งั้นเป็น crash loop




