Chuyển đến nội dung

Human-in-the-loop

Tổng quan

Làm cho loop có thể tạm dừng và tiếp tục từ trạng thái đã lưu. Cấu hình interrupt_on theo từng tool khiến một tool call khớp điều kiện được lưu thành một approval đang chờ; run chuyển sang awaiting_approval và worker nhả slot của nó. Sau đó một con người gọi POST /v1/runs/{id}/approvals với các quyết định approve, edit, reject hoặc respond, và run được đưa lại vào hàng đợi rồi tiếp tục đúng từ chỗ nó đã dừng. Đây là phase kiểm chứng mọi quyết định về độ bền (durability) trước đó: message append-only, state của run đã lưu, event log và việc sửa các tool call bị treo.

Yêu cầu

Chức năng

  • Config interrupt_on theo từng agent: {"tool_name": true} hoặc {"tool_name": {"allowed_decisions": ["approve","reject"], "when": {...}}}. InterruptConfig được định nghĩa ở phase 9; phase này mở rộng nó với mệnh đề When.
  • Các rule interrupt mặc định mà config của người dùng có thể nới lỏng nhưng không được âm thầm gỡ bỏ: `write_file` và `edit_file` nhắm vào `/memories/` hoặc `/skills/`. Chúng được gộp với config của người dùng, trong đó người dùng thắng theo từng tên tool, và muốn gỡ thì phải có `"interrupt_on": {"write_file": false}` tường minh, được log lúc lưu agent. Đây là thứ thay thế quy tắc tạm của phase 8 vốn biến các prefix đó thành chỉ dùng được qua API.
  • HumanInTheLoopMiddleware nằm muộn trong stack: trong WrapToolCall, một call khớp điều kiện được lưu thành một dòng pending_approvals và middleware trả về một sentinel error làm run tạm dừng.
  • Thêm run state awaiting_approval, với awaiting_since và một thời hạn cấu hình được (HANGO_APPROVAL_TTL, mặc định 24h), sau đó run bị một sweeper chuyển sang failed với lý do rõ ràng.
  • Mọi tool call trong cùng một batch được lưu cùng nhau: batch bị tạm dừng một cách nguyên tử, nên không tool nào trong batch chạy khi một tool anh em còn chờ approval.
  • Các quyết định: approve (chạy như đã yêu cầu), edit (chạy với đối số do con người cung cấp, và ghi lại cả hai), reject (trả về một tool error result mang thông điệp của con người và để model tiếp tục), respond (bỏ qua việc chạy và trả văn bản của con người làm tool result).
  • Đối số của một quyết định `edit` được **kiểm tra theo JSON Schema `Parameters()` của tool và chạy lại qua matcher `when` của rule** trước khi thực thi. Một con người (hoặc một session bị chiếm quyền) không được phép đưa cho tool những đối số mà model không bao giờ tạo ra được, cũng không được sửa một call ra ngoài phạm vi vốn làm nó bị interrupt ngay từ đầu. Kiểm tra thất bại thì trả 400 kèm lỗi schema và để approval ở trạng thái pending.
  • POST /v1/runs/{id}/approvals nhận một quyết định cho mỗi call đang chờ, kiểm tra quyết định đó được phép với tool ấy, và — trong một transaction duy nhất — lưu các quyết định, chuyển run awaiting_approval → queued có điều kiện theo state hiện tại, rồi trả về. Việc enqueue diễn ra sau commit và được khử trùng theo run id. Duyệt một phần (mới quyết định một số call) trả 409 kèm danh sách còn lại. Vì vậy hai request đồng thời mà mỗi cái đều hoàn tất quyết định cuối cùng chỉ tạo ra đúng một lần chuyển state và đúng một lần thực thi; request thua thấy xung đột và trả 409.
  • Handler tự publish `approval.decided` qua broadcaster, để client đang xem stream SSE thấy quyết định ngay lập tức dù lúc đó không có worker nào đang giữ run. Chờ worker đã resume phát nó sẽ để lại một khoảng trống nhìn thấy được từ vài giây đến vài phút.
  • GET /v1/runs/{id}/approvals liệt kê các call đang chờ cùng đối số của chúng; GET /v1/approvals?state=pending liệt kê hàng đợi của một tenant.
  • SSE phát approval.requested (kèm tên tool, đối số, các quyết định được phép) và approval.decided; stream vẫn mở xuyên suốt lúc tạm dừng để client đang xem thấy được sự chuyển tiếp.
  • Subagent kế thừa: subagent khai báo (declarative) kế thừa interrupt_on của cha trừ khi tự khai báo riêng; một lần tạm dừng của subagent làm tạm dừng cả run, và việc resume đi vào lại child tại call đang chờ.

