iop/apps/agent/internal/command/service.go
toki e5794fda2d fix(agent): task-loop 검증 경계와 상태 검사를 보강한다
리뷰에서 확인된 Milestone 식별자와 컴파일 바이너리 상태 검증의 빈틈을 보완하고, 활성 agent-task에서는 dispatcher만 실행 경로로 사용하도록 고정한다.
2026-07-31 18:40:26 +09:00

736 lines
22 KiB
Go

// Package command owns the narrow Cobra surface that exposes the agent runtime
// to a headless operator. It declares request/response DTOs and a service port
// so the root command can dispatch without depending on concrete task manager,
// config, or provider implementations.
//
// Every command builds a request DTO, hands it to a CommandService, and prints
// the response DTO. The service port is the only mutation boundary; preview
// deliberately cannot invoke mutation methods.
package command
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
)
// Service is the narrow application-facing port for the CLI command surface.
// Implementations supply configuration loading, provider discovery, project
// workflow observation, and task lifecycle control. Construction never starts
// or stops anything.
type Service interface {
// Validate returns a non-nil error when the supplied configuration paths
// cannot be loaded into a usable snapshot. It is side-effect free.
Validate(ctx context.Context, req ValidateRequest) (ValidateResponse, error)
// ProviderList returns the catalog entries registered under the supplied
// provider catalog path. It is side-effect free.
ProviderList(ctx context.Context, req ProviderListRequest) (ProviderListResponse, error)
// ProjectList returns registered projects and their Milestone metadata
// from the loaded runtime snapshot. It is side-effect free.
ProjectList(ctx context.Context, req ProjectListRequest) (ProjectListResponse, error)
// MilestoneList returns the workflow-backed Milestones discovered for the
// supplied project. It is side-effect free.
MilestoneList(ctx context.Context, req MilestoneListRequest) (MilestoneListResponse, error)
// MilestoneSelect records the user's explicit Milestone choice for the
// supplied project. Start rejects a project whose selected milestone is
// empty.
MilestoneSelect(ctx context.Context, req MilestoneSelectRequest) (MilestoneSelectResponse, error)
// Preview runs the same selection and dependency logic as start but
// returns a PreviewResponse without mutating durable state, starting
// processes, or acquiring leases.
Preview(ctx context.Context, req PreviewRequest) (PreviewResponse, error)
// Serve blocks until ctx is cancelled and drives the runtime lifecycle.
// It is the only command that performs sustained mutation.
Serve(ctx context.Context, req ServeRequest) error
// Start records a manual start intent for the supplied project. It
// rejects projects whose selected milestone is empty.
Start(ctx context.Context, req StartRequest) (StartResponse, error)
// Stop records a manual stop intent for the supplied project.
Stop(ctx context.Context, req StopRequest) (StopResponse, error)
// Resume records a manual resume intent for the supplied project.
Resume(ctx context.Context, req ResumeRequest) (ResumeResponse, error)
// Status returns the live project status including ordered per-work state,
// durable dispatch ordinals, overlay/integration state, and blockers.
Status(ctx context.Context, req StatusRequest) (StatusResponse, error)
// TaskLoop performs one bounded operator pass over the authoritative
// standalone runtime. Dry-run only reads projections; live operation may
// resume an explicitly requested blocked project and reconcile it once.
TaskLoop(ctx context.Context, req TaskLoopRequest) (TaskLoopResponse, error)
// TaskLoopParity validates and renders the S13 disposal manifest.
TaskLoopParity(ctx context.Context, output io.Writer) error
// ValidatePlan validates one candidate PLAN write set against a workspace.
// It is read-only and never constructs a runtime or provider.
ValidatePlan(ctx context.Context, req PlanValidationRequest) error
}
// RuntimeConfigPaths carries the repo-global and user-local configuration paths.
type RuntimeConfigPaths struct {
RepoGlobalPath string
UserLocalPath string
ProviderCatalog string
}
// ValidateRequest carries the configuration paths that the operator has
// supplied on the command line.
type ValidateRequest struct {
RepoGlobalPath string
UserLocalPath string
ProviderCatalog string
}
// ValidateResponse reports whether the supplied configuration loads into a
// usable snapshot.
type ValidateResponse struct {
Valid bool
Revision string
Projects int
Providers int
Profiles int
Errors []string
}
// ProviderListRequest carries the catalog path used to enumerate providers.
type ProviderListRequest struct {
CatalogPath string
}
// ProviderListResponse reports every provider declared in the catalog.
type ProviderListResponse struct {
Providers []ProviderEntry
}
// ProviderEntry is one catalog-declared provider identity.
type ProviderEntry struct {
ID string
Command string
Capabilities []string
}
// ProjectListRequest carries the runtime configuration paths used to enumerate projects.
type ProjectListRequest struct {
Config RuntimeConfigPaths
}
// ProjectListResponse reports every registered project and its Milestone
// selection state.
type ProjectListResponse struct {
Projects []ProjectEntry
}
// ProjectEntry is one registered project identity with its Milestone state.
type ProjectEntry struct {
ID string
Workspace string
Enabled bool
SelectedMilestone string
StartedMilestone string
AutoResumeInterrupt bool
}
// MilestoneListRequest carries the project identity used to list its
// workflow-backed Milestones.
type MilestoneListRequest struct {
Config RuntimeConfigPaths
Project string
}
// MilestoneListResponse reports every Milestone declared in the workspace for
// the supplied project.
type MilestoneListResponse struct {
Milestones []MilestoneEntry
}
// MilestoneEntry is one Milestone summary.
type MilestoneEntry struct {
ID string
Selected bool
WorkUnits int
CompletedWorkUnits int
}
// MilestoneSelectRequest records the operator's explicit Milestone choice.
type MilestoneSelectRequest struct {
Config RuntimeConfigPaths
Project string
MilestoneID string
}
// MilestoneSelectResponse reports the recorded selection.
type MilestoneSelectResponse struct {
Project string
MilestoneID string
}
// PreviewRequest carries the project identity used to run selection and
// dependency logic without mutation.
type PreviewRequest struct {
Config RuntimeConfigPaths
Project string
}
// PreviewResponse reports the selection and dependency verdict that start
// would apply. It must not call any mutation method on the backing service.
type PreviewResponse struct {
Project string
Selected bool
MilestoneID string
NextWork string
Blockers []BlockerEntry
}
// BlockerEntry is one active blocker code and human-readable message.
type BlockerEntry struct {
Code string
Message string
Retryable bool
}
// ServeRequest carries the configuration paths and runtime hooks used to
// drive the full lifecycle.
type ServeRequest struct {
RepoGlobalPath string
UserLocalPath string
ProviderCatalog string
}
// StartRequest carries the project identity used to record a manual start
// intent.
type StartRequest struct {
Config RuntimeConfigPaths
Project string
}
// StartResponse reports the recorded start intent.
type StartResponse struct {
Project string
Status string
}
// StopRequest carries the project identity used to record a manual stop
// intent.
type StopRequest struct {
Config RuntimeConfigPaths
Project string
}
// StopResponse reports the recorded stop intent.
type StopResponse struct {
Project string
Status string
}
// ResumeRequest carries the project identity used to record a manual resume
// intent.
type ResumeRequest struct {
Config RuntimeConfigPaths
Project string
}
// ResumeResponse reports the recorded resume intent.
type ResumeResponse struct {
Project string
Status string
}
// StatusRequest carries the project identity used to query live state.
type StatusRequest struct {
Config RuntimeConfigPaths
Project string
}
// StatusResponse reports the live project status including ordered per-work status
// entries and blockers.
type StatusResponse struct {
Project string
Status string
SelectedMilestone string
StartedMilestone string
Works []WorkStatusEntry
Blockers []BlockerEntry
}
// WorkStatusEntry is the status projection for one work unit.
type WorkStatusEntry struct {
ID string
State string
Overlay string
Integration string
DispatchOrdinal uint64
Blocker *BlockerEntry
}
// TaskLoopRequest is the bounded operator request used by the production
// orchestration skill. TaskGroup, when supplied, must be an exact m- prefixed
// selected-Milestone group; it never infers a dependency from numeric order.
type TaskLoopRequest struct {
Config RuntimeConfigPaths
TaskGroup string
DryRun bool
RetryBlocked bool
}
// PlanValidationRequest carries a candidate PLAN path and the workspace that
// bounds every declared write target. The candidate itself may be outside the
// workspace so plan/review rendering can use a temporary file.
type PlanValidationRequest struct {
Workspace string
PlanPath string
}
// TaskLoopResponse reports a single runtime pass. ExitCode is zero for a
// successful or dry-run pass and two only when the final reconciled selected
// scope is blocked with no started or running project.
type TaskLoopResponse struct {
DryRun bool
Projects []TaskLoopProject
ExitCode int
}
type TaskLoopProject struct {
Project string
Group string
Status string
NextWork string
Blockers []BlockerEntry
}
// FormatText renders a ValidateResponse as stable plain text.
func (r ValidateResponse) FormatText() string {
var b strings.Builder
if r.Valid {
b.WriteString("validate: ok\n")
} else {
b.WriteString("validate: FAIL\n")
}
b.WriteString(fmt.Sprintf(" revision: %s\n", r.Revision))
b.WriteString(fmt.Sprintf(" projects: %d\n", r.Projects))
b.WriteString(fmt.Sprintf(" providers: %d\n", r.Providers))
b.WriteString(fmt.Sprintf(" profiles: %d\n", r.Profiles))
for _, err := range r.Errors {
b.WriteString(fmt.Sprintf(" error: %s\n", err))
}
return b.String()
}
// FormatText renders a ProviderListResponse as stable plain text.
func (r ProviderListResponse) FormatText() string {
var b strings.Builder
b.WriteString(fmt.Sprintf("providers: %d\n", len(r.Providers)))
for _, p := range r.Providers {
b.WriteString(fmt.Sprintf(" - %s command=%s caps=%s\n", p.ID, p.Command, strings.Join(p.Capabilities, ",")))
}
return b.String()
}
// FormatText renders a ProjectListResponse as stable plain text.
func (r ProjectListResponse) FormatText() string {
var b strings.Builder
b.WriteString(fmt.Sprintf("projects: %d\n", len(r.Projects)))
for _, p := range r.Projects {
flag := "enabled"
if !p.Enabled {
flag = "disabled"
}
autoResume := "no"
if p.AutoResumeInterrupt {
autoResume = "yes"
}
b.WriteString(fmt.Sprintf(" - %s ws=%s %s selected=%q started=%q auto_resume=%s\n",
p.ID, p.Workspace, flag, p.SelectedMilestone, p.StartedMilestone, autoResume))
}
return b.String()
}
// FormatText renders a MilestoneListResponse as stable plain text.
func (r MilestoneListResponse) FormatText() string {
var b strings.Builder
b.WriteString(fmt.Sprintf("milestones: %d\n", len(r.Milestones)))
for _, m := range r.Milestones {
selected := "no"
if m.Selected {
selected = "yes"
}
b.WriteString(fmt.Sprintf(" - %s selected=%s work_units=%d completed=%d\n",
m.ID, selected, m.WorkUnits, m.CompletedWorkUnits))
}
return b.String()
}
// FormatText renders a MilestoneSelectResponse as stable plain text.
func (r MilestoneSelectResponse) FormatText() string {
return fmt.Sprintf("milestone select project=%s milestone=%s\n", r.Project, r.MilestoneID)
}
// FormatText renders a PreviewResponse as stable plain text.
func (r PreviewResponse) FormatText() string {
var b strings.Builder
b.WriteString(fmt.Sprintf("preview project=%s\n", r.Project))
if r.Selected {
b.WriteString(fmt.Sprintf(" selected: %s\n", r.MilestoneID))
b.WriteString(fmt.Sprintf(" next: %s\n", r.NextWork))
} else {
b.WriteString(" selected: (none)\n")
}
for _, bl := range r.Blockers {
retry := "no"
if bl.Retryable {
retry = "yes"
}
b.WriteString(fmt.Sprintf(" blocker: %s %s retryable=%s\n", bl.Code, bl.Message, retry))
}
return b.String()
}
// FormatText renders a StartResponse as stable plain text.
func (r StartResponse) FormatText() string {
return fmt.Sprintf("start project=%s status=%s\n", r.Project, r.Status)
}
// FormatText renders a StopResponse as stable plain text.
func (r StopResponse) FormatText() string {
return fmt.Sprintf("stop project=%s status=%s\n", r.Project, r.Status)
}
// FormatText renders a ResumeResponse as stable plain text.
func (r ResumeResponse) FormatText() string {
return fmt.Sprintf("resume project=%s status=%s\n", r.Project, r.Status)
}
// FormatText renders a StatusResponse as stable plain text.
func (r StatusResponse) FormatText() string {
var b strings.Builder
b.WriteString(fmt.Sprintf("status project=%s\n", r.Project))
b.WriteString(fmt.Sprintf(" state: %s\n", r.Status))
b.WriteString(fmt.Sprintf(" selected_milestone: %s\n", r.SelectedMilestone))
b.WriteString(fmt.Sprintf(" started_milestone: %s\n", r.StartedMilestone))
b.WriteString(fmt.Sprintf(" works: %d\n", len(r.Works)))
for _, w := range r.Works {
b.WriteString(fmt.Sprintf(" - %s state=%s overlay=%s integration=%s ordinal=%d\n",
w.ID, w.State, w.Overlay, w.Integration, w.DispatchOrdinal))
if w.Blocker != nil {
retry := "no"
if w.Blocker.Retryable {
retry = "yes"
}
b.WriteString(fmt.Sprintf(" blocker: %s %s retryable=%s\n",
w.Blocker.Code, w.Blocker.Message, retry))
}
}
for _, bl := range r.Blockers {
retry := "no"
if bl.Retryable {
retry = "yes"
}
b.WriteString(fmt.Sprintf(" blocker: %s %s retryable=%s\n", bl.Code, bl.Message, retry))
}
return b.String()
}
func (r TaskLoopResponse) FormatText() string {
var b strings.Builder
b.WriteString(fmt.Sprintf("task-loop dry_run=%t projects=%d\n", r.DryRun, len(r.Projects)))
for _, project := range r.Projects {
b.WriteString(fmt.Sprintf(" - project=%s group=%s status=%s next=%s\n", project.Project, project.Group, project.Status, project.NextWork))
for _, blocker := range project.Blockers {
b.WriteString(fmt.Sprintf(" blocker=%s retryable=%t %s\n", blocker.Code, blocker.Retryable, blocker.Message))
}
}
b.WriteString(fmt.Sprintf(" exit_code: %d\n", r.ExitCode))
return b.String()
}
// FormatJSON renders a ValidateResponse as stable JSON.
func (r ValidateResponse) FormatJSON() string {
type dto struct {
Valid bool `json:"valid"`
Revision string `json:"revision"`
Projects int `json:"projects"`
Providers int `json:"providers"`
Profiles int `json:"profiles"`
Errors []string `json:"errors"`
}
errs := r.Errors
if errs == nil {
errs = []string{}
}
data, _ := json.MarshalIndent(dto{
Valid: r.Valid,
Revision: r.Revision,
Projects: r.Projects,
Providers: r.Providers,
Profiles: r.Profiles,
Errors: errs,
}, "", " ")
return string(data)
}
// FormatJSON renders a ProviderListResponse as stable JSON.
func (r ProviderListResponse) FormatJSON() string {
type providerDTO struct {
ID string `json:"id"`
Command string `json:"command"`
Capabilities []string `json:"capabilities"`
}
type dto struct {
Providers []providerDTO `json:"providers"`
}
list := make([]providerDTO, 0, len(r.Providers))
for _, p := range r.Providers {
caps := p.Capabilities
if caps == nil {
caps = []string{}
}
list = append(list, providerDTO{
ID: p.ID,
Command: p.Command,
Capabilities: caps,
})
}
data, _ := json.MarshalIndent(dto{Providers: list}, "", " ")
return string(data)
}
// FormatJSON renders a ProjectListResponse as stable JSON.
func (r ProjectListResponse) FormatJSON() string {
type projectDTO struct {
ID string `json:"id"`
Workspace string `json:"workspace"`
Enabled bool `json:"enabled"`
SelectedMilestone string `json:"selected_milestone"`
StartedMilestone string `json:"started_milestone"`
AutoResumeInterrupt bool `json:"auto_resume_interrupted"`
}
type dto struct {
Projects []projectDTO `json:"projects"`
}
list := make([]projectDTO, 0, len(r.Projects))
for _, p := range r.Projects {
list = append(list, projectDTO{
ID: p.ID,
Workspace: p.Workspace,
Enabled: p.Enabled,
SelectedMilestone: p.SelectedMilestone,
StartedMilestone: p.StartedMilestone,
AutoResumeInterrupt: p.AutoResumeInterrupt,
})
}
data, _ := json.MarshalIndent(dto{Projects: list}, "", " ")
return string(data)
}
// FormatJSON renders a MilestoneListResponse as stable JSON.
func (r MilestoneListResponse) FormatJSON() string {
type milestoneDTO struct {
ID string `json:"id"`
Selected bool `json:"selected"`
WorkUnits int `json:"work_units"`
CompletedWorkUnits int `json:"completed_work_units"`
}
type dto struct {
Milestones []milestoneDTO `json:"milestones"`
}
list := make([]milestoneDTO, 0, len(r.Milestones))
for _, m := range r.Milestones {
list = append(list, milestoneDTO{
ID: m.ID,
Selected: m.Selected,
WorkUnits: m.WorkUnits,
CompletedWorkUnits: m.CompletedWorkUnits,
})
}
data, _ := json.MarshalIndent(dto{Milestones: list}, "", " ")
return string(data)
}
// FormatJSON renders a MilestoneSelectResponse as stable JSON.
func (r MilestoneSelectResponse) FormatJSON() string {
type dto struct {
Project string `json:"project"`
MilestoneID string `json:"milestone_id"`
}
data, _ := json.Marshal(dto{
Project: r.Project,
MilestoneID: r.MilestoneID,
})
return string(data)
}
// FormatJSON renders a PreviewResponse as stable JSON.
func (r PreviewResponse) FormatJSON() string {
type blockerDTO struct {
Code string `json:"code"`
Message string `json:"message"`
Retryable bool `json:"retryable"`
}
type dto struct {
Project string `json:"project"`
Selected bool `json:"selected"`
MilestoneID string `json:"milestone_id"`
NextWork string `json:"next_work"`
Blockers []blockerDTO `json:"blockers"`
}
blockers := make([]blockerDTO, 0, len(r.Blockers))
for _, bl := range r.Blockers {
blockers = append(blockers, blockerDTO{
Code: bl.Code,
Message: bl.Message,
Retryable: bl.Retryable,
})
}
data, _ := json.MarshalIndent(dto{
Project: r.Project,
Selected: r.Selected,
MilestoneID: r.MilestoneID,
NextWork: r.NextWork,
Blockers: blockers,
}, "", " ")
return string(data)
}
// FormatJSON renders a StartResponse as stable JSON.
func (r StartResponse) FormatJSON() string {
type dto struct {
Project string `json:"project"`
Status string `json:"status"`
}
data, _ := json.Marshal(dto{
Project: r.Project,
Status: r.Status,
})
return string(data)
}
// FormatJSON renders a StopResponse as stable JSON.
func (r StopResponse) FormatJSON() string {
type dto struct {
Project string `json:"project"`
Status string `json:"status"`
}
data, _ := json.Marshal(dto{
Project: r.Project,
Status: r.Status,
})
return string(data)
}
// FormatJSON renders a ResumeResponse as stable JSON.
func (r ResumeResponse) FormatJSON() string {
type dto struct {
Project string `json:"project"`
Status string `json:"status"`
}
data, _ := json.Marshal(dto{
Project: r.Project,
Status: r.Status,
})
return string(data)
}
// FormatJSON renders a StatusResponse as stable JSON.
func (r StatusResponse) FormatJSON() string {
type blockerDTO struct {
Code string `json:"code"`
Message string `json:"message"`
Retryable bool `json:"retryable"`
}
type workStatusDTO struct {
ID string `json:"id"`
State string `json:"state"`
Overlay string `json:"overlay"`
Integration string `json:"integration"`
DispatchOrdinal uint64 `json:"dispatch_ordinal"`
Blocker *blockerDTO `json:"blocker,omitempty"`
}
type dto struct {
Project string `json:"project"`
Status string `json:"status"`
SelectedMilestone string `json:"selected_milestone"`
StartedMilestone string `json:"started_milestone"`
Works []workStatusDTO `json:"works"`
Blockers []blockerDTO `json:"blockers"`
}
works := make([]workStatusDTO, 0, len(r.Works))
for _, w := range r.Works {
var b *blockerDTO
if w.Blocker != nil {
b = &blockerDTO{
Code: w.Blocker.Code,
Message: w.Blocker.Message,
Retryable: w.Blocker.Retryable,
}
}
works = append(works, workStatusDTO{
ID: w.ID,
State: w.State,
Overlay: w.Overlay,
Integration: w.Integration,
DispatchOrdinal: w.DispatchOrdinal,
Blocker: b,
})
}
blockers := make([]blockerDTO, 0, len(r.Blockers))
for _, bl := range r.Blockers {
blockers = append(blockers, blockerDTO{
Code: bl.Code,
Message: bl.Message,
Retryable: bl.Retryable,
})
}
data, _ := json.MarshalIndent(dto{
Project: r.Project,
Status: r.Status,
SelectedMilestone: r.SelectedMilestone,
StartedMilestone: r.StartedMilestone,
Works: works,
Blockers: blockers,
}, "", " ")
return string(data)
}
func (r TaskLoopResponse) FormatJSON() string {
type blockerDTO struct {
Code string `json:"code"`
Message string `json:"message"`
Retryable bool `json:"retryable"`
}
type projectDTO struct {
Project string `json:"project"`
Group string `json:"group"`
Status string `json:"status"`
NextWork string `json:"next_work"`
Blockers []blockerDTO `json:"blockers"`
}
projects := make([]projectDTO, 0, len(r.Projects))
for _, project := range r.Projects {
blockers := make([]blockerDTO, 0, len(project.Blockers))
for _, blocker := range project.Blockers {
blockers = append(blockers, blockerDTO{Code: blocker.Code, Message: blocker.Message, Retryable: blocker.Retryable})
}
projects = append(projects, projectDTO{Project: project.Project, Group: project.Group, Status: project.Status, NextWork: project.NextWork, Blockers: blockers})
}
data, _ := json.MarshalIndent(struct {
DryRun bool `json:"dry_run"`
Projects []projectDTO `json:"projects"`
ExitCode int `json:"exit_code"`
}{DryRun: r.DryRun, Projects: projects, ExitCode: r.ExitCode}, "", " ")
return string(data)
}
func joinStrings(items []string) string {
return strings.Join(items, ", ")
}