Private Docs

Plan — WorkflowService Integration

แผน implement การต่อ WorkflowService เข้ากับ orchestrator

อัปเดต: 2026-06-15

For agentic workers: REQUIRED SUB-SKILL: Use superpowers

(recommended) or superpowers
to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. ⚠️ Contract status: Workflow↔Orchestrator contract ยังไม่ lock — ส่วน A (Phase-0 coordination) ต้องเสร็จก่อนส่วน B

Goal: ให้ WorkflowService emit semantic integration events (Pickup/SentToReviewer/Decision) ผ่าน Azure Service Bus ให้ Orchestrator saga กิน + รับ HTTP create/resubmit/force-stop ที่คืน WfInstanceId และ idempotent + notify maker เอง — โดยไม่เปลี่ยน phase-model internals ของ Workflow

Architecture: Workflow ภายในเป็น multi-phase / multi-step state machine (WfInstance → WfPhaseInstance → WfStepInstance → WfStepActionInstance) ที่มี action แค่ APPROVE/REJECT + phase progression + OnRejectToPhaseOrder. งาน integration = เพิ่ม ASB publisher layer บาง ๆ ที่ แปลง native transition → semantic event (native→semantic mapping) แล้ว publish; ตัว phase engine (TakeActionHandler) ไม่แตะ logic เดิม แค่เพิ่ม emit point. WebhookService.SendWorkflowEventAsync ปัจจุบัน เป็น no-op (body ว่าง) — เป็นจุดที่ ASB layer จะเสียบแทน/ขนานกัน

Tech Stack: .NET, EF Core, Azure Service Bus (Azure.Messaging.ServiceBus), xUnit + Moq (test project WorkFlow05.UnitTest มีอยู่แล้ว)


สรุป code จริงที่สำรวจ (survey 2026-06-15 — อย่าเดา)

สิ่งสถานะจริงในโค้ดไฟล์
ASB / messaging infraไม่มีเลย (0) — มีแค่ secret-provider.yaml อ้าง
WebhookService.SendWorkflowEventAsyncno-op — body ว่างเปล่า (บรรทัด 24-26)WorkFlow02.Application/Services/WebhookService.cs
Native actionsAPPROVE / REJECT เท่านั้นTakeActionHandler.cs:116,133,173
ReworkREJECT + templateStep.OnRejectToPhaseOrder > 0 → set instance.CurrentPhaseId=target, IsEditable=true (ไม่ reset step ตอนนี้)TakeActionHandler.cs:148-167
Reject ถาวรREJECT + OnRejectToPhaseOrder == 0instance.Status="REJECTED"TakeActionHandler.cs:149-153
Approve final phaseAPPROVE phase สุดท้าย → instance.Status="COMPLETED"TakeActionHandler.cs:284-286
SentToReviewerไม่มี action แยก = APPROVE phase ที่ไม่ใช่ phase สุดท้าย → เปิด next phaseTakeActionHandler.cs:230-281
Pickup / claim / InReviewไม่มี concept ในโค้ดเลย — step ถูก assign ตอน create; ไม่มีกลไก “หยิบงาน”(ไม่พบ)
Cancelไม่มีใน TakeAction; มีแค่ ForceStopStep (admin force-stop)ForceStopStepHandler.cs
PostInstances คืนอะไรPostInstancesResponse.DocumentId (string) + payload — ไม่คืน WfInstanceIdPostInstancesResponse.cs:8
WfInstance.InstanceIdint identity (นี่คือ WfInstanceId ที่ orchestrator ต้องเก็บ)WfInstance.cs:17
ForceStopStep idempotent?ไม่ — throw "not found: No active step" ถ้าไม่มี IN_PROGRESS step (บรรทัด 38-40)ForceStopStepHandler.cs
HistoryAddHistoryAsync มี แต่ ไม่ถูกเรียกที่ไหนเลย (dead)TakeActionHandler.cs:370
occurredAt / decisionIdไม่มีในโค้ด — ต้อง mint ใหม่ทั้งคู่
Webhook payloadOnActionChangedPayload { InstanceId, BusinessRefId, BusinessType, StepInstanceId, ActionBy, ActionType, Remark, IsEdit, NextAssignees }WebhookService.cs:29
Test patternxUnit + Moq, mock ITemplateService/IWfInstanceRepository/IWebhookService; builder BuildSingleStepTemplate/BuildTwoPhaseTemplatetests/WorkFlow05.UnitTest/Handlers/TakeActionHandlerTests.cs
ASB publisher reference (อีก service, same SuperApp pattern)ServiceBusClient.CreateSender(topic), fire-and-forget never-throw, MessageId=eventId, camelCase + JsonStringEnumConverter, + Null fallbackBackend_UserService/.../Messaging/AsbFlowApprovalEventPublisher.cs