Phi chức năng

  • **Thứ tự resume là nạp quyết định rồi mới đến `BeforeRun`.** Worker nạp các approval `decided` của run vào `State.Private` *trước khi* các hook `BeforeRun` của chain chạy. Khi đó `RepairToolCallsMiddleware` **bỏ qua mọi `tool_call_id` có một pending approval đã được quyết định**: các call ấy không phải đang bị treo, chúng sắp được thực thi. Nếu không có thứ tự này, bước repair sẽ thấy các block `tool_use` chưa có kết quả của batch đang tạm dừng, tổng hợp các kết quả lỗi "tool call interrupted" cho chúng, và approval mà con người vừa cấp bị phá hủy trước khi được dùng.
  • **Resume chạy lại batch đang chờ qua tool chain**; nó không đi vào lại ở một lời gọi model. Điểm vào resume của loop nhận batch đang chờ, chạy từng call qua `Chain.ToolCall` (nơi `HITLMiddleware` tìm thấy quyết định và áp dụng nó), lưu message tool-result thu được, và chỉ sau đó mới tiếp tục sang lời gọi model kế tiếp. Đi vào lại ở một lời gọi model sẽ hoặc hỏi lại model (nhân đôi batch) hoặc gửi một request mà message assistant cuối cùng còn các block `tool_use` chưa khớp.
  • Việc resume không được chạy lại các tool call đã hoàn tất: loop được resume dựng lại state từ các message đã lưu cộng runs.state_json, và middleware repair chỉ điền các call thực sự đang bị treo và chưa được quyết định.

  • Slot của worker được nhả trong lúc tạm dừng; một run bị tạm dừng không giữ goroutine, không giữ entry registry trong bộ nhớ và không giữ kết nối database nào.

  • Ma trận phân quyền approval: chỉ `owner` hoặc `admin` của tenant chứa run mới được quyết định, **và không bao giờ là người tạo ra run bị interrupt trừ khi người đó là `owner` hoặc `admin`**. Tự duyệt làm vô hiệu toàn bộ cơ chế kiểm soát: ý nghĩa của một interrupt là một bên thứ hai review một hành động có đặc quyền. Một `member` — kể cả người tạo run — luôn nhận 403 ở `POST /v1/runs/{id}/approvals`. `GET /v1/runs/{id}/approvals` theo quy tắc hiển thị run thông thường (giới hạn theo người tạo với `member`), nên người tạo thấy được cái gì đang chờ mà không quyết định được. Mọi quyết định đều ghi `decided_by`.
  • Mọi kết quả approval đều được audit, chứ không chỉ log. Một quyết định của con người ghi một dòng `audit_events` với `actor_kind = 'user'`, action `approval.decide`, `target_kind = 'pending_approval'`, id của approval làm `target_id`, và `detail` mang `decided_by`, loại quyết định, tên tool và `tool_call_id` — không bao giờ mang đối số. Một lần hết hạn do sweeper ghi ra cùng hình dạng đó với `actor_kind = 'system'`, `actor_id` null và action `approval.expire`, vì "không ai quyết định, và hệ thống đã bỏ cuộc" chính là câu trả lời mà người review cần khi một run thất bại qua đêm. Cả hai dòng đều mang `trace_id` của trace đã sinh ra chúng, nên chúng nối với các trace của run bị tạm dừng và run được resume.
