Files
tutti/packages/agent/host/goal_generation_fence.go
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

450 lines
17 KiB
Go

package agenthost
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
storesqlite "github.com/tutti-os/tutti/packages/agent/store-sqlite"
)
const (
goalGenerationFenceLeaseDuration = 2 * time.Minute
goalGenerationFenceClearPrefix = "goal-generation-fence:"
)
func goalGenerationFenceID(workspaceID, agentSessionID, targetOperationID, clientSubmitID string) string {
stable := strings.TrimSpace(clientSubmitID)
if stable == "" {
stable = strings.TrimSpace(targetOperationID)
}
name := strings.Join([]string{
strings.TrimSpace(workspaceID), strings.TrimSpace(agentSessionID), stable,
}, "\x00")
return uuid.NewSHA1(uuid.NameSpaceURL, []byte("goal-generation-fence\x00"+name)).String()
}
// FindGoalControlOperationByClientSubmitID resolves the Host-owned operation
// identity after an accept-before-response crash without exposing or
// duplicating the deterministic ID algorithm in consumers.
func (h *Host) FindGoalControlOperationByClientSubmitID(
ctx context.Context,
ref SessionRef,
clientSubmitID string,
) (storesqlite.GoalControlOperation, bool, error) {
ref.WorkspaceID = strings.TrimSpace(ref.WorkspaceID)
ref.AgentSessionID = strings.TrimSpace(ref.AgentSessionID)
clientSubmitID = strings.TrimSpace(clientSubmitID)
if h == nil || h.goals == nil || ref.WorkspaceID == "" || ref.AgentSessionID == "" || clientSubmitID == "" {
return storesqlite.GoalControlOperation{}, false, ErrInvalidArgument
}
operationID := goalControlOperationID(ref.WorkspaceID, ref.AgentSessionID, clientSubmitID)
operation, found, err := h.goals.GetGoalControlOperation(ctx, ref.WorkspaceID, operationID)
if err != nil || !found {
return operation, found, err
}
if operation.AgentSessionID != ref.AgentSessionID || operation.ClientSubmitID != clientSubmitID {
return storesqlite.GoalControlOperation{}, false, storesqlite.ErrGoalOperationConflict
}
return operation, true, nil
}
// FenceGoalGeneration durably revokes one exact Goal operation generation.
// IntentAccepted means Host owns every remaining retry; it does not claim that
// provider quiescence or canonical settlement already completed.
func (h *Host) FenceGoalGeneration(ctx context.Context, input FenceGoalGenerationInput) (FenceGoalGenerationResult, error) {
input.WorkspaceID = strings.TrimSpace(input.WorkspaceID)
input.AgentSessionID = strings.TrimSpace(input.AgentSessionID)
input.TargetOperationID = strings.TrimSpace(input.TargetOperationID)
input.ClientSubmitID = strings.TrimSpace(input.ClientSubmitID)
input.Reason = strings.TrimSpace(input.Reason)
if h == nil || h.goals == nil || h.goalFences == nil || input.WorkspaceID == "" || input.AgentSessionID == "" ||
input.TargetOperationID == "" || input.ClientSubmitID == "" {
return FenceGoalGenerationResult{}, ErrInvalidArgument
}
if _, ok := h.runtime.(RuntimeSessionLiveness); !ok {
return FenceGoalGenerationResult{}, ErrRuntimeSessionLivenessUnavailable
}
fence, _, err := h.goalFences.PrepareGoalGenerationFence(ctx, storesqlite.GoalGenerationFencePrepare{
FenceID: goalGenerationFenceID(input.WorkspaceID, input.AgentSessionID, input.TargetOperationID, input.ClientSubmitID),
WorkspaceID: input.WorkspaceID, AgentSessionID: input.AgentSessionID,
TargetOperationID: input.TargetOperationID, ClientSubmitID: input.ClientSubmitID,
Reason: input.Reason, OccurredAtUnixMS: h.goalOperationNow().UnixMilli(),
})
if err != nil {
return FenceGoalGenerationResult{}, err
}
result := FenceGoalGenerationResult{
Fence: fence, IntentAccepted: true,
Settled: fence.Status == storesqlite.GoalGenerationFenceStatusCompleted,
}
if result.Settled {
return result, nil
}
processed, processErr := h.processGoalGenerationFenceSerialized(ctx, fence, false)
if processed.FenceID != "" {
result.Fence = processed
result.Settled = processed.Status == storesqlite.GoalGenerationFenceStatusCompleted
}
return result, processErr
}
func (h *Host) StepGoalGenerationFenceWorker(ctx context.Context) error {
return h.stepGoalGenerationFenceWorker(ctx, false)
}
func (h *Host) stepGoalGenerationFenceWorker(ctx context.Context, recovering bool) error {
if h == nil || h.goalFences == nil {
return nil
}
fences, err := h.goalFences.ListClaimableGoalGenerationFences(ctx, storesqlite.ListClaimableGoalGenerationFencesInput{
NowUnixMS: h.goalOperationNow().UnixMilli(), Limit: goalOperationBatchSize,
})
if err != nil {
return err
}
var errs []error
for _, fence := range fences {
if ctx.Err() != nil {
break
}
if _, processErr := h.processGoalGenerationFenceSerialized(ctx, fence, recovering); processErr != nil &&
!errors.Is(processErr, ErrRuntimeOperationInProgress) {
errs = append(errs, fmt.Errorf("process goal generation fence %s: %w", fence.FenceID, processErr))
}
}
return errors.Join(errs...)
}
func (h *Host) processGoalGenerationFenceSerialized(
ctx context.Context,
fence storesqlite.GoalGenerationFence,
recovering bool,
) (storesqlite.GoalGenerationFence, error) {
var result storesqlite.GoalGenerationFence
err := h.withSessionMutationActor(ctx, fence.WorkspaceID, fence.AgentSessionID, func(commandCtx context.Context) error {
return h.withGoalActor(commandCtx, fence.WorkspaceID, fence.AgentSessionID, func(actorCtx context.Context) error {
var processErr error
result, processErr = h.processGoalGenerationFence(actorCtx, fence, recovering)
return processErr
})
})
return result, err
}
func (h *Host) processGoalGenerationFence(
ctx context.Context,
candidate storesqlite.GoalGenerationFence,
recovering bool,
) (storesqlite.GoalGenerationFence, error) {
clearOperationID, err := h.ensureGoalGenerationFenceClear(ctx, candidate)
if err != nil {
return candidate, err
}
now := h.goalOperationNow()
fence, claimed, err := h.goalFences.ClaimGoalGenerationFence(ctx, storesqlite.ClaimGoalGenerationFenceInput{
FenceID: candidate.FenceID, LeaseOwner: h.goalOperationOwner(),
NowUnixMS: now.UnixMilli(), LeaseExpiresAtMS: now.Add(goalGenerationFenceLeaseDuration).UnixMilli(),
})
if err != nil || !claimed {
if err != nil {
return candidate, err
}
return candidate, ErrRuntimeOperationInProgress
}
if clearOperationID == "" {
clearOperationID = strings.TrimSpace(fence.ClearOperationID)
}
retry := func(cause error) (storesqlite.GoalGenerationFence, error) {
retryNow := h.goalOperationNow()
released, _, releaseErr := h.goalFences.ReleaseGoalGenerationFence(ctx, storesqlite.ReleaseGoalGenerationFenceInput{
FenceID: fence.FenceID, LeaseOwner: h.goalOperationOwner(), LastError: cause.Error(),
NowUnixMS: retryNow.UnixMilli(), NextAttemptAtMS: runtimeOperationNextAttemptAt(retryNow, fence.Attempt, false),
ClearOperationID: clearOperationID,
})
return released, errors.Join(cause, releaseErr)
}
deferPending := func(reason string) (storesqlite.GoalGenerationFence, error) {
retryNow := h.goalOperationNow()
released, _, releaseErr := h.goalFences.ReleaseGoalGenerationFence(ctx, storesqlite.ReleaseGoalGenerationFenceInput{
FenceID: fence.FenceID, LeaseOwner: h.goalOperationOwner(), LastError: reason,
NowUnixMS: retryNow.UnixMilli(), NextAttemptAtMS: runtimeOperationNextAttemptAt(retryNow, fence.Attempt, false),
ClearOperationID: clearOperationID,
})
return released, releaseErr
}
completeLocally := func() (storesqlite.GoalGenerationFence, error) {
if err := h.completeGoalGenerationFenceClearLocally(ctx, fence, clearOperationID); err != nil {
return retry(err)
}
return h.completeClaimedGoalGenerationFence(ctx, fence, clearOperationID)
}
// Startup recovery has no local provider process to quiesce. The durable
// conditional clear above is the restart-time source of truth, so finish it
// locally instead of reconnecting a provider merely to stop work that is no
// longer running.
if recovering && !h.runtimeSessionLive(fence.WorkspaceID, fence.AgentSessionID) {
return completeLocally()
}
if err = h.applyGoalGenerationFence(ctx, fence); err != nil {
if recovering && !h.runtimeSessionLive(fence.WorkspaceID, fence.AgentSessionID) &&
(errors.Is(err, ErrRuntimeSessionDisconnected) || errors.Is(err, ErrSessionNotFound)) {
return completeLocally()
}
if errors.Is(err, ErrRuntimeSessionDisconnected) {
return deferPending("waiting for a live provider session")
}
return retry(err)
}
turnSettled, err := h.cancelActiveTurnForGoalFence(ctx, fence)
if err != nil {
if recovering && !h.runtimeSessionLive(fence.WorkspaceID, fence.AgentSessionID) &&
(errors.Is(err, ErrRuntimeSessionDisconnected) || errors.Is(err, ErrSessionNotFound)) {
return completeLocally()
}
return retry(err)
}
if !turnSettled {
if recovering && !h.runtimeSessionLive(fence.WorkspaceID, fence.AgentSessionID) {
return completeLocally()
}
return deferPending("waiting for the exact fenced Turn to settle")
}
if clearOperationID != "" {
clearOperation, found, clearErr := h.goals.GetGoalControlOperation(ctx, fence.WorkspaceID, clearOperationID)
if clearErr != nil {
return retry(clearErr)
}
if !found {
return retry(errors.New("goal generation fence clear operation is missing"))
}
switch clearOperation.Status {
case storesqlite.GoalOperationStatusCompleted, storesqlite.GoalOperationStatusSuperseded:
case storesqlite.GoalOperationStatusFailed:
return retry(fmt.Errorf("goal generation fence clear failed: %s", strings.TrimSpace(clearOperation.LastError)))
default:
if recoverErr := h.recoverGoalOperation(ctx, clearOperation, false); recoverErr != nil &&
!errors.Is(recoverErr, ErrRuntimeOperationInProgress) {
return retry(recoverErr)
}
clearOperation, found, clearErr = h.goals.GetGoalControlOperation(ctx, fence.WorkspaceID, clearOperationID)
if clearErr != nil {
return retry(clearErr)
}
if !found || (clearOperation.Status != storesqlite.GoalOperationStatusCompleted &&
clearOperation.Status != storesqlite.GoalOperationStatusSuperseded) {
return deferPending("waiting for the conditional Goal clear to settle")
}
}
}
return h.completeClaimedGoalGenerationFence(ctx, fence, clearOperationID)
}
func (h *Host) completeClaimedGoalGenerationFence(
ctx context.Context,
fence storesqlite.GoalGenerationFence,
clearOperationID string,
) (storesqlite.GoalGenerationFence, error) {
completed, changed, err := h.goalFences.CompleteGoalGenerationFence(ctx, storesqlite.CompleteGoalGenerationFenceInput{
FenceID: fence.FenceID, LeaseOwner: h.goalOperationOwner(),
ClearOperationID: clearOperationID, OccurredAtUnixMS: h.goalOperationNow().UnixMilli(),
})
if err != nil || changed {
return completed, err
}
current, found, readErr := h.goalFences.GetGoalGenerationFence(ctx, fence.WorkspaceID, fence.FenceID)
if readErr != nil {
return current, readErr
}
if found && current.Status == storesqlite.GoalGenerationFenceStatusCompleted {
return current, nil
}
return current, ErrRuntimeOperationInProgress
}
func (h *Host) completeGoalGenerationFenceClearLocally(
ctx context.Context,
fence storesqlite.GoalGenerationFence,
clearOperationID string,
) error {
if clearOperationID == "" {
return nil
}
clearOperation, found, err := h.goals.GetGoalControlOperation(ctx, fence.WorkspaceID, clearOperationID)
if err != nil {
return err
}
if !found {
return errors.New("goal generation fence clear operation is missing")
}
if clearOperation.Status == storesqlite.GoalOperationStatusCompleted ||
clearOperation.Status == storesqlite.GoalOperationStatusSuperseded {
return nil
}
completed, _, _, err := h.goals.CompleteGoalControlOperation(ctx, storesqlite.GoalControlOperationComplete{
WorkspaceID: fence.WorkspaceID, OperationID: clearOperation.OperationID,
Mode: storesqlite.GoalControlCompletionModeLocalStop, Succeeded: true,
Evidence: map[string]any{
"source": "goal_generation_fence_recovery",
"fenceId": fence.FenceID,
"targetOperationId": fence.TargetOperationID,
"reason": fence.Reason,
},
OccurredAtUnixMS: h.goalOperationNow().UnixMilli(),
})
if err != nil {
return err
}
if completed.Status != storesqlite.GoalOperationStatusCompleted &&
completed.Status != storesqlite.GoalOperationStatusSuperseded {
return errors.New("goal generation fence clear did not complete locally")
}
return nil
}
func (h *Host) ensureGoalGenerationFenceClear(
ctx context.Context,
fence storesqlite.GoalGenerationFence,
) (string, error) {
if strings.TrimSpace(fence.ClearOperationID) != "" {
return strings.TrimSpace(fence.ClearOperationID), nil
}
clearClientSubmitID := goalGenerationFenceClearPrefix + fence.FenceID
clearOperationID := goalControlOperationID(fence.WorkspaceID, fence.AgentSessionID, clearClientSubmitID)
operation, _, _, err := h.goals.PrepareGoalControlOperation(ctx, storesqlite.GoalControlOperationPrepare{
OperationID: clearOperationID, WorkspaceID: fence.WorkspaceID, AgentSessionID: fence.AgentSessionID,
Action: "clear", ClientSubmitID: clearClientSubmitID, ExpectedRevision: fence.TargetRevision,
OccurredAtUnixMS: h.goalOperationNow().UnixMilli(),
})
if errors.Is(err, storesqlite.ErrGoalGenerationSuperseded) {
return "", nil
}
if err != nil {
return "", err
}
return operation.OperationID, nil
}
func goalGenerationFenceClearOperation(operation storesqlite.GoalControlOperation) bool {
return strings.HasPrefix(strings.TrimSpace(operation.ClientSubmitID), goalGenerationFenceClearPrefix)
}
func (h *Host) runtimeSessionLive(workspaceID, agentSessionID string) bool {
if h == nil || h.runtime == nil {
return false
}
if liveness, ok := h.runtime.(RuntimeSessionLiveness); ok {
return liveness.RuntimeSessionLive(strings.TrimSpace(workspaceID), strings.TrimSpace(agentSessionID))
}
return false
}
func (h *Host) cancelActiveTurnForGoalFence(ctx context.Context, fence storesqlite.GoalGenerationFence) (bool, error) {
session, found, err := h.store.GetSession(ctx, fence.WorkspaceID, fence.AgentSessionID)
if err != nil || !found || strings.TrimSpace(session.ActiveTurnID) == "" {
return err == nil, err
}
turn, found, err := h.store.GetTurn(ctx, fence.WorkspaceID, fence.AgentSessionID, session.ActiveTurnID)
if err != nil || !found || strings.TrimSpace(turn.SourceGoalOperationID) != fence.TargetOperationID ||
turn.SourceGoalRevision != fence.TargetRevision || turn.SourceGoalRepairEpoch != fence.TargetRepairEpoch {
return err == nil, err
}
result, cancelErr := h.CancelTurn(ctx, CancelTurnInput{
WorkspaceID: fence.WorkspaceID, AgentSessionID: fence.AgentSessionID,
TurnID: turn.TurnID, Reason: fence.Reason, RequireLive: true,
})
if result.Settled {
return true, nil
}
if result.IntentAccepted {
return false, nil
}
if errors.Is(cancelErr, ErrRuntimeSessionDisconnected) {
return false, nil
}
return false, cancelErr
}
func (h *Host) restoreGoalGenerationFences(ctx context.Context, ref SessionRef) error {
if h == nil || h.goalFences == nil {
return nil
}
fencer, ok := h.goalRuntime.(GoalRuntimeGenerationFencer)
if !ok {
return ErrGoalGenerationFenceUnavailable
}
fences, err := h.listRuntimeGoalGenerationFences(ctx, ref)
if err != nil {
return err
}
for _, fence := range fences {
if err := fencer.FenceGoalGeneration(ctx, fence); err != nil {
return err
}
}
return nil
}
func (h *Host) listRuntimeGoalGenerationFences(
ctx context.Context,
ref SessionRef,
) ([]RuntimeGoalGenerationFenceInput, error) {
if h == nil || h.goalFences == nil {
return nil, nil
}
fences, err := h.goalFences.ListGoalGenerationFencesForSession(ctx, ref.WorkspaceID, ref.AgentSessionID)
if err != nil {
return nil, err
}
result := make([]RuntimeGoalGenerationFenceInput, 0, len(fences))
for _, fence := range fences {
result = append(result, runtimeGoalGenerationFenceInput(fence, false))
}
return result, nil
}
func (h *Host) restoreGoalGenerationFencesOnce(ctx context.Context, ref SessionRef) error {
key := ref.WorkspaceID + "\x00" + ref.AgentSessionID
if _, restored := h.goalFencesRestored.Load(key); restored {
return nil
}
if err := h.restoreGoalGenerationFences(ctx, ref); err != nil {
return err
}
h.goalFencesRestored.Store(key, struct{}{})
return nil
}
func (h *Host) applyGoalGenerationFence(ctx context.Context, fence storesqlite.GoalGenerationFence) error {
fencer, ok := h.goalRuntime.(GoalRuntimeGenerationFencer)
if !ok {
return ErrGoalGenerationFenceUnavailable
}
return applyGoalGenerationFenceWithRuntime(ctx, fencer, fence, true)
}
func applyGoalGenerationFenceWithRuntime(
ctx context.Context,
fencer GoalRuntimeGenerationFencer,
fence storesqlite.GoalGenerationFence,
requireLive bool,
) error {
return fencer.FenceGoalGeneration(ctx, runtimeGoalGenerationFenceInput(fence, requireLive))
}
func runtimeGoalGenerationFenceInput(
fence storesqlite.GoalGenerationFence,
requireLive bool,
) RuntimeGoalGenerationFenceInput {
return RuntimeGoalGenerationFenceInput{
WorkspaceID: fence.WorkspaceID, AgentSessionID: fence.AgentSessionID,
TargetOperationID: fence.TargetOperationID, TargetRevision: fence.TargetRevision,
TargetRepairEpoch: fence.TargetRepairEpoch, Reason: fence.Reason, RequireLive: requireLive,
}
}