นัยสำคัญ: native→semantic mapping ทั้งหมด ตีความได้จากโค้ดจริง ยกเว้น Pickup/InReview ที่ “ไม่มีที่มา” และ Cancel ที่ “ต้องเพิ่ม path ใหม่” — สองอย่างนี้คือแกนของ Part A


Part A — Phase-0 Coordination (ต้องเสร็จก่อนเริ่ม Part B)

เป้าหมาย: lock 5 จุดสัญญา กับทีม Workflow (ทีมเดียวกัน ยืนยันแล้ว แต่ design ภายในยังไม่นิ่ง) ก่อนเขียน code ที่ผูกกับ payload/ความหมาย. แต่ละ item มี context จากโค้ดจริง + ตัวเลือก + default ที่แนะนำ + กระทบใคร

A.1 — Pickup / claim มีไหม → ตัดสิน InReview (🔴 สำคัญสุด — กระทบ status list ฝั่ง Lego)

  • คำถาม: Workflow มีกลไก “maker หยิบงานจากกองกลาง” (claim/pickup) ไหม
  • Context (โค้ดจริง): PostInstancesHandler assign step ตอน create เลย (step.Assignees หรือ request.Steps); TakeActionHandler แค่เช็ค action.UserId==request.UserId || action.Role==request.Role — ไม่มี state “หยิบแล้ว/ยังไม่หยิบ”; ไม่มี endpoint claim
  • ตัวเลือก:
    • (a) ไม่มี pickup (โค้ดปัจจุบัน): InReview ไม่มี trigger → ตัด InReview ทิ้ง หรือ map “phase แรก IN_PROGRESS (เข้าคิว)” = UnderReview ตั้งแต่ create
    • (b) เพิ่ม pickup: ต้องเพิ่ม endpoint + state ใหม่ใน Workflow (นอก scope grill — งานใหญ่)
  • แนะนำ: (a) — ตัด InReview / รวมเป็น UnderReview ตอน create; การเพิ่ม pickup = scope ใหม่ ไม่อยู่ใน integration seam
  • กระทบ: ถ้าตัด InReview → Lego ตัด status InReview + saga ตัด state UnderReview-from-pickup (เหลือ entry ที่ create) + M3 (Pickup event) หายไปทั้ง messageแจ้ง dev Lego (01) + dev Orchestrator (02) ทันทีเมื่อ decide
  • บล็อก: B.2 (publisher event set), B.6 (TakeAction emit point)

A.2 — แปลง native (APPROVE/REJECT/OnRejectToPhaseOrder) → Decision vocab ที่ไหน

  • คำถาม: publisher ฝั่ง Workflow เป็นคนแปลง native→semantic ใช่ไหม (ไม่ใช่ orchestrator)

  • Context (โค้ดจริง): ตอนจบ TakeActionHandler รู้ครบแล้วว่า: เป็น APPROVE/REJECT, phase ไหน, instance.Status กลายเป็น COMPLETED/REJECTED/RUNNING, templateStep.OnRejectToPhaseOrder, instance.CurrentPhaseId, IsEditable. ข้อมูลพอแปลง native→semantic ครบ

  • mapping ที่จะ encode (จากโค้ด):

    semantic eventเงื่อนไข native (หลัง transition)
    SentToReviewerActionType==APPROVE && stepFullyApproved && มี nextPhase (ยังไม่ COMPLETED)
    Decision(Approve)ActionType==APPROVE && instance.Status=="COMPLETED"
    Decision(Reject)ActionType==REJECT && templateStep.OnRejectToPhaseOrder==0 (instance.Status=="REJECTED")
    Decision(Rework)ActionType==REJECT && templateStep.OnRejectToPhaseOrder>0 (IsEditable==true)
    Pickupขึ้นกับ A.1
    Decision(Cancel)ขึ้นกับ A.5 (ไม่มีใน TakeAction)
  • แนะนำ: แปลงที่ publisher ฝั่ง Workflow (Workflow รู้ phase semantic ของตัวเอง) — สอดคล้อง 00 §7.2

  • บล็อก: B.2, B.6