Routeowner / adminmember (gồm cả người tạo run)
GET /v1/runs/{id}/approvalscho phép (mọi run trong tenant)cho phép (chỉ run của mình)
GET /v1/approvals?state=pendingcho phép (hàng đợi của tenant)cho phép (chỉ run của mình)
POST /v1/runs/{id}/approvalscho phéptừ chối (403)
  • Test cô lập chéo tenant và trong cùng tenant cho pending_approvals.

Kiến trúc

// internal/agent/mwhitl.go
// InterruptConfig is declared in phase 9 (internal/agent/subagent.go); this phase adds When.
// Red team: F15
type InterruptConfig struct {
	AllowedDecisions []string       `json:"allowed_decisions,omitempty"` // default all four
	When             *WhenClause    `json:"when,omitempty"`              // arg matchers
}

type Decision struct {
	Kind      string          // approve|edit|reject|respond
	Args      json.RawMessage // edit only
	Message   string          // reject|respond
	DecidedBy uuid.UUID
}

type HITLMiddleware struct {
	Config map[string]InterruptConfig
	Store  store.ApprovalStore
} // ToolWrapper

var ErrAwaitingApproval = errors.New("run suspended awaiting approval")

// internal/agent/loop.go (extended)
type Status uint8 // StatusDone, StatusFailed, StatusCancelled, StatusAwaitingApproval

// Run returns StatusAwaitingApproval without error; the worker persists and releases the slot.
// Red team: F3 — on resume the worker loads decisions into State.Private BEFORE Chain.BeforeRun,
// RepairToolCalls skips decided tool_call_ids, and the loop re-drives the pending batch through
// Chain.ToolCall rather than re-entering at a model call.
-- migrations/00010_pending_approvals.sql
create table pending_approvals (
  id uuid primary key,
  tenant_id uuid not null references tenants(id) on delete cascade,
  run_id uuid not null references runs(id) on delete cascade,
  turn int not null,
  tool_call_id text not null,
  tool_name text not null,
  arguments jsonb not null,
  allowed_decisions text[] not null,
  subagent_call_id uuid references subagent_calls(id) on delete cascade,
  state text not null check (state in ('pending','decided','expired')),
  decision text not null default '',
  decision_args jsonb,
  decision_message text not null default '',
  decided_by uuid references users(id),
  decided_at timestamptz,
  created_at timestamptz not null default now(),
  unique (run_id, tool_call_id)
);
create index pending_approvals_tenant_state_idx on pending_approvals (tenant_id, state, created_at);
create index pending_approvals_tenant_run_idx on pending_approvals (tenant_id, run_id);

alter table runs drop constraint runs_state_check;
alter table runs add constraint runs_state_check check (state in ('queued','running','awaiting_approval','succeeded','failed','cancelled'));
alter table runs add column awaiting_since timestamptz;

Instrumentation

  • Việc tạm dừng được thể hiện tường minh trong trace: span execute_tool kết thúc với hango.tool.outcome=awaiting_approval và một span event tool.approval_requested mang tên tool, call id và các quyết định được phép; span invoke_agent kết thúc với hango.run.outcome=awaiting_approval thay vì mở suốt nhiều giờ.
  • Việc resume bắt đầu một trace mới mà span gốc invoke_agent mang một span link tới span gốc của run bị tạm dừng cùng hango.run.resumed_from_span_id và hango.run.suspension_seconds; cả hai trace dùng chung langfuse.session.id và langfuse.trace.metadata.run_id để Langfuse nhóm chúng dưới một run.
  • Span event tool.approval_decided với hango.approval.decision và id user quyết định; các quyết định edit ghi lại rằng đối số đã đổi nhưng không ghi nội dung đối số trừ khi bật content tracing.
  • Metric: gauge hango.approvals.pending (attribute tool), histogram hango.approvals.wait_time, counter hango.approvals.decisions (attribute decision, tool), counter hango.approvals.expired, hango.runs.by_state có thêm state awaiting_approval.
  • Trường log: việc yêu cầu và quyết định approval được log ở mức info kèm run id, tool, call id, decision và user quyết định — dấu vết audit mà người review thực sự sẽ đọc.

