mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* fix(sessions): capture freeform apply_patch checkpoints Freeform apply_patch arguments are raw patch text, but both checkpoint paths passed them to json.Unmarshal and dropped the error, so they found no mutation targets and recorded no checkpoint. The tool still executed, leaving rewind unable to restore the edit. Resolve targets through a shared agent.CheckpointTargets helper that prepares freeform input on the registered built-in apply_patch, exactly as executeToolCall does. This keeps absolute patch headers workspace-relative (rewriting them and setting cwd) instead of handing absolute paths to the checkpoint layer. Non-freeform calls now use the same tolerant decoder as execution, so the two cannot disagree. Covers the CLI, spec-draft, and TUI capture paths. Fixes #1162 * test(sessions): canonicalize freeform absolute-header fixtures t.TempDir returns the symlinked /var spelling on macOS and short names on Windows, while the apply_patch tool resolves the physical root. The absolute-header cases must build the header from the resolved root, as the repo's cross-platform test guidance requires.
323 lines
11 KiB
Go
323 lines
11 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/Twigpine/zero/internal/agent"
|
|
"github.com/Twigpine/zero/internal/config"
|
|
"github.com/Twigpine/zero/internal/execution"
|
|
"github.com/Twigpine/zero/internal/modelregistry"
|
|
"github.com/Twigpine/zero/internal/notify"
|
|
"github.com/Twigpine/zero/internal/sandbox"
|
|
"github.com/Twigpine/zero/internal/sessions"
|
|
"github.com/Twigpine/zero/internal/specmode"
|
|
"github.com/Twigpine/zero/internal/streamjson"
|
|
"github.com/Twigpine/zero/internal/tools"
|
|
"github.com/Twigpine/zero/internal/usage"
|
|
"github.com/Twigpine/zero/internal/zeroruntime"
|
|
)
|
|
|
|
type execSpecDraftRun struct {
|
|
options execOptions
|
|
stdout io.Writer
|
|
stderr io.Writer
|
|
deps appDeps
|
|
workspaceRoot string
|
|
// trustRoot is the ORIGINAL launch directory (captured before any --worktree
|
|
// reassignment), so a --use-spec run inside a --worktree of a trusted repo
|
|
// still keys the trust check on the source repo, not the generated worktree path.
|
|
trustRoot string
|
|
// mcpSkip is the trust verdict from the workspace MCP registration in runExec
|
|
// (which runs before this spec-draft path), so the spec-draft notice can report a
|
|
// dropped project MCP layer instead of leaving it silent.
|
|
mcpSkip trustSkip
|
|
registry *tools.Registry
|
|
modelRegistry modelregistry.Registry
|
|
resolved config.ResolvedConfig
|
|
runMetadata execRunMetadata
|
|
provider zeroruntime.Provider
|
|
sandboxEngine *sandbox.Engine
|
|
prompt string
|
|
sessionTitle string
|
|
images []zeroruntime.ImageBlock
|
|
reasoningEffort string
|
|
specPermissionMode agent.PermissionMode
|
|
notifier *notify.Notifier
|
|
// profilePolicy carries the selected execution profile's escalation policy
|
|
// (nil when none): the profile displaces resolved.MaxTurns before this
|
|
// path branches off, so the spec-draft run needs the same safety net as
|
|
// the main run.
|
|
profilePolicy *agent.ProfilePolicy
|
|
}
|
|
|
|
type execSpecDraftInfo struct {
|
|
SpecID string
|
|
SpecTitle string
|
|
SpecFilePath string
|
|
RelativePath string
|
|
DraftSessionID string
|
|
}
|
|
|
|
func runExecSpecDraft(run execSpecDraftRun) int {
|
|
store := run.deps.newSessionStore()
|
|
draftSession, err := store.Create(sessions.CreateInput{
|
|
SessionID: run.options.initSessionID,
|
|
SessionKind: sessions.SessionKindSpecDraft,
|
|
Title: run.sessionTitle,
|
|
Cwd: run.workspaceRoot,
|
|
ModelID: run.resolved.Provider.Model,
|
|
Provider: run.runMetadata.Provider,
|
|
SpecDraftModelID: run.resolved.Provider.Model,
|
|
SpecDraftReasoning: run.reasoningEffort,
|
|
})
|
|
if err != nil {
|
|
return writeExecFormatUsageError(run.stdout, run.stderr, run.options.outputFormat, err.Error())
|
|
}
|
|
runID, err := streamjson.CreateRunID(run.deps.now())
|
|
if err != nil {
|
|
return writeAppError(run.stderr, "failed to create run id: "+err.Error(), exitCrash)
|
|
}
|
|
writer := execEventWriter{
|
|
stdout: run.stdout,
|
|
stderr: run.stderr,
|
|
format: run.options.outputFormat,
|
|
runID: runID,
|
|
sessionID: draftSession.SessionID,
|
|
streamedText: &strings.Builder{},
|
|
}
|
|
writer.runStart(run.workspaceRoot, run.runMetadata, run.specPermissionMode)
|
|
if writer.err != nil {
|
|
return exitCrash
|
|
}
|
|
|
|
preparedSession := sessions.PreparedExec{
|
|
Mode: sessions.ModeNew,
|
|
Session: draftSession,
|
|
Store: store,
|
|
}
|
|
sessionRecorder := execSessionRecorder{prepared: preparedSession}
|
|
// Surface a best-effort session-recording failure once, on every exit path.
|
|
defer sessionRecorder.warnIfRecordingFailed(run.stderr)
|
|
sessionRecorder.append(sessions.EventMessage, map[string]any{
|
|
"role": "user",
|
|
"content": run.prompt,
|
|
})
|
|
|
|
var draftInfo execSpecDraftInfo
|
|
runCtx, stopSignals := signalContext()
|
|
defer stopSignals()
|
|
// The spec-draft path activates no plugins, so the plugin skip is omitted. Trust
|
|
// keys on run.trustRoot (the original launch dir), not run.workspaceRoot, which
|
|
// may be a --worktree path; this keeps a --use-spec --worktree run of a trusted
|
|
// repo trusted. Emit at most one notice when project hooks or the project MCP
|
|
// layer (registered earlier in runExec, carried in run.mcpSkip) were dropped.
|
|
hookDispatcher, hookSkip := newHookDispatcher(run.workspaceRoot, run.trustRoot, execution.NewRunner(run.sandboxEngine))
|
|
emitTrustNotice(run.stderr, hookSkip, run.mcpSkip)
|
|
result, err := agent.Run(runCtx, run.prompt, run.provider, agent.Options{
|
|
MaxTurns: run.resolved.MaxTurns,
|
|
ContextWindow: resolveAgentContextWindow(runCtx, run.modelRegistry, run.resolved.Provider),
|
|
SessionID: draftSession.SessionID,
|
|
SessionTitle: run.sessionTitle,
|
|
ProviderName: run.resolved.Provider.Name,
|
|
Model: run.resolved.Provider.Model,
|
|
SupportsVision: func(modelID string) bool {
|
|
return modelregistry.SupportsVision(run.modelRegistry, modelID)
|
|
},
|
|
ReasoningEffort: run.reasoningEffort,
|
|
Profile: run.profilePolicy,
|
|
Cwd: run.workspaceRoot,
|
|
SystemPrompt: specmode.DraftSystemPrompt,
|
|
Images: run.images,
|
|
Registry: run.registry,
|
|
PermissionMode: agent.PermissionModeSpecDraft,
|
|
Autonomy: "low",
|
|
Sandbox: run.sandboxEngine,
|
|
FileTracker: tools.NewFileTracker(),
|
|
Hooks: hookDispatcher,
|
|
EnabledTools: run.options.enabledTools,
|
|
DisabledTools: run.options.disabledTools,
|
|
OnText: writer.text,
|
|
OnToolCall: func(call agent.ToolCall) {
|
|
writer.toolCall(call, run.registry)
|
|
sessionRecorder.append(sessions.EventToolCall, map[string]any{
|
|
"id": call.ID,
|
|
"name": call.Name,
|
|
"arguments": call.Arguments,
|
|
})
|
|
if checkpoint, ok := sessionRecorder.captureCheckpoint(run.workspaceRoot, run.registry, call); ok {
|
|
writer.checkpoint(checkpoint)
|
|
}
|
|
},
|
|
OnPermission: func(event agent.PermissionEvent) {
|
|
writer.permission(event)
|
|
sessionRecorder.append(sessionPermissionEventType(event), event)
|
|
},
|
|
OnToolResult: func(result agent.ToolResult) {
|
|
writer.toolResult(result)
|
|
if info, ok := execSpecDraftInfoFromToolResult(result); ok {
|
|
draftInfo = info
|
|
}
|
|
payload := map[string]any{
|
|
"toolCallId": result.ToolCallID,
|
|
"name": result.Name,
|
|
"status": string(result.Status),
|
|
"output": result.Output,
|
|
}
|
|
if len(result.Meta) > 0 {
|
|
payload["meta"] = result.Meta
|
|
}
|
|
if result.Redacted {
|
|
payload["redacted"] = true
|
|
}
|
|
if len(result.ChangedFiles) > 0 {
|
|
payload["changedFiles"] = result.ChangedFiles
|
|
}
|
|
sessionRecorder.append(sessions.EventToolResult, payload)
|
|
},
|
|
OnUsage: func(u agent.Usage) {
|
|
writer.usage(u)
|
|
sessionRecorder.append(sessions.EventUsage, usage.EventUsagePayload(u))
|
|
},
|
|
})
|
|
run.notifier.Notify(notify.Completion, notify.DefaultMessage(notify.Completion))
|
|
if writer.err != nil {
|
|
return exitCrash
|
|
}
|
|
if err != nil {
|
|
if errors.Is(err, context.Canceled) || runCtx.Err() != nil {
|
|
sessionRecorder.append(sessions.EventError, map[string]any{"message": "interrupted"})
|
|
if run.options.outputFormat == execOutputStreamJSON {
|
|
writer.errorEvent("interrupted", "run cancelled by signal", false)
|
|
writer.runEnd("interrupted", exitInterrupted)
|
|
if writer.err != nil {
|
|
return exitCrash
|
|
}
|
|
} else {
|
|
fmt.Fprintln(run.stderr, "Interrupted.")
|
|
}
|
|
return exitInterrupted
|
|
}
|
|
sessionRecorder.append(sessions.EventError, map[string]any{"message": err.Error()})
|
|
return writeExecSpecDraftProviderError(&writer, run.options.outputFormat, err.Error())
|
|
}
|
|
if result.StopReason != agent.StopReasonSpecReviewRequired || draftInfo.SpecID == "" || draftInfo.SpecFilePath == "" {
|
|
message := "spec draft ended without submit_spec"
|
|
sessionRecorder.append(sessions.EventError, map[string]any{"message": message})
|
|
return writeExecSpecDraftProviderError(&writer, run.options.outputFormat, message)
|
|
}
|
|
draftInfo.DraftSessionID = draftSession.SessionID
|
|
if _, _, err := store.RecordSpec(draftSession.SessionID, sessions.RecordSpecInput{
|
|
SpecID: draftInfo.SpecID,
|
|
SpecFilePath: draftInfo.SpecFilePath,
|
|
SpecStatus: sessions.SpecStatusDraft,
|
|
SpecDraftModelID: run.resolved.Provider.Model,
|
|
SpecDraftReasoning: run.reasoningEffort,
|
|
}); err != nil {
|
|
return writeAppError(run.stderr, err.Error(), exitCrash)
|
|
}
|
|
|
|
if emittedTerminal := writer.specReviewRequired(draftInfo); !emittedTerminal {
|
|
writer.runEnd(string(agent.StopReasonSpecReviewRequired), exitSuccess)
|
|
}
|
|
if writer.err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func execSpecDraftInfoFromToolResult(result agent.ToolResult) (execSpecDraftInfo, bool) {
|
|
if result.Name != specmode.SubmitToolName || result.Meta["control"] != specmode.ControlSpecReviewRequired {
|
|
return execSpecDraftInfo{}, false
|
|
}
|
|
return execSpecDraftInfo{
|
|
SpecID: strings.TrimSpace(result.Meta["specId"]),
|
|
SpecTitle: strings.TrimSpace(result.Meta["specTitle"]),
|
|
SpecFilePath: strings.TrimSpace(result.Meta["specFilePath"]),
|
|
RelativePath: strings.TrimSpace(result.Meta["relativePath"]),
|
|
}, true
|
|
}
|
|
|
|
func writeExecSpecDraftProviderError(writer *execEventWriter, format execOutputFormat, message string) int {
|
|
if format == execOutputStreamJSON {
|
|
writer.errorEvent("provider_error", message, false)
|
|
writer.runEnd("error", exitProvider)
|
|
if writer.err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitProvider
|
|
}
|
|
if format == execOutputJSON {
|
|
writer.errorEvent("provider_error", message, false)
|
|
writer.writeJSON(map[string]any{"type": "done", "exit_code": exitProvider})
|
|
if writer.err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitProvider
|
|
}
|
|
writer.errorEvent("provider_error", message, false)
|
|
if writer.err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitProvider
|
|
}
|
|
|
|
func (writer *execEventWriter) specReviewRequired(info execSpecDraftInfo) bool {
|
|
summary := formatExecSpecDraftSummary(info)
|
|
switch writer.format {
|
|
case execOutputJSON:
|
|
writer.writeJSON(map[string]any{
|
|
"type": string(agent.StopReasonSpecReviewRequired),
|
|
"status": string(sessions.SpecStatusDraft),
|
|
"stop_reason": string(agent.StopReasonSpecReviewRequired),
|
|
"spec_id": info.SpecID,
|
|
"spec_title": info.SpecTitle,
|
|
"spec_file_path": info.SpecFilePath,
|
|
"relative_path": info.RelativePath,
|
|
"draft_session_id": info.DraftSessionID,
|
|
})
|
|
writer.writeJSON(map[string]any{"type": "done", "exit_code": exitSuccess})
|
|
return true
|
|
case execOutputStreamJSON:
|
|
writer.writeStreamJSON(streamjson.Event{
|
|
Type: streamjson.EventFinal,
|
|
RunID: writer.runID,
|
|
Text: summary,
|
|
Meta: map[string]string{
|
|
"status": string(sessions.SpecStatusDraft),
|
|
"stopReason": string(agent.StopReasonSpecReviewRequired),
|
|
"specId": info.SpecID,
|
|
"specTitle": info.SpecTitle,
|
|
"specFilePath": info.SpecFilePath,
|
|
"relativePath": info.RelativePath,
|
|
"draftSessionId": info.DraftSessionID,
|
|
},
|
|
})
|
|
return false
|
|
default:
|
|
if writer.streamedText.Len() > 0 && !strings.HasSuffix(writer.streamedText.String(), "\n") {
|
|
writer.writeStdout("\n")
|
|
}
|
|
writer.writeStdout(summary + "\n")
|
|
return true
|
|
}
|
|
}
|
|
|
|
func formatExecSpecDraftSummary(info execSpecDraftInfo) string {
|
|
path := strings.TrimSpace(info.RelativePath)
|
|
if path == "" {
|
|
path = info.SpecFilePath
|
|
}
|
|
lines := []string{
|
|
"Spec draft saved for review.",
|
|
" spec: " + redact(info.SpecID),
|
|
" path: " + redact(path),
|
|
" draft session: " + redact(info.DraftSessionID),
|
|
"Next: zero spec show " + redact(info.SpecID) + "; zero spec approve " + redact(info.SpecID),
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|