A.3 — decisionId + occurredAt — ใคร/ตอนไหน mint

  • คำถาม: Workflow เป็นคน mint decisionId (Guid) + occurredAt (UTC) ต่อ Decision event ใช่ไหม + format อะไร
  • Context (โค้ดจริง): ไม่มีทั้งคู่ในโค้ด. Lego ordering ใช้ occurredAt (00 §3 progress guard); orchestrator ใช้ decisionId เป็น idempotency key ของ Decision (00 §7.2)
  • แนะนำ: Workflow mint ทั้งคู่ใน publisher ตอนจะ publish: decisionId = Guid.NewGuid(), occurredAt = DateTime.UtcNow (เก็บ/อิงจังหวะ commit transition); ทุก semantic event มี occurredAt, เฉพาะ Decision มี decisionId
  • เปิดประเด็น: ถ้าต้อง replay/republish event เดิม → decisionId ต้อง stable (mint ตอน transition แล้ว persist) ไม่ใช่ mint ตอน publish ทุกครั้ง → ถาม: ต้อง persist decisionId บน WfInstance/history ไหม (กระทบ B.1 ว่ามี migration หรือไม่)
  • บล็อก: B.2 (event schema), B.6

A.4 — PostInstances ต้องคืน WfInstanceId (M12)

  • คำถาม: เปลี่ยน PostInstancesResponse ให้คืน WfInstanceId (= WfInstance.InstanceId, int) ได้ไหม — มี consumer เดิมของ field DocumentId ที่จะพังไหม
  • Context (โค้ดจริง): PostInstancesResponse คืนแค่ DocumentId (string) + PayloadForCallback. instance.InstanceId ถูก set หลัง Commit() (identity). Orchestrator (M12) ต้องเก็บ WfInstanceId ใน Correlations เพื่อเรียก resubmit (M13) / force-stop (M14)
  • แนะนำ: เพิ่ม field WfInstanceId (ไม่ลบ DocumentId — additive, ไม่ทำ caller เดิมพัง). ดู B.3
  • เปิดประเด็น: orchestrator เก็บ correlation ด้วย WfInstanceId (int) หรือ refNo/DocumentId (string) เป็น key — กระทบว่า M13/M14 รับ param อะไร
  • บล็อก: B.3

A.5 — Cancel path (Workflow-initiated, M5 Decision(Cancel) + sweep)

  • คำถาม: Cancel ฝั่ง Workflow ใช้กลไกไหน — reuse ForceStopStep (มีอยู่) แล้ว publish Decision(Cancel)? หรือเพิ่ม cancel path ใหม่?
  • Context (โค้ดจริง): ForceStopStepHandler ปิด instance ทั้งหมด → Status="FORCE_STOPPED". ใกล้เคียง Cancel ที่สุด แต่: (1) ต้องการ user assignee อยู่ใน step ปัจจุบัน (บรรทัด 43-46) — admin sweep อาจไม่ผ่าน; (2) ไม่ idempotent (throw ถ้าไม่มี active step). 00 §4.3 ขา B = “Workflow cancel local + publish Decision(Cancel)/workflow-cancel”
  • ตัวเลือก:
    • (a) Cancel = ForceStopStep + publish Decision(Cancel, mode) — reuse, แต่ต้องแก้ idempotent + ผ่อน auth สำหรับ sweep
    • (b) เพิ่ม cancel path แยก
  • แนะนำ: (a) ตามทิศ 00 §4.3 + แก้ idempotent (B.5) — แต่ ยืนยันกับทีมก่อน ว่า FORCE_STOPPED semantics = Cancel
  • บล็อก: B.4 (Cancel publish), B.5 (idempotent force-stop), B.7 (sweep)