Các file liên quan

Tạo mới

  • internal/agent/mwhitl.go, internal/agent/mwhitl_test.go, internal/agent/when.go (các matcher đối số) và test của nó.
  • internal/store/pg/approval.go, internal/store/pg/approval_test.go.
  • internal/server/handlers_approvals.go.
  • internal/run/approval_sweeper.go — làm hết hạn các pending approval cũ và làm thất bại các run của chúng.
  • migrations/00010_pending_approvals.sql.
  • docs/learning/15-human-in-the-loop.md.

Sửa

  • internal/agent/loop.go — StatusAwaitingApproval, tiêu thụ quyết định trước khi tạm dừng, tạm dừng nguyên tử theo batch.
  • internal/agent/mwstack.go — điền slot HumanInTheLoop (sau Memory, trước allowlist) và gộp các rule interrupt của filesystem với config của người dùng, người dùng thắng theo từng tên tool; cập nhật test thứ tự đã ghim.
  • internal/agent/childbuilder.go — subagent kế thừa interrupt_on.
  • internal/run/worker.go — lưu awaiting_approval, nhả slot, enqueue lại khi có quyết định; đường resume nạp các quyết định.
  • internal/run/emitter.go — các loại event approval.requested / approval.decided.
  • internal/server/handlers_run_events.go — giữ stream mở xuyên suốt lúc tạm dừng thay vì đóng khi gặp một state không phải running.
  • internal/store/pg/run.go — state mới và awaiting_since.
  • internal/config/config.go — HANGO_APPROVAL_TTL, HANGO_APPROVAL_SWEEP_INTERVAL.
  • internal/store/pg/tenancy_test.go — thêm pending_approvals (chéo tenant và trong cùng tenant).
  • internal/server/authz.go — các dòng approvals của ma trận phân quyền.
  • deploy/grafana/provisioning/alerting.yaml, deploy/grafana/dashboards/hango-runs.json — alert và panel của approvals.
  • docs/system-architecture.md, docs/learning/README.md.

