Durable pause record and storage
apps/backend/internal/office/models/workspace_pause.go ↗The pause record is the single source of truth. A workspace is paused when one unreleased row exists.
Model
type WorkspacePause struct {
ID string `json:"id" db:"id"`
WorkspaceID string `json:"workspace_id" db:"workspace_id"`
Reason string `json:"reason" db:"reason"`
CreatedBy string `json:"created_by" db:"created_by"`
CreatedByKind string `json:"created_by_kind" db:"created_by_kind"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
ReleasedAt *time.Time `json:"released_at,omitempty" db:"released_at"`
ReleasedBy string `json:"released_by,omitempty" db:"released_by"`
ReleasedReason string `json:"released_reason,omitempty" db:"released_reason"`
}
Table and index
CREATE TABLE IF NOT EXISTS office_workspace_pauses (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
reason TEXT NOT NULL,
created_by TEXT NOT NULL,
created_by_kind TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
released_at TIMESTAMP,
released_by TEXT NOT NULL DEFAULT '',
released_reason TEXT NOT NULL DEFAULT ''
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_office_workspace_pause_active
ON office_workspace_pauses(workspace_id) WHERE released_at IS NULL;
Read gate
func (r *Repository) GetActiveWorkspacePause(ctx context.Context, workspaceID string) (*models.WorkspacePause, error) {
var pause models.WorkspacePause
err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(`
SELECT * FROM office_workspace_pauses
WHERE workspace_id = ? AND released_at IS NULL
`), workspaceID).StructScan(&pause)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &pause, nil
}
Pause service with insert-retry and sweep
apps/backend/internal/office/pause/service.go ↗The service owns the pause lifecycle, handles concurrent pause and resume races, and triggers the halt sweep after the row is durable.
Gate predicate
func (s *Service) PauseState(ctx context.Context, workspaceID string) (*models.WorkspacePause, error) {
return s.repo.GetActiveWorkspacePause(ctx, workspaceID)
}
Pause with detached sweep
func (s *Service) Pause(ctx context.Context, workspaceID, reason, actorID, actorKind string) (*PauseResult, error) {
if err := s.checkWorkspaceExists(ctx, workspaceID); err != nil {
return nil, err
}
trimmedReason, err := normalizeReason(reason, true)
if err != nil {
return nil, err
}
active, err := s.attemptPause(ctx, workspaceID, trimmedReason, actorID, actorKind)
if err != nil {
return nil, err
}
sweepCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), haltSweepTimeout)
defer cancel()
sweep := s.runHaltSweep(sweepCtx, workspaceID)
return &PauseResult{Pause: active, Sweep: sweep}, nil
}
Insert-retry-once
func (s *Service) attemptPause(ctx context.Context, workspaceID, reason, actorID, actorKind string) (*models.WorkspacePause, error) {
for attempt := 0; attempt < 2; attempt++ {
active, done, err := s.tryPauseOnce(ctx, workspaceID, reason, actorID, actorKind)
if err != nil {
return nil, err
}
if done {
return active, nil
}
}
s.logNoop(ctx, workspaceID, actorID, actorKind, "pause", reason, noopCauseLostRace)
return nil, ErrPauseContended
}
Halt sweep that cancels runs and executions
apps/backend/internal/office/pause/sweep.go ↗The sweep cancels queued and claimed runs, releases task checkouts, and cancels live task executions. Each step is best-effort and counts failures.
Sweep
func (s *Service) runHaltSweep(ctx context.Context, workspaceID string) SweepResult {
var result SweepResult
runs, err := s.repo.ListInflightRunsForWorkspace(ctx, workspaceID)
if err != nil {
s.logger.Warn("halt sweep: list inflight runs failed", zap.String("workspace_id", workspaceID), zap.Error(err))
result.Failures++
runs = nil
}
runIDs := make([]string, 0, len(runs))
taskIDSet := map[string]struct{}{}
for _, run := range runs {
runIDs = append(runIDs, run.RunID)
if run.TaskID != "" {
taskIDSet[run.TaskID] = struct{}{}
}
}
if len(runIDs) > 0 {
cancelled, err := s.repo.CancelRunsForWorkspace(ctx, runIDs, haltSweepCancelReason)
result.RunsCancelled = cancelled
if err != nil {
result.Failures++
}
if err := s.repo.ReleaseCheckoutsForWorkspace(ctx, runIDs); err != nil {
result.Failures++
}
}
liveRoutineTaskIDs, _ := s.repo.ListLiveRoutineTaskIDsForWorkspace(ctx, workspaceID)
for _, taskID := range liveRoutineTaskIDs {
taskIDSet[taskID] = struct{}{}
}
liveOfficeTaskIDs, _ := s.repo.ListLiveOfficeTaskIDsForWorkspace(ctx, workspaceID)
for _, taskID := range liveOfficeTaskIDs {
taskIDSet[taskID] = struct{}{}
}
s.cancelTaskExecutions(ctx, taskIDSet, &result)
return result
}
Result shape
type SweepResult struct {
RunsCancelled int64 `json:"runs_cancelled"`
ExecutionsCancelled int `json:"executions_cancelled"`
ExecutionsNotRunning int `json:"executions_not_running"`
Failures int `json:"failures"`
}
Shared PauseGate and launch-site wiring
apps/backend/internal/office/shared/interfaces.go ↗One narrow interface lets every launch site check pause without importing the pause package. Wiring in main.go connects the same service to all gates.
Interface and sentinels
var ErrWorkspacePaused = errors.New("office: workspace paused")
var ErrPauseGateUnavailable = errors.New("office: workspace pause state unavailable")
type PauseGate interface {
PauseState(ctx context.Context, workspaceID string) (*models.WorkspacePause, error)
}
QueueRun gate
func (s *Service) QueueRun(ctx context.Context, agentInstanceID, reason, payload, idempotencyKey string) (runsservice.QueueOutcome, error) {
agent, err := s.guardAgentStatus(ctx, agentInstanceID)
if err != nil {
return runsservice.QueueOutcomeNone, err
}
if err := s.checkPauseGateForAgent(ctx, agent, "queue_run"); err != nil {
return runsservice.QueueOutcomeNone, err
}
if s.runsService != nil {
return s.runsService.QueueRun(ctx, runsservice.QueueRunRequest{
Reason: reason, IdempotencyKey: idempotencyKey, Payload: payloadWithAgent(payload, agentInstanceID),
})
}
return s.queueRunInline(ctx, agentInstanceID, reason, payload, idempotencyKey)
}
Routine dispatch gate
func (s *RoutineService) checkPauseGate(ctx context.Context, workspaceID, routineID, triggerID, source string) error {
if s.pauseGate == nil || workspaceID == "" {
return nil
}
active, err := s.pauseGate.PauseState(ctx, workspaceID)
if err != nil {
pause.RecordGateError("routine_dispatch")
return shared.ErrPauseGateUnavailable
}
if active == nil {
return nil
}
pause.RecordBlocked("routine_dispatch")
s.repo.CreatePauseSkippedRoutineRun(ctx, routineID, triggerID, source, active.ID)
return &pausedDispatchError{workspaceID: active.WorkspaceID, reason: active.Reason}
}
Wiring in main.go
pauseSvc := officepause.NewService(repo, services.Office, services.Task, log)
routineSvc.SetPauseGate(pauseSvc)
routineWakeupDispatcher.SetPauseGate(pauseSvc)
schedulerSvc.SetPauseGate(pauseSvc)
if services.Office != nil {
services.Office.SetPauseGate(pauseSvc)
}
HTTP surface and authorization
apps/backend/internal/office/pause/handler.go ↗Three endpoints expose pause state. Mutations require workspace.manage, reject agent callers, and bound the request body.
Routes
func RegisterRoutes(api *gin.RouterGroup, h *Handler) {
api.GET("/workspaces/:wsId/pause", h.getPause)
api.POST("/workspaces/:wsId/pause", h.postPause)
api.POST("/workspaces/:wsId/resume", h.postResume)
}
Pause handler
func (h *Handler) postPause(c *gin.Context) {
if officeagents.CallerFromContext(c) != nil {
c.JSON(http.StatusForbidden, gin.H{fieldError: "agent callers may not pause a workspace"})
return
}
workspaceID := c.Param("wsId")
var body pauseRequestBody
if !h.bindPauseRequestBody(c, &body, false) {
return
}
result, err := h.svc.Pause(c.Request.Context(), workspaceID, body.Reason, actorID(c), actorKind(c))
if err != nil {
h.writeMutationError(c, workspaceID, err)
return
}
c.JSON(http.StatusOK, gin.H{
fieldWorkspaceID: workspaceID,
fieldPaused: true,
fieldPause: pausePayload(result.Pause),
"sweep": result.Sweep,
})
}
Scope guard
func requiresOfficeWorkspaceManage(c *gin.Context) bool {
if c.Request.Method != http.MethodPost || c.Param("wsId") == "" {
return false
}
switch c.FullPath() {
case officeRoutePrefix + "/workspaces/:wsId/pause", officeRoutePrefix + "/workspaces/:wsId/resume":
return true
default:
return false
}
}
Frontend state, banner, and controls
apps/web/hooks/domains/office/use-workspace-pause.ts ↗The hook and store guard against stale responses by workspace and request tag, and the banner keeps pause visible even when the read fails.
Hook read and mutate
const read = useCallback(async () => {
if (!workspaceId) return;
const tag = beginPauseRequest();
try {
const res = await getWorkspacePause(workspaceId);
const activeWorkspaceId = storeApi.getState().workspaces.activeId;
applyPauseResponse(tag, res.workspaceId, activeWorkspaceId, {
kind: "read-success", paused: res.paused, record: res.record,
});
} catch {
const activeWorkspaceId = storeApi.getState().workspaces.activeId;
applyPauseResponse(tag, workspaceId, activeWorkspaceId, { kind: "read-failure" });
}
}, [workspaceId, beginPauseRequest, applyPauseResponse, storeApi]);
Store guard
applyPauseResponse: (tag, responseWorkspaceId, activeWorkspaceId, outcome): boolean => {
let applied = false;
set((draft) => {
const pause = draft.office.pause;
if (responseWorkspaceId !== activeWorkspaceId || tag <= pause.appliedSeq) return;
applied = true;
pause.appliedSeq = tag;
switch (outcome.kind) {
case "read-success":
case "mutate-success":
pause.status = "known";
pause.record = outcome.paused ? outcome.record : null;
return;
case "read-failure":
pause.status = "unknown";
return;
case "mutate-failure":
return;
}
});
return applied;
}
Banner states
export function WorkspacePauseBanner() {
const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId);
const { record, status, refresh, pause, retryPause, resume, sweep } = useWorkspacePause(activeWorkspaceId);
if (!activeWorkspaceId) return null;
if (record) return <PausedBanner record={record} stale={status === "unknown"} onRefresh={refresh} onResume={resume} sweep={sweep} onRetryPause={retryPause} />;
if (status === "unknown") return <PauseStateUnavailableBar onRefresh={refresh} onPause={pause} />;
return <RunningControlBar onRefresh={refresh} onPause={pause} />;
}