A.6 — payload field-level M3/M4/M5 (ปิดท้ายเมื่อ A.1-A.5 ตกผลึก)

  • ยืนยัน field สุดท้าย ของแต่ละ event ให้ตรง 00 §7.1 + ฝั่ง orchestrator consumer (02):
    • SentToReviewer (M4): eventId, flowInstanceId(=refNo? หรือ WfInstanceId?), refNo, reviewerIdentity(=next assignees), occurredAt
    • Decision (M5): eventId, flowInstanceId, refNo, action(Approve/Reject/Rework/Cancel), decisionId, reworkTargets, reason, occurredAt
    • (Pickup M3): ตาม A.1
  • ยืนยัน key correlation: orchestrator match saga ด้วย refNo หรือ WfInstanceId — ฝั่ง Workflow BusinessRefId = document id (= refNo?). ต้องตรงกับ 01/02 ว่า field ชื่ออะไร map กับ Lego flowInstanceId
  • บล็อก: B.2 (schema final)

A.7 — reviewer identity = assignee ของ next phase (ยืนยัน model)

  • คำถาม: reviewerIdentity ใน SentToReviewer = NextAssignees (assignee ของ step IN_PROGRESS ใน next phase) ใช่ไหม
  • Context (โค้ดจริง): TakeActionHandler:336-343 คำนวณ NextAssignees จาก instance.WfStepInstanceItems.FirstOrDefault(s => s.Status=="IN_PROGRESS") อยู่แล้ว (เฉพาะ non-REJECT) → reuse ได้
  • แนะนำ: reuse logic เดิม
  • บล็อก: B.6

A.8 — Notify maker: audience + channel (W2)

  • คำถาม: Workflow เป็นเจ้าของ notify maker (grill D5). ยิงผ่านอะไร — เรียก NotificationService (HTTP/ASB) ที่มีอยู่? maker pool resolve จากไหน (role-based assignee)?
  • Context (โค้ดจริง): ไม่มี notification publisher ใน WorkflowService เลย; assignee เก็บใน WfStepActionInstance { UserId, Role }
  • 2 จังหวะ (grill): (1) create → notify maker pool (unassigned/role); (2) resubmit → notify assignee เดิม
  • บล็อก: B.8 — เขียน task ได้แต่ payload/channel ขึ้นกับคำตอบ

Part A — Definition of Done

  • A.1-A.8 มีคำตอบเขียนเป็นลายลักษณ์ (update ไฟล์ 03-workflowservice.md “Open items” → resolved)
  • update 00-OVERVIEW §7.1 (M3-M5, M12) ถ้า field/topic เปลี่ยน + แจ้ง dev 01 (Lego) ถ้า InReview ถูกตัด
  • ระบุ topic name จริงของแต่ละ semantic event (orchestrator consumer ต้องรู้)

Part B — Implementation (provisional — เริ่มได้หลัง Part A lock ตามที่ระบุ)

งานที่ ไม่ขึ้นกับคำตอบ Part A ทำได้เลย (B.1 infra, B.3 คืน WfInstanceId, B.5 idempotent force-stop). งานที่ ขึ้นกับ Part A flag ไว้ “blocked on A.N” — เขียน skeleton/test ได้แต่ assert ตาม contract สุดท้าย

B.1 — ASB publisher infra (sender + DI + Null fallback) [ทำได้เลย — ไม่ blocked]

อิงแบบ AsbFlowApprovalEventPublisher (UserService) 1

— same SuperApp pattern: ServiceBusClient.CreateSender(topic), fire-and-forget never-throw, MessageId=eventId, camelCase + JsonStringEnumConverter, Null fallback เมื่อไม่มี ASB (local dev)

  • B.1.1 เพิ่ม PackageReference Azure.Messaging.ServiceBus ใน WorkFlow02.Application (หรือ Infrastructure ตาม layer ที่ทีมใช้ — DI อยู่ WorkFlow01.API/ContainerModules)
    • verify: restore ผ่าน, solution build เขียว
  • B.1.2 นิยาม port IWorkflowEventPublisher (1 method ต่อ semantic event — เพิ่มตาม B.2): วาง interface ใน Application/Interfaces (คู่กับ IWebhookService)
  • B.1.3 AsbWorkflowEventPublisher : IWorkflowEventPublisher, IAsyncDisposable — copy โครง publisher อ้างอิง (try/catch + LogWarning, ไม่ throw); 1 sender ต่อ topic (หรือ sender map ต่อ event type)
  • B.1.4 NullWorkflowEventPublisher — log + Task.CompletedTask (fallback)
  • B.1.5 DI: register ServiceBusClient (ถ้ายังไม่มี) + bind Asb vs Null ตาม connection string present (ตาม pattern UserService DI)
    • verify (test): unit test ที่ inject mock IWorkflowEventPublisher แล้วยืนยันถูกเรียก (ไม่ต้องต่อ ASB จริง)
  • TDD: เขียน test ก่อน — AsbWorkflowEventPublisher serialize event เป็น camelCase JSON + set MessageId = eventId (mock ServiceBusSender หรือ assert ผ่าน abstraction); never-throw เมื่อ sender ขว้าง exception