Các bước triển khai

  1. Viết migrations/00010_pending_approvals.sql, gồm cả việc đổi constraint của runs.state và awaiting_since.
  2. Viết ApprovalStore có scope: CreateBatch, ListPending(runID), ListPendingByTenant, Decide(id, decision, args, message, userID), ExpireOlderThan.
  3. Viết internal/agent/when.go: một matcher nhỏ hỗ trợ khớp chính xác và khớp prefix trên các trường đối số cộng một ngưỡng kích thước, để các rule như “chỉ interrupt write_file dưới /memories/” diễn đạt được mà không cần code.
  4. Viết internal/agent/mwhitl.go: trong WrapToolCall, trước hết kiểm tra State.Private xem đã có quyết định nào được nạp cho tool_call_id này chưa. Nếu có thì áp dụng nó (approve → next; edit → kiểm tra đối số được cung cấp theo tool.Parameters() và chạy lại matcher when của rule, rồi next với đối số đã thay, ghi lại cả hai bộ đối số; reject → error result kèm thông điệp; respond → result không lỗi kèm thông điệp) và ghi span event. Nếu không có và tool khớp một rule interrupt, đăng ký call vào danh sách pending của batch và trả ErrAwaitingApproval.
  5. Làm cho việc tạm dừng nguyên tử theo batch trong loop.go: chạy các tool call của batch qua chain, thu thập hoặc kết quả hoặc các đăng ký pending; nếu có bất kỳ đăng ký nào, lưu toàn bộ các dòng pending trong một transaction, lưu State vào runs.state_json, và trả StatusAwaitingApproval mà không lưu tool result từng phần nào của batch.
  6. Mở rộng worker: khi gặp StatusAwaitingApproval, đặt run thành awaiting_approval với awaiting_since, phát các event approval.requested, kết thúc span của run với outcome tạm dừng, và nhả slot của lane.
  7. Viết các handler approval: phân quyền theo ma trận ở trên (chỉ owner/admin được quyết định, không bao giờ là member kể cả khi là người tạo run), kiểm tra quyết định theo allowed_decisions, kiểm tra schema đối số edit và when, kiểm tra tính đầy đủ kiểu tất-cả-hoặc-không trả 409 kèm các call còn lại, rồi một transaction lưu các quyết định và chuyển run awaiting_approval → queued có điều kiện; enqueue sau commit (có khử trùng). Publish approval.decided qua broadcaster từ handler. Gọi SetAuditAction(c, "approval.decide", "pending_approval", approvalID) với decided_by, loại quyết định, tên tool và tool_call_id trong detail; một 403 hoặc 409 cũng được audit, với outcome denied hoặc error.
  8. Mở rộng đường resume: worker nạp các approval đã quyết định của run vào State.Private trước Chain.BeforeRun, RepairToolCallsMiddleware bỏ qua các tool_call_id có approval đã quyết định, và loop chạy lại batch đang chờ qua Chain.ToolCall trước lời gọi model kế tiếp. Một tool call chưa bao giờ được quyết định (ví dụ sau một lần thất bại do hết hạn) vẫn được repair như bình thường.
  9. Viết internal/run/approval_sweeper.go như một ticker safego làm hết hạn các approval quá TTL. Mỗi lần hết hạn là một transaction: đánh dấu các approval là expired và chuyển run sang failed có điều kiện rằng nó vẫn đang awaiting_approval, để một quyết định đến cùng lúc hoặc thắng hoặc thấy xung đột — không bao giờ cả hai. Phát các event kết thúc sau commit. Sweeper không có request nên không có middleware: nó tự thêm trực tiếp một dòng audit_events cho mỗi approval hết hạn, với actor_kind = 'system' và action approval.expire.
  10. Nối việc subagent kế thừa trong child builder và xác minh rằng một lần tạm dừng bên trong child làm tạm dừng run cha với subagent_call_id được ghi lại.
  11. Giữ SSE handler mở xuyên suốt lúc tạm dừng: coi awaiting_approval là trạng thái chưa kết thúc và tiếp tục stream.
  12. Thêm instrumentation, gồm cả span link khi resume.
  13. Test: interrupt kích hoạt tạm dừng và không tool nào trong batch đó chạy; mỗi quyết định trong bốn loại tạo ra đúng tool result và run hoàn tất; một tool đã được approve chạy đúng một lần khi resume, khẳng định bằng một fake tool có đếm, và chứng minh repair không thay thế nó (test hồi quy phá hủy approval) ; hai request đồng thời mà mỗi cái hoàn tất quyết định cuối cùng chỉ tạo ra đúng một lần chuyển run và đúng một lần thực thi ; một quyết định chạy đua với expiry sweeper ra đúng một kết quả; duyệt một phần trả 409; một member, kể cả người tạo run, nhận 403 khi quyết định và 200 khi list cho run của mình ; một quyết định edit với đối số vi phạm schema của tool trả 400 và để approval ở pending, còn quyết định đẩy call ra ngoài phạm vi when thì bị từ chối ; matcher when giới hạn một interrupt vào một prefix đường dẫn; một write_file vào /memories/ bị interrupt theo mặc định ; hết hạn làm run thất bại; client SSE quan sát approval.requested rồi approval.decided do handler publish trên một kết nối; tạm dừng và resume của subagent; các ca chéo tenant và trong cùng tenant của pending_approvals.
  14. Thêm các alert rule của approvals vào deploy/grafana/provisioning/alerting.yaml (gauge pending tăng, tỉ lệ hết hạn) và các panel approvals vào hango-runs.
  15. Cập nhật tài liệu và viết tài liệu học.

Dàn ý tài liệu học

