Skip to main content

Harbormaster

Harbormaster is the HITL approval workflow. Agents call Gate before high-risk actions (destructive ops, production targets, expensive tool calls); if a rule matches, the action is queued in approvals_queue and either logged-and-continued (async) or paused until a human decides (sync). Decisions and timeouts emit journal entries so the audit trail is complete. This complements Lookout: Lookout sanitises content; Harbormaster gates actions.

Gate modes

Sync mode respects ctx.Done() (so request cancellation unblocks the poll) and uses a client-side deadline (TimeoutSecs, default 3600) on top of the server-side timeout sweeper — either can flip the row to timeout and unblock the gate.

Rule evaluation

The default Evaluator comes pre-loaded with rules for destructive ops, cost thresholds, and production target patterns (see rules.go). Callers can compose their own:
A rule fires when ANY of its non-zero conditions match. RequireWhen(tool, args) is a free-form last-resort predicate. The orchestrator wires this via approvalGateAdapter in internal/server/orchestrator_adapters.go, which uses NewEvaluatorWithDefaults().

Where the run-level mode comes from

Before a run starts, the orchestrator calls the gate once with tool agent_run. The mode for that check — none, async, or sync — is derived from the run’s crew autonomy level and stamped onto the dispatch request by the single request-builder that every path (chat, pipeline, cron, webhook, mission, peer) funnels through. The mapping is: Because the run-level mode is now sourced from policy on every dispatch path, the gate is live everywhere, not just interactive chat. Note that the baked-in default rule set does not match the agent_run tool, so raising a crew to sync does not block ordinary runs on its own — it takes effect once a rule matches the run (custom agent_run rules, cost, or a production target). The mode governs whether the rules are consulted at all.

Queue schema

Endpoints

  • GET /api/v1/approvals?status=pending&limit=50 — inbox. Status defaults to pending; use ?status=all for full history.
  • GET /api/v1/approvals/{id} — full request including payload.
  • POST /api/v1/approvals/{id}/decide — body {"status":"approved|denied","comment":"..."}. Requires OWNER or ADMIN workspace role; 403 otherwise.
  • POST /api/v1/approvals/{id}/cancel — withdraw a still-pending request without approving or denying it (flips the row to cancelled, records no approve/deny decision). Use when the request is moot.
  • POST /api/v1/approvals/reset-auto-tuning — body {"tool":"..."}. Clears the rolling gate_reward_history window for one tool (see Reward-adjusted gating).
Cross-tenant IDs return 404 with the “not found” shape. Deciding a non-pending row returns 409 (already decided). See Approvals API for full schemas.

Timeout sweeper

harbormaster.StartTimeoutSweeper(ctx, db, j, 30*time.Second) runs a background goroutine that flips rows past timeout_at from pending to timeout and emits approval.timeout. The server starts this once at boot. On ModeSync, the sweeper AND the client-side deadline both try to flip the row — whichever wins, the row is consistent. A race with a last-second decide is handled: if the UPDATE affects zero rows Gate re-reads the row and returns the human decision rather than misreporting a timeout.

CLI

See crewship approvals for the full command surface. Pending requests resolve by approve, deny, cancel, or the server-side timeout sweeper.

Hook integration

The hooks system fires on_approval_requested when Harbormaster determines approval is required — specifically, when Gate returns Required=true (applies to Approved, Denied, and Pending branches). The orchestrator’s HookDispatcher dispatches the event after the gate decision lands; the harbormaster package itself stays hook-agnostic. Use this hook to page oncall, post to Slack, or auto-escalate:

Reward-adjusted gating

Every Decide call also feeds gate_reward_history — one row per outcome, keyed by (workspace_id, tool_name, args_hash). On the next Gate() call for the same shape, harbormaster.AdjustMode walks the last 20 outcomes and:
  • downgrades sync → async when approval rate > 90% (humans are rubber-stamping — stop blocking the agent)
  • upgrades async → sync when denial rate > 70% (humans are rejecting — start blocking instead of logging and running anyway)
Both require a quorum of at least RewardHistorySize/2 = 10 decisions before tuning — a single denial won’t flip the mode. Timeouts and cancellations are tracked but excluded from the rate calculation so inaction doesn’t dilute operator intent. Every mode change emits a keeper.rule_auto_tuned journal entry so the audit trail shows why a later call took a different path than the rule says. Reset — operators can wipe the rolling window for a tool via CLI:
or HTTP:
Use when automation approved on behalf of humans for a while and biased the window (the next decisions will retrain naturally). args_hash is a sha256 over JSON-sorted keys — the raw args are never stored in gate_reward_history, only in the original approvals_queue row. Semantically-equal calls hash the same, so one cohort per operation shape. Inspired by Self-Evolve’s Q-value update loop, simplified: operator decision is the signal (no LLM judge needed).

Gotchas

  • Only OWNER and ADMIN can decide. The Decide handler inline-checks RoleFromContext and returns 403 for anyone else. This used to be documented as “middleware-enforced” but there was no middleware — the check is now explicit in the handler.
  • Soft-delete = denial. If the row vanishes between enqueue and poll (e.g. DB cleanup), Gate fails closed with Denied=true.
  • Sync mode holds an HTTP goroutine. A long-running sync approval pins one connection. Don’t route high-volume traffic through sync mode; use async + a hook for routing.
  • TimeoutSecs is per-call. If a caller passes 30 and the sweeper interval is 30, you can get one extra poll where the row is still pending but timeout_at has passed — both paths converge, but test expectations should allow 1-2s of slop.