B.2 — Event contracts (DTO ของ semantic events) [blocked on A.1, A.2, A.3, A.6]

  • B.2.1 นิยาม event record/class ตาม schema สุดท้ายจาก A.6:
    • SentToReviewerEvent { EventId, FlowInstanceId/RefNo, ReviewerIdentity, OccurredAt }
    • WorkflowDecisionEvent { EventId, FlowInstanceId/RefNo, Action(enum Approve/Reject/Rework/Cancel), DecisionId, ReworkTargets, Reason, OccurredAt }
    • PickupEvent { ... }เฉพาะถ้า A.1 = มี pickup; ถ้าตัด → ข้าม
  • B.2.2 เพิ่ม method ใน IWorkflowEventPublisher ต่อ event (PublishSentToReviewerAsync, PublishDecisionAsync, [PublishPickupAsync])
    • DO NOT เดา field — รอ A.6; เขียน record หลัง lock เท่านั้น
  • TDD: serialize round-trip ของแต่ละ event ตรง wire vocab (APPROVE/REJECT/REWORK/CANCEL หรือ enum string ที่ตกลง — ดู 00 §7.2)

B.3 — PostInstances คืน WfInstanceId (M12) [ทำได้เลย — blocked เฉพาะ field-name จาก A.4]

  • B.3.1 (test-first) เพิ่ม test ใน PostInstancesHandlerTests: หลัง handle สำเร็จ → response.WfInstanceId == instance.InstanceId (> 0)
  • B.3.2 เพิ่ม public int WfInstanceId { get; set; } ใน PostInstancesResponse (additive — คง DocumentId ไว้)
  • B.3.3 set WfInstanceId = instance.InstanceId ใน PostInstancesHandler (หลัง Commit() — บรรทัด ~134, instance.InstanceId พร้อมแล้ว) ทั้ง 2 return path (UseHook + non-hook)
    • verify: PostInstancesHandlerTests เดิมยังเขียว + test ใหม่ผ่าน

B.4 — Emit Decision/SentToReviewer จาก TakeAction [blocked on A.1, A.2, A.3, A.7]

  • B.4.1 inject IWorkflowEventPublisher เข้า TakeActionHandler (ctor — ปัจจุบันมี 3 dep)
  • B.4.2 หลัง Commit() (บรรทัด 313) เพิ่ม emit point ตาม mapping A.2:
    • APPROVE non-final + phaseComplete + มี nextPhase → PublishSentToReviewer (reviewerIdentity = NextAssignees logic, A.7)
    • APPROVE → instance.Status=="COMPLETED"PublishDecision(Approve)
    • REJECT + OnRejectToPhaseOrder==0PublishDecision(Reject)
    • REJECT + OnRejectToPhaseOrder>0PublishDecision(Rework, reworkTargets, reason=Remark)
    • ห้าม แตะ phase-progression logic เดิม — เพิ่มเฉพาะ emit หลัง state ตกผลึก
  • B.4.3 reworkTargets map จาก target phase/step ที่ตีกลับ (instance.CurrentPhaseId หลัง rework = target phase order) — รอ A.6 ยืนยัน shape
  • TDD: ต่อยอด TakeActionHandlerTests (builder มีแล้ว) — mock publisher, assert ถูกเรียกด้วย action ที่ map ถูกต้อง ต่อแต่ละ scenario (single-step approve=Decision(Approve); two-phase approve phase1=SentToReviewer; reject onReject=0=Reject; onReject>0=Rework)
  • flag: ถ้า A.1 = ไม่มี pickup → ไม่มี Pickup emit ที่ create; UnderReview เกิดตอน create (อาจต้อง emit จาก PostInstances แทน — ดู A.1 outcome)