docs/learning/15-human-in-the-loop.md phải có:

  • Khái niệm: vì sao HITL buộc loop phải là một state machine có thể resume chứ không phải một hàm chặn (blocking), và vì sao mọi quyết định về độ bền trước đó chính là thứ làm cho việc này rẻ.
  • Lựa chọn thiết kế: interrupt là một ToolWrapper trả về một sentinel, tạm dừng nguyên tử theo batch, bốn loại quyết định, quyết định được nạp vào state và được chính middleware đó tiêu thụ, nhả slot trong lúc tạm dừng, expiry sweeper, span link qua ranh giới tạm dừng.
  • Đánh đổi: sentinel error so với trả về status từ chain; tạm dừng cả batch so với chạy trước các call anh em an toàn (chọn nguyên tử: chạy từng phần làm ngữ nghĩa của reject không thể xác định); polling so với SSE để thông báo approval; hết hạn theo TTL so với chờ vô thời hạn.
  • Vì sao state machine của run giờ có sáu state và cái gì canh gác mỗi lần chuyển.
  • So sánh với goclaw: approval tồn tại như một setter interface ApprovalAware trên các tool với wiring trong tiến trình, không phải suspend/resume được lưu bền, nên restart làm mất các approval đang chờ.
  • So sánh với deepagents: interrupt_on với approve|edit|reject|respond, HumanInTheLoopMiddleware nằm muộn trong stack, FilesystemPermission{mode:"interrupt"} tự sinh các entry gộp với config của người dùng (người dùng thắng theo từng tên tool), yêu cầu có checkpointer, resume qua Command(resume={"decisions":[...]}) trên cùng thread_id, và các subagent khai báo kế thừa interrupt_on còn các subagent đã compile thì không bao giờ.
  • Vì sao hango đi khác: approval là các dòng có chủ sở hữu, có audit trail và có thời hạn, không phải state của checkpoint; resume là một lời gọi API có xác thực, không phải một command object phía client.
  • Cách debug tầng này: hango.approvals.pending theo tool cho thấy con người đang bị chặn ở đâu; hango.approvals.wait_time cho thấy hàng đợi có đang được xử lý hay không; hai trace được link (bị tạm dừng và được resume) cho thấy chính xác con người đã đổi gì, vì các quyết định edit được ghi cả dưới dạng span event lẫn dạng dòng; Loki lọc theo hango.run.id cho toàn bộ câu chuyện audit kể cả user quyết định.
  • Ràng buộc thứ tự resume (quyết định → repair → chạy lại batch) là điều tinh vi nhất trong toàn bộ roadmap, và mỗi thứ tự sai làm hỏng cái gì.
  • Bài tập: cấu hình write_file để interrupt, duyệt một call và sửa một call khác, rồi reject call thứ ba và xem model phục hồi; thử tự duyệt run của mình với tư cách member và đọc lỗi 403.
  • Vì sao log approval chưa đủ và dòng audit mới là artifact: một dòng log là best-effort, không có index và chịu chính sách retention; một dòng `audit_events` query được theo tenant và target, nối được với trace của nó, và sống sót qua pipeline log.
  • Ngân sách thời gian: 12h cài đặt + 2h tài liệu học.

Tiêu chí thành công

  • go build ./... && go vet ./... && go test ./... đều qua.
  • Một tool được cấu hình làm run tạm dừng sang awaiting_approval và không tool nào trong batch đó đã chạy (khẳng định bằng fake tool có đếm).
  • Mỗi quyết định trong approve, edit, reject, respond resume run và tạo ra đúng tool result đã mô tả (bốn test).
  • Duyệt một phần trả 409 liệt kê các call còn lại; một member — kể cả người tạo run — nhận 403 khi quyết định.
  • Một tool đã được approve chạy đúng một lần sau khi resume (fake tool có đếm), và RepairToolCalls không thay nó bằng một kết quả “interrupted”.
  • Hai approval cuối cùng đồng thời chỉ tạo ra đúng một lần chuyển run và đúng một lần thực thi; request thua nhận 409.
  • Một quyết định edit với đối số sai schema trả 400 và để approval ở pending; quyết định thoát khỏi phạm vi when của rule thì bị từ chối.
  • Một run bị tạm dừng không chiếm slot lane nào và không chiếm goroutine nào (test rò rỉ cộng khẳng định thống kê lane).
  • Một run được resume không chạy lại các tool call đã hoàn tất (test).
  • Expiry sweeper làm một run cũ thất bại với "approval expired" và phát run.finished.
  • Mọi kết quả approval đều được audit: một quyết định approve/edit/reject/respond ghi một dòng approval.decide mang decided_by và loại quyết định; một 403 của member ghi một dòng với outcome denied; một lần hết hạn do sweeper ghi một dòng approval.expire với actor_kind = 'system'. Không dòng nào chứa đối số của tool.
  • Một kết nối SSE quan sát approval.requested, rồi approval.decided do handler publish ngay lúc quyết định (không phải vài phút sau khi worker nhận run), rồi phần còn lại của run.
  • Khi không có config nào của người dùng, một write_file vào /memories/AGENTS.md tạm dừng để chờ approval.
  • Một interrupt ở cấp subagent làm tạm dừng run cha với subagent_call_id được đặt và resume vào lại child.
  • Trong Tempo, các trace bị tạm dừng và được resume được link với nhau và hango.run.suspension_seconds được ghi lại; trong Langfuse cả hai xuất hiện dưới cùng một session; các series hango.approvals.* tồn tại.

