diff --git a/cmd/mecatui/client/capabilities.go b/cmd/mecatui/client/capabilities.go index f0419ed4c5..e35f29732c 100644 --- a/cmd/mecatui/client/capabilities.go +++ b/cmd/mecatui/client/capabilities.go @@ -54,6 +54,12 @@ type Capabilities struct { // applies its auto/yolo posture process-wide whatever the session mode, so the // ui must not present a session mode as the effective permission mode there. SessionPermissionModes bool + // RefusedModeRecovery reports the refused_mode_recovery feature: a stored + // session parked on an approval in a mode the server no longer admits + // accepts SetMode to an admitted, strictly lower mode, which cancels the + // pending turn without running it. An older server refuses that SetMode + // mid-turn. + RefusedModeRecovery bool // ProjectTrust is "trusted" or "untrusted" for the server's launch // workspace, CHROME ONLY: the ui's header badge. Empty on an older server. ProjectTrust string @@ -124,11 +130,16 @@ func capabilitiesWithSessionMediaFrom(caps Capabilities, sessionCaps *mecatlv1.S // session-scoped permission modes. const SessionPermissionModesFeature = "session_permission_modes" +// RefusedModeRecoveryFeature is the compatibility feature identifier for the +// SetMode recovery of a stored approval parked in a refused mode. +const RefusedModeRecoveryFeature = "refused_mode_recovery" + // compatibilityCapabilitiesFrom maps a compatibility response: the capability // message plus the feature bits the ui consumes. func compatibilityCapabilitiesFrom(resp *mecatlv1.GetCompatibilityInfoResponse) Capabilities { caps := capabilitiesFrom(resp.GetCapabilities()) caps.SessionPermissionModes = slices.Contains(resp.GetFeatures(), SessionPermissionModesFeature) + caps.RefusedModeRecovery = slices.Contains(resp.GetFeatures(), RefusedModeRecoveryFeature) return caps } diff --git a/cmd/mecatui/client/mcp_authorization.go b/cmd/mecatui/client/mcp_authorization.go index fddeb35094..0d31bdeed8 100644 --- a/cmd/mecatui/client/mcp_authorization.go +++ b/cmd/mecatui/client/mcp_authorization.go @@ -21,12 +21,20 @@ const MCPAuthorizationPendingCode = "mcp_authorization_pending" // deliberately ignores status text so server wording cannot change the UI flow. func IsMCPAuthorizationPending(err error) bool { st, ok := status.FromError(err) - if !ok || st.Code() != codes.FailedPrecondition { + return ok && st.Code() == codes.FailedPrecondition && hasErrorReason(err, MCPAuthorizationPendingCode) +} + +// hasErrorReason reports whether err carries the server's stable application +// code as a mecatl ErrorInfo reason. It ignores status text so server wording +// cannot change the UI flow. +func hasErrorReason(err error, code string) bool { + st, ok := status.FromError(err) + if !ok { return false } for _, detail := range st.Details() { info, ok := detail.(*errdetails.ErrorInfo) - if ok && info.GetDomain() == "mecatl.stacklok.com" && info.GetReason() == MCPAuthorizationPendingCode { + if ok && info.GetDomain() == "mecatl.stacklok.com" && info.GetReason() == code { return true } } diff --git a/cmd/mecatui/client/models_test.go b/cmd/mecatui/client/models_test.go index 6518b1008e..2a31298ddb 100644 --- a/cmd/mecatui/client/models_test.go +++ b/cmd/mecatui/client/models_test.go @@ -282,6 +282,31 @@ func TestCreateSessionReadsSessionPermissionModesFeature(t *testing.T) { } } +// TestCreateSessionReadsRefusedModeRecoveryFeature pins that only the +// refused_mode_recovery feature lets the ui offer a mode change from a +// recovered pending approval. +func TestCreateSessionReadsRefusedModeRecoveryFeature(t *testing.T) { + for _, tc := range []struct { + name string + features []string + want bool + }{ + {name: "server without the feature", features: []string{"server_info", SessionPermissionModesFeature}, want: false}, + {name: "server advertising the feature", features: []string{"server_info", RefusedModeRecoveryFeature}, want: true}, + } { + t.Run(tc.name, func(t *testing.T) { + fake := &fakeModelsClient{caps: &mecatlv1.ServerCapabilities{Posture: "trusted"}, features: tc.features} + _, caps, _, err := newFakeClient(fake).CreateSession(context.Background(), mecatlv1.PermissionMode_PERMISSION_MODE_DEFAULT, ModelSelection{}) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + if caps.RefusedModeRecovery != tc.want { + t.Fatalf("capabilities refusedModeRecovery=%v, want %v", caps.RefusedModeRecovery, tc.want) + } + }) + } +} + // TestReasoningEffortIsZero asserts an effort-only selection is NOT zero (the client // must still send it) while a fully-empty selection is. func TestReasoningEffortIsZero(t *testing.T) { diff --git a/cmd/mecatui/client/pendingapproval.go b/cmd/mecatui/client/pendingapproval.go index 14faa6a139..88c9c7417c 100644 --- a/cmd/mecatui/client/pendingapproval.go +++ b/cmd/mecatui/client/pendingapproval.go @@ -39,17 +39,43 @@ const ( PendingApprovalWatchUnsupported PendingApprovalFailure = "watch_unsupported" PendingApprovalControlsUnsupported PendingApprovalFailure = "controls_unsupported" PendingApprovalNotRecoverable PendingApprovalFailure = "not_recoverable" + PendingApprovalModeNotAdmitted PendingApprovalFailure = "mode_not_admitted" PendingApprovalCorrelation PendingApprovalFailure = "correlation_mismatch" PendingApprovalUnavailable PendingApprovalFailure = "unavailable" ) type pendingApprovalError struct { - kind PendingApprovalFailure - text string + kind PendingApprovalFailure + text string + detail string } func (e *pendingApprovalError) Error() string { return e.text } +// StoredModeNotAdmittedCode is the stable server application code for a +// session whose stored permission mode the server no longer admits. +const StoredModeNotAdmittedCode = "session_mode_not_admitted" + +// PendingApprovalFailureDetail returns the server's reason for a +// PendingApprovalModeNotAdmitted control refusal, or "" for every other error. +// Only that category keeps it: the server is explaining why the exact ask +// cannot resume and how SetMode recovers it, which the user needs in order to +// recover. +func PendingApprovalFailureDetail(err error) string { + var target *pendingApprovalError + if errors.As(err, &target) && target.kind == PendingApprovalModeNotAdmitted { + return target.detail + } + return "" +} + +// PendingApprovalModeNotAdmittedError is the PendingApprovalModeNotAdmitted +// refusal carrying the server's reason, which PendingApprovalFailureDetail +// returns. +func PendingApprovalModeNotAdmittedError(detail string) error { + return &pendingApprovalError{kind: PendingApprovalModeNotAdmitted, text: pendingApprovalFailure(PendingApprovalModeNotAdmitted).Error(), detail: detail} +} + // IsPendingApprovalFailure reports a closed recovery refusal without exposing a // server error or tool arguments to presentation code. func IsPendingApprovalFailure(err error, kind PendingApprovalFailure) bool { @@ -74,6 +100,8 @@ func pendingApprovalFailure(kind PendingApprovalFailure) error { text = "pending approval recovery controls are not supported" case PendingApprovalNotRecoverable: text = "pending approval is no longer recoverable because its current state changed" + case PendingApprovalModeNotAdmitted: + text = "pending approval cannot resume in the session's stored permission mode" case PendingApprovalCorrelation: text = "pending approval recovery correlation changed" } @@ -85,6 +113,9 @@ func pendingApprovalControlFailure(err error) error { case codes.Aborted: return pendingApprovalFailure(PendingApprovalCorrelation) case codes.FailedPrecondition: + if hasErrorReason(err, StoredModeNotAdmittedCode) { + return PendingApprovalModeNotAdmittedError(status.Convert(err).Message()) + } return pendingApprovalFailure(PendingApprovalNotRecoverable) case codes.Unimplemented: return pendingApprovalFailure(PendingApprovalControlsUnsupported) diff --git a/cmd/mecatui/client/pendingapproval_test.go b/cmd/mecatui/client/pendingapproval_test.go index b36e92f34e..7abf05a0bf 100644 --- a/cmd/mecatui/client/pendingapproval_test.go +++ b/cmd/mecatui/client/pendingapproval_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" @@ -399,21 +400,31 @@ func TestResolvePendingApprovalChecksAckAndPayload(t *testing.T) { func TestPendingApprovalControlsClassifyClosedRPCFailures(t *testing.T) { tests := []struct { - name string - code codes.Code - kind PendingApprovalFailure + name string + code codes.Code + reason string + kind PendingApprovalFailure }{ - {"stale run", codes.Aborted, PendingApprovalCorrelation}, - {"state changed", codes.FailedPrecondition, PendingApprovalNotRecoverable}, - {"unsupported", codes.Unimplemented, PendingApprovalControlsUnsupported}, - {"transport", codes.Unavailable, PendingApprovalUnavailable}, - {"deadline", codes.DeadlineExceeded, PendingApprovalUnavailable}, - {"masked absence", codes.NotFound, PendingApprovalUnavailable}, - {"masked denial", codes.PermissionDenied, PendingApprovalUnavailable}, + {"stale run", codes.Aborted, "", PendingApprovalCorrelation}, + {"state changed", codes.FailedPrecondition, "", PendingApprovalNotRecoverable}, + {"ask already resolved", codes.FailedPrecondition, "ask_not_pending", PendingApprovalNotRecoverable}, + {"stored mode not admitted", codes.FailedPrecondition, StoredModeNotAdmittedCode, PendingApprovalModeNotAdmitted}, + {"unsupported", codes.Unimplemented, "", PendingApprovalControlsUnsupported}, + {"transport", codes.Unavailable, "", PendingApprovalUnavailable}, + {"deadline", codes.DeadlineExceeded, "", PendingApprovalUnavailable}, + {"masked absence", codes.NotFound, "", PendingApprovalUnavailable}, + {"masked denial", codes.PermissionDenied, "", PendingApprovalUnavailable}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { serverErr := status.Error(tt.code, "private backend detail") + if tt.reason != "" { + st, err := status.New(tt.code, "private backend detail").WithDetails(&errdetails.ErrorInfo{Reason: tt.reason, Domain: "mecatl.stacklok.com"}) + if err != nil { + t.Fatal(err) + } + serverErr = st.Err() + } service := &pendingApprovalWireServer{resolveErr: serverErr, cancelErr: serverErr} client := newPendingApprovalWireClient(t, service) approval := PendingApproval{SessionID: "session-1", RunID: "run-1", AskID: "ask-1"} @@ -424,6 +435,17 @@ func TestPendingApprovalControlsClassifyClosedRPCFailures(t *testing.T) { if !IsPendingApprovalFailure(err, tt.kind) || err.Error() == "private backend detail" { t.Fatalf("%s error = %v, want kind %q", name, err, tt.kind) } + // Only the stored-mode refusal keeps the server's reason, behind + // an explicit accessor, so the user can see why the ask cannot + // resume and how to recover it. Any other FailedPrecondition stays + // a closed category with no server text. + wantDetail := "" + if tt.kind == PendingApprovalModeNotAdmitted { + wantDetail = "private backend detail" + } + if got := PendingApprovalFailureDetail(err); got != wantDetail { + t.Fatalf("%s detail = %q, want %q", name, got, wantDetail) + } } }) } diff --git a/cmd/mecatui/client/session.go b/cmd/mecatui/client/session.go index 32170c609f..7e14cc7f6b 100644 --- a/cmd/mecatui/client/session.go +++ b/cmd/mecatui/client/session.go @@ -135,15 +135,24 @@ func (c *Client) GetSession(ctx context.Context, id string) (SessionSnapshot, er return snapshotFromWithGlobalCapabilities(resp.GetSession(), global), nil } +// ModeResult is the authoritative session a SetMode returned: its mode and +// lifecycle state. State is "cancelled" when the server's refused-mode +// recovery cancelled a pending approval to apply the mode. +type ModeResult struct { + Mode string + State string +} + // SetMode asks the server to change the session's permission posture and returns -// the updated authoritative mode. Mid-turn changes are rejected by the server; -// callers that want next-prompt semantics should defer and retry once idle. -func (c *Client) SetMode(ctx context.Context, id, mode string) (string, error) { +// the updated authoritative mode and state. Mid-turn changes are rejected by the +// server; callers that want next-prompt semantics should defer and retry once idle. +func (c *Client) SetMode(ctx context.Context, id, mode string) (ModeResult, error) { resp, err := c.svc.SetMode(withSessionAffinity(ctx, id), &mecatlv1.SetModeRequest{SessionId: id, Mode: ModeFromString(mode)}) if err != nil { - return "", fmt.Errorf("set mode: %w", err) + return ModeResult{}, fmt.Errorf("set mode: %w", err) } - return snapshotFrom(resp.GetSession()).Mode, nil + snap := snapshotFrom(resp.GetSession()) + return ModeResult{Mode: snap.Mode, State: snap.State}, nil } // ModeErrorText is the server's reason for a failed SetMode, without the @@ -226,14 +235,16 @@ type SessionGetter interface { // ModeSetter changes a server-side session's permission mode. type ModeSetter interface { - SetMode(ctx context.Context, id, mode string) (string, error) + SetMode(ctx context.Context, id, mode string) (ModeResult, error) } -// ModeChangedMsg carries the async result of SetModeCmd. +// ModeChangedMsg carries the async result of SetModeCmd. State is the +// session's authoritative lifecycle state after a successful change. type ModeChangedMsg struct { SessionID string Requested string Mode string + State string Err error } @@ -241,7 +252,7 @@ type ModeChangedMsg struct { func SetModeCmd(ctx context.Context, s ModeSetter, id, mode string) tea.Cmd { return func() tea.Msg { actual, err := s.SetMode(ctx, id, mode) - return ModeChangedMsg{SessionID: id, Requested: mode, Mode: actual, Err: err} + return ModeChangedMsg{SessionID: id, Requested: mode, Mode: actual.Mode, State: actual.State, Err: err} } } diff --git a/cmd/mecatui/client/session_test.go b/cmd/mecatui/client/session_test.go index cd83e5170e..895fe2f47d 100644 --- a/cmd/mecatui/client/session_test.go +++ b/cmd/mecatui/client/session_test.go @@ -53,6 +53,12 @@ type sessionCapabilitiesServer struct { globalErr error snapshot *mecatlv1.Session sessionMedia *mecatlv1.SessionCapabilities + setModeReq *mecatlv1.SetModeRequest +} + +func (s *sessionCapabilitiesServer) SetMode(_ context.Context, req *mecatlv1.SetModeRequest) (*mecatlv1.SetModeResponse, error) { + s.setModeReq = req + return &mecatlv1.SetModeResponse{Session: s.snapshot}, nil } func (s *sessionCapabilitiesServer) GetCompatibilityInfo(context.Context, *mecatlv1.GetCompatibilityInfoRequest) (*mecatlv1.GetCompatibilityInfoResponse, error) { @@ -117,6 +123,27 @@ func TestGetSessionCapabilitiesFallsBackWhenCompatibilityInfoIsUnavailable(t *te } } +// TestSetModeReturnsServerModeAndState pins the SetMode projection the +// refused-mode recovery relies on: the server's resulting state (cancelled when +// the pending turn was dropped without running) reaches the caller alongside +// the authoritative mode. +func TestSetModeReturnsServerModeAndState(t *testing.T) { + server := &sessionCapabilitiesServer{snapshot: &mecatlv1.Session{ + SessionId: "s", Mode: mecatlv1.PermissionMode_PERMISSION_MODE_ACCEPT_EDITS, State: "cancelled", + }} + cl := newSessionCapabilitiesClient(t, server) + got, err := cl.SetMode(t.Context(), "s", ModeDefaultString) + if err != nil { + t.Fatal(err) + } + if want := (ModeResult{Mode: ModeString(mecatlv1.PermissionMode_PERMISSION_MODE_ACCEPT_EDITS), State: "cancelled"}); got != want { + t.Fatalf("SetMode result = %+v, want %+v", got, want) + } + if req := server.setModeReq; req.GetSessionId() != "s" || req.GetMode() != mecatlv1.PermissionMode_PERMISSION_MODE_DEFAULT { + t.Fatalf("SetMode request = %+v", req) + } +} + // TestSnapshotFromReadsState asserts snapshotFrom projects the proto Session's // State field (issue #245) — the picker row needs it to render an // "open existing session" affordance. Covers the populated and nil cases. diff --git a/cmd/mecatui/command_test.go b/cmd/mecatui/command_test.go index 341b75796d..079552f717 100644 --- a/cmd/mecatui/command_test.go +++ b/cmd/mecatui/command_test.go @@ -118,8 +118,8 @@ func (*sessionsLaunchCreator) GetSession(context.Context, string) (client.Sessio return client.SessionSnapshot{}, nil } -func (*sessionsLaunchCreator) SetMode(context.Context, string, string) (string, error) { - return "", nil +func (*sessionsLaunchCreator) SetMode(context.Context, string, string) (client.ModeResult, error) { + return client.ModeResult{}, nil } func (*sessionsLaunchCreator) ForkSession(context.Context, string, string) (string, error) { diff --git a/cmd/mecatui/main.go b/cmd/mecatui/main.go index 65ceba1040..561b0da69a 100644 --- a/cmd/mecatui/main.go +++ b/cmd/mecatui/main.go @@ -1880,7 +1880,7 @@ func (s *sessionAdapter) GetSession(ctx context.Context, id string) (client.Sess return s.cl.GetSession(ctx, id) } -func (s *sessionAdapter) SetMode(ctx context.Context, id, mode string) (string, error) { +func (s *sessionAdapter) SetMode(ctx context.Context, id, mode string) (client.ModeResult, error) { return s.cl.SetMode(ctx, id, mode) } diff --git a/cmd/mecatui/sessionadapter/sessionadapter.go b/cmd/mecatui/sessionadapter/sessionadapter.go index 34b414e383..78ce59f6eb 100644 --- a/cmd/mecatui/sessionadapter/sessionadapter.go +++ b/cmd/mecatui/sessionadapter/sessionadapter.go @@ -15,7 +15,7 @@ type Client interface { CreateSessionWithCarryover(context.Context, client.ModelSelection, string) (string, client.Capabilities, client.ResolvedModel, error) CloseSession(context.Context, string) error GetSession(context.Context, string) (client.SessionSnapshot, error) - SetMode(context.Context, string, string) (string, error) + SetMode(context.Context, string, string) (client.ModeResult, error) ForkSession(context.Context, string, string, string) (string, error) } @@ -88,7 +88,7 @@ func (s *Adapter) GetSession(ctx context.Context, id string) (client.SessionSnap } // SetMode changes a session's permission mode. -func (s *Adapter) SetMode(ctx context.Context, id, mode string) (string, error) { +func (s *Adapter) SetMode(ctx context.Context, id, mode string) (client.ModeResult, error) { return s.cl.SetMode(ctx, id, mode) } diff --git a/cmd/mecatui/ui/fake_test.go b/cmd/mecatui/ui/fake_test.go index aa86b2d78d..bfd06c24f5 100644 --- a/cmd/mecatui/ui/fake_test.go +++ b/cmd/mecatui/ui/fake_test.go @@ -158,8 +158,10 @@ type fakeConv struct { mode string setModeErr error setModeSeen []string - created chan struct{} - createdOnce sync.Once + // setModeState is the session state SetMode reports; empty means "idle". + setModeState string + created chan struct{} + createdOnce sync.Once // resolvedModel is the EFFECTIVE model the fake's create response echoes back — // the header e2e asserts it lands in m.resolvedSessionModel and renders from turn zero. resolvedModel client.ResolvedModel @@ -339,15 +341,19 @@ func (c *fakeConv) GetSession(_ context.Context, id string) (client.SessionSnaps return client.SessionSnapshot{Mode: mode, ResolvedModel: resolved, Title: c.getSessionTitle, Capabilities: c.getSessionCaps}, nil } -func (c *fakeConv) SetMode(_ context.Context, _ string, mode string) (string, error) { +func (c *fakeConv) SetMode(_ context.Context, _ string, mode string) (client.ModeResult, error) { c.mu.Lock() defer c.mu.Unlock() c.setModeSeen = append(c.setModeSeen, mode) if c.setModeErr != nil { - return "", c.setModeErr + return client.ModeResult{}, c.setModeErr } c.mode = mode - return c.mode, nil + state := c.setModeState + if state == "" { + state = "idle" + } + return client.ModeResult{Mode: c.mode, State: state}, nil } func (c *fakeConv) setModes() []string { diff --git a/cmd/mecatui/ui/help.go b/cmd/mecatui/ui/help.go index 30ce1317cb..b0d7bbeab7 100644 --- a/cmd/mecatui/ui/help.go +++ b/cmd/mecatui/ui/help.go @@ -214,6 +214,7 @@ func helpBodyFor(th theme.Theme, caps client.Capabilities, hk helpKeys, embedded {key: "←/→/tab", action: "choose an action; enter confirms it"}, {key: hk.toolcallsFull, action: "open full approval details when available"}, {key: hk.rawArgs, action: "show raw arguments in the full view"}, + {key: hk.modeSwitch, action: "resumed request: switch to a lower mode, cancelling the request without running it", available: caps.RefusedModeRecovery, gated: true}, }) b.WriteString("\n" + heading.Render("Inspect and manage") + "\n") diff --git a/cmd/mecatui/ui/help_test.go b/cmd/mecatui/ui/help_test.go index 1b9389c7db..604a7a458d 100644 --- a/cmd/mecatui/ui/help_test.go +++ b/cmd/mecatui/ui/help_test.go @@ -22,7 +22,7 @@ func embeddedCaps() client.Capabilities { func allOnCaps() client.Capabilities { return client.Capabilities{ MCP: true, SlashCommands: true, Memory: true, Skills: true, Teams: true, Shell: true, - ModelSelection: true, Scheduling: true, Steer: true, + ModelSelection: true, Scheduling: true, Steer: true, RefusedModeRecovery: true, } } diff --git a/cmd/mecatui/ui/model.go b/cmd/mecatui/ui/model.go index 9eccb8ef6a..299fedc783 100644 --- a/cmd/mecatui/ui/model.go +++ b/cmd/mecatui/ui/model.go @@ -85,9 +85,9 @@ type SessionCreator interface { // the test fakes satisfy both. GetSession(ctx context.Context, id string) (client.SessionSnapshot, error) // SetMode asks the server to change the current session's permission mode and - // returns the server-confirmed mode. Mid-turn attempts may be rejected; the ui - // defers and retries at the next prompt boundary. - SetMode(ctx context.Context, id, mode string) (string, error) + // returns the server-confirmed mode and session state. Mid-turn attempts may be + // rejected; the ui defers and retries at the next prompt boundary. + SetMode(ctx context.Context, id, mode string) (client.ModeResult, error) // ForkSession creates a peer session from srcID's conversation-history snapshot // with an OPTIONAL reasoning-effort override (empty // inherits the source's) and returns the new session id. The /effort fork-resume @@ -703,6 +703,11 @@ type Model struct { pendingApproval *approvalResolvedIntent pendingRecovery *pendingApprovalRecovery pendingRecoveryGeneration uint64 + // recoveryModeSwitch is the session mode a recovered approval's own + // refused-mode recovery requested, non-empty while that SetMode is in + // flight. It outlives pendingRecovery: the watch may deliver the cancelled + // run's terminal before the SetMode response. + recoveryModeSwitch string // modelSwitchRequestToken correlates the asynchronous create-and-hydrate handoff. // A stale result must not replace a session selected by a later lifecycle action. modelSwitchRequestToken uint64 diff --git a/cmd/mecatui/ui/pending_approval.go b/cmd/mecatui/ui/pending_approval.go index 076b520c0d..8847f9257b 100644 --- a/cmd/mecatui/ui/pending_approval.go +++ b/cmd/mecatui/ui/pending_approval.go @@ -31,6 +31,17 @@ type pendingApprovalRecovery struct { resolved bool resolving bool cancelled bool + // verdictRefused records that the server refused a verdict for this ask + // because its stored mode is not admitted, while the refused-mode recovery + // is available. No + // verdict is resent; the modal stays open for ModeSwitch or exit. + verdictRefused bool + // modeTried is the ModeSwitch cycle token of the last recovery mode change, + // so a refused one moves on to the next lower mode. + modeTried string + // notice is the recovery's footer line while the approval modal is open, + // where the ordinary status line is not shown. + notice string } type pendingApprovalWatchOpenedMsg struct { @@ -98,7 +109,7 @@ func pendingApprovalRecvCmd(generation uint64, watch PendingApprovalWatch) tea.C } func (m *Model) resolvePendingApprovalCmd(verdict client.Verdict) tea.Cmd { - if m.pendingRecovery == nil || m.pendingRecovery.resolving || m.pendingRecovery.resolved { + if m.pendingRecovery == nil || m.pendingRecovery.resolving || m.pendingRecovery.resolved || m.recoveryVerdictsBlocked() { return nil } m.pendingRecovery.resolving = true @@ -205,6 +216,11 @@ func (m Model) updatePendingApprovalWatchOpened(msg pendingApprovalWatchOpenedMs ask := recovery.approval mm, cmd := m.applyPermissionAsk(client.PermissionAskMsg{RunID: ask.RunID, AskID: ask.AskID, Tool: ask.Tool, Args: ask.Args, Reason: ask.Reason, ExpectedRunID: ask.RunID, Recovery: true}) m = mm.(Model) + if m.recoveryModeSwitchAvailable() && m.activeModeAboveServerPosture() { + recovery.notice = "approving will be refused: mode " + m.activeMode + " is above this server's limit · " + + firstKey(m.keys.ModeSwitch, "shift+tab") + " switches to a lower mode and cancels the request" + m.refreshView() + } return m, tea.Batch(cmd, pendingApprovalRecvCmd(recovery.generation, msg.watch)), true } @@ -215,6 +231,9 @@ func (m Model) updatePendingApprovalResolved(msg pendingApprovalResolvedMsg) (te } recovery.resolving = false if msg.err != nil { + if client.IsPendingApprovalFailure(msg.err, client.PendingApprovalModeNotAdmitted) { + return m.refusedPendingApprovalVerdict(client.PendingApprovalFailureDetail(msg.err)), nil, true + } return m.refusePendingApprovalRecovery("the pending approval changed or could not be acknowledged; no verdict will be resent"), nil, true } recovery.resolved = true @@ -248,6 +267,12 @@ func (m Model) updatePendingApprovalWatch(msg pendingApprovalWatchMsg) (tea.Mode if msg.event.Kind == client.PendingApprovalEventResolved && !recovery.resolved && !recovery.resolving { return m.refusePendingApprovalRecovery("the pending approval changed before your choice; no verdict was sent"), nil, true } + if msg.event.Kind == client.PendingApprovalEventRetracted && m.recoveryModeSwitch != "" { + // The user's own refused-mode recovery withdraws the ask before the + // SetMode response may arrive. onRecoveryModeChanged closes the modal; + // a terminal arriving first settles the run through the ordinary path. + return m, pendingApprovalRecvCmd(recovery.generation, recovery.watch), true + } if msg.event.Kind == client.PendingApprovalEventRetracted { text := "the pending approval was withdrawn; no verdict was sent" if recovery.resolving || recovery.resolved { @@ -279,6 +304,26 @@ func (m Model) updatePendingApprovalWatch(msg pendingApprovalWatchMsg) (tea.Mode return m, tea.Batch(eventCmd, pendingApprovalRecvCmd(recovery.generation, recovery.watch)), true } +// refusedPendingApprovalVerdict handles a verdict the server refused because +// the session's stored mode is no longer admitted; the server ran nothing. With the refused-mode recovery available, the modal reopens with +// verdicts blocked so ModeSwitch can cancel the request; otherwise the recovery +// ends with the server's reason. No verdict is ever resent. +func (m Model) refusedPendingApprovalVerdict(detail string) Model { + text := recoveryRefusedVerdictNotice(detail) + if !m.caps.RefusedModeRecovery || m.deps.DebugTarget != "" || m.deps.Session == nil { + return m.refusePendingApprovalRecovery(text + "; no verdict will be resent") + } + if !(&m).restoreRefusedApproval(nil) { + return m.refusePendingApprovalRecovery(text + "; no verdict will be resent") + } + m.pendingRecovery.verdictRefused = true + modeKey := firstKey(m.keys.ModeSwitch, "shift+tab") + m.pendingRecovery.notice = text + " · " + modeKey + " switches to a lower mode and cancels it" + m.statusMsg = m.deps.Theme.Style("errorText").Render(text) + m.refreshView() + return m +} + func (m *Model) applyPendingApprovalEventMessage(message tea.Msg, recovery *pendingApprovalRecovery) tea.Cmd { if message == nil { return nil diff --git a/cmd/mecatui/ui/pending_approval_mode.go b/cmd/mecatui/ui/pending_approval_mode.go new file mode 100644 index 0000000000..67258bf914 --- /dev/null +++ b/cmd/mecatui/ui/pending_approval_mode.go @@ -0,0 +1,189 @@ +package ui + +import ( + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + + "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/internal/terminaltext" + "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback" +) + +// interruptedToolText is the result the server records for a tool call whose +// pending approval a mode change cancelled. It matches the session's own +// cancellation close-out, so the card reads the same before and after the +// durable tool.result arrives. +const interruptedToolText = "tool call interrupted by cancellation" + +// recoveryModeSwitchAvailable reports whether ModeSwitch in a recovered +// pending approval asks the server's refused-mode recovery for a lower mode. +// It needs the server feature, a shown approval backed by a live watch, and no +// verdict already sent. +func (m Model) recoveryModeSwitchAvailable() bool { + r := m.pendingRecovery + return r != nil && !r.resolving && !r.resolved && r.watch != nil && + m.caps.RefusedModeRecovery && m.deps.DebugTarget == "" && m.deps.Session != nil && + m.sessionID != "" && approvalSurfaceFor(&m) != nil +} + +// recoveryVerdictsBlocked reports that the recovered approval must not send a +// verdict: the user's recovery mode change is in flight, or the server already +// refused a verdict for it. +func (m Model) recoveryVerdictsBlocked() bool { + r := m.pendingRecovery + return r != nil && (m.recoveryModeSwitch != "" || r.verdictRefused) +} + +// isApprovalVerdictKey matches every key that resolves the approval surface. +func isApprovalVerdictKey(msg tea.KeyPressMsg, keys keyMap) bool { + return msg.String() == "enter" || key.Matches(msg, keys.Allow) || key.Matches(msg, keys.AllowAlways) || key.Matches(msg, keys.Deny) +} + +// permissionModeRank orders session modes plan → yolo, the server's admission +// order. An unknown mode ranks -1. +func permissionModeRank(mode string) int { + normalized := client.ModeString(client.ModeFromString(mode)) + for i, e := range PermissionModeVocabulary() { + if e.SessionMode == normalized { + return i + } + } + return -1 +} + +// recoveryModeTarget picks the next mode in the ModeSwitch cycle, after the +// one last tried, that is strictly lower than the session's stored mode and +// within the server's posture. The server still decides admission. +func (m Model) recoveryModeTarget() (PermissionModeEntry, bool) { + order := modeCycleOrder(m.legacyPermissionModes()) + active := permissionModeRank(m.activeMode) + cur := m.pendingRecovery.modeTried + if cur == "" { + cur = m.acceptedModeToken() + } + start := -1 + for i, name := range order { + if name == cur { + start = i + break + } + } + for step := 1; step <= len(order); step++ { + e := permissionModeEntry(order[(start+step+len(order))%len(order)]) + if permissionModeRank(e.SessionMode) < active && postureRank(e.Posture) <= postureRank(m.caps.Posture) { + return e, true + } + } + return PermissionModeEntry{}, false +} + +// activeModeAboveServerPosture reports a stored session mode whose posture the +// server's ceiling no longer covers, so approving its pending ask is refused. +func (m Model) activeModeAboveServerPosture() bool { + if m.activeMode == "" { + return false + } + return postureRank(permissionModeEntry(m.acceptedModeToken()).Posture) > postureRank(m.caps.Posture) +} + +// switchRecoveryMode sends the refused-mode recovery: SetMode to a lower mode, +// which the server applies by cancelling the pending turn without running it. +// It never sets pendingMode, so a refusal is final rather than deferred. +func (m Model) switchRecoveryMode() (tea.Model, tea.Cmd) { + r := m.pendingRecovery + if m.recoveryModeSwitch != "" { + r.notice = "switching to " + m.recoveryModeSwitch + "…" + m.refreshView() + return m, nil + } + target, ok := m.recoveryModeTarget() + if !ok { + r.notice = "no lower mode is available to cancel this request" + m.statusMsg = m.deps.Theme.Style("warning").Render(r.notice) + m.refreshView() + return m, nil + } + r.modeTried = target.Name + mode := client.ModeString(client.ModeFromString(target.SessionMode)) + m.recoveryModeSwitch = mode + r.notice = "switching to " + target.Name + "; the request will be cancelled without running" + m.statusMsg = m.deps.Theme.Style("muted").Render(r.notice + "…") + m.refreshView() + return m, client.SetModeCmd(m.deps.Ctx, m.deps.Session, m.sessionID, mode) +} + +// onRecoveryModeChanged settles the user's recovery mode change. Success means +// the pending approval no longer exists: the server cancelled it (or it was +// already settled) and applied the mode, so the recovery retires, its unanswered +// tool calls read as interrupted, and the session returns to idle with the draft +// intact. A refusal is final and keeps the approval and its modal open with the +// server's reason. +func (m Model) onRecoveryModeChanged(msg client.ModeChangedMsg, requested string) (tea.Model, tea.Cmd) { + m.recoveryModeSwitch = "" + modeKey := firstKey(m.keys.ModeSwitch, "shift+tab") + if msg.Err != nil { + reason := oneLine(terminaltext.Sanitize(client.ModeErrorText(msg.Err))) + text := "mode " + requested + " refused: " + reason + if r := m.pendingRecovery; r != nil { + r.notice = text + " · " + modeKey + " tries a lower mode" + text += "; the request is still pending" + } + m.statusMsg = m.deps.Theme.Style("warning").Render(text) + m.refreshView() + return m, nil + } + mode := client.ModeString(client.ModeFromString(msg.Mode)) + if msg.Mode == "" { + mode = requested + } + m.activeMode = mode + m.pendingMode = "" + m.modeCursor = modeCursor{} + m.modeRefused = "" + if msg.State != "" { + m.sessionState = msg.State + } + outcome := "the pending request is no longer pending" + if msg.State == "cancelled" { + outcome = "the pending request was cancelled without running" + } + // When the watch already delivered the run's terminal, the recovery is + // retired and the session may have moved on; only the mode is news then. + if m.pendingRecovery != nil { + (&m).retirePendingApprovalRecovery() + m.interruptUnresolvedToolCards() + m = m.endRun("") + } + m.conv.addNotice("mode " + mode + " applied; " + outcome) + m.statusMsg = m.deps.Theme.Style("success").Render("mode " + mode + " applied; " + outcome) + m.refreshView() + return m, m.armLiveFeed() +} + +// interruptUnresolvedToolCards settles every still-pending tool card with the +// cancellation close-out the server records for the cancelled turn. Cards the +// durable tool.result already settled are immutable, so this is idempotent. +func (m *Model) interruptUnresolvedToolCards() { + var pending []string + for i := 0; i < m.conv.scrollback.Len(); i++ { + card, ok := m.conv.scrollback.SnapshotAt(i).Payload.(scrollback.ToolCardSnapshot) + if ok && !card.Resolved { + pending = append(pending, card.Call.ID) + } + } + for _, id := range pending { + m.conv.resolveTool(id, interruptedToolText, true) + } +} + +// recoveryRefusedVerdictNotice names the refused-mode recovery after the +// server refused a verdict for the recovered approval. +func recoveryRefusedVerdictNotice(detail string) string { + text := "approval refused, nothing ran" + if detail = strings.TrimSpace(oneLine(terminaltext.Sanitize(detail))); detail != "" { + text += ": " + detail + } + return text +} diff --git a/cmd/mecatui/ui/pending_approval_mode_test.go b/cmd/mecatui/ui/pending_approval_mode_test.go new file mode 100644 index 0000000000..8efca587fa --- /dev/null +++ b/cmd/mecatui/ui/pending_approval_mode_test.go @@ -0,0 +1,352 @@ +package ui + +import ( + "errors" + "reflect" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/theme" + "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback" +) + +var shiftTab = tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift} + +// refusedModeCaps is a server whose posture no longer covers a stored yolo +// session and that advertises the refused-mode recovery when recovery is set. +func refusedModeCaps(recovery bool) client.Capabilities { + return client.Capabilities{Posture: postureTrusted, SessionPermissionModes: true, RefusedModeRecovery: recovery} +} + +// pendingModeRecoveryModel is pendingRecoveryModel for a stored session in +// mode whose transcript ends on the unanswered gated call w1, with conv as the +// SetMode transport. +func pendingModeRecoveryModel(t *testing.T, controller *pendingApprovalFixtureController, conv *fakeConv, caps client.Capabilities, mode, prompt string) Model { + t.Helper() + approval := client.PendingApproval{SessionID: "session", RunID: "run", AskID: "ask", Tool: "Write", Args: `{"path":"x"}`, Reason: "write?", Cursor: "cursor"} + transcript := client.SessionTranscript{SessionID: "session", Complete: true, Kind: client.SessionKindMain, Messages: []client.ConversationMessage{ + {Role: "user", Text: "go"}, + {Role: "assistant", ToolCalls: []client.ConvToolCall{{ID: "w1", Name: "Write", Args: `{"path":"x"}`}}}, + }} + m := newTestModelFromDeps(Deps{ + Theme: theme.New("aztec", theme.AztecPalette()), Ctx: t.Context(), Session: conv, + Resume: &client.ResumeSelection{ + Row: client.SessionListItem{ID: "session", Kind: client.SessionKindMain}, + Transcript: transcript, + Snapshot: client.SessionSnapshot{State: "awaiting", Mode: mode, Capabilities: caps}, Pending: &approval, + }, + PendingApprovals: controller, InitialPrompt: prompt, NoAltScreen: true, + }) + m = applyAll(m, tea.WindowSizeMsg{Width: 160, Height: 30}) + next, _ := m.Update(m.openPendingApprovalWatchCmd()()) + m = next.(Model) + approvalSurfaceOf(t, m) + return m +} + +// modeChangedFrom executes cmd's leaves and returns the ModeChangedMsg it +// produced, failing unless there is exactly one. +func modeChangedFrom(t *testing.T, cmd tea.Cmd) client.ModeChangedMsg { + t.Helper() + var found []client.ModeChangedMsg + var walk func(tea.Cmd) + walk = func(c tea.Cmd) { + if c == nil { + return + } + switch msg := c().(type) { + case tea.BatchMsg: + for _, child := range msg { + walk(child) + } + case client.ModeChangedMsg: + found = append(found, msg) + } + } + walk(cmd) + if len(found) != 1 { + t.Fatalf("command produced %d ModeChangedMsg, want 1", len(found)) + } + return found[0] +} + +// flatView is the rendered view with ANSI stripped and every whitespace run +// collapsed, so assertions survive the footer wrapping a long notice. +func flatView(m Model) string { + return strings.Join(strings.Fields(stripANSIstr(m.View().Content)), " ") +} + +func toolCard(t *testing.T, m Model, id string) scrollback.ToolCardSnapshot { + t.Helper() + for i := 0; i < m.conv.scrollback.Len(); i++ { + if card, ok := m.conv.scrollback.SnapshotAt(i).Payload.(scrollback.ToolCardSnapshot); ok && card.Call.ID == id { + return card + } + } + t.Fatalf("no tool card %q", id) + return scrollback.ToolCardSnapshot{} +} + +func newRecoveryFixtures(state string) (*pendingApprovalFixtureController, *fakeConv) { + return &pendingApprovalFixtureController{watch: &pendingApprovalFixtureWatch{}}, + &fakeConv{recv: &fakeRecver{}, send: &fakeSender{}, mode: "yolo", setModeState: state} +} + +// TestPendingApprovalRecoveryModeSwitchCancelsWithoutVerdict pins the TUI path +// to the refused-mode recovery: ModeSwitch in the recovered approval sends +// SetMode to the next lower mode and, on the server's cancelled answer, closes +// the modal, marks the gated call interrupted, and returns to idle with the +// draft kept and no verdict or cancel sent. +func TestPendingApprovalRecoveryModeSwitchCancelsWithoutVerdict(t *testing.T) { + controller, conv := newRecoveryFixtures("cancelled") + m := pendingModeRecoveryModel(t, controller, conv, refusedModeCaps(true), "yolo", "keep this draft") + if !strings.Contains(flatView(m), "approving will be refused") { + t.Fatalf("recovery entry footer lacks the refused-mode hint:\n%s", flatView(m)) + } + + next, cmd := m.Update(shiftTab) + m = next.(Model) + changed := modeChangedFrom(t, cmd) + if got := conv.setModes(); !reflect.DeepEqual(got, []string{"default"}) { + t.Fatalf("SetMode calls = %v, want [default]", got) + } + if m.pendingMode != "" { + t.Fatalf("recovery mode change armed the deferred pendingMode %q", m.pendingMode) + } + next, cmd = m.Update(changed) + m = next.(Model) + runBatchLeaves(cmd) + + if m.modal != nil || m.phase != phaseIdle || m.pendingRecovery != nil || !controller.watch.isClosed() { + t.Fatalf("after recovery: modal=%T phase=%v recovery=%v watch closed=%t", m.modal, m.phase, m.pendingRecovery != nil, controller.watch.isClosed()) + } + if resolves, cancels, _ := controller.counts(); resolves != 0 || cancels != 0 { + t.Fatalf("recovery sent controls: resolve %d cancel %d", resolves, cancels) + } + if m.prompt.Value() != "keep this draft" || m.activeMode != "default" || m.sessionState != "cancelled" { + t.Fatalf("draft %q mode %q state %q, want draft kept, default, cancelled", m.prompt.Value(), m.activeMode, m.sessionState) + } + if card := toolCard(t, m, "w1"); !card.Resolved || !card.Result.IsError || card.Result.Body != interruptedToolText { + t.Fatalf("gated call card = %+v, want the interrupted close-out", card) + } + if !strings.Contains(stripANSIstr(m.statusMsg), "cancelled without running") { + t.Fatalf("status = %q, want the cancellation outcome", stripANSIstr(m.statusMsg)) + } +} + +// TestPendingApprovalRecoveryModeSwitchNeedsFeatureAndOpenAsk pins that +// ModeSwitch does nothing in the approval without the server feature or once +// a verdict is in flight. +func TestPendingApprovalRecoveryModeSwitchNeedsFeatureAndOpenAsk(t *testing.T) { + t.Run("no feature", func(t *testing.T) { + controller, conv := newRecoveryFixtures("cancelled") + m := pendingModeRecoveryModel(t, controller, conv, refusedModeCaps(false), "yolo", "") + t.Cleanup(m.pendingRecovery.cancel) + if strings.Contains(flatView(m), "approving will be refused") { + t.Fatal("hint offered a recovery the server does not advertise") + } + next, cmd := m.Update(shiftTab) + m = next.(Model) + runBatchLeaves(cmd) + if got := conv.setModes(); len(got) != 0 || m.recoveryModeSwitch != "" { + t.Fatalf("SetMode calls = %v, in flight %q; want none", got, m.recoveryModeSwitch) + } + approvalSurfaceOf(t, m) + }) + t.Run("verdict in flight", func(t *testing.T) { + controller, conv := newRecoveryFixtures("cancelled") + m := pendingModeRecoveryModel(t, controller, conv, refusedModeCaps(true), "yolo", "") + t.Cleanup(m.pendingRecovery.cancel) + next, _ := m.Update(tea.KeyPressMsg{Code: 'a', Text: "a"}) + m = next.(Model) + // After a verdict the modal is gone and ModeSwitch is the ordinary + // running-phase cycle, never the recovery. + next, _ = m.Update(shiftTab) + m = next.(Model) + if m.recoveryModeSwitch != "" { + t.Fatalf("recovery mode change %q started after a verdict", m.recoveryModeSwitch) + } + }) +} + +// TestPendingApprovalRecoveryModeRefusalKeepsApproval pins that a refused +// recovery mode change is final: the modal and recovery stay, the server's +// reason shows, nothing is deferred, and the next press tries a lower mode. +func TestPendingApprovalRecoveryModeRefusalKeepsApproval(t *testing.T) { + controller, conv := newRecoveryFixtures("cancelled") + conv.setModeErr = status.Error(codes.InvalidArgument, `mode "default" exceeds the server ceiling "plan"`) + m := pendingModeRecoveryModel(t, controller, conv, refusedModeCaps(true), "yolo", "draft") + t.Cleanup(m.pendingRecovery.cancel) + + next, cmd := m.Update(shiftTab) + m = next.(Model) + next, _ = m.Update(modeChangedFrom(t, cmd)) + m = next.(Model) + approvalSurfaceOf(t, m) + if m.phase != phaseAwaitingApproval || m.pendingRecovery == nil || m.pendingMode != "" || m.recoveryModeSwitch != "" || m.activeMode != "yolo" { + t.Fatalf("refusal changed state: phase %v recovery %t pendingMode %q inflight %q mode %q", m.phase, m.pendingRecovery != nil, m.pendingMode, m.recoveryModeSwitch, m.activeMode) + } + if footer := flatView(m); !strings.Contains(footer, `exceeds the server ceiling "plan"`) { + t.Fatalf("footer lacks the server's reason:\n%s", footer) + } + + next, cmd = m.Update(shiftTab) + m = next.(Model) + modeChangedFrom(t, cmd) + if got := conv.setModes(); !reflect.DeepEqual(got, []string{"default", "plan"}) { + t.Fatalf("SetMode calls = %v, want [default plan]", got) + } + if resolves, cancels, _ := controller.counts(); resolves != 0 || cancels != 0 { + t.Fatalf("refusal sent controls: resolve %d cancel %d", resolves, cancels) + } +} + +// TestPendingApprovalRecoveryModeSwitchBlocksVerdicts pins that a verdict +// cannot race the in-flight recovery mode change. +func TestPendingApprovalRecoveryModeSwitchBlocksVerdicts(t *testing.T) { + controller, conv := newRecoveryFixtures("cancelled") + m := pendingModeRecoveryModel(t, controller, conv, refusedModeCaps(true), "yolo", "") + t.Cleanup(m.pendingRecovery.cancel) + next, _ := m.Update(shiftTab) + m = next.(Model) + for _, k := range []tea.KeyPressMsg{{Code: 'a', Text: "a"}, {Code: 'd', Text: "d"}, {Code: tea.KeyEnter}} { + next, cmd := m.Update(k) + m = applyPendingCommand(t, next.(Model), cmd) + } + if resolves, _, _ := controller.counts(); resolves != 0 { + t.Fatalf("verdict sent during the recovery mode change: %d", resolves) + } + approvalSurfaceOf(t, m) +} + +// TestPendingApprovalRecoveryModeSwitchToleratesEitherOrder pins that the +// recovery's own close-out on the watch (retract, tool result, cancelled +// terminal) is the expected outcome whether it lands before or after the +// SetMode response, never a fatal recovery failure. +func TestPendingApprovalRecoveryModeSwitchToleratesEitherOrder(t *testing.T) { + closeOut := []client.PendingApprovalEvent{ + {Kind: client.PendingApprovalEventRetracted, Message: client.PermissionRetractMsg{AskID: "ask"}}, + {Kind: client.PendingApprovalEventOther, Message: client.ToolResultMsg{CallID: "w1", Content: interruptedToolText, IsError: true}}, + {Kind: client.PendingApprovalEventTerminal, Message: client.ResultMsg{Stop: "cancelled"}}, + } + for _, order := range []string{"watch first", "response first"} { + t.Run(order, func(t *testing.T) { + controller, conv := newRecoveryFixtures("cancelled") + m := pendingModeRecoveryModel(t, controller, conv, refusedModeCaps(true), "yolo", "draft") + generation := m.pendingRecovery.generation + t.Cleanup(controller.watch.Close) + next, cmd := m.Update(shiftTab) + m = next.(Model) + changed := modeChangedFrom(t, cmd) + deliverWatch := func() { + for _, ev := range closeOut { + next, _ := m.Update(pendingApprovalWatchMsg{generation: generation, event: ev}) + m = next.(Model) + if m.phase == phaseFatal { + t.Fatalf("%s: event %s made recovery fatal: %s", order, ev.Kind, stripANSIstr(m.statusMsg)) + } + } + } + if order == "watch first" { + deliverWatch() + } + next, _ = m.Update(changed) + m = next.(Model) + if order == "response first" { + deliverWatch() + } + if m.phase != phaseIdle || m.modal != nil || m.pendingRecovery != nil || m.recoveryModeSwitch != "" || m.activeMode != "default" { + t.Fatalf("%s: phase %v modal %T recovery %t inflight %q mode %q", order, m.phase, m.modal, m.pendingRecovery != nil, m.recoveryModeSwitch, m.activeMode) + } + if card := toolCard(t, m, "w1"); !card.Resolved || card.Result.Body != interruptedToolText { + t.Fatalf("%s: gated call card = %+v", order, card) + } + if m.prompt.Value() != "draft" { + t.Fatalf("%s: draft = %q", order, m.prompt.Value()) + } + }) + } +} + +// TestPendingApprovalRecoveryRetractWithoutModeSwitchStaysFatal pins that the +// tolerance is scoped to the user's own recovery: a retract the user did not +// cause still ends recovery. +func TestPendingApprovalRecoveryRetractWithoutModeSwitchStaysFatal(t *testing.T) { + controller, conv := newRecoveryFixtures("cancelled") + m := pendingModeRecoveryModel(t, controller, conv, refusedModeCaps(true), "yolo", "") + next, _ := m.Update(pendingApprovalWatchMsg{generation: m.pendingRecovery.generation, event: client.PendingApprovalEvent{ + Kind: client.PendingApprovalEventRetracted, Message: client.PermissionRetractMsg{AskID: "ask"}, + }}) + if m = next.(Model); m.phase != phaseFatal { + t.Fatalf("unrequested retract phase = %v, want fatal", m.phase) + } +} + +// TestPendingApprovalRecoveryRefusedVerdictNamesRecovery pins the stored-mode +// verdict refusal: with the feature the modal reopens with the server's reason +// and verdicts blocked, so ModeSwitch can recover; without it recovery ends +// naming the server's reason, not the generic text. Any other refusal keeps the +// generic retirement and never offers the mode switch. +func TestPendingApprovalRecoveryRefusedVerdictNamesRecovery(t *testing.T) { + reason := `session "s" is in permission mode "yolo", which this server does not admit` + t.Run("with feature", func(t *testing.T) { + controller, conv := newRecoveryFixtures("cancelled") + controller.resolveErr = client.PendingApprovalModeNotAdmittedError(reason) + m := pendingModeRecoveryModel(t, controller, conv, refusedModeCaps(true), "yolo", "") + t.Cleanup(m.pendingRecovery.cancel) + next, cmd := m.Update(tea.KeyPressMsg{Code: 'a', Text: "a"}) + m = applyPendingCommand(t, next.(Model), cmd) + approvalSurfaceOf(t, m) + if m.phase != phaseAwaitingApproval || m.pendingRecovery == nil || !m.pendingRecovery.verdictRefused { + t.Fatalf("refused verdict: phase %v recovery %t", m.phase, m.pendingRecovery != nil) + } + footer := flatView(m) + if !strings.Contains(footer, "does not admit") || !strings.Contains(footer, "lower mode") { + t.Fatalf("footer does not name the server reason and the recovery:\n%s", footer) + } + next, cmd = m.Update(tea.KeyPressMsg{Code: 'a', Text: "a"}) + m = applyPendingCommand(t, next.(Model), cmd) + if resolves, _, _ := controller.counts(); resolves != 1 { + t.Fatalf("verdict resent after refusal: %d resolves", resolves) + } + next, cmd = m.Update(shiftTab) + m = next.(Model) + modeChangedFrom(t, cmd) + if got := conv.setModes(); !reflect.DeepEqual(got, []string{"default"}) { + t.Fatalf("SetMode calls = %v, want [default]", got) + } + }) + t.Run("without feature", func(t *testing.T) { + controller, conv := newRecoveryFixtures("cancelled") + controller.resolveErr = client.PendingApprovalModeNotAdmittedError(reason) + m := pendingModeRecoveryModel(t, controller, conv, refusedModeCaps(false), "yolo", "") + next, cmd := m.Update(tea.KeyPressMsg{Code: 'a', Text: "a"}) + m = applyPendingCommand(t, next.(Model), cmd) + status := stripANSIstr(m.statusMsg) + if m.phase != phaseFatal || !strings.Contains(status, "does not admit") || strings.Contains(status, "could not be acknowledged") { + t.Fatalf("refused verdict without feature: phase %v status %q", m.phase, status) + } + }) + t.Run("other refusal with feature", func(t *testing.T) { + // An ask another client already resolved is also a FailedPrecondition + // on the wire, but not the stored-mode code: the mode switch cannot + // recover it, so it must not be offered. + controller, conv := newRecoveryFixtures("cancelled") + controller.resolveErr = errors.New("pending approval is no longer recoverable because its current state changed") + m := pendingModeRecoveryModel(t, controller, conv, refusedModeCaps(true), "yolo", "") + next, cmd := m.Update(tea.KeyPressMsg{Code: 'a', Text: "a"}) + m = applyPendingCommand(t, next.(Model), cmd) + status := stripANSIstr(m.statusMsg) + if m.phase != phaseFatal || !strings.Contains(status, "could not be acknowledged") || strings.Contains(flatView(m), "lower mode") { + t.Fatalf("other refusal with feature: phase %v status %q", m.phase, status) + } + if got := conv.setModes(); len(got) != 0 { + t.Fatalf("SetMode calls = %v, want none", got) + } + }) +} diff --git a/cmd/mecatui/ui/permission_mode_vocabulary_test.go b/cmd/mecatui/ui/permission_mode_vocabulary_test.go index 23f4463843..4c279dbabd 100644 --- a/cmd/mecatui/ui/permission_mode_vocabulary_test.go +++ b/cmd/mecatui/ui/permission_mode_vocabulary_test.go @@ -3,6 +3,7 @@ package ui import ( "context" "fmt" + "reflect" "strings" "testing" "time" @@ -201,8 +202,15 @@ func TestUnavailableModeKeepsComposerUsable(t *testing.T) { mm, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) m = mm.(Model) - if m.prompt.Value() != "" || (cmd == nil && len(m.queued) == 0) { - t.Fatalf("enter did not send or queue the draft: prompt=%q queued=%v cmd=%v", m.prompt.Value(), m.queued, cmd != nil) + runBatchLeaves(cmd) + // Idle sends exactly one Prompt frame carrying the draft; running + // stages exactly that draft and sends nothing until the run ends. + wantSent, wantQueued := []string{"keep this draftx pasted"}, []string(nil) + if phase == phaseRunning { + wantSent, wantQueued = nil, []string{"keep this draftx pasted"} + } + if got := promptTexts(conv.send); m.prompt.Value() != "" || !reflect.DeepEqual(got, wantSent) || !reflect.DeepEqual(m.queued, wantQueued) { + t.Fatalf("enter: prompt=%q sent=%q queued=%q, want sent=%q queued=%q", m.prompt.Value(), got, m.queued, wantSent, wantQueued) } m = runModeCycle(t, m, []modeCycleStep{{token: "yolo", stays: "accept-edits"}}) @@ -229,6 +237,10 @@ func TestUnavailableModeDoesNotHoldQueuedPrompt(t *testing.T) { if cmd == nil || m.queuePaused != "" || m.phase != phaseRunning { t.Fatalf("drain: cmd=%v paused=%q phase=%v, want the follow-up sent", cmd != nil, m.queuePaused, m.phase) } + runBatchLeaves(cmd) + if got := promptTexts(conv.send); !reflect.DeepEqual(got, []string{"queued follow-up"}) || len(m.queued) != 0 { + t.Fatalf("drain sent %q (queued %q), want exactly one Prompt frame with the follow-up", got, m.queued) + } } // TestUnavailableModeHintNamesCheckerOnlyForAuto pins that only auto's diff --git a/cmd/mecatui/ui/quit_test.go b/cmd/mecatui/ui/quit_test.go index f563beccd1..f51a1c2b57 100644 --- a/cmd/mecatui/ui/quit_test.go +++ b/cmd/mecatui/ui/quit_test.go @@ -438,7 +438,9 @@ func (errSession) GetSession(_ context.Context, _ string) (client.SessionSnapsho return client.SessionSnapshot{}, nil } -func (errSession) SetMode(_ context.Context, _, mode string) (string, error) { return mode, nil } +func (errSession) SetMode(_ context.Context, _, mode string) (client.ModeResult, error) { + return client.ModeResult{Mode: mode, State: "idle"}, nil +} func (errSession) ForkSession(_ context.Context, _, _ string) (string, error) { return "", context.DeadlineExceeded diff --git a/cmd/mecatui/ui/testdata/help_all_on.golden b/cmd/mecatui/ui/testdata/help_all_on.golden index 610300e438..07b366ba31 100644 --- a/cmd/mecatui/ui/testdata/help_all_on.golden +++ b/cmd/mecatui/ui/testdata/help_all_on.golden @@ -16,7 +16,7 @@ ┃ also clears attachments, large pasted text, and pending media ┃ ┃ requires a terminal with enhanced key-event support ┃ ┃ fixed shortcut; repeats do not count, and other views handle esc first ┃ - ┃ lines 1–14 of 90 · esc or ? close · ↑/↓ scroll · pgup/pgdn page · home/end jump ┃ + ┃ lines 1–14 of 91 · esc or ? close · ↑/↓ scroll · pgup/pgdn page · home/end jump ┃ ┃ ┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ diff --git a/cmd/mecatui/ui/testdata/help_embedded.golden b/cmd/mecatui/ui/testdata/help_embedded.golden index 7f676681ae..610300e438 100644 --- a/cmd/mecatui/ui/testdata/help_embedded.golden +++ b/cmd/mecatui/ui/testdata/help_embedded.golden @@ -16,7 +16,7 @@ ┃ also clears attachments, large pasted text, and pending media ┃ ┃ requires a terminal with enhanced key-event support ┃ ┃ fixed shortcut; repeats do not count, and other views handle esc first ┃ - ┃ lines 1–14 of 89 · esc or ? close · ↑/↓ scroll · pgup/pgdn page · home/end jump ┃ + ┃ lines 1–14 of 90 · esc or ? close · ↑/↓ scroll · pgup/pgdn page · home/end jump ┃ ┃ ┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ diff --git a/cmd/mecatui/ui/update.go b/cmd/mecatui/ui/update.go index 325b20fa72..cb09bb18c0 100644 --- a/cmd/mecatui/ui/update.go +++ b/cmd/mecatui/ui/update.go @@ -1059,6 +1059,10 @@ func (m Model) updateLifecycle(msg tea.Msg) (tea.Model, tea.Cmd, bool) { case clipboardErrMsg: return m.onClipboardErr(msg), nil, true case client.ModeChangedMsg: + if m.recoveryModeSwitch != "" && msg.SessionID == m.sessionID && client.ModeString(client.ModeFromString(msg.Requested)) == m.recoveryModeSwitch { + mm, cmd := m.onRecoveryModeChanged(msg, m.recoveryModeSwitch) + return mm, cmd, true + } return m.onModeChanged(msg), nil, true case client.StreamClosedMsg: // Clear still owns the handoff, but the source stream can settle before the @@ -2354,11 +2358,24 @@ func (m Model) onGlobalLifecycleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bo } func (m Model) onPendingApprovalRecoveryKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bool) { - if m.pendingRecovery == nil || m.pendingRecovery.resolving || m.pendingRecovery.resolved || !key.Matches(msg, m.keys.Close) { + if m.pendingRecovery == nil || m.pendingRecovery.resolving || m.pendingRecovery.resolved { return m, nil, false } - (&m).retirePendingApprovalRecovery() - return m, tea.Quit, true + if key.Matches(msg, m.keys.Close) { + (&m).retirePendingApprovalRecovery() + return m, tea.Quit, true + } + // Ahead of the approval surface, which owns every other key: ModeSwitch asks + // the refused-mode recovery for a lower mode, and verdict keys are swallowed + // while that change is in flight or after the server refused a verdict. + if key.Matches(msg, m.keys.ModeSwitch) && m.recoveryModeSwitchAvailable() { + mm, cmd := m.switchRecoveryMode() + return mm, cmd, true + } + if m.recoveryVerdictsBlocked() && isApprovalVerdictKey(msg, m.keys) { + return m, nil, true + } + return m, nil, false } // selection owner is active. @@ -4309,6 +4326,10 @@ func (m Model) onMouseMsg(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.MouseWheelMsg: return m.onMouseWheel(msg) case tea.MouseClickMsg: + if m.recoveryVerdictsBlocked() { + // A button click would send the verdict the recovery withholds. + return m, nil + } return m.onMousePress(msg.Mouse()) case tea.MouseMotionMsg, tea.MouseReleaseMsg: return m, nil diff --git a/cmd/mecatui/ui/view.go b/cmd/mecatui/ui/view.go index b631478a6e..9fae96ad1c 100644 --- a/cmd/mecatui/ui/view.go +++ b/cmd/mecatui/ui/view.go @@ -444,6 +444,9 @@ func (m Model) footerActivity() string { label = fmt.Sprintf("%s (1 of %d)", label, 1+approval.queued) } left = m.deps.Theme.Style("askTitle").Render(label) + if m.pendingRecovery != nil && m.pendingRecovery.notice != "" { + left += " " + m.deps.Theme.Style("warning").Render(m.pendingRecovery.notice) + } case phaseConnecting: left = m.sp.View() + " connecting…" if m.statusMsg != "" { diff --git a/contracts/gen/go/mecatl/v1/harness.pb.go b/contracts/gen/go/mecatl/v1/harness.pb.go index f1841c7f59..1c06f6812d 100644 --- a/contracts/gen/go/mecatl/v1/harness.pb.go +++ b/contracts/gen/go/mecatl/v1/harness.pb.go @@ -18680,7 +18680,8 @@ type ApprovePlanRequest struct { SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` // target_mode selects the verdict / resulting posture: DEFAULT → allow-once // (flip to default), ACCEPT_EDITS → allow-always (flip to accept-edits), - // PLAN/UNSPECIFIED → deny (iterate, no flip, no continuation run). + // PLAN/UNSPECIFIED/unrecognized → deny (iterate, no flip, no continuation + // run). TargetMode PermissionMode `protobuf:"varint,2,opt,name=target_mode,json=targetMode,proto3,enum=mecatl.v1.PermissionMode" json:"target_mode,omitempty"` // note is an OPTIONAL operator remark appended to the proceed message // injected into the continuation run's first user message (allow paths only). diff --git a/contracts/gen/go/mecatl/v1/harness_grpc.pb.go b/contracts/gen/go/mecatl/v1/harness_grpc.pb.go index bf91c54326..4d9015dc97 100644 --- a/contracts/gen/go/mecatl/v1/harness_grpc.pb.go +++ b/contracts/gen/go/mecatl/v1/harness_grpc.pb.go @@ -172,7 +172,12 @@ type HarnessServiceClient interface { // One exception, advertised by the refused_mode_recovery feature: for a stored // session awaiting an approval in a mode the server no longer admits, a change // to an admitted, strictly lower mode cancels the pending turn without running - // it and applies the mode. + // it and applies the mode. The session's event log then records, for the + // parked run, a permission.retract of the open ask, a cancellation tool result + // for each unanswered call, and a cancelled result. The same feature covers a + // stored session a crash left running in such a mode: when no server still + // drives that run, a change to an admitted mode ends the interrupted turn + // without running it and applies the mode. SetMode(ctx context.Context, in *SetModeRequest, opts ...grpc.CallOption) (*SetModeResponse, error) // CloseSession ends a session and releases its server-side resources (learned // permission rules, bound placement, and any per-session engine). Idempotent: @@ -522,9 +527,9 @@ type HarnessServiceClient interface { // - PERMISSION_MODE_DEFAULT → allow-once (the session flips to DEFAULT). // - PERMISSION_MODE_ACCEPT_EDITS → allow-always (the session flips to // ACCEPT_EDITS). - // - PERMISSION_MODE_PLAN / UNSPECIFIED → deny (the run stays in plan mode; - // no continuation run starts — the model iterates on the plan on the next - // prompt). + // - PERMISSION_MODE_PLAN / UNSPECIFIED / an unrecognized value → deny (the + // run stays in plan mode; no continuation run starts — the model iterates + // on the plan on the next prompt). // // `note` is an OPTIONAL operator remark appended to the proceed message // injected into the continuation run's first user message (allow paths only). @@ -1454,7 +1459,12 @@ type HarnessServiceServer interface { // One exception, advertised by the refused_mode_recovery feature: for a stored // session awaiting an approval in a mode the server no longer admits, a change // to an admitted, strictly lower mode cancels the pending turn without running - // it and applies the mode. + // it and applies the mode. The session's event log then records, for the + // parked run, a permission.retract of the open ask, a cancellation tool result + // for each unanswered call, and a cancelled result. The same feature covers a + // stored session a crash left running in such a mode: when no server still + // drives that run, a change to an admitted mode ends the interrupted turn + // without running it and applies the mode. SetMode(context.Context, *SetModeRequest) (*SetModeResponse, error) // CloseSession ends a session and releases its server-side resources (learned // permission rules, bound placement, and any per-session engine). Idempotent: @@ -1804,9 +1814,9 @@ type HarnessServiceServer interface { // - PERMISSION_MODE_DEFAULT → allow-once (the session flips to DEFAULT). // - PERMISSION_MODE_ACCEPT_EDITS → allow-always (the session flips to // ACCEPT_EDITS). - // - PERMISSION_MODE_PLAN / UNSPECIFIED → deny (the run stays in plan mode; - // no continuation run starts — the model iterates on the plan on the next - // prompt). + // - PERMISSION_MODE_PLAN / UNSPECIFIED / an unrecognized value → deny (the + // run stays in plan mode; no continuation run starts — the model iterates + // on the plan on the next prompt). // // `note` is an OPTIONAL operator remark appended to the proceed message // injected into the continuation run's first user message (allow paths only). diff --git a/contracts/proto/mecatl/v1/harness.proto b/contracts/proto/mecatl/v1/harness.proto index 0a9455e7d1..897804864d 100644 --- a/contracts/proto/mecatl/v1/harness.proto +++ b/contracts/proto/mecatl/v1/harness.proto @@ -79,7 +79,12 @@ service HarnessService { // One exception, advertised by the refused_mode_recovery feature: for a stored // session awaiting an approval in a mode the server no longer admits, a change // to an admitted, strictly lower mode cancels the pending turn without running - // it and applies the mode. + // it and applies the mode. The session's event log then records, for the + // parked run, a permission.retract of the open ask, a cancellation tool result + // for each unanswered call, and a cancelled result. The same feature covers a + // stored session a crash left running in such a mode: when no server still + // drives that run, a change to an admitted mode ends the interrupted turn + // without running it and applies the mode. rpc SetMode(SetModeRequest) returns (SetModeResponse); // CloseSession ends a session and releases its server-side resources (learned @@ -492,9 +497,9 @@ service HarnessService { // - PERMISSION_MODE_DEFAULT → allow-once (the session flips to DEFAULT). // - PERMISSION_MODE_ACCEPT_EDITS → allow-always (the session flips to // ACCEPT_EDITS). - // - PERMISSION_MODE_PLAN / UNSPECIFIED → deny (the run stays in plan mode; - // no continuation run starts — the model iterates on the plan on the next - // prompt). + // - PERMISSION_MODE_PLAN / UNSPECIFIED / an unrecognized value → deny (the + // run stays in plan mode; no continuation run starts — the model iterates + // on the plan on the next prompt). // // `note` is an OPTIONAL operator remark appended to the proceed message // injected into the continuation run's first user message (allow paths only). @@ -3972,7 +3977,8 @@ message ApprovePlanRequest { string session_id = 1 [(buf.validate.field).string.min_len = 1]; // target_mode selects the verdict / resulting posture: DEFAULT → allow-once // (flip to default), ACCEPT_EDITS → allow-always (flip to accept-edits), - // PLAN/UNSPECIFIED → deny (iterate, no flip, no continuation run). + // PLAN/UNSPECIFIED/unrecognized → deny (iterate, no flip, no continuation + // run). PermissionMode target_mode = 2; // note is an OPTIONAL operator remark appended to the proceed message // injected into the continuation run's first user message (allow paths only). diff --git a/docs/architecture/api-surface.md b/docs/architecture/api-surface.md index 356ac66a61..d36a4c2f5f 100644 --- a/docs/architecture/api-surface.md +++ b/docs/architecture/api-surface.md @@ -166,7 +166,12 @@ a later failure publishes no successor but the source stays cancelled. already in flight; clients defer it to the next prompt. The exception is a stored session awaiting an approval in a mode the server no longer admits: a switch to an admitted, lower mode cancels the pending turn without running it, then applies the mode -(`refused_mode_recovery`). `CompactSession` likewise runs +(`refused_mode_recovery`). The event log then closes the parked run as a live cancel +would: a retract of the ask, a cancellation result for each unanswered tool call, and a +cancelled result, so watchers see the turn end. A stored session a crash left running in +such a mode recovers the same way: once the session lease proves no server drives the run, +a switch to an admitted mode abandons the interrupted turn without running it. +`CompactSession` likewise runs only at an idle or terminal boundary. ## Scheduled tasks diff --git a/docs/architecture/deployment-and-hardening.md b/docs/architecture/deployment-and-hardening.md index c12496760e..6b420ff3db 100644 --- a/docs/architecture/deployment-and-hardening.md +++ b/docs/architecture/deployment-and-hardening.md @@ -344,7 +344,8 @@ All three server roots share `app.Build` and `server.Service`; they differ aroun Prometheus and OpenTelemetry, and administrative subcommands. It defaults to interactive approval and the strict posture. Session store and event log are each in memory, local, or a remote driver. A local store directory always gets a file-lock session lease; -otherwise leasing is off unless the operator selects a lease backend. +otherwise leasing is off unless the operator selects a lease backend, and startup warns +when that leaves a store other processes may share without one. `mecated config validate` checks operator settings offline with the runtime parser. See [run mecated](../../user-docs/operating/mecated.md). diff --git a/docs/architecture/governance.md b/docs/architecture/governance.md index bb8586f6b2..ecdb834745 100644 --- a/docs/architecture/governance.md +++ b/docs/architecture/governance.md @@ -81,7 +81,8 @@ re-checks a persisted session's mode at run, approval resume, fork, and clear, s mode admitted under a looser deployment fails with `FailedPrecondition` until the owner switches it; history is untouched. A session parked awaiting an approval in such a mode recovers through `SetMode` to an admitted, lower mode, which cancels the pending turn -without running it (`cancelRefusedAwaiting`). A session parked on an MCP authorization +without running it (`cancelRefusedAwaiting`) and closes the parked run on the event log +with the ask's retract, cancellation tool results, and a cancelled result. A session parked on an MCP authorization settles the parked call without a continuation, so it ends idle and can switch. A project file's `permissionMode:` is ignored with a warning, and `auto` or `yolo` are refused as root without a declared sandbox. @@ -89,7 +90,11 @@ Delegated children receive the parent session's mode through `parentCaps.parentM `effectiveChildMode` (`engine/agent/subagent.go`) runs a default-configured Subagent child, Parallel branch, or Team member in its parent's mode when that mode is `auto` or `yolo`, and keeps an explicitly configured child mode or a member definition's -`permissionMode`. `Service.AdmittedModes` lists the modes `admitMode` accepts, which the +`permissionMode`. A resumed Subagent child runs in the narrower of its stored mode and +`effectiveChildMode` for the current parent (`resumedChildMode`), and the composition +root passes `server.ModeAdmission`, the rule behind `admitMode`, through +`WithChildModeAdmission`, so a resumed child in a mode the server no longer admits is +refused with a tool error before it runs. `Service.AdmittedModes` lists the modes `admitMode` accepts, which the ACP adapter offers as its mode picker. A mode change is a session-aggregate transition that `session.SetMode` refuses while the diff --git a/engine/CHANGELOG.md b/engine/CHANGELOG.md index 3c04699626..ce95032c22 100644 --- a/engine/CHANGELOG.md +++ b/engine/CHANGELOG.md @@ -21,6 +21,12 @@ The covered surface is the eight core packages (`session`, `governance`, `learni branches on the value. Adapters that leave it empty keep the previous text. Added (minor). +- **`Session.InterruptCloseOuts`** — returns the synthetic cancellation tool + results `Interrupt` would append for the trailing assistant turn's unanswered + tool calls, in call order, without mutating the session. A host that cancels a + parked turn out of band records the same close-out on its durable event stream, + so the log stays foldable. Added (minor). + - **Agent-bound lifecycle persistence** — adds `Session.AgentCreateIntent`, an opaque original explicit-ID create fingerprint, and `Session.TightenConfiguration` for atomic, tighten-only configuration of an unpublished successor. Snapshot and @@ -43,6 +49,16 @@ The covered surface is the eight core packages (`session`, `governance`, `learni auto or yolo successor can keep or narrow its source's mode instead of being rejected. Added (minor). +- **Resumed-child mode admission** - adds `agent.WithChildModeAdmission`, a + `SubagentOption` carrying the host's permission-mode admission check. A + `resume` call passes the child's re-admitted mode (the narrower of its stored + mode and what a fresh child of the current parent would get) to the check + before the child is recovered or run; a refusal is a tool error, the child does + not run, and its persisted session is unchanged. Hosts pass the rule they apply + to stored top-level sessions, so a child stored in `auto` cannot resume without + an enforcing checker. Fresh children are not checked. A nil check (the default) + keeps the previous behavior. Added (minor). + - **Schedule-store claim and retry conformance** — adds `scheduleconformance.RunClaimAndRetryConformance` with the complete shared suite plus unresolved prior-claim rejection, late-result isolation, and diff --git a/engine/agent/child_mode_inherit_test.go b/engine/agent/child_mode_inherit_test.go index 1dacdd9c7a..40e4a60c33 100644 --- a/engine/agent/child_mode_inherit_test.go +++ b/engine/agent/child_mode_inherit_test.go @@ -2,6 +2,8 @@ package agent_test import ( "context" + "errors" + "reflect" "slices" "strings" "sync" @@ -95,6 +97,13 @@ func TestSubagentInheritsYoloParentMode(t *testing.T) { // mutatingChildEngine builds a child engine whose only tool mutates and asks // under the default mode (no rules, headless → denied), counting executions. func mutatingChildEngine(t *testing.T, executed *atomic.Int32, turns ...mockllm.Turn) *agent.Engine { + t.Helper() + return mutatingChildEngineOn(t, executed, mockllm.New(turns...)) +} + +// mutatingChildEngineOn is mutatingChildEngine over a caller-held provider, so a +// test can assert whether the child ever called its model. +func mutatingChildEngineOn(t *testing.T, executed *atomic.Int32, llm *mockllm.Provider) *agent.Engine { t.Helper() childTool := &fakeTool{name: "Mutate", readOnly: false, exec: func(_ context.Context, in session.ToolCall, _ tool.Workspace) (session.ToolResult, error) { @@ -102,7 +111,7 @@ func mutatingChildEngine(t *testing.T, executed *atomic.Int32, turns ...mockllm. return session.NewToolResult(in.ID, "ran"), nil }} return agent.NewEngine(agent.Deps{ - LLM: mockllm.New(turns...), + LLM: llm, Catalog: catalogWith(t, childTool), Policy: permpolicy.NewPolicy(nil, nil), Model: "child-model", @@ -248,6 +257,7 @@ func TestResumedChildMode(t *testing.T) { {session.ModeYolo, "", session.ModeAuto, session.ModeAuto}, {session.ModeYolo, "", session.ModeYolo, session.ModeYolo}, {session.ModeAuto, "", session.ModeYolo, session.ModeAuto}, + {session.ModeAuto, "", session.ModeDefault, session.ModeDefault}, {session.ModeDefault, "", session.ModeYolo, session.ModeDefault}, {session.ModePlan, "", session.ModeYolo, session.ModePlan}, {session.ModeYolo, session.ModeAccept, session.ModeYolo, session.ModeAccept}, @@ -378,3 +388,168 @@ func TestSharedReviewerEnforcesForNarrowerChildOfYoloParent(t *testing.T) { }) } } + +// rejectingMutateReviewer prohibits and enforces against the child's Mutate +// action only, so the parent's own Subagent and SubagentStatus calls are never +// reviewed while a background child is running. +type rejectingMutateReviewer struct{ rejectingActionReviewer } + +func (rejectingMutateReviewer) GuardrailReviewPolicy(toolName string, job agent.ReviewJob, _ bool) (bool, bool) { + return job == agent.ReviewJobAction && toolName == "Mutate", true +} + +// refuseAutoAdmission mirrors a host with no enforcing guardrails checker: it +// admits every mode except auto. +func refuseAutoAdmission(mode session.PermissionMode) error { + if mode == session.ModeAuto { + return errors.New(`mode "auto" requires an enforcing guardrails checker`) + } + return nil +} + +// storeAutoChild runs an auto parent whose fresh child (inheriting auto) +// executes one mutation and is persisted as "subagent-s1-p1" in mode auto. +func storeAutoChild(t *testing.T, store *memstore.Store, sess *session.Session, executed *atomic.Int32) { + t.Helper() + first := agent.NewSubagentTool(mutatingChildEngine(t, executed, + mockllm.ToolCallTurn(toolCall("k1", "Mutate", `{}`)), mockllm.TextTurn("FIRST_PASS")), + agent.WithSubagentStore(store)) + runParent(t, sess, session.ModeAuto, mockllm.New( + mockllm.ToolCallTurn(toolCall("p1", "Subagent", `{"prompt":"go"}`)), mockllm.TextTurn("ok")), first) + if executed.Load() != 1 { + t.Fatalf("auto parent: child mutation executed %d times, want 1", executed.Load()) + } + if stored, err := store.Load(context.Background(), "subagent-s1-p1"); err != nil || stored.Mode != session.ModeAuto { + t.Fatalf("persisted child = %v, %v; want mode auto", stored, err) + } +} + +// resumeTurns is the parent script that resumes "subagent-s1-p1", collecting a +// background resume through SubagentStatus when collect is true. +func resumeTurns(background, collect bool) ([]mockllm.Turn, session.ToolCallID) { + if !background { + return []mockllm.Turn{mockllm.ToolCallTurn(toolCall("p2", "Subagent", `{"prompt":"again","resume":"subagent-s1-p1"}`)), mockllm.TextTurn("ok")}, "p2" + } + turns := []mockllm.Turn{mockllm.ToolCallTurn(toolCall("p2", "Subagent", `{"prompt":"again","resume":"subagent-s1-p1","background":true}`))} + if collect { + turns = append(turns, mockllm.ToolCallTurn(toolCall("p3", "SubagentStatus", `{"agent_id":"subagent-s1-p1","wait_ms":30000}`))) + } + return append(turns, mockllm.TextTurn("ok")), map[bool]session.ToolCallID{true: "p3", false: "p2"}[collect] +} + +// TestResumedAutoChildRefusedWhenHostDoesNotAdmitAuto proves the host's mode +// admission reaches a resumed child: after a restart that leaves no enforcing +// checker, a child stored in auto and resumed by a yolo parent keeps auto (the +// narrower mode) and is refused with a tool error, on both resume paths, before +// it calls its model or runs a tool. Its persisted session is unchanged and the +// parent run continues to completion. +func TestResumedAutoChildRefusedWhenHostDoesNotAdmitAuto(t *testing.T) { + for _, background := range []bool{false, true} { + name := "foreground" + if background { + name = "background" + } + t.Run(name, func(t *testing.T) { + store := memstore.New() + var executed atomic.Int32 + sess := newSession(t, session.Limits{}) + storeAutoChild(t, store, sess, &executed) + before, err := store.Load(context.Background(), "subagent-s1-p1") + if err != nil { + t.Fatal(err) + } + + childLLM := mockllm.New(mockllm.ToolCallTurn(toolCall("k2", "Mutate", `{}`)), mockllm.TextTurn("SECOND_PASS")) + second := agent.NewSubagentTool(mutatingChildEngineOn(t, &executed, childLLM), + agent.WithSubagentStore(store), agent.WithChildModeAdmission(refuseAutoAdmission)) + turns, call := resumeTurns(background, false) + evs := runParent(t, sess, session.ModeYolo, mockllm.New(turns...), second, agent.NewSubagentStatusTool()) + + res := resultByCallID(evs)[call] + if res == nil || !res.IsError { + t.Fatalf("resume result = %+v, want a tool error; events %v", res, typesOf(evs)) + } + for _, want := range []string{`permission mode "auto"`, "does not admit", "requires an enforcing guardrails checker", "Start a new Subagent"} { + if !strings.Contains(res.Content, want) { + t.Errorf("resume error %q does not contain %q", res.Content, want) + } + } + if sess.State != session.StateCompleted { + t.Errorf("parent state = %s, want completed after the refused resume", sess.State) + } + if n := childLLM.Calls(); n != 0 { + t.Errorf("refused child called its model %d times, want 0", n) + } + if executed.Load() != 1 { + t.Errorf("refused child executed its mutation (count %d, want 1)", executed.Load()) + } + after, err := store.Load(context.Background(), "subagent-s1-p1") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, after) { + t.Errorf("refused resume changed the persisted child:\nbefore %+v\nafter %+v", before, after) + } + }) + } +} + +// TestResumedAutoChildAdmittedRunsEnforcedOrUnchecked proves the admitted side: +// with an admission that allows auto, the resumed child keeps auto under a yolo +// parent and the shared rejecting reviewer ENFORCES against its action (auto, +// not the parent's yolo, selects enforcement), on both resume paths. A nil +// admission keeps the previous behaviour: the resume is not checked and, with no +// reviewer, the auto child's action runs. +func TestResumedAutoChildAdmittedRunsEnforcedOrUnchecked(t *testing.T) { + for _, tc := range []struct { + name string + admission func(session.PermissionMode) error + reviewer bool + background bool + wantRuns bool + }{ + {"admitted-enforced-foreground", func(session.PermissionMode) error { return nil }, true, false, false}, + {"admitted-enforced-background", func(session.PermissionMode) error { return nil }, true, true, false}, + {"nil-admission", nil, false, false, true}, + } { + t.Run(tc.name, func(t *testing.T) { + store := memstore.New() + var executed atomic.Int32 + sess := newSession(t, session.Limits{}) + storeAutoChild(t, store, sess, &executed) + + opts := []agent.SubagentOption{agent.WithSubagentStore(store)} + if tc.admission != nil { + opts = append(opts, agent.WithChildModeAdmission(tc.admission)) + } + second := agent.NewSubagentTool(mutatingChildEngine(t, &executed, + mockllm.ToolCallTurn(toolCall("k2", "Mutate", `{}`)), mockllm.TextTurn("SECOND_PASS")), opts...) + turns, call := resumeTurns(tc.background, true) + if err := sess.Reopen(); err != nil { + t.Fatal(err) + } + if err := sess.SetMode(session.ModeYolo); err != nil { + t.Fatal(err) + } + deps := agent.Deps{LLM: mockllm.New(turns...), Catalog: catalogWith(t, second, agent.NewSubagentStatusTool())} + if tc.reviewer { + deps.ToolReviewer = rejectingMutateReviewer{} + } + evs := drainObserving(t, newEngine(deps).Run(context.Background(), sess, agent.MemEnv("/ws"), agent.RunRequest{Text: "go"}), nil) + + if res := resultByCallID(evs)[call]; res == nil || !strings.Contains(res.Content, "SECOND_PASS") { + t.Fatalf("resumed child did not complete: %+v; events %v", res, typesOf(evs)) + } + if got := executed.Load() == 2; got != tc.wantRuns { + t.Errorf("resumed auto child's mutation ran=%t (count %d), want %t", got, executed.Load(), tc.wantRuns) + } + stored, err := store.Load(context.Background(), "subagent-s1-p1") + if err != nil { + t.Fatal(err) + } + if stored.Mode != session.ModeAuto { + t.Errorf("re-persisted child mode = %q, want %q", stored.Mode, session.ModeAuto) + } + }) + } +} diff --git a/engine/agent/subagent.go b/engine/agent/subagent.go index 230c779c5f..b6bdb60b0c 100644 --- a/engine/agent/subagent.go +++ b/engine/agent/subagent.go @@ -687,6 +687,11 @@ type SubagentTool struct { // default) inherits the parent's mode; see effectiveChildMode. childMode session.PermissionMode + // admitChildMode, when non-nil, is the host's permission-mode admission + // check applied to a resumed child's re-admitted mode before it runs; see + // WithChildModeAdmission. + admitChildMode func(session.PermissionMode) error + // hooks fires the SubagentStop lifecycle hook when a child finishes // (best-effort; nil disables it). It is separate from the child Engine's own // PreToolUse/PostToolUse hooks. @@ -1025,6 +1030,20 @@ func WithChildMode(m session.PermissionMode) SubagentOption { return func(t *SubagentTool) { t.childMode = m } } +// WithChildModeAdmission installs the host's permission-mode admission check +// for RESUMED children. A resume computes the child's re-admitted mode +// (the narrower of its stored mode and what a fresh child of the current parent +// would get) and passes it to admit before the child is recovered, forked, or +// re-persisted; a non-nil error refuses the resume with a tool error naming the +// mode and the error, the child does not run, and its persisted session is left +// untouched. Hosts pass the same check they apply to stored top-level sessions, +// so a child stored in auto cannot resume without the enforcing checker auto +// requires. A fresh child needs no check: it inherits auto or yolo only from a +// parent the host already admitted. Nil (the default) admits every mode. +func WithChildModeAdmission(admit func(session.PermissionMode) error) SubagentOption { + return func(t *SubagentTool) { t.admitChildMode = admit } +} + // effectiveChildMode is the mode a delegated child session runs in. A child // with no configured mode ("") inherits an auto or yolo parent's mode, so the // parent's mode-driven authority (allow-all, and in yolo the loosened @@ -2301,7 +2320,7 @@ func (t *SubagentTool) prepareChildSession(ctx context.Context, call session.Too var resumedChild *session.Session priorRef := session.EnvironmentRef{} if resuming { - loaded, errRes, rok := t.resolveResumeSession(ctx, call.ID, childID, args) + loaded, errRes, rok := t.resolveResumeSession(ctx, call.ID, childID, parentMode, args) if !rok { return nil, tool.Environment{}, noop, "", false, errRes, false } @@ -2907,7 +2926,7 @@ func (t *SubagentTool) startBackground(ctx context.Context, b backgroundChild) s // non-resumable state is the model's immediate, addressable error — never a // "started" result whose collection later reveals the call never could run. if b.resuming { - loaded, errResult, lok := t.resolveResumeSession(ctx, b.call.ID, b.childID, b.args) + loaded, errResult, lok := t.resolveResumeSession(ctx, b.call.ID, b.childID, b.caps.parentMode, b.args) if !lok { b.release() abort() @@ -4112,12 +4131,23 @@ func (t *SubagentTool) loadOwnedResumeSession(ctx context.Context, callID sessio // re-fails cleanly, which is strictly better than never being able to try. // // It returns the recovered session on success, or a model-addressable error ToolResult -// (ok=false) on a load failure or non-resumable state. -func (t *SubagentTool) resolveResumeSession(ctx context.Context, callID session.ToolCallID, id session.SessionID, args subagentArgs) (*session.Session, session.ToolResult, bool) { +// (ok=false) on a load failure, a non-resumable state, or a re-admitted permission +// mode the host refuses (WithChildModeAdmission). +func (t *SubagentTool) resolveResumeSession(ctx context.Context, callID session.ToolCallID, id session.SessionID, parentMode session.PermissionMode, args subagentArgs) (*session.Session, session.ToolResult, bool) { loaded, res, ok := t.loadOwnedResumeSession(ctx, callID, id) if !ok { return nil, res, false } + // Host admission runs on the mode the child would resume in, before any + // recovery mutates the loaded copy, so a refusal fails fast and inline on both + // resume paths and leaves the persisted child exactly as it was. + if t.admitChildMode != nil { + mode := resumedChildMode(loaded.Mode, t.childMode, parentMode) + if err := t.admitChildMode(mode); err != nil { + return nil, session.NewToolError(callID, + fmt.Sprintf("Subagent: subagent %q would resume in permission mode %q, which the host does not admit (%v); it was not resumed and its history is unchanged. Start a new Subagent for this work instead of resuming this one", id, mode, err)), false + } + } switch loaded.State { case session.StateCompleted: if rerr := loaded.Reopen(); rerr != nil { diff --git a/engine/api/agent.txt b/engine/api/agent.txt index 0796c61941..e6672cf6a6 100644 --- a/engine/api/agent.txt +++ b/engine/api/agent.txt @@ -206,6 +206,7 @@ func WithAutoMerge(m tool.EnvironmentMerger) ParallelOption func WithChildForker(f tool.EnvironmentForker) SubagentOption func WithChildLimits(l session.Limits) SubagentOption func WithChildMode(m session.PermissionMode) SubagentOption +func WithChildModeAdmission(admit func(session.PermissionMode) error) SubagentOption func WithChildSessionPrefix(p string) SubagentOption func WithForker(f tool.EnvironmentForker) SupervisorOption func WithJudgeLimits(l session.Limits) EngineJudgeOption diff --git a/engine/api/session.txt b/engine/api/session.txt index 3b1e2ffc70..a911a0b5c2 100644 --- a/engine/api/session.txt +++ b/engine/api/session.txt @@ -24,6 +24,7 @@ method func (*Session).InstructionSnapshot() InstructionSnapshot method func (*Session).Interrupt() error method func (*Session).InterruptAuthorization() ([]ToolResult, error) + method func (*Session).InterruptCloseOuts() []ToolResult method func (*Session).LastError() string method func (*Session).LatestContextOccupancy() (ContextOccupancy, bool) method func (*Session).LimitTripped() (StopReason, bool) diff --git a/engine/session/session.go b/engine/session/session.go index 7c1b09f2a2..a8c18f7e95 100644 --- a/engine/session/session.go +++ b/engine/session/session.go @@ -1083,6 +1083,17 @@ const ( // orphan. Mutates Conversation only via its append method. Shared by Interrupt // (cancelled) and Recover (failed). func (s *Session) closeOutInterruptedTurn(message string) { + for _, result := range s.closeOutResults(message) { + s.Conversation.Append(NewToolMessage(result)) + } +} + +// closeOutResults returns, in ToolCalls order, one synthetic error result +// carrying message for each tool call on the trailing assistant message that no +// following tool-role message answers. It does not mutate the session. IsError +// here means the call never ran to a result (interrupted/aborted), NOT a real +// tool failure. +func (s *Session) closeOutResults(message string) []ToolResult { msgs := s.Conversation.Messages // Find the LAST assistant message. lastAssistant := -1 @@ -1093,7 +1104,7 @@ func (s *Session) closeOutInterruptedTurn(message string) { } } if lastAssistant < 0 { - return + return nil } // Collect the set of CallIDs already answered by tool-role messages that // follow the trailing assistant message. @@ -1103,15 +1114,24 @@ func (s *Session) closeOutInterruptedTurn(message string) { answered[m.ToolResult.CallID] = struct{}{} } } - // For each tool call on the trailing assistant message not yet answered, - // append a synthetic error result in ToolCalls order. IsError here means the - // call never ran to a result (interrupted/aborted), NOT a real tool failure. + var out []ToolResult for _, call := range msgs[lastAssistant].ToolCalls { if _, ok := answered[call.ID]; ok { continue } - s.Conversation.Append(NewToolMessage(NewToolError(call.ID, message))) + out = append(out, NewToolError(call.ID, message)) } + return out +} + +// InterruptCloseOuts returns the synthetic error tool results Interrupt would +// append for the trailing assistant turn's unanswered tool calls, in call +// order, without mutating the session. A host that cancels a parked turn out of +// band uses it to record the same close-out on its durable event stream that +// the next run's Interrupt writes into the conversation. It returns nil when +// nothing is unanswered. +func (s *Session) InterruptCloseOuts() []ToolResult { + return s.closeOutResults(interruptCloseOutMessage) } // Interrupt recovers a CANCELLED session to StateIdle after repairing the diff --git a/engine/session/session_test.go b/engine/session/session_test.go index 32aa1d5e6f..347ce0006f 100644 --- a/engine/session/session_test.go +++ b/engine/session/session_test.go @@ -764,6 +764,60 @@ func TestInterruptPartialToolResultsCloseOutRemainder(t *testing.T) { } } +// TestInterruptCloseOutsPredictInterruptWithoutMutating proves the read-only +// preview returns exactly the results Interrupt then appends, and leaves the +// conversation untouched. +func TestInterruptCloseOutsPredictInterruptWithoutMutating(t *testing.T) { + s := newTestSession(Limits{}) + if err := s.RecordUserPrompt("do three things", nil); err != nil { + t.Fatalf("RecordUserPrompt: %v", err) + } + if err := s.BeginTurn(); err != nil { + t.Fatalf("BeginTurn: %v", err) + } + calls := []ToolCall{ + NewToolCall("call-a", "read_file", nil), + NewToolCall("call-b", "write_file", nil), + NewToolCall("call-c", "list_dir", nil), + } + if err := s.RecordAssistant(NewAssistantMessage("", "", calls)); err != nil { + t.Fatalf("RecordAssistant: %v", err) + } + if err := s.RecordToolResults([]ToolResult{NewToolResult("call-a", "ok")}); err != nil { + t.Fatalf("RecordToolResults: %v", err) + } + before := len(s.Conversation.Messages) + + preview := s.InterruptCloseOuts() + if len(s.Conversation.Messages) != before { + t.Fatalf("InterruptCloseOuts mutated the conversation: %d messages, want %d", len(s.Conversation.Messages), before) + } + want := []ToolResult{ + NewToolError("call-b", "tool call interrupted by cancellation"), + NewToolError("call-c", "tool call interrupted by cancellation"), + } + if !reflect.DeepEqual(preview, want) { + t.Fatalf("InterruptCloseOuts = %+v, want %+v", preview, want) + } + + if err := s.Cancel(); err != nil { + t.Fatalf("Cancel: %v", err) + } + if err := s.Interrupt(); err != nil { + t.Fatalf("Interrupt: %v", err) + } + var appended []ToolResult + for _, m := range s.Conversation.Messages[before:] { + appended = append(appended, *m.ToolResult) + } + if !reflect.DeepEqual(appended, want) { + t.Fatalf("Interrupt appended %+v, want the previewed %+v", appended, want) + } + if got := s.InterruptCloseOuts(); got != nil { + t.Fatalf("InterruptCloseOuts after Interrupt = %+v, want nil", got) + } +} + func TestInterruptNoOpWhenHistoryClean(t *testing.T) { t.Run("assistant with no tool calls", func(t *testing.T) { s := newTestSession(Limits{}) diff --git a/internal/adapter/server/approve_plan_test.go b/internal/adapter/server/approve_plan_test.go index 51cc00c08b..4ad2dce2f2 100644 --- a/internal/adapter/server/approve_plan_test.go +++ b/internal/adapter/server/approve_plan_test.go @@ -906,6 +906,54 @@ func TestApprovePlanGRPCStreaming(t *testing.T) { } } +// TestApprovePlanGRPCUnspecifiedTargetDenies pins the proto contract that an +// UNSPECIFIED (omitted) or unrecognized target_mode is a deny, the same as PLAN +// and the HTTP path's empty or unknown target: the plan iterates, the session +// stays in plan mode, and no continuation run starts. +func TestApprovePlanGRPCUnspecifiedTargetDenies(t *testing.T) { + for name, target := range map[string]mecatlv1.PermissionMode{ + "unspecified": mecatlv1.PermissionMode_PERMISSION_MODE_UNSPECIFIED, + "unrecognized": mecatlv1.PermissionMode(99), + } { + t.Run(name, func(t *testing.T) { + llm := mockllm.New(mockllm.ToolCallTurn(call("c1", "PresentPlan", `{"note":"plan"}`))) + svc := planApprovalService(t, llm, allowRules()) + client, cleanup := dialGRPC(t, svc) + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + sess, err := svc.CreateSession(ctx, session.ModePlan, session.Limits{}) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + _, finishParked := parkPlanAsk(t, svc, sess.ID) + defer finishParked() + + stream, err := client.ApprovePlan(ctx, &mecatlv1.ApprovePlanRequest{SessionId: string(sess.ID), TargetMode: target}) + if err != nil { + t.Fatalf("ApprovePlan: %v", err) + } + evs := recvAllApprovePlanEvents(t, stream) + + if indexOfProtoStop(evs, "plan_iterate") < 0 { + t.Fatalf("%s target must deny and iterate; events = %v", name, protoTypesOf(evs)) + } + if indexOfProtoStop(evs, "plan_approved") >= 0 || indexOfProtoStop(evs, "end_turn") >= 0 { + t.Fatalf("%s target must not approve the plan or start a continuation run", name) + } + got, err := svc.GetSession(ctx, sess.ID) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if got.Mode != session.ModePlan { + t.Fatalf("mode after %s ApprovePlan = %q, want %q (no flip)", name, got.Mode, session.ModePlan) + } + }) + } +} + // TestApprovePlanGRPCMidRunFailsPrecondition asserts that the gRPC ApprovePlan // maps ErrNotAwaitingPlan to codes.FailedPrecondition. Error arrives on Recv() // (server-streaming RPC: the initial call opens the stream, the handler error diff --git a/internal/adapter/server/errorcodes.go b/internal/adapter/server/errorcodes.go index 55c1a04eb5..c9f1cdb9b8 100644 --- a/internal/adapter/server/errorcodes.go +++ b/internal/adapter/server/errorcodes.go @@ -121,6 +121,7 @@ var errorRegistry = []errorCodeEntry{ {Sentinel: ErrDreamDeadline, Code: "dream_deadline", GRPC: codes.DeadlineExceeded, HTTPStatus: http.StatusGatewayTimeout, Title: "Dream plan deadline exceeded"}, {Sentinel: ErrDreamRequestFailed, Code: "dream_request_failed", GRPC: codes.Internal, HTTPStatus: http.StatusInternalServerError, Title: "Dream request failed"}, {Sentinel: ErrFailedStepRetryIneligible, Code: "failed_step_retry_ineligible", GRPC: codes.FailedPrecondition, HTTPStatus: http.StatusConflict, Title: "Failed-step retry is not eligible"}, + {Sentinel: ErrStoredModeNotAdmitted, Code: "session_mode_not_admitted", GRPC: codes.FailedPrecondition, HTTPStatus: http.StatusPreconditionFailed, Title: "Session permission mode is not admitted"}, {Sentinel: errMCPAuthorizationPending, Code: "mcp_authorization_pending", GRPC: codes.FailedPrecondition, HTTPStatus: http.StatusConflict, Title: "MCP authorization is pending"}, {Sentinel: ErrEnvironmentLogicalRootUnavailable, Code: "environment_logical_root_unavailable", GRPC: codes.FailedPrecondition, HTTPStatus: http.StatusPreconditionFailed, Title: "Repository worktree attachment unavailable"}, {Sentinel: ErrFailedPrecondition, Code: "failed_precondition", GRPC: codes.FailedPrecondition, HTTPStatus: http.StatusPreconditionFailed, Title: "Failed precondition"}, diff --git a/internal/adapter/server/errors.go b/internal/adapter/server/errors.go index a33e3085eb..c6d06995c6 100644 --- a/internal/adapter/server/errors.go +++ b/internal/adapter/server/errors.go @@ -70,6 +70,11 @@ var ( // session cannot retry its failed model step from persisted conversation state. // Eligibility is based only on typed persisted state. ErrFailedStepRetryIneligible = fmt.Errorf("%w: failed-step retry is not eligible", ErrFailedPrecondition) + // ErrStoredModeNotAdmitted is the precondition sentinel returned when a + // session's stored permission mode is one this server no longer admits. Its + // stable code lets a client offer the SetMode recovery for exactly this + // refusal rather than for every FailedPrecondition. + ErrStoredModeNotAdmitted = fmt.Errorf("%w: stored permission mode is not admitted", ErrFailedPrecondition) // ErrFailedPrecondition signals the request is well-formed but the server is // in a state that forbids it — typically a server-side misconfiguration the // client cannot fix by changing its arguments (e.g. spawning a Mutating team diff --git a/internal/adapter/server/features.go b/internal/adapter/server/features.go index d08cafac38..e1156007dd 100644 --- a/internal/adapter/server/features.go +++ b/internal/adapter/server/features.go @@ -99,7 +99,14 @@ const ( // parked awaiting an approval in a mode this server no longer admits: a // SetMode to an admitted, strictly lower mode cancels the pending turn // without running it and applies the mode, instead of refusing mid-turn. - // Every other SetMode on an awaiting session is still refused. + // The durable event log then closes the parked run the way a live + // cancellation does: permission.retract for the open ask, a cancellation + // tool result for each unanswered call, and result{cancelled}, all stamped + // with that run's id. Every other SetMode on an awaiting session is still + // refused. SetMode likewise recovers a stored session a crash left running + // in such a mode: once the session lease proves no server drives the run, a + // SetMode to an admitted mode abandons the interrupted turn without running + // it and applies the mode. FeatureRefusedModeRecovery = "refused_mode_recovery" ) diff --git a/internal/adapter/server/grpc_approveplan.go b/internal/adapter/server/grpc_approveplan.go index bea913245c..e1fee20a00 100644 --- a/internal/adapter/server/grpc_approveplan.go +++ b/internal/adapter/server/grpc_approveplan.go @@ -36,7 +36,7 @@ func (h *HarnessServer) ApprovePlan(req *mecatlv1.ApprovePlanRequest, stream mec ctx, cancel := context.WithCancel(stream.Context()) defer cancel() - events, err := h.svc.ApprovePlan(ctx, session.SessionID(req.GetSessionId()), modeFromProto(req.GetTargetMode()), req.GetNote()) + events, err := h.svc.ApprovePlan(ctx, session.SessionID(req.GetSessionId()), planModeFromProto(req.GetTargetMode()), req.GetNote()) if err != nil { return toStatus(err) } diff --git a/internal/adapter/server/lease_loss_capability_test.go b/internal/adapter/server/lease_loss_capability_test.go index 1382aea06d..175a49f4b4 100644 --- a/internal/adapter/server/lease_loss_capability_test.go +++ b/internal/adapter/server/lease_loss_capability_test.go @@ -247,12 +247,14 @@ func (r *leaseLossRecorder) ToolCall(session.SessionID, session.ToolCall, sessio type leaseLossDiagnostics struct { mu sync.Mutex messages []string + levels []port.Level } -func (d *leaseLossDiagnostics) Log(_ context.Context, _ port.Level, msg string, _ ...any) { +func (d *leaseLossDiagnostics) Log(_ context.Context, level port.Level, msg string, _ ...any) { d.mu.Lock() defer d.mu.Unlock() d.messages = append(d.messages, msg) + d.levels = append(d.levels, level) } func (d *leaseLossDiagnostics) With(...any) port.Diagnostics { return d } @@ -269,6 +271,18 @@ func (d *leaseLossDiagnostics) count(msg string) int { return n } +func (d *leaseLossDiagnostics) countAt(level port.Level, msg string) int { + d.mu.Lock() + defer d.mu.Unlock() + n := 0 + for i, got := range d.messages { + if got == msg && d.levels[i] == level { + n++ + } + } + return n +} + func newCapabilityService(t *testing.T, lease port.SessionLease, capability *server.SessionMutationCapability, recorder port.ToolCallRecorder, diag port.Diagnostics) (*server.Service, *memstore.Store, port.SessionStore, port.ToolCallRecorder) { t.Helper() store := memstore.New() @@ -526,8 +540,8 @@ func TestOptionalLeaseCompatibilityAndUnsupportedFallback(t *testing.T) { t.Fatalf("unsupported lease acquires = %d, want 1", acquires) } const unsupportedMessage = "session leasing unsupported by backend; disabling (running without cross-process exclusion)" - if got := diag.count(unsupportedMessage); got != 1 { - t.Fatalf("unsupported diagnostics = %d, want one sticky message", got) + if got := diag.countAt(port.LevelWarn, unsupportedMessage); got != 1 { + t.Fatalf("unsupported WARN diagnostics = %d, want one sticky WARN (losing cross-process exclusion is operator-actionable)", got) } }) } diff --git a/internal/adapter/server/mapper.go b/internal/adapter/server/mapper.go index 8bec0bdb59..7f6f039f7e 100644 --- a/internal/adapter/server/mapper.go +++ b/internal/adapter/server/mapper.go @@ -1425,6 +1425,24 @@ func createModeFromProto(m mecatlv1.PermissionMode) session.PermissionMode { return modeFromProto(m) } +// planModeFromProto maps an ApprovePlanRequest target_mode. Unlike +// modeFromProto, only a named mode passes through: UNSPECIFIED and any value +// this server does not recognize are a deny (ModePlan), as the proto documents +// and as the HTTP plan:approve path treats an empty or unknown target, so a +// malformed target never approves a plan. AUTO and YOLO pass through so the +// Service refuses them explicitly. +func planModeFromProto(m mecatlv1.PermissionMode) session.PermissionMode { + switch m { + case mecatlv1.PermissionMode_PERMISSION_MODE_DEFAULT, + mecatlv1.PermissionMode_PERMISSION_MODE_ACCEPT_EDITS, + mecatlv1.PermissionMode_PERMISSION_MODE_AUTO, + mecatlv1.PermissionMode_PERMISSION_MODE_YOLO: + return modeFromProto(m) + default: + return session.ModePlan + } +} + // scheduleModeToProto reports a stored schedule mode. An empty mode means the // fire uses the server's default session mode, so it reads back as UNSPECIFIED // (round-tripping through createModeFromProto) rather than as an explicit diff --git a/internal/adapter/server/mode_recovery_test.go b/internal/adapter/server/mode_recovery_test.go index 0b21a299a5..376fa05687 100644 --- a/internal/adapter/server/mode_recovery_test.go +++ b/internal/adapter/server/mode_recovery_test.go @@ -4,11 +4,13 @@ import ( "context" "encoding/json" "errors" + "reflect" "strings" "sync/atomic" "testing" "time" + "github.com/stacklok/mecatl/engine/adapter/eventsource" "github.com/stacklok/mecatl/engine/adapter/memlease" "github.com/stacklok/mecatl/engine/adapter/memstore" "github.com/stacklok/mecatl/engine/adapter/mockllm" @@ -26,6 +28,16 @@ import ( // on a loose deployment, then rewrites the stored mode to model a snapshot // parked under a deployment that admitted stored. func parkAwaitingInMode(t *testing.T, store port.SessionStore, stored session.PermissionMode) (session.SessionID, string) { + t.Helper() + id, askID, _ := parkAwaitingLogged(t, store, nil, stored) + return id, askID +} + +// parkAwaitingLogged is parkAwaitingInMode that also records the parked run's +// events, through its permission.ask, into log (when non-nil), modelling the +// relay's durable log of a run that parked and then died with its ask still +// open. It returns the parked run's id as well. +func parkAwaitingLogged(t *testing.T, store port.SessionStore, log port.EventLog, stored session.PermissionMode) (session.SessionID, string, string) { t.Helper() ctx := context.Background() var ran atomic.Int64 @@ -35,7 +47,30 @@ func parkAwaitingInMode(t *testing.T, store port.SessionStore, stored session.Pe if err != nil { t.Fatalf("CreateSession: %v", err) } - askID := driveServiceToAwaiting(t, loose, sess.ID) + run, err := loose.StartRun(ctx, sess.ID, "go") + if err != nil { + t.Fatalf("StartRun: %v", err) + } + var askID, runID string + for ev := range run.Events() { + if askID != "" { + continue // the dying run's tail never reaches the durable log + } + if log != nil { + if err := log.Append(ctx, sess.ID, ev); err != nil { + t.Fatalf("seed event log: %v", err) + } + } + if ev.Type == session.EvPermissionAsk && ev.Ask != nil { + askID, runID = ev.Ask.AskID, ev.RunID + loose.Persist(ctx, sess.ID) + run.Cancel() + } + } + loose.FinishRun(sess.ID, run) + if askID == "" || runID == "" { + t.Fatalf("the run never raised a run-stamped permission ask (ask %q run %q)", askID, runID) + } parked, err := store.Load(ctx, sess.ID) if err != nil { t.Fatal(err) @@ -44,19 +79,25 @@ func parkAwaitingInMode(t *testing.T, store port.SessionStore, stored session.Pe if err := store.Save(ctx, parked); err != nil { t.Fatal(err) } - return sess.ID, askID + return sess.ID, askID, runID } // recoveryService is a restarted deployment over store with the given ceiling // and checker; lease, when non-nil, is shared with a peer. -func recoveryService(t *testing.T, store port.SessionStore, llm *mockllm.Provider, ran *atomic.Int64, ceiling session.PermissionMode, checker bool, lease port.SessionLease) *server.Service { +// log, when non-nil, is the durable event log. +func recoveryService(t *testing.T, store port.SessionStore, llm *mockllm.Provider, ran *atomic.Int64, ceiling session.PermissionMode, checker bool, lease port.SessionLease, log port.EventLog) *server.Service { t.Helper() - return recoveryServiceAs(t, store, llm, ran, ceiling, checker, lease, "recovering") + return newRecoveryService(t, store, llm, ran, ceiling, checker, lease, "recovering", log) } // recoveryServiceAs is recoveryService with an explicit lease owner, so two // replicas can share one lease backend. func recoveryServiceAs(t *testing.T, store port.SessionStore, llm *mockllm.Provider, ran *atomic.Int64, ceiling session.PermissionMode, checker bool, lease port.SessionLease, owner string) *server.Service { + t.Helper() + return newRecoveryService(t, store, llm, ran, ceiling, checker, lease, owner, memstore.NewEventLog()) +} + +func newRecoveryService(t *testing.T, store port.SessionStore, llm *mockllm.Provider, ran *atomic.Int64, ceiling session.PermissionMode, checker bool, lease port.SessionLease, owner string, log port.EventLog) *server.Service { t.Helper() cat := tool.NewCatalog() cat.MustRegister(&writeAskTool{ran: ran}) @@ -66,6 +107,7 @@ func recoveryServiceAs(t *testing.T, store port.SessionStore, llm *mockllm.Provi Now: func() time.Time { return time.Unix(0, 0) }, MaxAllowedMode: ceiling, EnforcingCheckerConfigured: checker, + EventLog: log, } if lease != nil { cfg.SessionLease, cfg.LeaseOwner, cfg.LeaseTTL, cfg.LeaseRenewInterval = lease, owner, time.Minute, time.Hour @@ -90,11 +132,16 @@ func TestSetModeDowngradeRecoversRefusedAwaitingSession(t *testing.T) { var ran atomic.Int64 llm := mockllm.New(mockllm.TextTurn("continued")) - svc := recoveryService(t, store, llm, &ran, session.ModeAccept, true, nil) + svc := recoveryService(t, store, llm, &ran, session.ModeAccept, true, nil, nil) _, err := svc.ApproveRun(ctx, id, askID, session.VerdictAllowOnce, "") - if !errors.Is(err, server.ErrFailedPrecondition) || !strings.Contains(err.Error(), "SetMode to an admitted lower mode") { - t.Fatalf("ApproveRun err = %v, want FailedPrecondition naming the SetMode downgrade", err) + if !errors.Is(err, server.ErrStoredModeNotAdmitted) || !strings.Contains(err.Error(), "SetMode to an admitted lower mode") { + t.Fatalf("ApproveRun err = %v, want ErrStoredModeNotAdmitted naming the SetMode downgrade", err) + } + // A client offers the recovery on this stable code alone, not on every + // FailedPrecondition the verdict path can return. + if got := server.ClassifyErrorCodeForTest(err); got != "session_mode_not_admitted" { + t.Fatalf("ApproveRun error code = %q, want session_mode_not_admitted", got) } got, err := svc.SetMode(ctx, id, session.ModeDefault) @@ -157,7 +204,9 @@ func TestSetModeOnAwaitingSessionStillRefusedOutsideRecovery(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { store := memstore.New() - id, askID := parkAwaitingInMode(t, store, tc.stored) + log := memstore.NewEventLog() + id, askID, _ := parkAwaitingLogged(t, store, log, tc.stored) + parkedLog := readEventLog(t, log, id) var lease port.SessionLease if tc.peer { shared := memlease.New(wallclock.Clock{}, time.Minute) @@ -168,7 +217,7 @@ func TestSetModeOnAwaitingSessionStillRefusedOutsideRecovery(t *testing.T) { } var ran atomic.Int64 llm := mockllm.New(mockllm.TextTurn("must not run")) - svc := recoveryService(t, store, llm, &ran, tc.ceiling, tc.checker, lease) + svc := recoveryService(t, store, llm, &ran, tc.ceiling, tc.checker, lease, log) if _, err := svc.SetMode(ctx, id, tc.target); !errors.Is(err, tc.want) { t.Fatalf("SetMode(%s) err = %v, want %v", tc.target, err, tc.want) @@ -183,6 +232,306 @@ func TestSetModeOnAwaitingSessionStillRefusedOutsideRecovery(t *testing.T) { if pending, ok := kept.PendingAsk(); kept.State != session.StateAwaiting || !ok || pending.AskID != askID || kept.Mode != tc.stored { t.Fatalf("refusal changed the session: state %q mode %q pending %t", kept.State, kept.Mode, ok) } + if got := readEventLog(t, log, id); !reflect.DeepEqual(got, parkedLog) { + t.Fatalf("refusal appended %d event(s) to the durable log, want none", len(got)-len(parkedLog)) + } + }) + } +} + +// crashOrphanInMode stores a session as a crash leaves it mid-turn: running in +// stored, with an assistant tool call that never got a result and no process +// driving it. It returns the session id and the unanswered call's id. +func crashOrphanInMode(t *testing.T, store port.SessionStore, stored session.PermissionMode) (session.SessionID, session.ToolCallID) { + t.Helper() + ctx := context.Background() + var ran atomic.Int64 + loose := recoveryService(t, store, mockllm.New(), &ran, session.ModeYolo, true, nil, nil) + created, err := loose.CreateSession(ctx, session.ModeDefault, session.Limits{}) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + sess, err := store.Load(ctx, created.ID) + if err != nil { + t.Fatal(err) + } + if err := sess.RecordUserPrompt("go", nil); err != nil { + t.Fatal(err) + } + if err := sess.BeginTurn(); err != nil { + t.Fatal(err) + } + call := session.NewToolCall("w1", "Write", json.RawMessage(`{"path":"a.go"}`)) + if err := sess.RecordAssistant(session.NewAssistantMessage("", "", []session.ToolCall{call})); err != nil { + t.Fatal(err) + } + sess.Mode = stored + if err := store.Save(ctx, sess); err != nil { + t.Fatal(err) + } + return sess.ID, call.ID +} + +// TestSetModeRecoversRefusedCrashOrphanedRunningSession pins the recovery for +// a session a crash left running in a mode the restarted server refuses. +// StartRun refuses it before its crash-orphan repair, so without this nothing +// short of the background stale sweep could unstick it. Once the lease proves +// no server drives the run, SetMode to an admitted mode abandons the dead turn +// to idle without provider or tool work and applies the mode in one save; the +// next StartRun then runs in the new mode. +func TestSetModeRecoversRefusedCrashOrphanedRunningSession(t *testing.T) { + ctx := context.Background() + store := memstore.New() + id, callID := crashOrphanInMode(t, store, session.ModeYolo) + + var ran atomic.Int64 + llm := mockllm.New(mockllm.TextTurn("continued")) + lease := memlease.New(wallclock.Clock{}, time.Minute) + svc := recoveryService(t, store, llm, &ran, session.ModeAccept, true, lease, nil) + + _, err := svc.StartRun(ctx, id, "continue") + if !errors.Is(err, server.ErrStoredModeNotAdmitted) || !strings.Contains(err.Error(), "SetMode to an admitted mode ends the interrupted turn") { + t.Fatalf("StartRun err = %v, want ErrStoredModeNotAdmitted naming the SetMode recovery", err) + } + + got, err := svc.SetMode(ctx, id, session.ModeDefault) + if err != nil { + t.Fatalf("SetMode(default) on refused crash-orphaned session: %v", err) + } + if got.Mode != session.ModeDefault || got.State != session.StateIdle { + t.Fatalf("SetMode returned mode %q state %q, want default/idle", got.Mode, got.State) + } + if llm.Calls() != 0 || ran.Load() != 0 { + t.Fatalf("recovery ran work: provider calls %d, tool executions %d", llm.Calls(), ran.Load()) + } + kept, err := store.Load(ctx, id) + if err != nil { + t.Fatal(err) + } + if kept.Mode != session.ModeDefault || kept.State != session.StateIdle { + t.Fatalf("stored after recovery: mode %q state %q, want default/idle", kept.Mode, kept.State) + } + msgs := kept.Conversation.Messages + if last := msgs[len(msgs)-1]; last.ToolResult == nil || last.ToolResult.CallID != callID || !last.ToolResult.IsError { + t.Fatalf("the unanswered call was not closed out: last message %+v", last) + } + + run, err := svc.StartRun(ctx, id, "continue") + if err != nil { + t.Fatalf("StartRun after recovery: %v", err) + } + drainRun(t, run) + svc.FinishRun(id, run) + if llm.Calls() != 1 || ran.Load() != 0 { + t.Fatalf("next run: provider calls %d, tool executions %d; want 1, 0", llm.Calls(), ran.Load()) + } + after, err := store.Load(ctx, id) + if err != nil { + t.Fatal(err) + } + if after.Mode != session.ModeDefault || after.State != session.StateCompleted { + t.Fatalf("after next run: mode %q state %q, want default/completed", after.Mode, after.State) + } +} + +// TestSetModeOnRunningSessionStillRefusedOutsideRecovery pins that only the +// narrow recovery widens SetMode on a running session: an admitted stored mode +// is left for StartRun's repair, an inadmissible target is refused, and a peer +// holding the lease (the run may still be live there) blocks the abandon. Each +// refusal leaves the running snapshot unchanged and runs no work. +func TestSetModeOnRunningSessionStillRefusedOutsideRecovery(t *testing.T) { + ctx := context.Background() + for _, tc := range []struct { + name string + stored session.PermissionMode + ceiling session.PermissionMode + target session.PermissionMode + peer bool + want error + }{ + {"stored mode still admitted", session.ModeAccept, session.ModeAccept, session.ModeDefault, false, server.ErrInvalidArgument}, + {"target above ceiling", session.ModeYolo, session.ModeAccept, session.ModeAuto, false, server.ErrInvalidArgument}, + {"peer holds the lease", session.ModeYolo, session.ModeAccept, session.ModeDefault, true, server.ErrSessionLeasedElsewhere}, + } { + t.Run(tc.name, func(t *testing.T) { + store := memstore.New() + id, _ := crashOrphanInMode(t, store, tc.stored) + lease := memlease.New(wallclock.Clock{}, time.Minute) + if tc.peer { + if _, err := lease.Acquire(ctx, id, "peer"); err != nil { + t.Fatalf("peer Acquire: %v", err) + } + } + var ran atomic.Int64 + llm := mockllm.New(mockllm.TextTurn("must not run")) + svc := recoveryService(t, store, llm, &ran, tc.ceiling, true, lease, nil) + + if _, err := svc.SetMode(ctx, id, tc.target); !errors.Is(err, tc.want) { + t.Fatalf("SetMode(%s) err = %v, want %v", tc.target, err, tc.want) + } + if llm.Calls() != 0 || ran.Load() != 0 { + t.Fatalf("refusal ran work: provider calls %d, tool executions %d", llm.Calls(), ran.Load()) + } + kept, err := store.Load(ctx, id) + if err != nil { + t.Fatal(err) + } + if kept.State != session.StateRunning || kept.Mode != tc.stored { + t.Fatalf("refusal changed the session: state %q mode %q", kept.State, kept.Mode) + } }) } } + +// TestSetModeRecoveryClosesTheParkedTurnForWatchers pins that the recovery is +// observable: after the snapshot save, the durable log gains, stamped with the +// parked run and the acting caller, a retract of the open ask, the cancellation +// close-out for the unanswered call, and result{cancelled}. A watch attached +// before SetMode and a live subscriber both receive them, and the log still +// folds to the committed snapshot instead of an open ask. +func TestSetModeRecoveryClosesTheParkedTurnForWatchers(t *testing.T) { + store := memstore.New() + log := memstore.NewEventLog() + id, askID, runID := parkAwaitingLogged(t, store, log, session.ModeYolo) + parkedLog := readEventLog(t, log, id) + + var ran atomic.Int64 + llm := mockllm.New(mockllm.TextTurn("must not run")) + svc := recoveryService(t, store, llm, &ran, session.ModeAccept, true, nil, log) + + ctx, cancel := context.WithTimeout(session.WithPrincipal(context.Background(), alice), 15*time.Second) + defer cancel() + live, unsubscribe, err := svc.Subscribe(ctx, id) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + defer unsubscribe() + envelopes, err := svc.WatchSessionEvents(ctx, id, "", "") + if err != nil { + t.Fatalf("WatchSessionEvents: %v", err) + } + watched := make(chan []session.Event, 1) + caughtUp := make(chan struct{}) + go func() { + var got []session.Event + defer func() { watched <- got }() + for env, iterErr := range envelopes { + if iterErr != nil { + return + } + if env.Event == nil { + close(caughtUp) + continue + } + if env.Phase == server.WatchPhaseLive { + got = append(got, *env.Event) + if env.Event.Type == session.EvResult { + return + } + } + } + }() + select { + case <-caughtUp: + case <-ctx.Done(): + t.Fatal("watch never reached the live boundary") + } + + got, err := svc.SetMode(ctx, id, session.ModeDefault) + if err != nil { + t.Fatalf("SetMode(default): %v", err) + } + if got.State != session.StateCancelled || got.Mode != session.ModeDefault { + t.Fatalf("SetMode returned state %q mode %q, want cancelled/default", got.State, got.Mode) + } + if llm.Calls() != 0 || ran.Load() != 0 { + t.Fatalf("recovery ran work: provider calls %d, tool executions %d", llm.Calls(), ran.Load()) + } + + all := readEventLog(t, log, id) + tail := all[len(parkedLog):] + wantTypes := []session.EventType{session.EvPermissionRetract, session.EvToolResult, session.EvResult} + if len(tail) != len(wantTypes) { + t.Fatalf("recovery appended %d event(s) %v, want %v", len(tail), eventTypes(tail), wantTypes) + } + for i, ev := range tail { + if ev.Type != wantTypes[i] || ev.RunID != runID { + t.Fatalf("appended[%d] = %s run %q, want %s run %q", i, ev.Type, ev.RunID, wantTypes[i], runID) + } + if ev.Actor == nil || *ev.Actor != *alice { + t.Fatalf("appended[%d] actor = %+v, want the acting caller", i, ev.Actor) + } + } + if tail[0].Ask == nil || tail[0].Ask.AskID != askID { + t.Fatalf("retract = %+v, want ask %q", tail[0].Ask, askID) + } + if tr := tail[1].ToolResult; tr == nil || tr.CallID != "w1" || !tr.IsError || tr.Content != "tool call interrupted by cancellation" { + t.Fatalf("close-out = %+v, want the cancellation close-out for w1", tr) + } + if res := tail[2].Result; res == nil || res.Stop != session.StopCancelled { + t.Fatalf("result = %+v, want stop cancelled", res) + } + + var streamed []session.Event + for range wantTypes { + select { + case ev := <-live: + streamed = append(streamed, ev) + case <-ctx.Done(): + t.Fatalf("live subscriber received %v, want %v", eventTypes(streamed), wantTypes) + } + } + if !reflect.DeepEqual(eventTypes(streamed), wantTypes) { + t.Fatalf("live subscriber received %v, want %v", eventTypes(streamed), wantTypes) + } + if w := <-watched; !reflect.DeepEqual(eventTypes(w), wantTypes) || w[0].RunID != runID { + t.Fatalf("watch delivered %v live, want %v for run %q", eventTypes(w), wantTypes, runID) + } + + kept, err := store.Load(context.Background(), id) + if err != nil { + t.Fatal(err) + } + folded, err := eventsource.Fold(eventsource.SessionMeta{ID: id, Mode: kept.Mode, EnvironmentRef: kept.EnvironmentRef, CreatedAt: kept.CreatedAt}, log.Read(context.Background(), id)) + if err != nil { + t.Fatalf("Fold after recovery: %v", err) + } + foldedStop, _ := folded.RecordedStopReason() + keptStop, _ := kept.RecordedStopReason() + if _, pending := folded.PendingAsk(); pending || folded.State != kept.State || foldedStop != keptStop { + t.Fatalf("folded state %q stop %q pending %t, want the snapshot's %q/%q with no ask", folded.State, foldedStop, pending, kept.State, keptStop) + } + // The log's close-out is exactly what the next run's Interrupt repairs the + // snapshot's history with. + if err := kept.Interrupt(); err != nil { + t.Fatalf("Interrupt: %v", err) + } + if !reflect.DeepEqual(conversationShape(folded.Conversation.Messages), conversationShape(kept.Conversation.Messages)) { + t.Fatalf("folded history %v, want the interrupted snapshot's %v", conversationShape(folded.Conversation.Messages), conversationShape(kept.Conversation.Messages)) + } +} + +func eventTypes(events []session.Event) []session.EventType { + out := make([]session.EventType, 0, len(events)) + for _, ev := range events { + out = append(out, ev.Type) + } + return out +} + +// conversationShape is the structural history a fold reconstructs: role, text, +// call ids, and tool results. +func conversationShape(msgs []session.Message) []string { + out := make([]string, 0, len(msgs)) + for _, m := range msgs { + line := string(m.Role) + ":" + m.Text + for _, c := range m.ToolCalls { + line += " call=" + string(c.ID) + } + if m.ToolResult != nil { + line += " result=" + string(m.ToolResult.CallID) + "/" + m.ToolResult.Content + } + out = append(out, line) + } + return out +} diff --git a/internal/adapter/server/service.go b/internal/adapter/server/service.go index 282a1db346..978489f155 100644 --- a/internal/adapter/server/service.go +++ b/internal/adapter/server/service.go @@ -974,7 +974,7 @@ type Config struct { // posture. The loop NEVER imports port.SessionLease; // the renewer and the held-lease registry live entirely on Service (the same // storage-agnostic discipline as EventLog). A backend that reports - // ErrLeaseUnsupported is stickily disabled (one INFO, then the no-lease path). + // ErrLeaseUnsupported is stickily disabled (one WARN, then the no-lease path). SessionLease port.SessionLease // MutationCapability is the process-local admission gate shared by Service @@ -4814,8 +4814,9 @@ func (s *Service) managementTargetAwaitingDrain(ctx context.Context, id session. // mode, and propagates session.ErrIllegalTransition (wrapped as ErrInvalidArgument) // when the session is mid-turn (running/awaiting) — the aggregate refuses a mode // change while a turn is in flight, so the caller must defer it to the next -// prompt. The one exception is cancelRefusedAwaiting's recovery for a stored -// awaiting session whose mode this server refuses. A change to the mode the +// prompt. The exceptions are the recoveries for a stored session whose mode +// this server refuses: abandonRefusedRunning for one a crash left running, and +// cancelRefusedAwaiting for one awaiting an approval. A change to the mode the // session already has is a no-op success. func (s *Service) SetMode(ctx context.Context, id session.SessionID, mode session.PermissionMode) (*session.Session, error) { if s.draining.Load() { @@ -4851,6 +4852,7 @@ func (s *Service) SetMode(ctx context.Context, id session.SessionID, mode sessio s.mu.Unlock() var sess *session.Session + var recovery *refusedAwaitingRecovery if live { sess = st.sess if err := s.authorizeSession(ctx, sess); err != nil { @@ -4862,9 +4864,14 @@ func (s *Service) SetMode(ctx context.Context, id session.SessionID, mode sessio return nil, loadErr } sess = loaded - if err := s.cancelRefusedAwaiting(sess, mode); err != nil { + if err := s.abandonRefusedRunning(sess); err != nil { return nil, err } + rec, err := s.cancelRefusedAwaiting(sess, mode) + if err != nil { + return nil, err + } + recovery = rec } if sess.AgentDefinitionName != "" { @@ -4883,6 +4890,9 @@ func (s *Service) SetMode(ctx context.Context, id session.SessionID, mode sessio if err := s.saveSession(ctx, sess); err != nil { return nil, fmt.Errorf("server: persist session: %w", err) } + if recovery != nil { + s.recordRefusedAwaitingRecovery(ctx, id, recovery) + } return sess, nil } @@ -4897,18 +4907,34 @@ func (s *Service) admitMode(mode session.PermissionMode) error { } func admitModeFor(cfg *Config, mode session.PermissionMode) error { - if mode.Rank() == 0 { - return fmt.Errorf("%w: unknown permission mode %q", ErrInvalidArgument, mode) - } - if cfg.MaxAllowedMode != "" && !modeWithinCeiling(mode, cfg.MaxAllowedMode) { - return fmt.Errorf("%w: mode %q exceeds the server ceiling %q; the server must be relaunched with a higher --permission-mode to allow it", ErrInvalidArgument, mode, cfg.MaxAllowedMode) - } - if mode == session.ModeAuto && !cfg.EnforcingCheckerConfigured { - return fmt.Errorf("%w: mode %q requires an enforcing guardrails checker; configure one with --guardrails-model or bind the guardrail model slot, or use yolo for gate-free autonomous operation", ErrInvalidArgument, mode) + if err := ModeAdmission(cfg.MaxAllowedMode, cfg.EnforcingCheckerConfigured)(mode); err != nil { + return fmt.Errorf("%w: %v", ErrInvalidArgument, err) } return nil } +// ModeAdmission returns the session-mode admission rule for a deployment with +// the given ceiling (Config.MaxAllowedMode) and checker state +// (Config.EnforcingCheckerConfigured): an unknown mode or one above the ceiling +// is refused, and auto requires an enforcing guardrails checker. admitMode +// wraps its refusal in ErrInvalidArgument; the composition root passes the +// returned function to the Subagent tool so a resumed child is admitted by the +// same rule as a stored top-level session. +func ModeAdmission(ceiling session.PermissionMode, enforcingChecker bool) func(session.PermissionMode) error { + return func(mode session.PermissionMode) error { + if mode.Rank() == 0 { + return fmt.Errorf("unknown permission mode %q", mode) + } + if ceiling != "" && !modeWithinCeiling(mode, ceiling) { + return fmt.Errorf("mode %q exceeds the server ceiling %q; the server must be relaunched with a higher --permission-mode to allow it", mode, ceiling) + } + if mode == session.ModeAuto && !enforcingChecker { + return fmt.Errorf("mode %q requires an enforcing guardrails checker; configure one with --guardrails-model or bind the guardrail model slot, or use yolo for gate-free autonomous operation", mode) + } + return nil + } +} + // normalizeDefaultMode defaults an empty DefaultMode to default and runs the // complete session admission against it, so a service whose default sessions // could never be created (unknown mode, above the ceiling, or auto without an @@ -4942,18 +4968,42 @@ func (s *Service) AdmittedModes() []session.PermissionMode { // admitStoredMode re-admits a persisted session's mode against the CURRENT // ceiling and checker before execution, approval resume, or successor // publication. A refusal leaves the session and its history untouched; the -// owner can SetMode to an admitted mode and continue. For a session awaiting -// an approval that SetMode must be a downgrade, which cancels the pending turn -// (cancelRefusedAwaiting). +// owner can SetMode to an admitted mode and continue. For a session a crash +// left running, that SetMode first abandons the dead turn +// (abandonRefusedRunning). For a session awaiting an approval it must be a +// downgrade, which cancels the pending turn (cancelRefusedAwaiting). func (s *Service) admitStoredMode(sess *session.Session) error { if err := s.admitMode(sess.Mode); err != nil { + if sess.State == session.StateRunning { + return fmt.Errorf("%w: session %q is in permission mode %q, which this server does not admit (%v), and its stored run never finished; once no server is driving that run, SetMode to an admitted mode ends the interrupted turn without running it and lets the session continue", ErrStoredModeNotAdmitted, sess.ID, sess.Mode, err) + } if sess.State == session.StateAwaiting { - return fmt.Errorf("%w: session %q is in permission mode %q, which this server does not admit (%v), and its pending approval cannot resume in that mode; SetMode to an admitted lower mode cancels the pending turn without running it and lets the session continue", ErrFailedPrecondition, sess.ID, sess.Mode, err) + return fmt.Errorf("%w: session %q is in permission mode %q, which this server does not admit (%v), and its pending approval cannot resume in that mode; SetMode to an admitted lower mode cancels the pending turn without running it and lets the session continue", ErrStoredModeNotAdmitted, sess.ID, sess.Mode, err) } if sess.State == session.StateAuthorizing { - return fmt.Errorf("%w: session %q is in permission mode %q, which this server does not admit (%v), and its pending MCP authorization cannot resume in that mode; completing or cancelling the authorization settles the parked call without running the turn, after which SetMode can switch it to an admitted mode", ErrFailedPrecondition, sess.ID, sess.Mode, err) + return fmt.Errorf("%w: session %q is in permission mode %q, which this server does not admit (%v), and its pending MCP authorization cannot resume in that mode; completing or cancelling the authorization settles the parked call without running the turn, after which SetMode can switch it to an admitted mode", ErrStoredModeNotAdmitted, sess.ID, sess.Mode, err) } - return fmt.Errorf("%w: session %q is in permission mode %q, which this server does not admit (%v); switch it to an admitted mode with SetMode to continue", ErrFailedPrecondition, sess.ID, sess.Mode, err) + return fmt.Errorf("%w: session %q is in permission mode %q, which this server does not admit (%v); switch it to an admitted mode with SetMode to continue", ErrStoredModeNotAdmitted, sess.ID, sess.Mode, err) + } + return nil +} + +// abandonRefusedRunning is the recovery for a stored session a crash left +// running in a mode this server refuses: admitStoredMode refuses every run +// entry before StartRunContent's crash-orphan repair, the aggregate refuses +// SetMode from running, and there is no live run to cancel. The caller holds +// runEntryMu and the mutation lease and found no registered run, the same +// proof that repair relies on that the running snapshot is orphaned. So it +// abandons the dead turn to idle, closing out its unanswered tool calls, +// without executing anything, which makes the caller's mode change and single +// save legal. A running session whose mode is admitted is left for that +// repair, which the next run entry performs. +func (s *Service) abandonRefusedRunning(sess *session.Session) error { + if sess.State != session.StateRunning || s.admitMode(sess.Mode) == nil || s.IsLive(sess.ID) { + return nil + } + if err := sess.Abandon(); err != nil { + return fmt.Errorf("%w: %v", ErrInvalidArgument, err) } return nil } @@ -4967,14 +5017,62 @@ func (s *Service) admitStoredMode(sess *session.Session) error { // the ordinary interrupt path. Every other awaiting session is left for the // aggregate's mid-turn refusal. The caller holds runEntryMu and the mutation // lease, found no live run, and passes the snapshot loaded under that lease. -func (s *Service) cancelRefusedAwaiting(sess *session.Session, mode session.PermissionMode) error { +// +// It returns the facts the durable event stream needs to close the parked turn +// (recordRefusedAwaitingRecovery), captured before Cancel clears them, or nil +// when it cancelled nothing. +func (s *Service) cancelRefusedAwaiting(sess *session.Session, mode session.PermissionMode) (*refusedAwaitingRecovery, error) { if sess.State != session.StateAwaiting || s.admitMode(sess.Mode) == nil || mode.Rank() >= sess.Mode.Rank() { - return nil + return nil, nil + } + rec := &refusedAwaitingRecovery{runID: sess.RunID(), closeOuts: sess.InterruptCloseOuts()} + if ask, ok := sess.PendingAsk(); ok { + rec.askID = ask.AskID } if err := sess.Cancel(); err != nil { - return fmt.Errorf("%w: %v", ErrInvalidArgument, err) + return nil, fmt.Errorf("%w: %v", ErrInvalidArgument, err) + } + return rec, nil +} + +// refusedAwaitingRecovery is what cancelRefusedAwaiting cancelled: the parked +// run, its open ask, and the cancellation close-outs for the trailing turn's +// unanswered tool calls. +type refusedAwaitingRecovery struct { + runID string + askID string + closeOuts []session.ToolResult +} + +// recordRefusedAwaitingRecovery closes the parked turn on the durable event +// stream after SetMode persisted the recovery, so a watcher sees the ask +// withdrawn and the run end instead of an ask that stays open forever. The +// events mirror a live cancellation, all stamped with the parked run's id: +// permission.retract for the ask, one cancellation tool.result per unanswered +// call (the same close-out the next run's Interrupt writes into the +// conversation, which keeps the log foldable), then result{cancelled}. Each +// event is appended at most once on a cancellation-detached context and +// published live after its append. The snapshot is already committed, so an +// append failure is a diagnostic, not a SetMode failure (as for manual +// compaction). +func (s *Service) recordRefusedAwaitingRecovery(ctx context.Context, id session.SessionID, rec *refusedAwaitingRecovery) { + events := make([]session.Event, 0, len(rec.closeOuts)+2) + if rec.askID != "" { + events = append(events, session.Event{Type: session.EvPermissionRetract, RunID: rec.runID, Ask: &session.PendingAsk{AskID: rec.askID}}) + } + for i := range rec.closeOuts { + events = append(events, session.Event{Type: session.EvToolResult, RunID: rec.runID, ToolResult: &rec.closeOuts[i]}) + } + events = append(events, session.Event{Type: session.EvResult, RunID: rec.runID, Result: &session.ResultPayload{Stop: session.StopCancelled}}) + + appendCtx := context.WithoutCancel(ctx) + for _, ev := range events { + if err := s.appendEvent(appendCtx, id, ev); err != nil { + s.cfg.Diagnostics.Log(appendCtx, port.LevelWarn, "refused-mode recovery event append failed; the cancelled snapshot remains committed", "session", string(id), "event", string(ev.Type), "error", err) + continue + } + s.PublishSessionEvent(id, ev) } - return nil } // adoptLeasedSession replaces a run entry's pre-lease snapshot with the @@ -5376,8 +5474,9 @@ func (s *Service) reopenLoadedSession(ctx context.Context, sess *session.Session // an illegal RecordUserPrompt-from-terminal. Awaiting, idle, and running are // deliberate no-ops: awaiting is the preserved resume point (repairing // it would clear its still-resolvable PendingAsk); idle is the target state; -// running is repaired ONLY by StartRunContent's crash-orphan Abandon arm, AFTER -// it holds the real lock/lease (issue #475) — never here. +// running is repaired ONLY by StartRunContent's crash-orphan Abandon arm (or +// SetMode's abandonRefusedRunning), AFTER it holds the real lock/lease (issue +// #475) — never here. func (s *Service) repairTerminalState(ctx context.Context, sess *session.Session) error { switch sess.State { case session.StateCompleted: @@ -8945,7 +9044,7 @@ func (s *Service) acquireLeaseCore(ctx context.Context, id session.SessionID, by s.mu.Unlock() s.cfg.MutationCapability.Disable() if firstTime { - s.cfg.Diagnostics.Log(ctx, port.LevelInfo, "session leasing unsupported by backend; disabling (running without cross-process exclusion)", + s.cfg.Diagnostics.Log(ctx, port.LevelWarn, "session leasing unsupported by backend; disabling (running without cross-process exclusion)", "owner", s.cfg.LeaseOwner) } return nil diff --git a/internal/adapter/server/session_mutation_inventory.go b/internal/adapter/server/session_mutation_inventory.go index 21748d8122..89ffa49f02 100644 --- a/internal/adapter/server/session_mutation_inventory.go +++ b/internal/adapter/server/session_mutation_inventory.go @@ -68,11 +68,13 @@ var sessionMutationInventory = map[string]SessionMutationEntry{ "Persist": {SessionMutationLeaseProven, "relay persistence is admitted only while the registered run's held lease remains valid"}, "saveSession": {SessionMutationLeaseProven, "single pre-backend snapshot-save gate revalidates the current process-local mutation capability"}, "repairRunningSession": {SessionMutationLeaseProven, "run-entry crash repair executes only after acquisition and persists through saveSession"}, + "abandonRefusedRunning": {SessionMutationLeaseProven, "SetMode's refused-mode crash repair runs only after run-entry and lease acquisition; SetMode persists through saveSession"}, "deleteSessionFamily": {SessionMutationLeaseProven, "single pre-backend family-delete gate revalidates the exact held session lease"}, "appendEvent": {SessionMutationLeaseProven, "relay event append is admitted only while the registered run or management mutation holds the lease"}, "recordPlanContinuationFailure": {SessionMutationLeaseProven, "known failed proceed start appends through appendEvent's held-lease gate; publication follows only a successful append"}, "persistTitle": {SessionMutationLeaseProven, "title claim and completion hold the session mutation lease through snapshot persistence and publication"}, "publishTitle": {SessionMutationLeaseProven, "called only by persistTitle after a lease-owned snapshot save"}, + "recordRefusedAwaitingRecovery": {SessionMutationLeaseProven, "called only by SetMode after its lease-owned recovery save, while it still holds run-entry and the session mutation lease"}, "engine/agent/dispatch.go:ToolCall": {SessionMutationLeaseProven, "tool-call recording occurs only inside a Service-admitted run that already owns the session lease; engine remains lease-unaware"}, "adapter:family-derivatives": {SessionMutationLeaseProven, "snapshot save, delete, event append, and tool-call record adapters update metadata indexes and sidecars inside the same backend call"}, "SettleIfStale": {SessionMutationLeaseOwned, "stale repair owns run-entry and the session mutation lease before abandonment save"}, diff --git a/internal/app/build.go b/internal/app/build.go index 39c968fcce..0c3895be3f 100644 --- a/internal/app/build.go +++ b/internal/app/build.go @@ -886,6 +886,10 @@ type Config struct { // the shared engine, whose rules follow the server's default session mode. guardrailSessionMode session.PermissionMode planApprovals *planApprovalReceipts + // modeAdmission is the deployment's session-mode admission rule (ceiling and + // auto's enforcing-checker requirement), set once by Build and applied to + // resumed Subagent children. Nil outside Build admits every mode. + modeAdmission func(session.PermissionMode) error // Slash commands: directory of .md templates; EnableCommands turns on the // default directories when CommandsDir is empty. @@ -2129,6 +2133,11 @@ func Build(ctx context.Context, cfg Config) (*Built, error) { } cfg = resolveAskReviewerDefault(cfg) narratePermissionMode(cfg, checkerConfigured) + // One mode-admission rule for stored top-level sessions (server.Config below) + // and resumed Subagent children (buildSubagentTool), so a child stored in auto + // cannot resume where a stored auto session would be refused. + enforcingChecker := autoCheckerEnforcing(cfg, checkerConfigured) + cfg.modeAdmission = server.ModeAdmission(cfg.MaxAllowedMode, enforcingChecker) // Emit the build-once composition facts (token counter / compaction strategy / // slash commands) EXACTLY ONCE here, through the injected Diagnostics — keyed to // the resolved MAIN model. The per-derivation builders do not log these (they @@ -2496,7 +2505,7 @@ func Build(ctx context.Context, cfg Config) (*Built, error) { // server's own ModeDefault. DefaultMode: cfg.DefaultSessionMode, MaxAllowedMode: cfg.MaxAllowedMode, - EnforcingCheckerConfigured: autoCheckerEnforcing(cfg, checkerConfigured), + EnforcingCheckerConfigured: enforcingChecker, BuildID: buildinfo.BuildID, ServerImplementation: cfg.ServerImplementation, // GetServerInfo looks up only a caller-selected, already-known provider @@ -3931,6 +3940,14 @@ func buildSessionLease(cfg Config, store port.SessionStore) (port.SessionLease, cfg.diag().Log(context.Background(), port.LevelInfo, "session lease: store-provided", "owner", owner) return lease, owner, noop, nil } + if !localStorageMaintenanceSingleWriter(store) { + // Only the process-private store is single-writer without a lease. Any + // other store may be shared by replicas, and with no lease nothing stops + // two of them writing the same session (saves overwrite whole snapshots). + cfg.diag().Log(context.Background(), port.LevelWarn, + "session lease: none for a store other processes may share; replicas sharing it are not excluded from the same session. Configure --session-lease-k8s-namespace, --session-lease-url, or --session-lease-dir for multi-replica use", + "store", fmt.Sprintf("%T", store)) + } return nil, "", noop, nil } @@ -7807,6 +7824,7 @@ func buildSubagentTool(ctx context.Context, cfg Config, provReg *providerRegistr // the namespace stays disjoint by prefix convention, not engineering). agent.WithSubagentStore(store), agent.WithSubagentOwnershipEnforced(cfg.OwnershipEnforced), + agent.WithChildModeAdmission(cfg.modeAdmission), } // Issue #40: when the WORKSPACE-TRUST gate (not --no-shell / an empty shell) is what // nil'd the runner, tell the model honestly via the Spec — otherwise the description @@ -7954,6 +7972,7 @@ func buildNoFSSubagentTool(ctx context.Context, cfg Config, provReg *providerReg agent.WithSubagentStopHook(hooks), agent.WithSubagentStore(store), agent.WithSubagentOwnershipEnforced(cfg.OwnershipEnforced), + agent.WithChildModeAdmission(cfg.modeAdmission), agent.WithSubagentNoFSNote(), agent.WithSubagentReadLedgerFactory(func() tool.ReadLedger { return memledger.New() }), agent.WithSubagentProvider(childProviderID), diff --git a/internal/app/permission_mode_test.go b/internal/app/permission_mode_test.go index d2b5d6606b..10f41d45ed 100644 --- a/internal/app/permission_mode_test.go +++ b/internal/app/permission_mode_test.go @@ -10,6 +10,7 @@ import ( mecatlv1 "github.com/stacklok/mecatl/contracts/gen/go/mecatl/v1" "github.com/stacklok/mecatl/engine/adapter/memfs" + "github.com/stacklok/mecatl/engine/adapter/mockllm" "github.com/stacklok/mecatl/engine/port" "github.com/stacklok/mecatl/engine/session" "github.com/stacklok/mecatl/internal/adapter/permconfig" @@ -415,6 +416,49 @@ func TestInteractiveTrustTokenGrantsTrust(t *testing.T) { } } +// projectInstructionsReachModel seeds an AGENTS.md sentinel in cfg's workspace, +// runs one turn of a server-default session through the real Build, and reports +// whether the sentinel reached the provider request. It observes the effect trust +// controls (project instructions becoming model context), not only its narration. +func projectInstructionsReachModel(t *testing.T, cfg Config) bool { + t.Helper() + const sentinel = "SEEDED-PROJECT-INSTRUCTION-SENTINEL" + if err := os.WriteFile(filepath.Join(cfg.Workspace, "AGENTS.md"), []byte(sentinel), 0o600); err != nil { + t.Fatal(err) + } + var requests []port.LLMRequest + cfg.UseMock = false + cfg.MockProvider = mockllm.NewWith([]mockllm.Option{mockllm.WithRequestObserver(func(r port.LLMRequest) { requests = append(requests, r) })}, mockllm.TextTurn("done")) + built, err := buildIsolated(t, context.Background(), cfg) + if err != nil { + t.Fatalf("Build: %v", err) + } + defer built.Close() + sess, err := built.Service.CreateSession(context.Background(), "", defaultLimits()) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + run, err := built.Service.StartRun(context.Background(), sess.ID, "hello") + if err != nil { + t.Fatalf("StartRun: %v", err) + } + for range run.Events() { + } + built.Service.FinishRun(sess.ID, run) + if len(requests) == 0 { + t.Fatal("no provider request") + } + if strings.Contains(requests[0].System.Render(), sentinel) { + return true + } + for _, m := range requests[0].Messages { + if strings.Contains(m.Text, sentinel) { + return true + } + } + return false +} + // TestPermissionModeTokenDerivesNoTrust pins that the mode surface is not a // trust decision: an interactive root under any --permission-mode token stays // untrusted unless an explicit trust source is present, while the deprecated @@ -443,12 +487,18 @@ func TestPermissionModeTokenDerivesNoTrust(t *testing.T) { if trustOf(t, cfg, rec) { t.Fatalf("interactive --permission-mode %s granted project trust with no trust source", token) } + if projectInstructionsReachModel(t, cfg) { + t.Fatalf("interactive --permission-mode %s fed untrusted project instructions to the model", token) + } rec = slogdiagBuffer(t) cfg.Diagnostics = rec.diag cfg.TrustProject = true if !trustOf(t, cfg, rec) { t.Fatalf("--permission-mode %s --trust-project must be trusted", token) } + if !projectInstructionsReachModel(t, cfg) { + t.Fatalf("--permission-mode %s --trust-project must feed the project instructions to the model", token) + } }) } withTrustEnv(t, trustSettingsEnv(t.TempDir(), nil)) @@ -457,6 +507,9 @@ func TestPermissionModeTokenDerivesNoTrust(t *testing.T) { if !trustOf(t, legacy, rec) { t.Fatal("deprecated interactive --posture yolo must keep its trust floor") } + if !projectInstructionsReachModel(t, legacy) { + t.Fatal("deprecated interactive --posture yolo trust floor must feed the project instructions to the model") + } } func TestAutoNarratesSubstitutionGuard(t *testing.T) { diff --git a/internal/app/session_lease_warning_test.go b/internal/app/session_lease_warning_test.go new file mode 100644 index 0000000000..6cb7b1f5e8 --- /dev/null +++ b/internal/app/session_lease_warning_test.go @@ -0,0 +1,49 @@ +package app + +import ( + "strings" + "testing" + + "github.com/stacklok/mecatl/engine/adapter/memstore" + "github.com/stacklok/mecatl/engine/port" +) + +// sharedStoreStub stands in for a store other processes may share (a remote +// driver, Redis): it is not the process-private memstore and provides no lease. +type sharedStoreStub struct{ port.SessionStore } + +// TestNoLeaseForSharedStoreWarns pins that Build warns when a store replicas may +// share resolves no session lease, since nothing then excludes two replicas from +// the same session, and stays quiet for the stores that are safe without an +// explicit lease: the process-private memstore and a local StoreDir (which gets +// the flock lease). +func TestNoLeaseForSharedStoreWarns(t *testing.T) { + const warning = "session lease: none for a store other processes may share" + for _, tc := range []struct { + name string + cfg Config + store port.SessionStore + warns bool + }{ + {"shared store without a lease", Config{}, sharedStoreStub{memstore.New()}, true}, + {"process-private memstore", Config{}, memstore.New(), false}, + {"local store dir", Config{StoreDir: t.TempDir()}, sharedStoreStub{memstore.New()}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + rec := slogdiagBuffer(t) + tc.cfg.Diagnostics = rec.diag + lease, _, closeLease, err := buildSessionLease(tc.cfg, tc.store) + if err != nil { + t.Fatalf("buildSessionLease: %v", err) + } + closeLease() + line := rec.lineContaining(warning) + if tc.warns && (line == "" || lease != nil || !strings.Contains(line, "level=WARN")) { + t.Fatalf("want one WARN naming the missing lease; lease=%v log:\n%s", lease, rec.String()) + } + if !tc.warns && line != "" { + t.Fatalf("unexpected missing-lease warning: %q", line) + } + }) + } +} diff --git a/internal/app/session_liveness.go b/internal/app/session_liveness.go index dec93bcc2a..fed530744d 100644 --- a/internal/app/session_liveness.go +++ b/internal/app/session_liveness.go @@ -178,7 +178,7 @@ func (r *sessionLiveness) disableLeasing(ctx context.Context) { stop() } if first { - r.diag.Log(ctx, port.LevelInfo, "child session leasing unsupported by backend; disabling (running without cross-process exclusion)", + r.diag.Log(ctx, port.LevelWarn, "child session leasing unsupported by backend; disabling (running without cross-process exclusion)", "owner", r.owner) } } diff --git a/internal/app/subagent_resume_admission_test.go b/internal/app/subagent_resume_admission_test.go new file mode 100644 index 0000000000..697b3a0994 --- /dev/null +++ b/internal/app/subagent_resume_admission_test.go @@ -0,0 +1,123 @@ +package app + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/stacklok/mecatl/engine/adapter/mockllm" + "github.com/stacklok/mecatl/engine/port" + "github.com/stacklok/mecatl/engine/session" + "github.com/stacklok/mecatl/internal/adapter/server" +) + +// TestResumedAutoSubagentRefusedAfterCheckerRemoved proves Build hands the +// server's mode admission to the Subagent tool. A yolo-ceiling deployment with +// an enforcing checker runs an auto session whose child inherits and persists +// auto. After a restart over the same store without a checker, the parent is +// switched to yolo (auto is no longer admitted) and resumes the child: the +// child would keep auto, so the resume is refused with the server's reason, +// the child never calls the model, and the parent run completes. Both Subagent +// builders carry the admission, so it runs for a filesystem and a no-FS parent. +func TestResumedAutoSubagentRefusedAfterCheckerRemoved(t *testing.T) { + for name, profile := range map[string]server.SessionProfile{"filesystem": server.ProfileDefault, "no-fs": server.ProfileNoFS} { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + storeDir := t.TempDir() + memoryDir := t.TempDir() + workspace := t.TempDir() + baseCfg := func(llm *mockllm.Provider) Config { + return Config{ + Workspace: workspace, + NoSoul: true, + StoreDir: storeDir, + MemoryDir: memoryDir, + Posture: PostureYolo, + PostureFlagSet: true, + GuardrailsDisabled: true, + envDetector: fakeEnv(map[string]string{"OPENAI_API_KEY": "sk-openai"}), + liveModelHTTPClient: offlineHTTPClient(), + providerConstructor: func(Config, string, string, string) port.LLMProvider { return llm }, + } + } + + // Build #1: an enforcing checker admits auto; the child inherits auto. + first := mockllm.New( + mockllm.ToolCallTurn(session.NewToolCall("p1", "Subagent", json.RawMessage(`{"prompt":"investigate"}`))), + mockllm.TextTurn("FIRST_PASS"), + mockllm.TextTurn("parent done"), + ) + built1, err := buildIsolated(t, ctx, withInertEnforcingChecker(baseCfg(first))) + if err != nil { + t.Fatalf("Build #1: %v", err) + } + sess, err := built1.Service.CreateSessionWithProfile(ctx, session.ModeAuto, session.Limits{}, server.ProviderSelector{}, profile) + if err != nil { + built1.Close() + t.Fatalf("CreateSession(auto): %v", err) + } + run1, err := built1.Service.StartRun(ctx, sess.ID, "delegate it") + if err != nil { + built1.Close() + t.Fatalf("StartRun #1: %v", err) + } + var childID string + for ev := range run1.Events() { + if ev.Type == session.EvSubagentStart && ev.Subagent != nil { + childID = ev.Subagent.ChildID + } + } + built1.Close() + if childID == "" { + t.Fatal("the first run never started a subagent") + } + + // Build #2: same store, no checker, so auto is no longer admitted. + second := mockllm.New( + mockllm.ToolCallTurn(session.NewToolCall("p2", "Subagent", + json.RawMessage(`{"prompt":"again","resume":"`+childID+`"}`))), + mockllm.TextTurn("parent done again"), + ) + built2, err := buildIsolated(t, ctx, baseCfg(second)) + if err != nil { + t.Fatalf("Build #2: %v", err) + } + defer built2.Close() + if _, err := built2.Service.SetMode(ctx, sess.ID, session.ModeYolo); err != nil { + t.Fatalf("SetMode(yolo): %v", err) + } + run2, err := built2.Service.StartRun(ctx, sess.ID, "resume it") + if err != nil { + t.Fatalf("StartRun #2: %v", err) + } + var resumed *session.ToolResult + var terminal session.StopReason + for ev := range run2.Events() { + if ev.ToolResult != nil && ev.ToolResult.CallID == "p2" { + resumed = ev.ToolResult + } + if ev.Type == session.EvSubagentStart { + t.Errorf("the refused child started: %+v", ev.Subagent) + } + if ev.Result != nil { + terminal = ev.Result.Stop + } + } + if resumed == nil || !resumed.IsError { + t.Fatalf("resume result = %+v, want a tool error", resumed) + } + for _, want := range []string{`permission mode "auto"`, "does not admit", "requires an enforcing guardrails checker", "Start a new Subagent"} { + if !strings.Contains(resumed.Content, want) { + t.Errorf("resume error %q does not contain %q", resumed.Content, want) + } + } + if terminal != session.StopEndTurn { + t.Errorf("parent run stop = %q, want %q", terminal, session.StopEndTurn) + } + if n := second.Calls(); n != 2 { + t.Errorf("provider calls = %d, want 2 (parent only; the refused child must not call the model)", n) + } + }) + } +} diff --git a/sdk/typescript/etc/mecatl-sdk-deno.api.md b/sdk/typescript/etc/mecatl-sdk-deno.api.md index e6246dcee0..8cebeb937e 100644 --- a/sdk/typescript/etc/mecatl-sdk-deno.api.md +++ b/sdk/typescript/etc/mecatl-sdk-deno.api.md @@ -809,7 +809,7 @@ export interface McpInventory { export const MECATL_ATTACH_FILTERED_KINDS: readonly ["approval", "compaction.archive", "network.attempt", "request.manifest", "user_prompt"]; // @public -export const MECATL_ERROR_CODES: readonly ["activity_gap", "ask_not_pending", "approval_grant_ineligible", "approval_intent_mismatch", "approval_not_pending", "approval_unsupported", "attempt_live_claim_conflict", "attempt_terminal_conflict", "attempt_version_conflict", "child_not_found", "cleanup_backend", "cleanup_plan_stale", "cleanup_unsupported", "client_mcp_unreachable", "client_mcp_unsupported", "conflict", "context_window_unavailable", "cursor_expired", "cursor_malformed", "draining", "dream_apply_failed", "dream_capacity", "dream_conflict", "dream_deadline", "dream_generate_failed", "dream_in_progress", "dream_not_found", "dream_request_failed", "dream_terminal_conflict", "dream_unavailable", "environment_logical_root_unavailable", "execution_templates_disabled", "failed_precondition", "failed_step_retry_ineligible", "fire_now_overlap", "identity_broker_unavailable", "internal", "invalid_argument", "learning_unavailable", "management_unauthorized", "mcp_connector_unavailable", "mcp_authorization_pending", "no_active_run", "no_event_log", "no_mcp_provider", "no_schedule_store", "not_awaiting_plan", "not_found", "placement_binding_invalid", "placement_changed", "placement_selector_invalid", "placement_selector_not_found", "placement_selector_stale", "placement_unavailable", "plan_resolution_required", "proposal_conflict", "reflection_cancelled", "reflection_deadline", "reflection_failed", "reflection_queue_full", "request_too_large", "resource_exhausted", "schedule_disabled", "schedule_exhausted", "schedule_not_found", "schedule_not_leader", "schedule_unsupported", "scheduler_not_running", "session_delete_unsupported", "session_leased_elsewhere", "session_metadata_cursor_restart", "session_metadata_paging_unsupported", "session_not_found", "skill_file_not_found", "skill_file_not_text", "skill_file_too_large", "skill_not_found", "stale_run_control", "storage_health_backend", "team_not_found", "team_not_running", "team_running", "teams_disabled", "too_many_session_engines", "too_many_teams", "unauthenticated", "unimplemented", "watch_capacity", "watch_lagging", "watch_unsupported"]; +export const MECATL_ERROR_CODES: readonly ["activity_gap", "ask_not_pending", "approval_grant_ineligible", "approval_intent_mismatch", "approval_not_pending", "approval_unsupported", "attempt_live_claim_conflict", "attempt_terminal_conflict", "attempt_version_conflict", "child_not_found", "cleanup_backend", "cleanup_plan_stale", "cleanup_unsupported", "client_mcp_unreachable", "client_mcp_unsupported", "conflict", "context_window_unavailable", "cursor_expired", "cursor_malformed", "draining", "dream_apply_failed", "dream_capacity", "dream_conflict", "dream_deadline", "dream_generate_failed", "dream_in_progress", "dream_not_found", "dream_request_failed", "dream_terminal_conflict", "dream_unavailable", "environment_logical_root_unavailable", "execution_templates_disabled", "failed_precondition", "failed_step_retry_ineligible", "fire_now_overlap", "identity_broker_unavailable", "internal", "invalid_argument", "learning_unavailable", "management_unauthorized", "mcp_connector_unavailable", "mcp_authorization_pending", "no_active_run", "no_event_log", "no_mcp_provider", "no_schedule_store", "not_awaiting_plan", "not_found", "placement_binding_invalid", "placement_changed", "placement_selector_invalid", "placement_selector_not_found", "placement_selector_stale", "placement_unavailable", "plan_resolution_required", "proposal_conflict", "reflection_cancelled", "reflection_deadline", "reflection_failed", "reflection_queue_full", "request_too_large", "resource_exhausted", "schedule_disabled", "schedule_exhausted", "schedule_not_found", "schedule_not_leader", "schedule_unsupported", "scheduler_not_running", "session_delete_unsupported", "session_leased_elsewhere", "session_metadata_cursor_restart", "session_metadata_paging_unsupported", "session_mode_not_admitted", "session_not_found", "skill_file_not_found", "skill_file_not_text", "skill_file_too_large", "skill_not_found", "stale_run_control", "storage_health_backend", "team_not_found", "team_not_running", "team_running", "teams_disabled", "too_many_session_engines", "too_many_teams", "unauthenticated", "unimplemented", "watch_capacity", "watch_lagging", "watch_unsupported"]; // @public export const MECATL_EVENT_KINDS: readonly ["approval", "authorization.required", "authorization.resolved", "compaction", "compaction.archive", "control.refused", "hook", "message.delta", "model.retry", "network.attempt", "no_progress", "parallel.branch", "parallel.end", "parallel.start", "permission.ask", "permission.retract", "plan.continuation_failed", "provider.route", "reasoning.delta", "recover_notice", "request.manifest", "result", "schedule.failed", "schedule.fired", "schedule.skipped", "session.init", "session.title", "steer", "steer.outcome", "subagent.end", "subagent.start", "subagent.tool", "team.end", "team.findings", "team.member", "team.start", "team.tasks", "tool.call", "tool.progress", "tool.result", "tool.result.available", "turn.end", "turn.start", "user_prompt"]; diff --git a/sdk/typescript/etc/mecatl-sdk-node.api.md b/sdk/typescript/etc/mecatl-sdk-node.api.md index 6d9dd28a3a..603be6594f 100644 --- a/sdk/typescript/etc/mecatl-sdk-node.api.md +++ b/sdk/typescript/etc/mecatl-sdk-node.api.md @@ -830,7 +830,7 @@ export interface McpInventory { export const MECATL_ATTACH_FILTERED_KINDS: readonly ["approval", "compaction.archive", "network.attempt", "request.manifest", "user_prompt"]; // @public -export const MECATL_ERROR_CODES: readonly ["activity_gap", "ask_not_pending", "approval_grant_ineligible", "approval_intent_mismatch", "approval_not_pending", "approval_unsupported", "attempt_live_claim_conflict", "attempt_terminal_conflict", "attempt_version_conflict", "child_not_found", "cleanup_backend", "cleanup_plan_stale", "cleanup_unsupported", "client_mcp_unreachable", "client_mcp_unsupported", "conflict", "context_window_unavailable", "cursor_expired", "cursor_malformed", "draining", "dream_apply_failed", "dream_capacity", "dream_conflict", "dream_deadline", "dream_generate_failed", "dream_in_progress", "dream_not_found", "dream_request_failed", "dream_terminal_conflict", "dream_unavailable", "environment_logical_root_unavailable", "execution_templates_disabled", "failed_precondition", "failed_step_retry_ineligible", "fire_now_overlap", "identity_broker_unavailable", "internal", "invalid_argument", "learning_unavailable", "management_unauthorized", "mcp_connector_unavailable", "mcp_authorization_pending", "no_active_run", "no_event_log", "no_mcp_provider", "no_schedule_store", "not_awaiting_plan", "not_found", "placement_binding_invalid", "placement_changed", "placement_selector_invalid", "placement_selector_not_found", "placement_selector_stale", "placement_unavailable", "plan_resolution_required", "proposal_conflict", "reflection_cancelled", "reflection_deadline", "reflection_failed", "reflection_queue_full", "request_too_large", "resource_exhausted", "schedule_disabled", "schedule_exhausted", "schedule_not_found", "schedule_not_leader", "schedule_unsupported", "scheduler_not_running", "session_delete_unsupported", "session_leased_elsewhere", "session_metadata_cursor_restart", "session_metadata_paging_unsupported", "session_not_found", "skill_file_not_found", "skill_file_not_text", "skill_file_too_large", "skill_not_found", "stale_run_control", "storage_health_backend", "team_not_found", "team_not_running", "team_running", "teams_disabled", "too_many_session_engines", "too_many_teams", "unauthenticated", "unimplemented", "watch_capacity", "watch_lagging", "watch_unsupported"]; +export const MECATL_ERROR_CODES: readonly ["activity_gap", "ask_not_pending", "approval_grant_ineligible", "approval_intent_mismatch", "approval_not_pending", "approval_unsupported", "attempt_live_claim_conflict", "attempt_terminal_conflict", "attempt_version_conflict", "child_not_found", "cleanup_backend", "cleanup_plan_stale", "cleanup_unsupported", "client_mcp_unreachable", "client_mcp_unsupported", "conflict", "context_window_unavailable", "cursor_expired", "cursor_malformed", "draining", "dream_apply_failed", "dream_capacity", "dream_conflict", "dream_deadline", "dream_generate_failed", "dream_in_progress", "dream_not_found", "dream_request_failed", "dream_terminal_conflict", "dream_unavailable", "environment_logical_root_unavailable", "execution_templates_disabled", "failed_precondition", "failed_step_retry_ineligible", "fire_now_overlap", "identity_broker_unavailable", "internal", "invalid_argument", "learning_unavailable", "management_unauthorized", "mcp_connector_unavailable", "mcp_authorization_pending", "no_active_run", "no_event_log", "no_mcp_provider", "no_schedule_store", "not_awaiting_plan", "not_found", "placement_binding_invalid", "placement_changed", "placement_selector_invalid", "placement_selector_not_found", "placement_selector_stale", "placement_unavailable", "plan_resolution_required", "proposal_conflict", "reflection_cancelled", "reflection_deadline", "reflection_failed", "reflection_queue_full", "request_too_large", "resource_exhausted", "schedule_disabled", "schedule_exhausted", "schedule_not_found", "schedule_not_leader", "schedule_unsupported", "scheduler_not_running", "session_delete_unsupported", "session_leased_elsewhere", "session_metadata_cursor_restart", "session_metadata_paging_unsupported", "session_mode_not_admitted", "session_not_found", "skill_file_not_found", "skill_file_not_text", "skill_file_too_large", "skill_not_found", "stale_run_control", "storage_health_backend", "team_not_found", "team_not_running", "team_running", "teams_disabled", "too_many_session_engines", "too_many_teams", "unauthenticated", "unimplemented", "watch_capacity", "watch_lagging", "watch_unsupported"]; // @public export const MECATL_EVENT_KINDS: readonly ["approval", "authorization.required", "authorization.resolved", "compaction", "compaction.archive", "control.refused", "hook", "message.delta", "model.retry", "network.attempt", "no_progress", "parallel.branch", "parallel.end", "parallel.start", "permission.ask", "permission.retract", "plan.continuation_failed", "provider.route", "reasoning.delta", "recover_notice", "request.manifest", "result", "schedule.failed", "schedule.fired", "schedule.skipped", "session.init", "session.title", "steer", "steer.outcome", "subagent.end", "subagent.start", "subagent.tool", "team.end", "team.findings", "team.member", "team.start", "team.tasks", "tool.call", "tool.progress", "tool.result", "tool.result.available", "turn.end", "turn.start", "user_prompt"]; diff --git a/sdk/typescript/etc/mecatl-sdk.api.md b/sdk/typescript/etc/mecatl-sdk.api.md index 21447f6286..b4b3118adc 100644 --- a/sdk/typescript/etc/mecatl-sdk.api.md +++ b/sdk/typescript/etc/mecatl-sdk.api.md @@ -794,7 +794,7 @@ export interface McpInventory { export const MECATL_ATTACH_FILTERED_KINDS: readonly ["approval", "compaction.archive", "network.attempt", "request.manifest", "user_prompt"]; // @public -export const MECATL_ERROR_CODES: readonly ["activity_gap", "ask_not_pending", "approval_grant_ineligible", "approval_intent_mismatch", "approval_not_pending", "approval_unsupported", "attempt_live_claim_conflict", "attempt_terminal_conflict", "attempt_version_conflict", "child_not_found", "cleanup_backend", "cleanup_plan_stale", "cleanup_unsupported", "client_mcp_unreachable", "client_mcp_unsupported", "conflict", "context_window_unavailable", "cursor_expired", "cursor_malformed", "draining", "dream_apply_failed", "dream_capacity", "dream_conflict", "dream_deadline", "dream_generate_failed", "dream_in_progress", "dream_not_found", "dream_request_failed", "dream_terminal_conflict", "dream_unavailable", "environment_logical_root_unavailable", "execution_templates_disabled", "failed_precondition", "failed_step_retry_ineligible", "fire_now_overlap", "identity_broker_unavailable", "internal", "invalid_argument", "learning_unavailable", "management_unauthorized", "mcp_connector_unavailable", "mcp_authorization_pending", "no_active_run", "no_event_log", "no_mcp_provider", "no_schedule_store", "not_awaiting_plan", "not_found", "placement_binding_invalid", "placement_changed", "placement_selector_invalid", "placement_selector_not_found", "placement_selector_stale", "placement_unavailable", "plan_resolution_required", "proposal_conflict", "reflection_cancelled", "reflection_deadline", "reflection_failed", "reflection_queue_full", "request_too_large", "resource_exhausted", "schedule_disabled", "schedule_exhausted", "schedule_not_found", "schedule_not_leader", "schedule_unsupported", "scheduler_not_running", "session_delete_unsupported", "session_leased_elsewhere", "session_metadata_cursor_restart", "session_metadata_paging_unsupported", "session_not_found", "skill_file_not_found", "skill_file_not_text", "skill_file_too_large", "skill_not_found", "stale_run_control", "storage_health_backend", "team_not_found", "team_not_running", "team_running", "teams_disabled", "too_many_session_engines", "too_many_teams", "unauthenticated", "unimplemented", "watch_capacity", "watch_lagging", "watch_unsupported"]; +export const MECATL_ERROR_CODES: readonly ["activity_gap", "ask_not_pending", "approval_grant_ineligible", "approval_intent_mismatch", "approval_not_pending", "approval_unsupported", "attempt_live_claim_conflict", "attempt_terminal_conflict", "attempt_version_conflict", "child_not_found", "cleanup_backend", "cleanup_plan_stale", "cleanup_unsupported", "client_mcp_unreachable", "client_mcp_unsupported", "conflict", "context_window_unavailable", "cursor_expired", "cursor_malformed", "draining", "dream_apply_failed", "dream_capacity", "dream_conflict", "dream_deadline", "dream_generate_failed", "dream_in_progress", "dream_not_found", "dream_request_failed", "dream_terminal_conflict", "dream_unavailable", "environment_logical_root_unavailable", "execution_templates_disabled", "failed_precondition", "failed_step_retry_ineligible", "fire_now_overlap", "identity_broker_unavailable", "internal", "invalid_argument", "learning_unavailable", "management_unauthorized", "mcp_connector_unavailable", "mcp_authorization_pending", "no_active_run", "no_event_log", "no_mcp_provider", "no_schedule_store", "not_awaiting_plan", "not_found", "placement_binding_invalid", "placement_changed", "placement_selector_invalid", "placement_selector_not_found", "placement_selector_stale", "placement_unavailable", "plan_resolution_required", "proposal_conflict", "reflection_cancelled", "reflection_deadline", "reflection_failed", "reflection_queue_full", "request_too_large", "resource_exhausted", "schedule_disabled", "schedule_exhausted", "schedule_not_found", "schedule_not_leader", "schedule_unsupported", "scheduler_not_running", "session_delete_unsupported", "session_leased_elsewhere", "session_metadata_cursor_restart", "session_metadata_paging_unsupported", "session_mode_not_admitted", "session_not_found", "skill_file_not_found", "skill_file_not_text", "skill_file_too_large", "skill_not_found", "stale_run_control", "storage_health_backend", "team_not_found", "team_not_running", "team_running", "teams_disabled", "too_many_session_engines", "too_many_teams", "unauthenticated", "unimplemented", "watch_capacity", "watch_lagging", "watch_unsupported"]; // @public export const MECATL_EVENT_KINDS: readonly ["approval", "authorization.required", "authorization.resolved", "compaction", "compaction.archive", "control.refused", "hook", "message.delta", "model.retry", "network.attempt", "no_progress", "parallel.branch", "parallel.end", "parallel.start", "permission.ask", "permission.retract", "plan.continuation_failed", "provider.route", "reasoning.delta", "recover_notice", "request.manifest", "result", "schedule.failed", "schedule.fired", "schedule.skipped", "session.init", "session.title", "steer", "steer.outcome", "subagent.end", "subagent.start", "subagent.tool", "team.end", "team.findings", "team.member", "team.start", "team.tasks", "tool.call", "tool.progress", "tool.result", "tool.result.available", "turn.end", "turn.start", "user_prompt"]; diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index c2e514558c..7920e66ff6 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -75,6 +75,7 @@ export const MECATL_ERROR_CODES = [ "session_leased_elsewhere", "session_metadata_cursor_restart", "session_metadata_paging_unsupported", + "session_mode_not_admitted", "session_not_found", "skill_file_not_found", "skill_file_not_text", diff --git a/sdk/typescript/src/gen/mecatl/v1/harness_pb.ts b/sdk/typescript/src/gen/mecatl/v1/harness_pb.ts index 16ea3326ed..6a81b5f69d 100644 --- a/sdk/typescript/src/gen/mecatl/v1/harness_pb.ts +++ b/sdk/typescript/src/gen/mecatl/v1/harness_pb.ts @@ -10029,7 +10029,8 @@ export type ApprovePlanRequest = Message<"mecatl.v1.ApprovePlanRequest"> & { /** * target_mode selects the verdict / resulting posture: DEFAULT → allow-once * (flip to default), ACCEPT_EDITS → allow-always (flip to accept-edits), - * PLAN/UNSPECIFIED → deny (iterate, no flip, no continuation run). + * PLAN/UNSPECIFIED/unrecognized → deny (iterate, no flip, no continuation + * run). * * @generated from field: mecatl.v1.PermissionMode target_mode = 2; */ @@ -11152,7 +11153,12 @@ export const HarnessService: GenService<{ * One exception, advertised by the refused_mode_recovery feature: for a stored * session awaiting an approval in a mode the server no longer admits, a change * to an admitted, strictly lower mode cancels the pending turn without running - * it and applies the mode. + * it and applies the mode. The session's event log then records, for the + * parked run, a permission.retract of the open ask, a cancellation tool result + * for each unanswered call, and a cancelled result. The same feature covers a + * stored session a crash left running in such a mode: when no server still + * drives that run, a change to an admitted mode ends the interrupted turn + * without running it and applies the mode. * * @generated from rpc mecatl.v1.HarnessService.SetMode */ @@ -12034,9 +12040,9 @@ export const HarnessService: GenService<{ * - PERMISSION_MODE_DEFAULT → allow-once (the session flips to DEFAULT). * - PERMISSION_MODE_ACCEPT_EDITS → allow-always (the session flips to * ACCEPT_EDITS). - * - PERMISSION_MODE_PLAN / UNSPECIFIED → deny (the run stays in plan mode; - * no continuation run starts — the model iterates on the plan on the next - * prompt). + * - PERMISSION_MODE_PLAN / UNSPECIFIED / an unrecognized value → deny (the + * run stays in plan mode; no continuation run starts — the model iterates + * on the plan on the next prompt). * * `note` is an OPTIONAL operator remark appended to the proceed message * injected into the continuation run's first user message (allow paths only). diff --git a/user-docs/features/security-and-execution/permissions-and-posture.md b/user-docs/features/security-and-execution/permissions-and-posture.md index 73573a1392..396e7fc2dc 100644 --- a/user-docs/features/security-and-execution/permissions-and-posture.md +++ b/user-docs/features/security-and-execution/permissions-and-posture.md @@ -131,11 +131,19 @@ started. If the server restarts with a lower ceiling, or without the checker `auto` needs, a stored session in a mode it no longer admits refuses to run, resume an approval, fork, or clear until its owner switches it to an admitted mode. Its -history is unchanged. If that session is waiting on an approval, switch it to an +history is unchanged. A resumed subagent gets the same check, as described in +[Subagents under auto](#subagents-under-auto). If that session is waiting on an approval, switch it to an admitted mode lower than its stored one: the server cancels the pending request without running it, then applies the mode, and your next prompt continues the -conversation. Servers that support this advertise the `refused_mode_recovery` -feature identifier. If the session is waiting on an MCP server authorization, +conversation. Clients watching the session's event log see the request +withdrawn and the run end as cancelled. Servers that support this advertise the +`refused_mode_recovery` feature identifier. In `mecatui`, see +[Recover a pending approval](/mecatui/sessions.md#recover-a-pending-approval). +If a server crashed while the session was running, switch it to any admitted +mode. The server ends the interrupted turn without running anything, records +each unfinished tool call as interrupted, then applies the mode. The switch is +refused while another server still holds the session's lease. +If the session is waiting on an MCP server authorization, completing or cancelling that authorization records a result for the waiting tool call without running it or resuming the turn, and the session then accepts a switch to an admitted mode. @@ -222,6 +230,12 @@ Subagents, `Parallel` branches, and team members started from an `auto` or `permissionMode` keeps that mode instead. Children of a `plan`, `default`, or `accept-edits` session run in `default` mode. +A resumed subagent runs in the narrower of its stored mode and the mode its +parent session has now, and checker enforcement follows the subagent's own mode. +If that mode is `auto` and the server has no enforcing guardrails checker, the +resume is refused with a tool error and the subagent's history is unchanged. +The agent can start a new subagent instead. + Under `auto`, the main agent and subagents keep the command-substitution guard. A Shell command containing `$(...)`, backticks, or a subshell that Mecatl cannot prove read-only is not run automatically; it becomes an approval request. diff --git a/user-docs/mecatui/keybindings.md b/user-docs/mecatui/keybindings.md index 401653c1bc..6afc431c6a 100644 --- a/user-docs/mecatui/keybindings.md +++ b/user-docs/mecatui/keybindings.md @@ -23,7 +23,7 @@ bindings and dims features that the connected server does not provide. |`esc`|Clear an active selection first. While work is running, cancel directly and preserve the draft, queued follow-ups, and steer. While idle with a paused queue, clear that queue but preserve the draft.| |`ctrl+t`|Open `/toolcalls` for the current session; approval requests use it for details.| |`f9`|Reveal conversation details: reasoning, turn stats, changed files, permanent error details, benign guardrail notices, and individual settled tool-call summaries. Use `ctrl+t` for tool arguments and results.| -|`shift+tab`|Cycle every permission mode: **default → plan → accept-edits → auto → yolo → default**. A mode above the server's ceiling keeps the current mode and your draft, and the footer says how to make it available. In an MCP prompt argument form, it instead moves to the previous required field.| +|`shift+tab`|Cycle every permission mode: **default → plan → accept-edits → auto → yolo → default**. A mode above the server's ceiling keeps the current mode and your draft, and the footer says how to make it available. In an MCP prompt argument form, it instead moves to the previous required field. In a recovered approval that the server refuses because of the chat's stored mode, it switches to the next lower mode and cancels the request; see [Recover a pending approval](./sessions.md#recover-a-pending-approval).| |`ctrl+g`|Select all prompt text.| |`ctrl+y`|Copy the active prompt or visible conversation selection.| |`f6` / `f7` / `f8`|Open Agents / Effort / MCP Prompts.| diff --git a/user-docs/mecatui/sessions.md b/user-docs/mecatui/sessions.md index 75241c9542..40afed85e3 100644 --- a/user-docs/mecatui/sessions.md +++ b/user-docs/mecatui/sessions.md @@ -53,6 +53,14 @@ original tool, arguments, and reason. Choose **Allow once** or **Deny**. The client follows the same run and displays its later model messages and tool results. **Allow always** is unavailable during recovery. +If the server no longer admits the chat's stored permission mode, it refuses +the approval, and the footer says so. Press `shift+tab` to switch the chat to +the next lower mode. The server cancels the pending request without running the +tool, the tool call shows as interrupted, and the chat returns to idle with your +draft kept. If the server refuses that mode too, the request stays pending and +the footer shows the server's reason; press `shift+tab` again to try a lower +mode. This needs a server that advertises the `refused_mode_recovery` feature. + `--resume-latest` continues to exclude chats that are waiting for approval. A `--prompt` value remains in the composer as a draft. `mecatui` does not send it before your choice or automatically send it after the recovered run finishes. diff --git a/user-docs/reference/grpc-api.md b/user-docs/reference/grpc-api.md index 58a0b4a848..0ea52f139b 100644 --- a/user-docs/reference/grpc-api.md +++ b/user-docs/reference/grpc-api.md @@ -80,7 +80,7 @@ run carrying the proceed message. One stream carries both runs' events. |-|-| |`DEFAULT`|Allow once, switch to default mode, and start the continuation.| |`ACCEPT_EDITS`|Allow always, switch to accept-edits mode, and start the continuation.| -|`PLAN` or `UNSPECIFIED`|Deny and iterate in plan mode, with no mode switch or continuation.| +|`PLAN`, `UNSPECIFIED`, or an unrecognized value|Deny and iterate in plan mode, with no mode switch or continuation.| A live run returns `FAILED_PRECONDITION`; resolve its ask with the `Converse` `resume_approval` frame instead. A session that is not `awaiting` a diff --git a/user-docs/reference/grpc-schema.md b/user-docs/reference/grpc-schema.md index b964ad29a2..04938423e5 100644 --- a/user-docs/reference/grpc-schema.md +++ b/user-docs/reference/grpc-schema.md @@ -39,7 +39,7 @@ the bidi Converse stream that drives one agent run. | **RPC:** `ListGuardrailCoverage`
**Request:** `ListGuardrailCoverageRequest`
**Response:** `ListGuardrailCoverageResponse` | Client streamingNo | Server streamingNo | | | **RPC:** `GetGuardrailReviewDetail`
**Request:** `GetGuardrailReviewDetailRequest`
**Response:** `GetGuardrailReviewDetailResponse` | Client streamingNo | Server streamingNo | | | **RPC:** `GetSessionTranscript`
**Request:** `GetSessionTranscriptRequest`
**Response:** `GetSessionTranscriptResponse` | Client streamingNo | Server streamingNo | DescriptionGetSessionTranscript returns the authoritative, snapshot-derived human transcript for one owned session. It is read-only and does not use EventLog. | -| **RPC:** `SetMode`
**Request:** `SetModeRequest`
**Response:** `SetModeResponse` | Client streamingNo | Server streamingNo | DescriptionSetMode changes an existing session's permission posture. The session aggregate remains authoritative: a mid-turn change is rejected with InvalidArgument, so clients that want "next prompt" semantics must defer and retry once idle. One exception, advertised by the refused_mode_recovery feature: for a stored session awaiting an approval in a mode the server no longer admits, a change to an admitted, strictly lower mode cancels the pending turn without running it and applies the mode. | +| **RPC:** `SetMode`
**Request:** `SetModeRequest`
**Response:** `SetModeResponse` | Client streamingNo | Server streamingNo | DescriptionSetMode changes an existing session's permission posture. The session aggregate remains authoritative: a mid-turn change is rejected with InvalidArgument, so clients that want "next prompt" semantics must defer and retry once idle. One exception, advertised by the refused_mode_recovery feature: for a stored session awaiting an approval in a mode the server no longer admits, a change to an admitted, strictly lower mode cancels the pending turn without running it and applies the mode. The session's event log then records, for the parked run, a permission.retract of the open ask, a cancellation tool result for each unanswered call, and a cancelled result. The same feature covers a stored session a crash left running in such a mode: when no server still drives that run, a change to an admitted mode ends the interrupted turn without running it and applies the mode. | | **RPC:** `CloseSession`
**Request:** `CloseSessionRequest`
**Response:** `CloseSessionResponse` | Client streamingNo | Server streamingNo | DescriptionCloseSession ends a session and releases its server-side resources (learned permission rules, bound placement, and any per-session engine). Idempotent: closing an unknown or already-closed session via the wire returns NotFound only for a never-created id; an already-released session succeeds. | | **RPC:** `RenameSession`
**Request:** `RenameSessionRequest`
**Response:** `RenameSessionResponse` | Client streamingNo | Server streamingNo | DescriptionRenameSession explicitly replaces an idle main session's title. | | **RPC:** `DeleteSession`
**Request:** `DeleteSessionRequest`
**Response:** `DeleteSessionResponse` | Client streamingNo | Server streamingNo | DescriptionDeleteSession physically removes an idle main session and its sidecars. | @@ -107,7 +107,7 @@ the bidi Converse stream that drives one agent run. | **RPC:** `RunTeam`
**Request:** `RunTeamRequest`
**Response:** `TeamEvent` | Client streamingNo | Server streamingYes | DescriptionRunTeam drives the team to quiescence, streaming every member's events — each tagged with the member name — until the team finishes. The stream ends with a single terminal frame carrying TeamEvent.outcome (rounds, stop, budget_exhausted, usage, dispositions, findings); no member events follow it. | | **RPC:** `ListTeam`
**Request:** `ListTeamRequest`
**Response:** `ListTeamResponse` | Client streamingNo | Server streamingNo | DescriptionListTeam returns a snapshot of the team roster, the shared task list, and whether the team has reached quiescence. | | **RPC:** `CleanupTeam`
**Request:** `CleanupTeamRequest`
**Response:** `CleanupTeamResponse` | Client streamingNo | Server streamingNo | DescriptionCleanupTeam tears down a finished team and releases its resources. | -| **RPC:** `ApprovePlan`
**Request:** `ApprovePlanRequest`
**Response:** `Event` | Client streamingNo | Server streamingYes | DescriptionApprovePlan atomically resolves a parked plan-approval ask (a PresentPlan call surfaced in plan mode, issue #206) and — on an ALLOW verdict — starts a fresh continuation run carrying the harness proceed message, streaming BOTH the resumed run's and the continuation run's events on the one response stream. It composes EXISTING seams (Service.resumeFromAwaiting + StartRunContent over the engine's ResumeApproval): the resumed run applies the verdict, sets the plan-approved target mode, terminates with StopPlanApproved, and flips the session mode at the terminal boundary; the continuation run then drives the model on the FLIPPED mode (default / accept-edits) with the proceed message as ordinary recorded history. target_mode → verdict mapping: - PERMISSION_MODE_DEFAULT → allow-once (the session flips to DEFAULT). - PERMISSION_MODE_ACCEPT_EDITS → allow-always (the session flips to ACCEPT_EDITS). - PERMISSION_MODE_PLAN / UNSPECIFIED → deny (the run stays in plan mode; no continuation run starts — the model iterates on the plan on the next prompt). `note` is an OPTIONAL operator remark appended to the proceed message injected into the continuation run's first user message (allow paths only). Preconditions (else FailedPrecondition): the session must NOT have a live in-flight run (an approve mid-run is rejected — use the Converse ResumeApproval frame for a live run), and it MUST be in the awaiting state parked on a PLAN-ORIGINATED ask (not a generic tool-permission ask). An unknown session yields NotFound. The stream ends with the terminal `result` event of whichever run ran last (the continuation run on an allow path; the resumed run on a deny path). | +| **RPC:** `ApprovePlan`
**Request:** `ApprovePlanRequest`
**Response:** `Event` | Client streamingNo | Server streamingYes | DescriptionApprovePlan atomically resolves a parked plan-approval ask (a PresentPlan call surfaced in plan mode, issue #206) and — on an ALLOW verdict — starts a fresh continuation run carrying the harness proceed message, streaming BOTH the resumed run's and the continuation run's events on the one response stream. It composes EXISTING seams (Service.resumeFromAwaiting + StartRunContent over the engine's ResumeApproval): the resumed run applies the verdict, sets the plan-approved target mode, terminates with StopPlanApproved, and flips the session mode at the terminal boundary; the continuation run then drives the model on the FLIPPED mode (default / accept-edits) with the proceed message as ordinary recorded history. target_mode → verdict mapping: - PERMISSION_MODE_DEFAULT → allow-once (the session flips to DEFAULT). - PERMISSION_MODE_ACCEPT_EDITS → allow-always (the session flips to ACCEPT_EDITS). - PERMISSION_MODE_PLAN / UNSPECIFIED / an unrecognized value → deny (the run stays in plan mode; no continuation run starts — the model iterates on the plan on the next prompt). `note` is an OPTIONAL operator remark appended to the proceed message injected into the continuation run's first user message (allow paths only). Preconditions (else FailedPrecondition): the session must NOT have a live in-flight run (an approve mid-run is rejected — use the Converse ResumeApproval frame for a live run), and it MUST be in the awaiting state parked on a PLAN-ORIGINATED ask (not a generic tool-permission ask). An unknown session yields NotFound. The stream ends with the terminal `result` event of whichever run ran last (the continuation run on an allow path; the resumed run on a deny path). | | **RPC:** `ConnectWorkspaceServices`
**Request:** `WorkspaceEnrollmentConnectRequest`
**Response:** `WorkspaceEnrollment` | Client streamingNo | Server streamingNo | DescriptionWorkspace enrollment is a client-owned pre-prompt bundle. These unary controls carry only whole-bundle correlation; no backend selector exists. | | **RPC:** `RetryWorkspaceEnrollment`
**Request:** `WorkspaceEnrollmentControlRequest`
**Response:** `WorkspaceEnrollment` | Client streamingNo | Server streamingNo | | | **RPC:** `CancelWorkspaceEnrollment`
**Request:** `WorkspaceEnrollmentControlRequest`
**Response:** `WorkspaceEnrollment` | Client streamingNo | Server streamingNo | | @@ -224,7 +224,7 @@ atomic resume + continuation contract. | Field | Type | Label | Oneof | Description | |---|---|---|---|---| | `session_id` | `string` | | | session_id is the session parked awaiting a plan-approval ask. | -| `target_mode` | `PermissionMode` | | | target_mode selects the verdict / resulting posture: DEFAULT → allow-once (flip to default), ACCEPT_EDITS → allow-always (flip to accept-edits), PLAN/UNSPECIFIED → deny (iterate, no flip, no continuation run). | +| `target_mode` | `PermissionMode` | | | target_mode selects the verdict / resulting posture: DEFAULT → allow-once (flip to default), ACCEPT_EDITS → allow-always (flip to accept-edits), PLAN/UNSPECIFIED/unrecognized → deny (iterate, no flip, no continuation run). | | `note` | `string` | | | note is an OPTIONAL operator remark appended to the proceed message injected into the continuation run's first user message (allow paths only). | diff --git a/user-docs/reference/http-sse-api.md b/user-docs/reference/http-sse-api.md index 21b29ab8e9..1641be02e4 100644 --- a/user-docs/reference/http-sse-api.md +++ b/user-docs/reference/http-sse-api.md @@ -482,8 +482,8 @@ $ curl -s -N -X POST http://127.0.0.1:8081/v1/sessions//plan:approve - `"default"` → allow-once: flip to `default` mode (deny→ask→allow) and execute. - `"accept_edits"` → allow-always: flip to `acceptEdits` mode (auto-accept edits for the execution phase). -- `"plan"` or `""` → deny/iterate: stay in plan mode, no continuation run starts - (the model re-plans on the next prompt). +- `"plan"`, `""`, or an unrecognized value → deny/iterate: stay in plan mode, no + continuation run starts (the model re-plans on the next prompt). A `409` means a precondition failed: the session has a live run (use the `Converse` `resume_approval` frame for an in-flight run), is not `awaiting`, or diff --git a/user-docs/reference/typescript-sdk-api/core.md b/user-docs/reference/typescript-sdk-api/core.md index b99a4df4e6..6a9f0b3d18 100644 --- a/user-docs/reference/typescript-sdk-api/core.md +++ b/user-docs/reference/typescript-sdk-api/core.md @@ -7444,7 +7444,7 @@ MECATL_ATTACH_FILTERED_KINDS: readonly ["approval", "compaction.archive", "netwo Stable server error codes, kept in parity with the Go registry. ```ts -MECATL_ERROR_CODES: readonly ["activity_gap", "ask_not_pending", "approval_grant_ineligible", "approval_intent_mismatch", "approval_not_pending", "approval_unsupported", "attempt_live_claim_conflict", "attempt_terminal_conflict", "attempt_version_conflict", "child_not_found", "cleanup_backend", "cleanup_plan_stale", "cleanup_unsupported", "client_mcp_unreachable", "client_mcp_unsupported", "conflict", "context_window_unavailable", "cursor_expired", "cursor_malformed", "draining", "dream_apply_failed", "dream_capacity", "dream_conflict", "dream_deadline", "dream_generate_failed", "dream_in_progress", "dream_not_found", "dream_request_failed", "dream_terminal_conflict", "dream_unavailable", "environment_logical_root_unavailable", "execution_templates_disabled", "failed_precondition", "failed_step_retry_ineligible", "fire_now_overlap", "identity_broker_unavailable", "internal", "invalid_argument", "learning_unavailable", "management_unauthorized", "mcp_connector_unavailable", "mcp_authorization_pending", "no_active_run", "no_event_log", "no_mcp_provider", "no_schedule_store", "not_awaiting_plan", "not_found", "placement_binding_invalid", "placement_changed", "placement_selector_invalid", "placement_selector_not_found", "placement_selector_stale", "placement_unavailable", "plan_resolution_required", "proposal_conflict", "reflection_cancelled", "reflection_deadline", "reflection_failed", "reflection_queue_full", "request_too_large", "resource_exhausted", "schedule_disabled", "schedule_exhausted", "schedule_not_found", "schedule_not_leader", "schedule_unsupported", "scheduler_not_running", "session_delete_unsupported", "session_leased_elsewhere", "session_metadata_cursor_restart", "session_metadata_paging_unsupported", "session_not_found", "skill_file_not_found", "skill_file_not_text", "skill_file_too_large", "skill_not_found", "stale_run_control", "storage_health_backend", "team_not_found", "team_not_running", "team_running", "teams_disabled", "too_many_session_engines", "too_many_teams", "unauthenticated", "unimplemented", "watch_capacity", "watch_lagging", "watch_unsupported"] +MECATL_ERROR_CODES: readonly ["activity_gap", "ask_not_pending", "approval_grant_ineligible", "approval_intent_mismatch", "approval_not_pending", "approval_unsupported", "attempt_live_claim_conflict", "attempt_terminal_conflict", "attempt_version_conflict", "child_not_found", "cleanup_backend", "cleanup_plan_stale", "cleanup_unsupported", "client_mcp_unreachable", "client_mcp_unsupported", "conflict", "context_window_unavailable", "cursor_expired", "cursor_malformed", "draining", "dream_apply_failed", "dream_capacity", "dream_conflict", "dream_deadline", "dream_generate_failed", "dream_in_progress", "dream_not_found", "dream_request_failed", "dream_terminal_conflict", "dream_unavailable", "environment_logical_root_unavailable", "execution_templates_disabled", "failed_precondition", "failed_step_retry_ineligible", "fire_now_overlap", "identity_broker_unavailable", "internal", "invalid_argument", "learning_unavailable", "management_unauthorized", "mcp_connector_unavailable", "mcp_authorization_pending", "no_active_run", "no_event_log", "no_mcp_provider", "no_schedule_store", "not_awaiting_plan", "not_found", "placement_binding_invalid", "placement_changed", "placement_selector_invalid", "placement_selector_not_found", "placement_selector_stale", "placement_unavailable", "plan_resolution_required", "proposal_conflict", "reflection_cancelled", "reflection_deadline", "reflection_failed", "reflection_queue_full", "request_too_large", "resource_exhausted", "schedule_disabled", "schedule_exhausted", "schedule_not_found", "schedule_not_leader", "schedule_unsupported", "scheduler_not_running", "session_delete_unsupported", "session_leased_elsewhere", "session_metadata_cursor_restart", "session_metadata_paging_unsupported", "session_mode_not_admitted", "session_not_found", "skill_file_not_found", "skill_file_not_text", "skill_file_too_large", "skill_not_found", "stale_run_control", "storage_health_backend", "team_not_found", "team_not_running", "team_running", "teams_disabled", "too_many_session_engines", "too_many_teams", "unauthenticated", "unimplemented", "watch_capacity", "watch_lagging", "watch_unsupported"] ``` MECATL_EVENT_KINDS