B.5 — ForceStopStep idempotent + ทน “ยังไม่ถูกสร้าง” (M14) [ทำได้เลย]

  • B.5.1 (test-first) เพิ่ม test ใน ForceStopStepHandlerTests:
    • force-stop instance ที่ ไม่มี IN_PROGRESS step (เช่น COMPLETED/REJECTED/FORCE_STOPPED แล้ว) → คืน Ok (no-op) ไม่ throw
    • force-stop ซ้ำ 2 ครั้ง → ครั้งที่ 2 no-op
  • B.5.2 แก้ ForceStopStepHandler: เปลี่ยน throw "not found: No active step" (บรรทัด 38-40) → ถ้าไม่มี active step และ instance terminal แล้ว → return Ok (idempotent no-op); ถ้า instance ไม่พบ → ยังคง behavior เดิม (ดู A.5 ว่า treat ยังไงตอน sweep)
    • flag: การผ่อน auth check (บรรทัด 43-46) สำหรับ admin sweep = blocked on A.5 (sweep actor ไม่ใช่ step assignee)
    • verify: test เดิม (happy force-stop) ยังเขียว + idempotent test ผ่าน

B.6 — Cancel publish จาก force-stop [blocked on A.5]

  • B.6.1 เมื่อ A.5 = (a): หลัง force-stop สำเร็จ → PublishDecision(Cancel, mode) ผ่าน IWorkflowEventPublisher
  • B.6.2 mode (Manual/Expiry) มาจาก caller (sweep ส่ง Expiry, admin ส่ง Manual) — รอ A.5/A.6 field
  • TDD: assert publisher ถูกเรียกด้วย Decision(Cancel) หลัง force-stop

B.7 — Background sweep หมดอายุ → Cancel (W5) [blocked on A.5]

  • B.7.1 BackgroundService (IHostedService) scan instance ที่ expired (เกณฑ์หมดอายุ = ? — ต้องนิยามกับทีม) → cancel local (reuse force-stop path) + publish Decision(Cancel, mode=Expiry)
  • B.7.2 ต้อง idempotent (พึ่ง B.5) + ทน double-sweep กับขา Lego (00 §6)
  • flag: เกณฑ์ expiry + ว่ามีอยู่จริงในขอบเขต sprint นี้ไหม = ยืนยันกับทีม (อาจ defer)

B.8 — Notify maker เอง (W2) [blocked on A.8]

  • B.8.1 ตอน PostInstances (create) → notify maker pool (role/unassigned audience)
  • B.8.2 ตอน Resubmit → notify assignee เดิม (current step assignees)
  • B.8.3 channel = ตาม A.8 (HTTP NotificationService / ASB) — อย่าเขียน code จนกว่า A.8 ตอบ (ไม่มี notification infra ใน Workflow ตอนนี้)
  • TDD: mock notify port, assert audience ถูกต้องต่อ create vs resubmit

B.9 — Wire publisher แทน/ขนาน webhook no-op [blocked on B.2]

  • B.9.1 ตัดสินใจกับทีม: WebhookService.SendWorkflowEventAsync (no-op) ถูกแทนด้วย ASB publisher หรือคงไว้ขนานกัน — ปัจจุบัน no-op จึงไม่มี behavior ให้รักษา
  • B.9.2 ตรวจ emit points ครบ: create (B.8), TakeAction (B.4), force-stop (B.6) — ไม่มี semantic transition ที่ตกหล่น

Part B — Definition of Done

  • dotnet build ทั้ง solution เขียว
  • WorkFlow05.UnitTest เขียวทั้งหมด (รวม test ใหม่ B.1/B.3/B.4/B.5)
  • publisher emit semantic event ครบทุก transition ที่ contract กำหนด (ตาม A ที่ lock)
  • PostInstances คืน WfInstanceId; ForceStopStep idempotent
  • ไม่มี migration ใหม่ เว้นแต่ A.3 ตัดสินให้ persist decisionId (แล้วต้องทำ migration บน MIGRATION branch — ดู MEMORY: MIGRATION branch workflow)

ลำดับแนะนำ

  1. Part A ทั้งหมด (coordination) — โดยเฉพาะ A.1 (Pickup/InReview) ก่อน เพราะกระทบ Lego/Orchestrator
  2. B.1, B.3, B.5 (ไม่ blocked) — ทำขนานระหว่างรอ A
  3. B.2 → B.4 → B.6 → B.7 → B.8 → B.9 (ตามลำดับ dependency หลัง A lock)