Đánh giá rủi ro

Rủi roKhả năng x Tác độngTín hiệuCách xử lý đã định sẵn
Resume chạy lại các tool có tác dụng phụTrung bình x Nghiêm trọngGhi trùng sau khi approveTạm dừng nguyên tử theo batch nghĩa là không kết quả nào của batch đó được lưu, và các quyết định được khóa theo tool_call_id; test fake tool có đếm là cổng chặn
Run tạm dừng mãi mãi và làm rò rỉ quota/sự chú ýCao x Trung bìnhhango.approvals.pending tăng không giới hạnExpiry sweeper theo TTL cộng gauge pending trên dashboard và một alert trong bộ rule của phase 14
Tool call bị treo sau một lần thất bại do hết hạnCao x CaoProvider trả 400 ở run kế tiếp của session đóMiddleware repair (phase 7) tổng hợp error tool result chỉ cho các call chưa được quyết định; một test chạy hết hạn rồi một run mới trong cùng session
Repair phá hủy batch mà nó lẽ ra phải resumeTrung bình x Nghiêm trọngTool đã approve không bao giờ chạy; model thấy “tool call interrupted”Quyết định được nạp trước BeforeRun, repair bỏ qua các tool_call_id đã quyết định, và resume chạy lại batch qua tool chain; test exactly-once là cổng chặn
Phân quyền quyết định quá lỏngTrung bình x CaoMột user duyệt chính hành động có đặc quyền của mìnhChỉ owner/admin, và không bao giờ là người tạo run trừ khi họ giữ một trong các vai trò đó; thực thi trong authz.go, có test, và mọi quyết định đều ghi decided_by
Một quyết định và một lần hết hạn, hoặc hai quyết định, chạy đuaTrung bình x CaoMột run chạy hai lần, hoặc vừa hết hạn vừa được resumeQuyết định và hết hạn đều chuyển run có điều kiện trong cùng transaction với lần ghi của chính chúng; test approval đồng thời là một tiêu chí nghiệm thu
Tạm dừng lâu làm đứt tính liên tục của traceTrung bình x ThấpTrace mồ côi trong TempoSpan được đóng lúc tạm dừng và trace được resume mang một span link cộng session id Langfuse dùng chung
Kết nối SSE nhàn rỗi hàng giờ trong lúc tạm dừngTrung bình x Trung bìnhProxy giết streamCác ping keepalive tiếp tục trong lúc tạm dừng; client được dặn kết nối lại với Last-Event-ID, cách này vẫn dùng được vì event log bền vững

Rollback

goose down một bước xóa pending_approvals, runs.awaiting_since và khôi phục constraint năm state (mọi run đang ở awaiting_approval phải được chuyển sang failed trước; migration down làm việc này một cách tường minh). Gỡ slot HITL khỏi DefaultStack vô hiệu hóa hoàn toàn các interrupt và loop trở về hành vi của phase 14.