Files
kandev/apps/backend/internal/task/models/models.go
T
2026-09-21 00:02:29 +01:00

2938 lines
127 KiB
Go

package models
import (
"encoding/json"
"errors"
"maps"
"strings"
"time"
"github.com/kandev/kandev/internal/agentruntime"
"github.com/kandev/kandev/internal/sysprompt"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// ErrExecutorRunningNotFound is returned when no executor running record exists for a session.
var ErrExecutorRunningNotFound = errors.New("executor running not found")
// ErrTaskSessionNotFound is returned when no task session record exists.
var ErrTaskSessionNotFound = errors.New("task session not found")
// ErrTaskWalkthroughNotFound is returned when no walkthrough record exists.
var ErrTaskWalkthroughNotFound = errors.New("task walkthrough not found")
// ErrTaskReviewRunNotFound is returned when no code-review run record exists.
var ErrTaskReviewRunNotFound = errors.New("task review run not found")
// ErrTaskReviewFindingNotFound is returned when no code-review finding record exists.
var ErrTaskReviewFindingNotFound = errors.New("task review finding not found")
// ErrExecutorNotFound is returned by the executor repository when no
// executor row exists for the given ID. Callers should use errors.Is to
// distinguish "row doesn't exist" (404 semantically) from transport-level
// failures (storage outage, timeout, ctx cancel) that happen to also
// surface from the same lookup.
var ErrExecutorNotFound = errors.New("executor not found")
// ErrExecutionRotated is returned by CAS-style updates on executors_running when the
// row's agent_execution_id has rotated since the caller observed it. Indicates the
// caller's write target a now-defunct execution and should be discarded — typically
// a stale resume-token event from an execution that was replaced (model switch,
// context reset, fresh re-launch). Callers should not retry; the new execution
// will produce its own events.
var ErrExecutionRotated = errors.New("execution rotated; CAS write rejected")
// Status values for executors_running.status. The lifecycle manager mirrors
// active execution state into this column; the orchestrator flips a row to
// "prepared" when a prepare-only launch finishes with the agent process
// intentionally not started.
const (
ExecutorRunningStatusStarting = "starting"
ExecutorRunningStatusRunning = "running"
ExecutorRunningStatusReady = "ready"
ExecutorRunningStatusFailed = "failed"
ExecutorRunningStatusStopped = "stopped"
ExecutorRunningStatusComplete = "completed"
ExecutorRunningStatusPrepared = "prepared"
createdAtField = "created_at"
)
// ListMessagesOptions defines pagination options for listing messages
type ListMessagesOptions struct {
Limit int
Before string
After string
Sort string
AuthorType string
// AuthorTypes narrows by any of the listed authors (IN); when non-empty
// it takes precedence over AuthorType. TaskID, when non-empty, narrows
// to rows for that task.
AuthorTypes []string
TaskID string
Around string
}
// SearchMessagesOptions defines options for searching a session's messages.
type SearchMessagesOptions struct {
Query string
Limit int
}
// PluginMessageFilter defines the filters for the plugin Host data API's
// message reader (ADR 0047). SessionIDs and TaskIDs narrow by session/task
// (ORed within each, ANDed across the two — a message must match any
// requested session AND any requested task when both are set); empty means
// unconstrained on that axis. Since/Until bound created_at (Since inclusive,
// Until exclusive); nil means unbounded. Types narrows by message type; empty
// means all types. Results are ordered oldest-first by (created_at, id) with
// SQL Limit/Offset pagination.
type PluginMessageFilter struct {
SessionIDs []string
TaskIDs []string
Types []string
Since *time.Time
Until *time.Time
Limit int
Offset int
}
// Task metadata keys used for deferred agent start (e.g., task.moved → handleTaskMovedNoSession).
const (
MetaKeyAgentProfileID = "agent_profile_id"
MetaKeyExecutorID = "executor_id"
MetaKeyExecutorProfileID = "executor_profile_id"
// Automation target metadata is written to continuation tasks so a
// change from hidden to visible ownership or from repository-backed to
// repository-free execution cannot silently reuse the old task.
MetaKeyAutomationTaskMode = "automation_task_mode"
MetaKeyAutomationRepositoryMode = "automation_repository_mode"
MetaKeyDeferredLaunch = "deferred_launch"
// MetaKeyWorkflowInitialSession is a write-once task-local snapshot of
// the first session identity used by workflow session targeting.
MetaKeyWorkflowInitialSession = "workflow_initial_session"
// MetaKeyWorkflowSessionRoute stores the bounded prepared/committed route
// identity used to retry an explicit workflow session selection.
MetaKeyWorkflowSessionRoute = "workflow_session_route"
// MetaKeyQueuedMoveExitPending identifies a queued manual move whose
// source-step on_exit side effect is not yet complete. Its value records the
// source step so recovery can resume the work after a restart.
MetaKeyQueuedMoveExitPending = "queued_move_exit_pending"
// MetaKeyQueuedMoveExitCompleted is durable evidence that the source-step
// on_exit side effect for a queued manual move finished. Promotion must not
// enter the destination until this marker exists.
MetaKeyQueuedMoveExitCompleted = "queued_move_exit_completed"
// MetaKeyQueuePromotionPending is a one-shot token for destination entry
// after queue promotion. It prevents duplicate task.queue_promoted events
// from repeating on_enter or auto-start behavior.
MetaKeyQueuePromotionPending = "queue_promotion_pending"
// MetaKeyWorkflowMovePending carries the one-shot entry options of a direct
// (immediately applied) workflow move whose target-step entry has not yet
// run. Its value records the source step, the move ID, and the encoded
// entry options. The public task.moved event carries only the move ID; the
// instructions and profile choice ride on this transient marker and are
// cleared once the target entry is dispatched.
MetaKeyWorkflowMovePending = "workflow_move_pending"
// MetaKeyManualMoveLifecyclePending identifies an admitted manual move whose
// task.moved lifecycle must finish before feeder reconciliation. Its value
// records the source step so stale deliveries cannot run the wrong exit.
MetaKeyManualMoveLifecyclePending = "manual_move_lifecycle_pending"
// MetaKeyManualMoveLifecycleCompleted records that the admitted manual move
// lifecycle finished. It is cleared once its continuation has run and no
// MetaKeyManualMoveLifecyclePending token remains, so it does not
// accumulate as permanent startup-recovery work; a fresh manual move
// replaces it with a new pending token before it would be cleared.
MetaKeyManualMoveLifecycleCompleted = "manual_move_lifecycle_completed"
// MetaKeyAppliedDeferredMoves stores deferred move IDs that have already
// been applied, preventing a stale queue rollback from replaying one.
MetaKeyAppliedDeferredMoves = "applied_deferred_moves"
// DeferredLaunchStartWhenUnblockedKey marks a deferred launch intent as
// belonging to a task dependency chain rather than to WIP overflow. The
// record itself is identical; the flag lets dependency resolution recognise
// its own intents and keeps a WIP-only intent from being read as a chain
// step (and vice versa).
DeferredLaunchStartWhenUnblockedKey = "start_when_unblocked"
// DeferredLaunchUserIDKey preserves the authenticated creator so a
// selector-backed deferred task-create launch can update that user's
// task_create history after the launch is promoted by the workflow engine.
// It is server-owned state inside the protected deferred launch record and
// must not be accepted from task metadata request surfaces.
DeferredLaunchUserIDKey = "user_id"
// DeferredLaunchRecordRecentUseKey marks a deferred launch that originated
// from a selector-backed task-create surface and is therefore eligible to
// update task_create profile history after promotion. It is server-owned
// state and is omitted from task DTOs.
DeferredLaunchRecordRecentUseKey = "record_recent_use"
// MetaKeyWorkspacePath is the optional host folder for repo-less tasks
// (set by CreateTask, read by the orchestrator when building a session).
// Centralised here so the set/read sites can't drift apart.
MetaKeyWorkspacePath = "workspace_path"
// MetaKeyAutoStartGuard is a permanent marker set at create time on tasks
// where both the watcher's synchronous Path B (autoStartReviewTask) and the
// promotion's event-driven Path A (autoStartTaskForStep) could both try to
// launch an agent. Its presence signals that the one-shot MetaKeyAutoStartClaimed
// token governs who may launch. Never removed after task creation.
MetaKeyAutoStartGuard = "auto_start_guard"
// MetaKeyAutoStartClaimed is a one-shot token set alongside MetaKeyAutoStartGuard.
// Both auto-start paths atomically remove this key; only the first removal
// succeeds and that path proceeds to StartTask. The other path skips launch
// because the winner will (or already did) handle it.
// Absent on ordinary (non-watcher) auto-start tasks, which launch normally.
MetaKeyAutoStartClaimed = "auto_start_claimed"
// MetaKeyInterruptedAt is set by startup reconciliation when a task's
// session was mid-turn (STARTING/RUNNING) when the backend died. Its
// presence makes the task DTO report `interrupted: true` so task-list
// surfaces show the red interruption icon; the orchestrator removes the
// key when a session of the task next enters STARTING/RUNNING.
MetaKeyInterruptedAt = "interrupted_at"
// MetaKeyAutoStartFailed is set when a workflow step's auto_start_agent
// on_enter action fails to launch a run (kanban StartTask error, or an
// Office task queue-run error/unresolvable agent). Its presence makes the
// task DTO report `auto_start_failed: true` so the failure surfaces on
// the card instead of only in backend logs; the orchestrator removes the
// key when a session of the task next enters STARTING/RUNNING (mirrors
// MetaKeyInterruptedAt).
MetaKeyAutoStartFailed = "auto_start_failed"
// MetaKeyAgentTitlePending marks tasks whose provisional title still needs
// the first eligible agent session to replace it.
MetaKeyAgentTitlePending = "agent_title_pending"
// MetaKeyAgentTitleOwnerSessionID records the one session that atomically
// claimed the first-turn title handoff for a pending task.
MetaKeyAgentTitleOwnerSessionID = "agent_title_owner_session_id"
// MetaKeyPortForwardingEnabled controls whether a task exposes its
// session port-forwarding controls in the task UI.
MetaKeyPortForwardingEnabled = "port_forwarding_enabled"
// MetaKeyAutomationTargetTaskID binds a merged-PR automation run to the
// task selected by its event. The archive handler enforces this value.
MetaKeyAutomationTargetTaskID = "automation_target_task_id"
// Parent-question message metadata is durable state for an autopilot child
// waiting for a decision from its direct parent. The question ID is also
// the child clarification message ID, so a parent reply can be idempotent
// without a second persistence table.
MetaKeyParentQuestionID = "parent_question_id"
MetaKeyParentQuestion = "parent_question"
MetaKeyParentQuestionParentID = "parent_task_id"
MetaKeyParentQuestionChildID = "child_task_id"
MetaKeyParentQuestionStatus = "parent_question_status"
MetaKeyParentQuestionResponse = "parent_question_response"
// MetaKeyInitialSessionRuntimeConfig is a launch-only seed for the first
// session created for a task. PrepareSession consumes it into session
// runtime overrides and never leaves it in session metadata.
MetaKeyInitialSessionRuntimeConfig = "initial_session_runtime_config"
// MetaKeyInitialSessionRuntimeConfigProfileID identifies the agent profile
// that produced the launch-only runtime seed. A seed is valid only for this
// profile, even if task profile selection changes before the first launch.
MetaKeyInitialSessionRuntimeConfigProfileID = "initial_session_runtime_config_profile_id"
// MetaKeyAutoStartOnCreate is a positive opt-in a task creator stamps when
// it wants task.created to evaluate the destination step's on_enter
// actions immediately, as if creation were itself a transition into that
// step. Absence is the default and preserves existing behavior for every
// other producer (REST/MCP/WS create with or without start_agent /
// prepare_session, CreateChildTask, etc.) — those already have their own
// launch decision, and task.created must not second-guess it. Set today
// only by CreateOfficeTaskInWorkflow for materialized heavy-routine runs,
// whose Routine workflow start step has no other transition to carry it
// into an auto_start_agent evaluation.
MetaKeyAutoStartOnCreate = "auto_start_on_create"
// MetaKeyAutoStartOnCreateInFlight is a durable hand-off marker for an
// auto-start-on-create launch. The original intent is consumed before the
// detached launch starts, but this marker remains until a session or run
// is durable. That lets startup recovery retry a process that exits in the
// gap instead of losing the last recovery signal.
MetaKeyAutoStartOnCreateInFlight = "auto_start_on_create_in_flight"
// MetaKeyStepHandoffCarry is a single-slot, task-scoped token carrying one
// consuming transition's completion handoff exactly one hop, to the next
// step's first dispatched prompt. Its value is a StepHandoffCarryToken.
// Recording it replaces any existing token (single-slot by construction);
// claiming it removes it. See REQ-TASKS-SIGNAL-PAYLOAD-DELIVERY-001.
MetaKeyStepHandoffCarry = "step_handoff_carry"
)
// WorkflowInitialSessionSnapshot identifies the immutable task-initial
// conversation and the logical profile selected when it was created.
type WorkflowInitialSessionSnapshot struct {
SessionID string `json:"session_id"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
}
// WorkflowSessionRoute records one in-flight or committed explicit recipient
// selection. It is replaced by the next route operation rather than growing a
// task history.
type WorkflowSessionRoute struct {
OperationID string `json:"operation_id"`
DestinationStepID string `json:"destination_step_id"`
// EntryIdentity binds the route to the workflow entry that created it. It
// prevents a late replay from consuming a newer route for the same step.
EntryIdentity string `json:"entry_identity,omitempty"`
TargetKind string `json:"target_kind"`
TargetStepID string `json:"target_step_id,omitempty"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
SourceSessionID string `json:"source_session_id,omitempty"`
DestinationID string `json:"destination_session_id,omitempty"`
Phase string `json:"phase"`
}
// LoadWorkflowSessionRoute decodes the bounded route record stored in task
// metadata. Invalid or incomplete values are ignored so a stale record cannot
// redirect a workflow entry to an unrelated session.
func LoadWorkflowSessionRoute(metadata map[string]interface{}) (WorkflowSessionRoute, bool) {
value, ok := metadata[MetaKeyWorkflowSessionRoute]
if !ok {
return WorkflowSessionRoute{}, false
}
payload, err := json.Marshal(value)
if err != nil {
return WorkflowSessionRoute{}, false
}
var route WorkflowSessionRoute
if err := json.Unmarshal(payload, &route); err != nil {
return WorkflowSessionRoute{}, false
}
if route.OperationID == "" || route.DestinationStepID == "" || route.TargetKind == "" || route.Phase == "" {
return WorkflowSessionRoute{}, false
}
return route, true
}
// WorkflowSessionBinding records the latest session selected when a direct
// profile workflow step was entered. The session pointer is nullable because
// deleting a session must preserve the logical profile for a later fresh
// fallback.
type WorkflowSessionBinding struct {
TaskID string `json:"task_id"`
TargetKey string `json:"target_key"`
WorkflowID string `json:"workflow_id"`
AgentProfileID string `json:"agent_profile_id"`
SessionID string `json:"session_id,omitempty"`
OperationID string `json:"operation_id"`
UpdatedAt time.Time `json:"updated_at"`
}
// LoadWorkflowInitialSessionSnapshot decodes the write-once task metadata
// value. Invalid or incomplete values are treated as unavailable so callers
// can apply the conservative legacy fallback.
func LoadWorkflowInitialSessionSnapshot(metadata map[string]interface{}) (WorkflowInitialSessionSnapshot, bool) {
value, ok := metadata[MetaKeyWorkflowInitialSession]
if !ok {
return WorkflowInitialSessionSnapshot{}, false
}
object, ok := value.(map[string]interface{})
if !ok {
return WorkflowInitialSessionSnapshot{}, false
}
sessionID, _ := object["session_id"].(string)
profileID, _ := object["agent_profile_id"].(string)
if sessionID == "" {
return WorkflowInitialSessionSnapshot{}, false
}
return WorkflowInitialSessionSnapshot{SessionID: sessionID, AgentProfileID: profileID}, true
}
// StepHandoffCarryToken is the JSON shape stored under
// tasks.metadata[MetaKeyStepHandoffCarry]. Stamp is a fresh unique value
// (uuid.NewString()) minted on every write, compared by the claim's
// compare-and-swap alongside StepID; it must never be content-derived.
type StepHandoffCarryToken struct {
Handoff string `json:"handoff"`
StepID string `json:"step_id"`
Stamp string `json:"stamp"`
}
// IsAgentTitlePending reports whether task metadata contains the durable
// pending title marker. JSON rehydration produces bool values, while a
// few in-process callers may provide typed metadata, so only an explicit true
// value enables the capability.
func IsAgentTitlePending(metadata map[string]interface{}) bool {
pending, ok := metadata[MetaKeyAgentTitlePending].(bool)
return ok && pending
}
// AgentTitleOwnerSessionID returns the session that owns the pending title
// handoff, if one has been claimed.
func AgentTitleOwnerSessionID(metadata map[string]interface{}) string {
owner, _ := metadata[MetaKeyAgentTitleOwnerSessionID].(string)
return owner
}
// IsAgentTitleOwner reports whether sessionID owns the pending title handoff.
func IsAgentTitleOwner(metadata map[string]interface{}, sessionID string) bool {
return sessionID != "" && IsAgentTitlePending(metadata) && AgentTitleOwnerSessionID(metadata) == sessionID
}
// HasAutoStartOnCreateIntent reports whether task metadata carries the
// positive MetaKeyAutoStartOnCreate opt-in. Only an explicit true value
// counts — absence (the default for nearly every task producer) must never
// be read as "please auto-start me".
func HasAutoStartOnCreateIntent(metadata map[string]interface{}) bool {
intent, ok := metadata[MetaKeyAutoStartOnCreate].(bool)
return ok && intent
}
// HasAutoStartOnCreateInFlight reports whether a launch attempt still owns
// the durable hand-off marker for a create-time auto-start. Only an explicit
// true value counts; JSON rehydration and in-process callers use the same
// representation as the other lifecycle markers.
func HasAutoStartOnCreateInFlight(metadata map[string]interface{}) bool {
inFlight, ok := metadata[MetaKeyAutoStartOnCreateInFlight].(bool)
return ok && inFlight
}
// TaskSession.Metadata key that records how the session came into existence.
// workflow_switch means the session profile was selected by workflow routing
// rather than direct user selection.
const (
SessionMetaKeyCreatedBy = "created_by"
SessionCreatedByWorkflowSwitch = "workflow_switch"
// SessionMetaKeyCompletionFollowUp marks a completed conversation that was
// explicitly resumed for conversational follow-up. It is not workflow
// ownership and must not be treated as a task-state transition.
SessionMetaKeyCompletionFollowUp = "completion_follow_up"
// SessionMetaKeyWorkflowProfileSwitchStopIntent identifies the transient
// coordination record used to suppress the lifecycle event caused by a
// parked workflow profile switch.
SessionMetaKeyWorkflowProfileSwitchStopIntent = "workflow_profile_switch_stop_intent"
// SessionMetaKeyOrigin identifies immutable task-session provenance. Unlike
// IsPrimary, it never changes when the user selects another conversation tab.
SessionMetaKeyOrigin = "origin"
SessionOriginTaskInitial = "task_initial"
SessionMetaKeyContextWindow = "context_window"
SessionMetaKeyContextCompactionCount = "context_compaction_count"
// SessionMetaKeyRecoveryResolvedAt stores the server timestamp of the
// latest successful agent boot. Recovery cards compare this timestamp with
// their own creation time, so the result survives transcript write failures.
SessionMetaKeyRecoveryResolvedAt = "recovery_resolved_at"
// SessionMetaKeyInitialCreatePromptPassthrough stores the durable
// execution and turn evidence for a creation prompt admitted before a
// passthrough agent.running event. It prevents recovery from evaluating
// the same on_turn_start transition a second time after restart.
SessionMetaKeyInitialCreatePromptPassthrough = "initial_create_prompt_passthrough"
)
// IsCompletionFollowUpSession reports whether a session was explicitly
// reopened only to continue its completed conversation. The marker remains
// meaningful after the session settles back to WAITING_FOR_INPUT.
func IsCompletionFollowUpSession(metadata map[string]interface{}) bool {
followUp, ok := metadata[SessionMetaKeyCompletionFollowUp].(bool)
return ok && followUp
}
// WorkflowProfileSwitchStopIntent binds a deliberate parked-session stop to
// one exact runtime execution. Stamp is compared before the metadata value is
// marked consumed, so a delayed event cannot consume a newer switch intent.
type WorkflowProfileSwitchStopIntent struct {
ExecutionID string `json:"execution_id"`
Stamp string `json:"stamp"`
// Consumed is a durable tombstone for the matching terminal callback. It
// remains in session metadata so delayed callbacks after a restart cannot
// advance the workflow.
Consumed bool `json:"consumed,omitempty"`
}
// SessionMetaKeySessionMode records the agent's last-known session permission
// mode (auto / default / accept-edits, etc.) so it survives a backend restart or
// SSR reload, alongside the in-memory re-apply on context reset. See issue #1183.
const SessionMetaKeySessionMode = "session_mode"
// SessionMetaKeyRuntimeConfig records the provider's latest session runtime
// state (model, mode, and dynamic config options) separately from the profile
// snapshot that seeded the session.
const SessionMetaKeyRuntimeConfig = "runtime_config"
// SessionMetaKeyRuntimeConfigOverrides records explicit user selections
// separately from provider snapshots so delayed events cannot clobber resume
// intent. Overrides are applied after SessionMetaKeyRuntimeConfig.
const SessionMetaKeyRuntimeConfigOverrides = "runtime_config_overrides"
// SessionMetaKeyOriginalEffectiveConfig stores the write-once effective model
// and selectable ACP option values after profile settings settle. It is the
// restore source for workflow rules and is intentionally separate from the
// provider-default comparison baseline and mutable runtime state.
const SessionMetaKeyOriginalEffectiveConfig = "original_effective_config"
// SessionMetaKeyACPConfigBaseline records the write-once effective ACP select
// values with which a task session started. It is comparison metadata only;
// runtime restoration continues to use SessionMetaKeyRuntimeConfig.
const SessionMetaKeyACPConfigBaseline = "acp_config_baseline"
// SessionMetaKeyACPModelState records the provider's latest complete model
// selector state so task-detail boot hydration does not wait for WebSocket
// reconnection. It is display metadata and is not replayed to the provider.
const SessionMetaKeyACPModelState = "acp_model_state"
// SessionMetaKeyMCPAttachmentState records bounded, safe evidence about MCP
// attachment for this task session and its immediate prior attempts.
const SessionMetaKeyMCPAttachmentState = "mcp_attachment_state"
// SessionMetaKeyGitCredentialSnapshot records the non-secret Git credential
// routing contract that successfully launched or resumed a session.
const SessionMetaKeyGitCredentialSnapshot = "git_credential_snapshot"
// GitCredentialSnapshot is launch-time display metadata. It never contains a
// token, broker lease, helper command, credential file, or SSH key detail.
type GitCredentialSnapshot struct {
Version int `json:"version"`
Policy string `json:"policy"`
Source string `json:"source"`
WorkspaceMethod string `json:"workspace_method,omitempty"`
Actor string `json:"actor"`
Transport string `json:"transport"`
ExecutorType string `json:"executor_type,omitempty"`
CapturedAt time.Time `json:"captured_at"`
}
// TurnMetaKeyRuntimeConfigSnapshot stores the immutable effective runtime
// configuration attributed to one prompt/response turn.
const TurnMetaKeyRuntimeConfigSnapshot = "runtime_config_snapshot"
// TurnMetaKeyWorkflowStepIDAtStart records the workflow step the turn's task
// was in when the turn started. Absent when the task held no step.
const TurnMetaKeyWorkflowStepIDAtStart = "workflow_step_id_at_start"
// TurnMetaKeyLifecycleOnly marks a turn created only to parent a lifecycle
// message (for example the agent_boot script_execution message on resume).
// A lifecycle turn never reflects real agent work and must never be current-
// turn authority, so every current-turn resolution site excludes it.
const TurnMetaKeyLifecycleOnly = "lifecycle_only"
// TurnMetaKeyErrorTerminated marks a turn that ended in a recoverable agent
// failure. The failure's recovery/error entry is the turn's outcome, so a turn
// carrying this marker reports had_output=true at completion even though a
// status/recovery message does not otherwise count as agent output — this
// keeps the frontend from showing a spurious empty-turn notice for the failed
// turn.
const TurnMetaKeyErrorTerminated = "error_terminated"
// TurnMetaKeyPromptDispatchPending marks a successor created before agentctl
// acknowledges its prompt. Empty marked turns are not current-turn authority
// unless dispatch ambiguity was recorded; publication clears the marker, while
// a referencing message also proves ambiguous acceptance after a crash.
const TurnMetaKeyPromptDispatchPending = "prompt_dispatch_pending"
const (
TurnMetaKeyPromptDispatchAttempted = "prompt_dispatch_attempted"
TurnMetaKeyPromptDispatchClarificationPendingID = "prompt_dispatch_clarification_pending_id"
TurnMetaKeyPromptDispatchClarificationTurnID = "prompt_dispatch_clarification_turn_id"
TurnMetaKeyPromptDispatchClarificationMessageIDs = "prompt_dispatch_clarification_message_ids"
// TurnMetaKeyPromptDispatchStartEventPending is a durable outbox marker.
// Startup recovery sets it after accepting an ambiguous reservation and
// clears it only after the task service replays the public turn events.
TurnMetaKeyPromptDispatchStartEventPending = "prompt_dispatch_start_event_pending"
)
var promptDispatchMetadataKeys = [...]string{
TurnMetaKeyPromptDispatchPending,
TurnMetaKeyPromptDispatchAttempted,
TurnMetaKeyPromptDispatchClarificationPendingID,
TurnMetaKeyPromptDispatchClarificationTurnID,
TurnMetaKeyPromptDispatchClarificationMessageIDs,
TurnMetaKeyPromptDispatchStartEventPending,
}
// ClearPromptDispatchMetadata removes every reservation-only field after live
// publication or successful startup event replay.
func ClearPromptDispatchMetadata(metadata map[string]interface{}) {
for _, key := range promptDispatchMetadataKeys {
delete(metadata, key)
}
}
// publicTurnMetadataKeys is the allowlist for first-party live turn
// projections. Prompt-dispatch recovery state and arbitrary repository-owned
// fields stay inside the service and are not sent through the shared event
// bus. The REST turn DTO has its own compatibility contract; this narrower
// projection is used only for the v2 Host conversation transport.
var publicTurnMetadataKeys = [...]string{
TurnMetaKeyRuntimeConfigSnapshot,
TurnMetaKeyWorkflowStepIDAtStart,
TurnMetaKeyLifecycleOnly,
"prompt_usage",
"model",
"agent_id",
"agent_type",
"usage_multiplier",
}
// ProjectTurnMetadata returns the first-party-safe metadata needed by live
// core conversation consumers without exposing arbitrary turn state.
func ProjectTurnMetadata(metadata map[string]interface{}) map[string]interface{} {
if len(metadata) == 0 {
return nil
}
projected := make(map[string]interface{}, len(publicTurnMetadataKeys))
for _, key := range publicTurnMetadataKeys {
if value, ok := metadata[key]; ok {
projected[key] = value
}
}
ClearPromptDispatchMetadata(projected)
if len(projected) == 0 {
return nil
}
return projected
}
// PromptDispatchRecovery identifies the exact clarification claim that an
// unpublished successor reservation must restore after a backend restart.
type PromptDispatchRecovery struct {
PendingID string
TurnID string
MessageIDs []string
}
// SessionRuntimeConfig is persisted as provider state or explicit overrides.
// On resume, explicit values take precedence over the latest provider snapshot
// so delayed provider events cannot replace user intent.
type SessionRuntimeConfig struct {
Model string `json:"model,omitempty"`
Mode string `json:"mode,omitempty"`
ConfigOptions map[string]string `json:"config_options,omitempty"`
}
// LoadInitialSessionRuntimeConfig decodes the task launch seed from typed or
// JSON-rehydrated task metadata.
func LoadInitialSessionRuntimeConfig(metadata map[string]interface{}) (SessionRuntimeConfig, bool) {
return loadSessionRuntimeConfig(metadata, MetaKeyInitialSessionRuntimeConfig)
}
// LoadInitialSessionRuntimeConfigProfileID returns the profile that owns the
// launch-only runtime seed. Older tasks did not store a separate owner, so
// their resolved task profile remains a compatibility fallback.
func LoadInitialSessionRuntimeConfigProfileID(metadata map[string]interface{}) string {
if metadata == nil {
return ""
}
if profileID := StringFromAny(metadata[MetaKeyInitialSessionRuntimeConfigProfileID]); profileID != "" {
return profileID
}
return StringFromAny(metadata[MetaKeyAgentProfileID])
}
// LoadEffectiveSessionRuntimeConfig resolves the effective model, mode, and
// dynamic options for a task session. The profile snapshot is the base, the
// provider runtime state replaces it, the persisted session mode takes
// precedence over provider mode, and explicit runtime overrides win last.
func LoadEffectiveSessionRuntimeConfig(session *TaskSession) (SessionRuntimeConfig, bool) {
if session == nil {
return SessionRuntimeConfig{}, false
}
effective := runtimeConfigFromAgentProfileSnapshot(session.AgentProfileSnapshot)
if runtime, ok := LoadSessionRuntimeConfig(session.Metadata); ok {
mergeSessionRuntimeConfig(&effective, runtime)
if runtime.ConfigOptions != nil {
// Provider runtime options are a complete replacement for profile
// options. Explicit overrides below are the only later merge.
effective.ConfigOptions = maps.Clone(runtime.ConfigOptions)
}
}
if mode := StringFromAny(session.Metadata[SessionMetaKeySessionMode]); mode != "" {
effective.Mode = mode
}
if overrides, ok := LoadSessionRuntimeConfigOverrides(session.Metadata); ok {
mergeSessionRuntimeConfig(&effective, overrides)
}
effective.ConfigOptions = cleanRuntimeConfigOptions(effective.ConfigOptions, session)
return effective, !effective.IsZero()
}
func runtimeConfigFromAgentProfileSnapshot(snapshot map[string]interface{}) SessionRuntimeConfig {
if snapshot == nil {
return SessionRuntimeConfig{}
}
config := SessionRuntimeConfig{
Model: StringFromAny(snapshot["model"]),
Mode: StringFromAny(snapshot["mode"]),
}
config.ConfigOptions = stringMapFromAny(snapshot["config_options"])
if config.ConfigOptions == nil {
config.ConfigOptions = stringMapFromAny(snapshot["configOptions"])
}
return config
}
func mergeSessionRuntimeConfig(target *SessionRuntimeConfig, source SessionRuntimeConfig) {
if source.Model != "" {
target.Model = source.Model
}
if source.Mode != "" {
target.Mode = source.Mode
}
if len(source.ConfigOptions) == 0 {
return
}
if target.ConfigOptions == nil {
target.ConfigOptions = make(map[string]string, len(source.ConfigOptions))
}
for key, value := range source.ConfigOptions {
target.ConfigOptions[key] = value
}
}
func cleanRuntimeConfigOptions(options map[string]string, session *TaskSession) map[string]string {
if len(options) == 0 {
return nil
}
cleaned := maps.Clone(options)
// Model and mode are carried as top-level fields. Other keys are provider-defined.
delete(cleaned, "model")
delete(cleaned, "mode")
if isLegacyAgentIdentity(session, cleaned["agent"]) {
delete(cleaned, "agent")
}
if len(cleaned) == 0 {
return nil
}
return cleaned
}
func isLegacyAgentIdentity(session *TaskSession, value string) bool {
if session == nil || value == "" || session.AgentProfileSnapshot == nil {
return false
}
for _, key := range []string{"agent_id", "agent_name"} {
if StringFromAny(session.AgentProfileSnapshot[key]) == value {
return true
}
}
return false
}
// SessionOriginalEffectiveConfiguration is the immutable configuration a task
// session had after provider defaults and its profile settings settled.
type SessionOriginalEffectiveConfiguration struct {
Model string `json:"model,omitempty"`
ConfigOptions map[string]string `json:"config_options,omitempty"`
}
// LoadOriginalSessionEffectiveConfiguration decodes the original configuration
// from typed or JSON-rehydrated session metadata.
func LoadOriginalSessionEffectiveConfiguration(metadata map[string]interface{}) (SessionOriginalEffectiveConfiguration, bool) {
if metadata == nil {
return SessionOriginalEffectiveConfiguration{}, false
}
raw, ok := metadata[SessionMetaKeyOriginalEffectiveConfig]
if !ok || raw == nil {
return SessionOriginalEffectiveConfiguration{}, false
}
if config, ok := raw.(SessionOriginalEffectiveConfiguration); ok {
config.ConfigOptions = maps.Clone(config.ConfigOptions)
return config, config.Model != "" || len(config.ConfigOptions) > 0
}
data, err := json.Marshal(raw)
if err != nil {
return SessionOriginalEffectiveConfiguration{}, false
}
var config SessionOriginalEffectiveConfiguration
if err := json.Unmarshal(data, &config); err != nil {
return SessionOriginalEffectiveConfiguration{}, false
}
return config, config.Model != "" || len(config.ConfigOptions) > 0
}
// IsOriginalTaskSession reports whether immutable task-session provenance marks
// this row as the session created when the task first started.
func IsOriginalTaskSession(metadata map[string]interface{}) bool {
if metadata == nil {
return false
}
return StringFromAny(metadata[SessionMetaKeyOrigin]) == SessionOriginTaskInitial
}
// TurnRuntimeConfigSnapshot is the minimal display state captured when a turn
// starts. It intentionally excludes the provider's complete option catalog.
type TurnRuntimeConfigSnapshot struct {
Model string `json:"model,omitempty"`
Mode string `json:"mode,omitempty"`
ConfigOptions []TurnRuntimeConfigOption `json:"config_options,omitempty"`
ConfigBaseline map[string]string `json:"config_baseline,omitempty"`
}
// TurnRuntimeConfigOption preserves one selected value in provider order.
type TurnRuntimeConfigOption struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Value string `json:"value"`
ValueName string `json:"value_name,omitempty"`
}
// LoadTurnRuntimeConfigSnapshot decodes typed and JSON-rehydrated turn metadata.
func LoadTurnRuntimeConfigSnapshot(metadata map[string]interface{}) (TurnRuntimeConfigSnapshot, bool) {
if metadata == nil {
return TurnRuntimeConfigSnapshot{}, false
}
raw := metadata[TurnMetaKeyRuntimeConfigSnapshot]
if snapshot, ok := raw.(TurnRuntimeConfigSnapshot); ok {
return snapshot, snapshot.Model != "" || snapshot.Mode != "" || len(snapshot.ConfigOptions) > 0
}
data, err := json.Marshal(raw)
if err != nil {
return TurnRuntimeConfigSnapshot{}, false
}
var snapshot TurnRuntimeConfigSnapshot
if err := json.Unmarshal(data, &snapshot); err != nil {
return TurnRuntimeConfigSnapshot{}, false
}
return snapshot, snapshot.Model != "" || snapshot.Mode != "" || len(snapshot.ConfigOptions) > 0
}
// SessionMetaKeyPendingStepCompletion stores the agent's (or manual fallback's)
// step-complete signal under TaskSession.Metadata. ADR 0015: the orchestrator
// reads this on turn-end for steps with AutoAdvanceRequiresSignal=true and
// only fires on_turn_complete transitions when a matching signal is present.
// Cleared on successful transition, on user reply before transition, or any
// other step change.
const SessionMetaKeyPendingStepCompletion = "pending_step_completion_signal"
// SessionMetaKeyLastAgentError stores the last recoverable agent runtime
// failure for UI surfaces that need to keep the error visible after auto-resume.
const SessionMetaKeyLastAgentError = "last_agent_error"
// LastAgentError is persisted under TaskSession.Metadata[SessionMetaKeyLastAgentError].
// RemediationURL is only ever set from an adapter-validated provider
// diagnostic; it is never reconstructed from the error message.
type LastAgentError struct {
Message string `json:"message"`
OccurredAt time.Time `json:"occurred_at"`
Scope string `json:"scope,omitempty"`
AgentExecutionID string `json:"agent_execution_id,omitempty"`
ExecutionID string `json:"execution_id,omitempty"`
Phase string `json:"phase,omitempty"`
AttemptID string `json:"attempt_id,omitempty"`
Causes []AgentErrorCause `json:"causes,omitempty"`
RemediationURL string `json:"remediation_url,omitempty"`
Code string `json:"code,omitempty"`
Details string `json:"details,omitempty"`
RecoveryActions []string `json:"recovery_actions,omitempty"`
TaskRepositoryID string `json:"task_repository_id,omitempty"`
StampValue string `json:"stamp,omitempty"`
DismissedAt *time.Time `json:"dismissed_at,omitempty"`
}
func LoadLastAgentError(metadata map[string]interface{}) (LastAgentError, bool) {
if metadata == nil {
return LastAgentError{}, false
}
raw, ok := metadata[SessionMetaKeyLastAgentError]
if !ok || raw == nil {
return LastAgentError{}, false
}
var out LastAgentError
if err := mapToLastAgentError(raw, &out); err != nil || out.Message == "" {
return LastAgentError{}, false
}
return normalizeLastAgentError(out), true
}
func mapToLastAgentError(raw interface{}, out *LastAgentError) error {
data, err := json.Marshal(raw)
if err != nil {
return err
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
return err
}
// Optional bootstrap fields are deliberately decoded independently. A
// malformed optional field must not hide a valid legacy session error.
optional := map[string]json.RawMessage{}
for _, key := range []string{"execution_id", "phase", "attempt_id", "causes"} {
if value, ok := fields[key]; ok {
optional[key] = value
delete(fields, key)
}
}
legacyData, err := json.Marshal(fields)
if err != nil {
return err
}
if err := json.Unmarshal(legacyData, out); err != nil {
return err
}
if value, ok := optional["execution_id"]; ok {
var executionID string
if json.Unmarshal(value, &executionID) == nil {
out.ExecutionID = executionID
}
}
if value, ok := optional["phase"]; ok {
var phase string
if json.Unmarshal(value, &phase) == nil {
out.Phase = phase
}
}
if value, ok := optional["attempt_id"]; ok {
var attemptID string
if json.Unmarshal(value, &attemptID) == nil {
out.AttemptID = attemptID
}
}
if value, ok := optional["causes"]; ok {
var causes []AgentErrorCause
if json.Unmarshal(value, &causes) == nil {
out.Causes = causes
}
}
return nil
}
func (e LastAgentError) Stamp() string {
if stamp := boundedLaunchErrorStamp(e.StampValue); stamp != "" {
return stamp
}
return e.OccurredAt.UTC().Format(time.RFC3339Nano) + ":" + e.Message
}
func (e LastAgentError) MatchesStamp(stamp string) bool {
if stamp == e.Stamp() {
return true
}
if boundedLaunchErrorStamp(e.StampValue) != "" {
return false
}
suffix := ":" + e.Message
if !strings.HasSuffix(stamp, suffix) {
return false
}
rawOccurredAt := strings.TrimSuffix(stamp, suffix)
if rawOccurredAt == "" {
return e.OccurredAt.IsZero()
}
occurredAt, err := time.Parse(time.RFC3339Nano, rawOccurredAt)
if err != nil {
return false
}
return occurredAt.Equal(e.OccurredAt)
}
func (e LastAgentError) IsDismissed() bool {
return e.DismissedAt != nil && !e.DismissedAt.IsZero()
}
// PendingStepCompletionSignal source values.
const (
StepCompletionSourceAgent = "agent"
StepCompletionSourceManualFallback = "manual_fallback"
)
// PendingStepCompletionSignal is the JSON shape persisted under
// TaskSession.Metadata[SessionMetaKeyPendingStepCompletion]. It records an
// agent-emitted (or user-emitted via the fallback button) completion signal
// that the orchestrator should consume to drive a workflow step transition.
// See ADR 0015 for the lifecycle (set → read → clear).
type PendingStepCompletionSignal struct {
StepID string `json:"step_id"`
Source string `json:"source"`
Summary string `json:"summary"`
Handoff string `json:"handoff,omitempty"`
Blockers string `json:"blockers,omitempty"`
SignaledAt time.Time `json:"signaled_at"`
}
// LoadSessionRuntimeConfig decodes the runtime-config bag entry from session
// metadata. It tolerates both typed values and JSON-rehydrated maps.
func LoadSessionRuntimeConfig(metadata map[string]interface{}) (SessionRuntimeConfig, bool) {
return loadSessionRuntimeConfig(metadata, SessionMetaKeyRuntimeConfig)
}
// LoadSessionRuntimeConfigOverrides decodes explicit user runtime selections.
func LoadSessionRuntimeConfigOverrides(metadata map[string]interface{}) (SessionRuntimeConfig, bool) {
return loadSessionRuntimeConfig(metadata, SessionMetaKeyRuntimeConfigOverrides)
}
func loadSessionRuntimeConfig(metadata map[string]interface{}, key string) (SessionRuntimeConfig, bool) {
if metadata == nil {
return SessionRuntimeConfig{}, false
}
raw, ok := metadata[key]
if !ok || raw == nil {
return SessionRuntimeConfig{}, false
}
switch v := raw.(type) {
case SessionRuntimeConfig:
v.ConfigOptions = maps.Clone(v.ConfigOptions)
return v, !v.IsZero()
case map[string]string:
out := SessionRuntimeConfig{
Model: v["model"],
Mode: v["mode"],
ConfigOptions: maps.Clone(v),
}
delete(out.ConfigOptions, "model")
delete(out.ConfigOptions, "mode")
if len(out.ConfigOptions) == 0 {
out.ConfigOptions = nil
}
return out, !out.IsZero()
case map[string]interface{}:
out := SessionRuntimeConfig{
Model: StringFromAny(v["model"]),
Mode: StringFromAny(v["mode"]),
}
if opts := stringMapFromAny(v["config_options"]); len(opts) > 0 {
out.ConfigOptions = opts
}
return out, !out.IsZero()
default:
return SessionRuntimeConfig{}, false
}
}
// LoadSessionACPConfigBaseline decodes the write-once ACP configuration
// baseline from session metadata. It tolerates typed and JSON-rehydrated maps.
func LoadSessionACPConfigBaseline(metadata map[string]interface{}) (map[string]string, bool) {
if metadata == nil {
return nil, false
}
values := stringMapFromAnyPreservingEmpty(metadata[SessionMetaKeyACPConfigBaseline])
if len(values) == 0 {
return nil, false
}
return values, true
}
func stringMapFromAnyPreservingEmpty(raw interface{}) map[string]string {
switch values := raw.(type) {
case map[string]string:
return maps.Clone(values)
case map[string]interface{}:
out := make(map[string]string, len(values))
for key, value := range values {
if str, ok := value.(string); ok {
out[key] = str
}
}
return out
default:
return nil
}
}
// IsZero reports whether the runtime config carries any selected value.
func (c SessionRuntimeConfig) IsZero() bool {
return c.Model == "" && c.Mode == "" && len(c.ConfigOptions) == 0
}
func stringMapFromAny(raw interface{}) map[string]string {
switch v := raw.(type) {
case map[string]string:
return maps.Clone(v)
case map[string]interface{}:
out := make(map[string]string, len(v))
for key, value := range v {
if str := StringFromAny(value); str != "" {
out[key] = str
}
}
return out
default:
return nil
}
}
// LoadPendingStepSignal decodes the pending-completion bag entry from a
// session's metadata. Single source of truth shared by the MCP handler
// (write site, idempotency check) and the orchestrator (read site, gating).
// Survives both the in-process typed shape and the JSON-rehydrated
// `map[string]interface{}` shape produced when the row is loaded fresh from
// SQLite after a backend restart.
func LoadPendingStepSignal(metadata map[string]interface{}) (PendingStepCompletionSignal, bool) {
if metadata == nil {
return PendingStepCompletionSignal{}, false
}
raw, ok := metadata[SessionMetaKeyPendingStepCompletion]
if !ok || raw == nil {
return PendingStepCompletionSignal{}, false
}
switch v := raw.(type) {
case PendingStepCompletionSignal:
return v, true
case map[string]interface{}:
out := PendingStepCompletionSignal{
StepID: StringFromAny(v["step_id"]),
Source: StringFromAny(v["source"]),
Summary: StringFromAny(v["summary"]),
Handoff: StringFromAny(v["handoff"]),
Blockers: StringFromAny(v["blockers"]),
}
if ts, ok := v["signaled_at"].(string); ok {
if parsed, err := time.Parse(time.RFC3339Nano, ts); err == nil {
out.SignaledAt = parsed
}
}
return out, out.StepID != ""
}
return PendingStepCompletionSignal{}, false
}
// StringFromAny narrows an interface{} slot to a string, returning "" when
// the value is absent or of a different type. Used by both the
// PendingStepCompletionSignal map-shape decoder and the orchestrator's
// step-completion event-payload parser — single source of truth so the
// two decoders can't drift on what counts as a "missing string".
func StringFromAny(v interface{}) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
// Task origin values for the Origin field.
const (
TaskOriginManual = "manual"
TaskOriginAgentCreated = "agent_created"
TaskOriginRoutine = "routine"
TaskOriginOnboarding = "onboarding"
TaskOriginAutomationRun = "automation_run"
// TaskOriginAutomationTask is a normal, user-visible task created by an
// automation. Unlike automation_run, it remains in Kanban/sidebar flows.
TaskOriginAutomationTask = "automation_task"
)
// Task represents a task in the database
type Task struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
WorkflowID string `json:"workflow_id"`
WorkflowStepID string `json:"workflow_step_id"`
// WorkflowAgentOverrides is scoped to WorkflowID and expands the grouped
// create choice into fixed step bindings. It is nil for ordinary tasks.
WorkflowAgentOverrides *WorkflowAgentOverrides `json:"workflow_agent_overrides,omitempty"`
Title string `json:"title"`
Description string `json:"description"`
State v1.TaskState `json:"state"`
Priority string `json:"priority"`
Position int `json:"position"` // Order within workflow step
// WIPAdmitted indicates whether this task consumes an active slot in its
// current workflow step. Queued tasks remain visible but do not consume the
// destination step's WIP capacity.
WIPAdmitted bool `json:"wip_admitted"`
QueuedForStepID string `json:"queued_for_step_id,omitempty"`
QueuedAt *time.Time `json:"queued_at,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
Repositories []*TaskRepository `json:"repositories,omitempty"`
WorkspaceFolders []*TaskWorkspaceFolder `json:"workspace_folders,omitempty"`
IsEphemeral bool `json:"is_ephemeral"` // Ephemeral tasks are not shown in kanban, used for quick chat
ParentID string `json:"parent_id,omitempty"` // FK to parent task for subtasks
Autopilot bool `json:"autopilot"` // Fixed at task creation
ArchivedAt *time.Time `json:"archived_at,omitempty"`
// ArchivedByCascadeID is set when the task was archived as part of an
// office task-handoffs cascade (phase 6). UnarchiveTaskByCascade uses
// it to scope restoration to exactly the descendants this cascade
// owned, leaving manually-archived tasks alone. Empty for
// manually-archived tasks and any task archived before phase 6.
ArchivedByCascadeID string `json:"archived_by_cascade_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// WorkflowStepTransitionID is the immutable ledger row identifier for the
// latest workflow-step write. Repositories populate it after a transition;
// it is transient and is not stored in the tasks table.
WorkflowStepTransitionID int64 `json:"-"`
// FromStepID is the workflow_step_id the task left on the same write that
// set WorkflowStepTransitionID, read inside that write's own transaction
// (readTaskStepInTx) rather than from any earlier snapshot. Empty when no
// transition occurred (WorkflowStepTransitionID == 0) or on task creation.
// Transient and is not stored in the tasks table.
FromStepID string `json:"-"`
// FromWorkflowID is the workflow_id the task left on the same write that
// set WorkflowStepTransitionID, read inside that write's own transaction.
// It is transient and is not stored in the tasks table.
FromWorkflowID string `json:"-"`
// Office extensions.
//
// ADR 0005 Wave F: the task's assignee is no longer a column on the
// tasks table. The canonical store is a `runner` row in
// workflow_step_participants (resolve via
// workflow.Repository.ResolveCurrentRunner). The struct field below
// is a read-time projection populated by repo SELECT queries through
// a correlated subquery and consumed unchanged by callers that
// previously read tasks.assignee_agent_profile_id. Repo writes to
// this field are routed to SetTaskRunner / ClearTaskRunner inside
// the task repository.
AssigneeAgentProfileID string `json:"assignee_agent_profile_id,omitempty"`
// AssigneeUserID is the HUMAN assignee, entirely independent of the agent
// assignee above: a task can carry both, and setting one never clears the
// other. It is advisory and gates nothing; taking a task over is a
// reassignment plus a prompt, not a lock.
AssigneeUserID string `json:"assignee_user_id,omitempty"`
Origin string `json:"origin,omitempty"` // manual, agent_created, routine
ProjectID string `json:"project_id,omitempty"` // FK to office project
Labels string `json:"labels,omitempty"` // JSON array string, default "[]"
Identifier string `json:"identifier,omitempty"` // e.g. "KAN-42"
// ExternalID is a caller-supplied identity used for create-idempotency
// (docs/specs/tasks/requirements/external-id-idempotency.md). Empty when the task
// holds none. Unique per (workspace_id, external_id) when non-empty.
ExternalID string `json:"external_id,omitempty"`
// ExternalIDSettledAt is non-nil once the create that claimed ExternalID
// finished its required synchronous work. Nil means unsettled — the
// claiming create may still be in progress. Both fields are cleared
// together on release.
ExternalIDSettledAt *time.Time `json:"external_id_settled_at,omitempty"`
// IsFromOffice is a read-time projection set by the task repo's SELECT
// (see isFromOfficeProjection in repository/sqlite/task.go). True when
// the task is owned by office: either it has a non-empty ProjectID, or
// its workflow matches the workspace's office_workflow_id. Kanban-board
// tasks always come back false. UI callers gate office-only surfaces on
// this (e.g. the "Open in office view" topbar link).
IsFromOffice bool `json:"is_from_office,omitempty"`
}
// IsOfficeOwnedAndAssigned reports whether runtime behavior belongs to an
// Office-owned task with a designated runner. Kanban tasks may also project a
// runner from their workflow step, but retain normal per-session semantics.
func (t *Task) IsOfficeOwnedAndAssigned() bool {
return t != nil && t.IsFromOffice && t.AssigneeAgentProfileID != ""
}
// OfficeDecisionWaitCandidate is the compact projection the Office
// decision-waiting detector scans (REQ-OFFICE-STALL-VISIBILITY-002). It is a
// candidate, not a finding: the repository query only establishes that the
// task is Office-owned, sits at a step carrying a decision-required seat, and
// has been quiet since UpdatedAt. Whether a decision was already recorded and
// whether a run is still in flight are judged by the detector, so each
// rejection has its own countable reason.
type OfficeDecisionWaitCandidate struct {
TaskID string `db:"id"`
StepID string `db:"workflow_step_id"`
UpdatedAt time.Time `db:"updated_at"`
}
// OfficeDecisionWaitCursor identifies the last candidate in one ordered page.
// The task repository uses it to continue a bounded scan without repeatedly
// returning the same oldest rows.
type OfficeDecisionWaitCursor struct {
UpdatedAt time.Time
TaskID string
}
// ChildCompletionRow is the compact active-child projection used to decide
// whether a parent task's on_children_completed trigger is ready to fire.
type ChildCompletionRow struct {
ID string `json:"id" db:"id"`
State v1.TaskState `json:"state" db:"state"`
Title string `json:"title" db:"title"`
WorkflowStepID string `json:"workflow_step_id" db:"workflow_step_id"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// HasStartWhenUnblockedIntent reports whether a task's deferred launch intent
// belongs to a dependency chain rather than to WIP overflow.
//
// The two share one record; DeferredLaunchStartWhenUnblockedKey is what tells
// them apart. Defined here, next to the key, so the DTO layer, the task service
// and the orchestrator cannot drift on what counts as a chain intent.
func HasStartWhenUnblockedIntent(task *Task) bool {
if task == nil || task.Metadata == nil {
return false
}
raw, ok := task.Metadata[MetaKeyDeferredLaunch]
if !ok {
return false
}
launch, ok := raw.(map[string]interface{})
if !ok {
return false
}
flag, ok := launch[DeferredLaunchStartWhenUnblockedKey].(bool)
return ok && flag
}
// DropWIPDeferredLaunch removes a deferred launch record that only existed
// because the task was waiting on WIP capacity.
//
// Every WIP admission point calls this instead of deleting the key outright.
// An admitted task has nothing left to wait for as far as WIP is concerned, so
// its overflow record is stale. A start-when-unblocked intent is NOT stale in
// that situation: it is waiting on a predecessor, not on capacity, and the two
// share this one metadata record. Deleting it on admission silently unmakes
// every dependency chain, because admission happens at create time for any task
// entering a step with room.
func DropWIPDeferredLaunch(task *Task) {
if task == nil || task.Metadata == nil || HasStartWhenUnblockedIntent(task) || HasCeilingDeferredIntent(task) {
return
}
delete(task.Metadata, MetaKeyDeferredLaunch)
}
// IsTerminalTaskState reports whether a task state means no further child work
// is expected from that task.
func IsTerminalTaskState(state v1.TaskState) bool {
switch state {
case v1.TaskStateCompleted, v1.TaskStateFailed, v1.TaskStateCancelled:
return true
default:
return false
}
}
// TaskTreeFilters provides filter options for the task tree query.
type TaskTreeFilters struct {
ProjectID string
AssigneeID string
WorkflowID string
Origin string
}
// WorkflowStyle values are persisted in workflows.style. See ADR-0004.
// They're a UX hint for the frontend; backend code MUST NOT branch on them.
const (
WorkflowStyleKanban = "kanban"
WorkflowStyleOffice = "office"
WorkflowStyleCustom = "custom"
)
// WorkflowProfileSessionStartPolicy controls how a fixed-profile workflow step
// obtains a session when it starts after a profile switch.
type WorkflowProfileSessionStartPolicy string
const (
WorkflowProfileSessionStartPolicyReuse WorkflowProfileSessionStartPolicy = "reuse"
WorkflowProfileSessionStartPolicyNew WorkflowProfileSessionStartPolicy = "new"
)
// NormalizeWorkflowProfileSessionStartPolicy returns the safe default for empty
// and unknown workflow step session-start policy values.
func NormalizeWorkflowProfileSessionStartPolicy(value string) WorkflowProfileSessionStartPolicy {
value = strings.TrimSpace(value)
if WorkflowProfileSessionStartPolicy(value) == WorkflowProfileSessionStartPolicyNew {
return WorkflowProfileSessionStartPolicyNew
}
return WorkflowProfileSessionStartPolicyReuse
}
// WorkflowProfileSessionEndPolicy controls what happens to a fixed-profile
// workflow step's session when the workflow leaves it for another profile.
type WorkflowProfileSessionEndPolicy string
const (
WorkflowProfileSessionEndPolicyComplete WorkflowProfileSessionEndPolicy = "complete"
WorkflowProfileSessionEndPolicyPark WorkflowProfileSessionEndPolicy = "park"
)
// NormalizeWorkflowProfileSessionEndPolicy returns the safe default for empty
// and unknown workflow step session-end policy values.
func NormalizeWorkflowProfileSessionEndPolicy(value string) WorkflowProfileSessionEndPolicy {
value = strings.TrimSpace(value)
if WorkflowProfileSessionEndPolicy(value) == WorkflowProfileSessionEndPolicyComplete {
return WorkflowProfileSessionEndPolicyComplete
}
return WorkflowProfileSessionEndPolicyPark
}
// WorkflowSource values are persisted in workflows.source and record where a
// workflow definition came from. Manual workflows are user-managed; GitHub
// workflows are owned by the workflow-sync poller and may be overwritten or
// removed on each sync.
const (
WorkflowSourceManual = "manual"
WorkflowSourceGitHub = "github"
)
// Workflow represents a task workflow
type Workflow struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description"`
// Prompt is optional workflow-level agent instructions prepended at
// every step entry before the step prompt. Empty means off.
Prompt string `json:"prompt,omitempty"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
WorkflowTemplateID *string `json:"workflow_template_id,omitempty"`
SortOrder int `json:"sort_order"`
// Source records where the workflow definition came from ("manual" or
// "github"). SourcePath is the repo-relative file path the definition
// was synced from; empty for manual workflows.
Source string `json:"source,omitempty"`
SourcePath string `json:"source_path,omitempty"`
// Hidden workflows are excluded from management and picker UIs by default.
// Used by system-only flows like Improve Kandev.
Hidden bool `json:"hidden,omitempty"`
// Style is a Phase 2 (ADR-0004) UX hint read by the frontend ONLY.
// Allowed values: "kanban" | "office" | "custom". Empty / unknown
// values fall back to "kanban" via the schema default. Backend code
// MUST NOT branch on this field — it is a presentation hint, not a
// behavioural invariant.
Style string `json:"style,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// WorkspaceNameImproveKandev is the exact name of the dedicated Improve Kandev
// workspace created by the improve-kandev bootstrap. The workspace is matched
// by this name everywhere (bootstrap, guards, frontend) and is
// configuration-immutable: its workflows and repositories are read-only.
const WorkspaceNameImproveKandev = "Improve Kandev"
// IsImproveKandev reports whether the workspace is the dedicated Improve
// Kandev workspace (matched by exact name).
func (w *Workspace) IsImproveKandev() bool {
return w != nil && w.Name == WorkspaceNameImproveKandev
}
// Workspace represents a workspace
type Workspace struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
OwnerID string `json:"owner_id"`
// OrgID is the owning tenant. Empty means organizations are off or the
// tenancy migration has not run.
OrgID string `json:"org_id,omitempty"`
// UnitID places the workspace in the organization unit tree. Reach is
// resolved from this placement, so a workspace without one is reachable by
// nobody.
UnitID string `json:"unit_id,omitempty"`
DefaultExecutorID *string `json:"default_executor_id,omitempty"`
DefaultEnvironmentID *string `json:"default_environment_id,omitempty"`
DefaultAgentProfileID *string `json:"default_agent_profile_id,omitempty"`
DefaultConfigAgentProfileID *string `json:"default_config_agent_profile_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Office extensions
TaskPrefix string `json:"task_prefix,omitempty"` // e.g. "KAN"
TaskSequence int `json:"task_sequence,omitempty"` // auto-incrementing counter
OfficeWorkflowID string `json:"office_workflow_id,omitempty"` // FK to system office workflow
}
// TaskRepository represents a repository associated with a task
type TaskRepository struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
RepositoryID string `json:"repository_id"`
BaseBranch string `json:"base_branch"`
CheckoutBranch string `json:"checkout_branch,omitempty"`
BranchPolicyID string `json:"branch_policy_id,omitempty"`
BranchPolicyName string `json:"branch_policy_name,omitempty"`
BranchPolicyBaseBranch string `json:"branch_policy_base_branch,omitempty"`
BranchPolicyBranchTemplate string `json:"branch_policy_branch_template,omitempty"`
BranchPolicyPullRequestTarget string `json:"branch_policy_pull_request_target,omitempty"`
Position int `json:"position"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TaskWorkspaceFolder is a canonical host-folder attachment owned by a task.
// It stays separate from TaskRepository because folders are not Git sources.
type TaskWorkspaceFolder struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
LocalPath string `json:"local_path"`
DisplayName string `json:"display_name"`
Position int `json:"position"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// WorkspaceSourceBatch identifies exactly the durable rows created by one
// attachment operation, so a later materialization failure can compensate
// without touching pre-existing sources.
type WorkspaceSourceBatch struct {
TaskID string `json:"task_id"`
Sources []WorkspaceSource `json:"sources,omitempty"`
RepositoryUpdates []WorkspaceSourceRepositoryUpdate `json:"repository_updates,omitempty"`
ExpectedParentID string `json:"-"`
ExpectedParentWorkspaceID string `json:"-"`
}
// WorkspaceSourceRepositoryUpdate records a legacy association branch derived
// during workspace-source validation. PreviousBaseBranch permits compensation
// if later runtime materialization fails.
type WorkspaceSourceRepositoryUpdate struct {
TaskRepositoryID string `json:"task_repository_id"`
BaseBranch string `json:"base_branch"`
PreviousBaseBranch string `json:"previous_base_branch"`
}
// WorkspaceSource carries exactly one durable source kind. Sources preserve
// the caller's mixed order for global position allocation.
type WorkspaceSource struct {
Repository *TaskRepository `json:"repository,omitempty"`
Folder *TaskWorkspaceFolder `json:"folder,omitempty"`
}
// MessageAuthorType represents who authored a message
type MessageAuthorType string
const (
// MessageAuthorUser indicates a message from a human user
MessageAuthorUser MessageAuthorType = "user"
// MessageAuthorAgent indicates a message from an AI agent
MessageAuthorAgent MessageAuthorType = "agent"
)
// MessageType represents the type of message content
type MessageType string
const (
// MessageTypeMessage is the default type for user/agent regular messages
MessageTypeMessage MessageType = "message"
// MessageTypeContent is for agent response content
MessageTypeContent MessageType = "content"
// MessageTypeToolCall is when agent uses a tool
MessageTypeToolCall MessageType = "tool_call"
// MessageTypeToolEdit is for file edit operations with diff visualization
MessageTypeToolEdit MessageType = "tool_edit"
// MessageTypeToolRead is for file read operations
MessageTypeToolRead MessageType = "tool_read"
// MessageTypeToolSearch is for code and file search operations
MessageTypeToolSearch MessageType = "tool_search"
// MessageTypeToolExecute is for command execution operations
MessageTypeToolExecute MessageType = "tool_execute"
// MessageTypeProgress is for progress updates
MessageTypeProgress MessageType = "progress"
// MessageTypeLog is for agent log messages (info, debug, warning, etc.)
MessageTypeLog MessageType = "log"
// MessageTypeError is for error messages
MessageTypeError MessageType = "error"
// MessageTypeStatus is for status changes: started, completed, failed
MessageTypeStatus MessageType = "status"
// MessageTypePermissionRequest is for agent permission requests
MessageTypePermissionRequest MessageType = "permission_request"
// MessageTypeClarificationRequest is for agent clarification questions
MessageTypeClarificationRequest MessageType = "clarification_request"
// MessageTypeScriptExecution is for setup/cleanup script execution messages
MessageTypeScriptExecution MessageType = "script_execution"
// MessageTypeThinking is for agent thinking/reasoning content
MessageTypeThinking MessageType = "thinking"
// MessageTypeAgentPlan is for agent native plan content (e.g. ExitPlanMode)
MessageTypeAgentPlan MessageType = "agent_plan"
// MessageTypeTodo is for agent todo/task list updates
MessageTypeTodo MessageType = "todo"
)
// PermissionStatus is the resolution status of a permission request message,
// stored under metadata.status. Pending is the absence sentinel: a freshly
// created permission_request message does not set metadata.status, and any
// reader treats that as PermissionStatusPending.
type PermissionStatus string
const (
// PermissionStatusPending is the empty-string sentinel for the unresolved
// state. CreatePermissionRequestMessage does not write metadata.status at
// all; readers compare against this constant instead of the bare "".
PermissionStatusPending PermissionStatus = ""
// PermissionStatusApproved means the user (or automation) accepted the request.
PermissionStatusApproved PermissionStatus = "approved"
// PermissionStatusRejected means the user did not accept the request.
// Collapses two distinct ACP outcomes: explicit Deny (selected option with
// kind reject_once/reject_always) AND dialog dismissal (ACP cancelled
// outcome on the wire, triggered today only by the fallback path when the
// prompt offers no reject_* option). Split into separate Rejected /
// Dismissed statuses if the audit trail ever needs to distinguish them.
PermissionStatusRejected PermissionStatus = "rejected"
// PermissionStatusExpired means the request became unanswerable: the agent
// withdrew the prompt before a response arrived (permission_cancelled
// event), or agentctl rejected the response because the pending entry was
// already gone. No ACP outcome ever reaches the wire in this state.
PermissionStatusExpired PermissionStatus = "expired"
)
type PermissionResolutionActorKind string
const (
PermissionActorBrowser PermissionResolutionActorKind = "browser"
PermissionActorPersonalAccessToken PermissionResolutionActorKind = "personal_access_token"
PermissionActorAutomation PermissionResolutionActorKind = "automation"
PermissionActorSynthetic PermissionResolutionActorKind = "synthetic"
)
type PermissionResolutionSource string
const (
PermissionSourceWeb PermissionResolutionSource = "web"
PermissionSourceExternalMCP PermissionResolutionSource = "external_mcp"
PermissionSourceAutomation PermissionResolutionSource = "automation"
// PermissionSourceAutomationMCP identifies a resolution made by the
// fixed in-session coordinator surface. It is distinct from legacy
// backend automation and from the authenticated external MCP bridge.
PermissionSourceAutomationMCP PermissionResolutionSource = "automation_mcp"
)
type PermissionResolutionResult string
const (
PermissionResolutionDispatching PermissionResolutionResult = "dispatching"
PermissionResolutionAccepted PermissionResolutionResult = "accepted"
PermissionResolutionStale PermissionResolutionResult = "stale"
PermissionResolutionExpired PermissionResolutionResult = "expired"
PermissionResolutionFailed PermissionResolutionResult = "failed"
PermissionResolutionIndeterminate PermissionResolutionResult = "indeterminate"
)
// PermissionResolutionAudit is the durable, presentation-free record of one
// exact resolution attempt. Credential-bearing action data is not part of this
// type by design.
type PermissionResolutionAudit struct {
ClaimID string `json:"claim_id"`
ActorUserID string `json:"actor_user_id,omitempty"`
ActorKind PermissionResolutionActorKind `json:"actor_kind"`
Source PermissionResolutionSource `json:"source"`
RequestID string `json:"request_id"`
PendingID string `json:"pending_id"`
OptionID string `json:"option_id"`
OptionKind string `json:"option_kind"`
SelectedAt time.Time `json:"selected_at"`
FinalizedAt *time.Time `json:"finalized_at,omitempty"`
Result PermissionResolutionResult `json:"result"`
}
type PermissionResolutionClaimOutcome string
const (
PermissionClaimed PermissionResolutionClaimOutcome = "claimed"
PermissionClaimNotFound PermissionResolutionClaimOutcome = "not_found"
PermissionClaimInProgress PermissionResolutionClaimOutcome = "in_progress"
PermissionClaimAlreadyFinal PermissionResolutionClaimOutcome = "already_final"
)
type PermissionResolutionClaimRequest struct {
TaskID string
SessionID string
Audit PermissionResolutionAudit
}
type PermissionResolutionClaimResult struct {
Outcome PermissionResolutionClaimOutcome
Message *Message
}
type PermissionResolutionFinalizeOutcome string
const (
PermissionFinalized PermissionResolutionFinalizeOutcome = "finalized"
PermissionFinalizeNotFound PermissionResolutionFinalizeOutcome = "not_found"
PermissionFinalizeClaimMismatch PermissionResolutionFinalizeOutcome = "claim_mismatch"
PermissionFinalizeAlreadyFinal PermissionResolutionFinalizeOutcome = "already_final"
)
type PermissionResolutionFinalizeRequest struct {
TaskID string
SessionID string
RequestID string
PendingID string
ClaimID string
Result PermissionResolutionResult
Status PermissionStatus
FinalizedAt time.Time
}
type PermissionResolutionFinalizeResult struct {
Outcome PermissionResolutionFinalizeOutcome
Message *Message
}
// TaskPendingAction is the compact task-list projection for a session blocked
// on user input.
type TaskPendingAction string
// PendingActionRevision is a logical clock shared by REST and WebSocket
// pending-action projections. Epoch is a durable, monotonically allocated
// backend generation; Sequence orders snapshots within that generation.
type PendingActionRevision struct {
Epoch string `json:"epoch"`
Sequence uint64 `json:"sequence"`
}
const (
TaskPendingActionClarification TaskPendingAction = "clarification"
TaskPendingActionPermission TaskPendingAction = "permission"
)
// Message represents a message in a task session
type Message struct {
ID string `json:"id"`
TaskSessionID string `json:"session_id"`
TaskID string `json:"task_id,omitempty"`
TurnID string `json:"turn_id"` // FK to task_session_turns
AuthorType MessageAuthorType `json:"author_type"`
AuthorID string `json:"author_id,omitempty"` // User ID or Agent Execution ID
Content string `json:"content"`
Type MessageType `json:"type,omitempty"` // Defaults to "message"
Metadata map[string]interface{} `json:"metadata,omitempty"`
RequestsInput bool `json:"requests_input"` // True if agent is requesting user input
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` // Authoritative per-message change signal
// PromptIndex is the 1-based ordinal of the message among ALL user
// messages of its session, ordered by normalized-microsecond created_at
// ascending with ties broken by id ascending. Read-time derived: zero for
// non-user messages and for legacy 12-column reads, and serialized only
// when > 0 (json omitempty).
PromptIndex int `json:"prompt_index,omitempty"`
}
// ToAPI converts internal Message to API type.
// Only true system-injected content (wrapped in <kandev-system> tags) is stripped
// from the visible content sent to the UI.
func (m *Message) ToAPI() *v1.Message {
messageType := string(m.Type)
if messageType == "" {
messageType = string(MessageTypeMessage)
}
hasHidden := sysprompt.HasSystemContent(m.Content)
meta := ProjectMessageMetadata(m.Metadata)
if hasHidden {
if meta == nil {
meta = make(map[string]interface{})
} else {
// Copy to avoid mutating original
meta = copyMetadata(meta)
}
meta["has_hidden_prompts"] = true
}
result := &v1.Message{
ID: m.ID,
TaskSessionID: m.TaskSessionID,
TaskID: m.TaskID,
TurnID: m.TurnID,
AuthorType: string(m.AuthorType),
AuthorID: m.AuthorID,
Content: sysprompt.StripSystemContent(m.Content),
Type: messageType,
Metadata: meta,
RequestsInput: m.RequestsInput,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
PromptIndex: m.PromptIndex,
}
if hasHidden {
result.RawContent = m.Content
}
return result
}
// copyMetadata returns a shallow copy of a metadata map.
func copyMetadata(m map[string]any) map[string]any {
cp := make(map[string]any, len(m))
maps.Copy(cp, m)
return cp
}
// Turn represents a single prompt/response cycle within a task session.
// A turn starts when a user sends a prompt and ends when the agent completes,
// cancels, or errors.
type Turn struct {
ID string `json:"id"`
TaskSessionID string `json:"session_id"`
TaskID string `json:"task_id"`
// ExecutionProfileID and RouteGeneration are immutable attribution for the
// concrete route that started this turn. They are kept alongside the
// existing metadata during the compatibility migration so old rows remain
// readable.
ExecutionProfileID string `json:"execution_profile_id,omitempty"`
RouteGeneration int64 `json:"route_generation,omitempty"`
StartedAt time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ReviewStatus represents the review state of a TaskSession. The zero value
// (ReviewStatusNone, the empty string) means "no review needed" and serializes
// out via omitempty, preserving the JSON shape of the legacy *string field.
// The persisted column (task_sessions.review_status TEXT DEFAULT ”) is
// compatible with these values without any migration.
type ReviewStatus string
const (
// ReviewStatusNone is the zero value meaning no review is needed.
ReviewStatusNone ReviewStatus = ""
// ReviewStatusPending indicates the session is waiting on a human review.
ReviewStatusPending ReviewStatus = "pending"
// ReviewStatusApproved indicates the reviewer accepted the session output.
ReviewStatusApproved ReviewStatus = "approved"
)
// String returns the underlying string value for the review status.
func (r ReviewStatus) String() string { return string(r) }
// TaskSessionState represents the state of an agent session
type TaskSessionState string
// PromptableTaskSessionClaimStatus describes the outcome of atomically
// reserving a session for a lifecycle prompt.
type PromptableTaskSessionClaimStatus string
const (
PromptableTaskSessionClaimed PromptableTaskSessionClaimStatus = "claimed"
PromptableTaskSessionBusy PromptableTaskSessionClaimStatus = "busy"
PromptableTaskSessionInactive PromptableTaskSessionClaimStatus = "inactive"
)
// PromptableTaskSessionClaim is the result of a lifecycle prompt reservation.
// PreviousState is set only when Status is PromptableTaskSessionClaimed.
type PromptableTaskSessionClaim struct {
Status PromptableTaskSessionClaimStatus
PreviousState TaskSessionState
}
const (
// TaskSessionStateCreated - session created but agent not started
TaskSessionStateCreated TaskSessionState = "CREATED"
// TaskSessionStateStarting - agent is starting up
TaskSessionStateStarting TaskSessionState = "STARTING"
// TaskSessionStateRunning - agent is actively running
TaskSessionStateRunning TaskSessionState = "RUNNING"
// TaskSessionStateIdle - office sessions only: agent process and executor torn down
// between turns; conversation (acp_session_id) preserved for the next run.
TaskSessionStateIdle TaskSessionState = "IDLE"
// TaskSessionStateWaitingForInput - agent waiting for user input
TaskSessionStateWaitingForInput TaskSessionState = "WAITING_FOR_INPUT"
// TaskSessionStateCompleted - agent finished successfully
TaskSessionStateCompleted TaskSessionState = "COMPLETED"
// TaskSessionStateFailed - agent failed with error
TaskSessionStateFailed TaskSessionState = "FAILED"
// TaskSessionStateCancelled - agent was manually stopped
TaskSessionStateCancelled TaskSessionState = "CANCELLED"
)
// AllTaskSessionStates is the canonical, exhaustive list of TaskSessionState
// constants. Code that must cover every state (drift-guard tests, admin
// tooling) should range over this slice instead of hand-writing its own
// literal: Go does not enforce switch/slice exhaustiveness (the exhaustive
// linter is not enabled in this repo), so a hand-written literal silently
// stops covering new states the moment one is added here. Add a new
// TaskSessionState constant to this slice in the same change that adds the
// const.
var AllTaskSessionStates = []TaskSessionState{
TaskSessionStateCreated,
TaskSessionStateStarting,
TaskSessionStateRunning,
TaskSessionStateIdle,
TaskSessionStateWaitingForInput,
TaskSessionStateCompleted,
TaskSessionStateFailed,
TaskSessionStateCancelled,
}
// SessionBranchInfo is a lightweight projection of a session with its worktree branch.
// Used by the PR watch reconciler to find sessions that may need PR watches.
type SessionBranchInfo struct {
SessionID string
TaskID string
WorkspaceID string
Branch string
}
// TaskSession represents a persistent agent execution session for a task.
// This replaces the in-memory TaskExecution tracking and survives backend restarts.
type TaskSession struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
QueueIncarnationID string `json:"queue_incarnation_id"`
Name string `json:"name,omitempty"` // Optional user-supplied label shown on the session tab
AgentExecutionID string `json:"agent_execution_id"` // Docker container/agent execution
ContainerID string `json:"container_id"` // Docker container ID for cleanup
AgentProfileID string `json:"agent_profile_id"` // ID of the agent profile used
ExecutionProfileID string `json:"execution_profile_id"` // Concrete profile used for this execution
RouteGeneration int64 `json:"route_generation,omitempty"`
RouteState string `json:"route_state,omitempty"`
RouteReason string `json:"route_reason,omitempty"`
DownstreamACPSessionID string `json:"downstream_acp_session_id,omitempty"`
RouteErrorCode string `json:"route_error_code,omitempty"`
RouteErrorClass string `json:"route_error_class,omitempty"`
RouteCatalogueVersion string `json:"route_catalogue_version,omitempty"`
RouteRetryOrdinal int64 `json:"route_retry_ordinal,omitempty"`
RouteDeadline *time.Time `json:"route_deadline,omitempty"`
RoutePendingOutcome string `json:"route_pending_outcome,omitempty"`
ExecutorID string `json:"executor_id"`
ExecutorProfileID string `json:"executor_profile_id"`
EnvironmentID string `json:"environment_id"`
RepositoryID string `json:"repository_id"` // Primary repository (for backward compatibility)
BaseBranch string `json:"base_branch"` // Primary base branch (for backward compatibility)
BaseCommitSHA string `json:"base_commit_sha"` // Git commit SHA at session start (for cumulative diff)
WorkspacePath string `json:"workspace_path"` // Effective task workspace root; legacy repo-less sessions may use the picked host folder
Worktrees []*TaskEnvironmentRepo `json:"-"` // Environment repository rows for this session's workspace
AgentProfileSnapshot map[string]interface{} `json:"agent_profile_snapshot,omitempty"`
ExecutorSnapshot map[string]interface{} `json:"executor_snapshot,omitempty"`
EnvironmentSnapshot map[string]interface{} `json:"environment_snapshot,omitempty"`
RepositorySnapshot map[string]interface{} `json:"repository_snapshot,omitempty"`
State TaskSessionState `json:"state"`
ErrorMessage string `json:"error_message,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
StartedAt time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
// Environment reference
TaskEnvironmentID string `json:"task_environment_id,omitempty"` // FK to task_environments for shared env
// TaskRunnerResolvedFromTask marks a session whose executor profile came
// from task metadata rather than an explicit launch argument. It is
// transient and lets the persistence transaction reject a stale
// pre-lock resolution before inserting the session or environment.
TaskRunnerResolvedFromTask bool `json:"-"`
// TaskRunnerProfileAtResolution is the task metadata value observed when
// the session's runner was resolved. It is transient and paired with
// TaskRunnerResolvedFromTask for the persistence-time recheck.
TaskRunnerProfileAtResolution string `json:"-"`
// Workflow-related fields
IsPrimary bool `json:"is_primary"` // Whether this is the primary session for the task
IsPassthrough bool `json:"is_passthrough"` // Whether this session uses passthrough (PTY) mode
ReviewStatus ReviewStatus `json:"review_status,omitempty"` // zero value = no review needed
// LastReadMessageID is the id of the newest message the frontend has
// marked as read for this session — the Slack-style "read cursor" used
// to position the unread ("New") divider in the transcript. Advanced to
// the latest message id whenever the session becomes the visible chat
// panel; the frontend snapshots the PRIOR value at that moment to draw
// the divider before overwriting it.
LastReadMessageID string `json:"last_read_message_id,omitempty"`
// Usage/cost rollup columns (docs/specs/task-cost-ledger/spec.md AC-28,
// AC-29). task_usage_events is the source of truth; these are the
// running totals internal/task/usage's writer maintains transactionally
// alongside each ledger insert via IncrementTaskSessionUsageTx.
CostSubcents int64 `json:"cost_subcents"`
TokensIn int64 `json:"tokens_in"`
TokensCachedIn int64 `json:"tokens_cached_in"`
TokensOut int64 `json:"tokens_out"`
}
// ActiveSessionCancellationCandidate is the compare-and-set snapshot used by
// the active-session stall healer. An empty ExpectedTurnID means that no turn
// may be active when the cancellation is written.
type ActiveSessionCancellationCandidate struct {
SessionID string
ExpectedUpdatedAt time.Time
ExpectedLastEventAt time.Time
ExpectedTurnID string
}
// ToAPI converts internal TaskSession to API type
// TODO: Add v1.TaskSession type to pkg/api/v1/
func (s *TaskSession) ToAPI() map[string]interface{} {
result := map[string]interface{}{
"id": s.ID,
"task_id": s.TaskID,
"queue_incarnation_id": s.QueueIncarnationID,
"agent_execution_id": s.AgentExecutionID,
"container_id": s.ContainerID,
"agent_profile_id": s.AgentProfileID,
"execution_profile_id": s.ExecutionProfileID,
"executor_id": s.ExecutorID,
"executor_profile_id": s.ExecutorProfileID,
"environment_id": s.EnvironmentID,
"repository_id": s.RepositoryID,
"base_branch": s.BaseBranch,
"base_commit_sha": s.BaseCommitSHA,
"state": string(s.State),
"started_at": s.StartedAt,
"updated_at": s.UpdatedAt,
}
if s.RouteGeneration > 0 {
result["route_generation"] = s.RouteGeneration
}
if s.RouteState != "" {
result["route_state"] = s.RouteState
}
if s.RouteReason != "" {
result["route_reason"] = s.RouteReason
}
if s.DownstreamACPSessionID != "" {
result["downstream_acp_session_id"] = s.DownstreamACPSessionID
}
if s.RouteErrorCode != "" {
result["route_error_code"] = s.RouteErrorCode
}
if s.RouteErrorClass != "" {
result["route_error_class"] = s.RouteErrorClass
}
if s.RouteCatalogueVersion != "" {
result["route_catalogue_version"] = s.RouteCatalogueVersion
}
if s.RouteRetryOrdinal > 0 {
result["route_retry_ordinal"] = s.RouteRetryOrdinal
}
if s.RouteDeadline != nil {
result["route_deadline"] = s.RouteDeadline
}
if s.RoutePendingOutcome != "" {
result["route_pending_outcome"] = s.RoutePendingOutcome
}
if worktrees := s.WorktreesAPI(); len(worktrees) > 0 {
result["worktrees"] = worktrees
}
if s.Name != "" {
result["name"] = s.Name
}
if s.ErrorMessage != "" {
result["error_message"] = s.ErrorMessage
}
if s.CompletedAt != nil {
result["completed_at"] = s.CompletedAt
}
if s.Metadata != nil {
result["metadata"] = s.Metadata
}
if s.AgentProfileSnapshot != nil {
result["agent_profile_snapshot"] = s.AgentProfileSnapshot
}
if s.ExecutorSnapshot != nil {
result["executor_snapshot"] = s.ExecutorSnapshot
}
if s.EnvironmentSnapshot != nil {
result["environment_snapshot"] = s.EnvironmentSnapshot
}
if s.RepositorySnapshot != nil {
result["repository_snapshot"] = s.RepositorySnapshot
}
result["is_passthrough"] = s.IsPassthrough
if s.TaskEnvironmentID != "" {
result["task_environment_id"] = s.TaskEnvironmentID
}
// Office sessions carry an agent_profile_id; the frontend uses it
// to distinguish per-(task, agent) office sessions from per-launch
// kanban/quick-chat sessions, which drives "live" detection in the
// chat. Omit when blank so kanban responses look unchanged.
if s.AgentProfileID != "" {
result["agent_profile_id"] = s.AgentProfileID
}
if s.LastReadMessageID != "" {
result["last_read_message_id"] = s.LastReadMessageID
}
return result
}
// WorktreesAPI maps the session's environment repository rows to the legacy
// session-worktree API shape. The wire contract is stable: the frontend reads
// id, worktree_id, repository_id, branch_slug, position, worktree_path, and
// worktree_branch from each entry, and worktree_path/worktree_branch are
// mirrored onto the session for backward compatibility.
func (s *TaskSession) WorktreesAPI() []map[string]interface{} {
if len(s.Worktrees) == 0 {
return nil
}
out := make([]map[string]interface{}, 0, len(s.Worktrees))
for _, repo := range s.Worktrees {
if repo == nil {
continue
}
entry := map[string]interface{}{
"id": repo.ID,
"session_id": s.ID,
"worktree_id": repo.WorktreeID,
"repository_id": repo.RepositoryID,
"position": repo.Position,
createdAtField: repo.CreatedAt,
}
if repo.BranchSlug != "" {
entry["branch_slug"] = repo.BranchSlug
}
if repo.WorktreePath != "" {
entry["worktree_path"] = repo.WorktreePath
}
if repo.WorktreeBranch != "" {
entry["worktree_branch"] = repo.WorktreeBranch
}
out = append(out, entry)
}
return out
}
// WorktreePath returns the workspace path of the session's first repository
// row, used for backward-compatible projections.
func (s *TaskSession) WorktreePath() string {
if len(s.Worktrees) > 0 && s.Worktrees[0] != nil {
return s.Worktrees[0].WorktreePath
}
return ""
}
// WorktreeBranch returns the branch of the session's first repository row,
// used for backward-compatible projections.
func (s *TaskSession) WorktreeBranch() string {
if len(s.Worktrees) > 0 && s.Worktrees[0] != nil {
return s.Worktrees[0].WorktreeBranch
}
return ""
}
// Repository represents a workspace repository
type Repository struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
SourceType string `json:"source_type"`
// LocalPath is the path to a local checkout; for provider-backed repos, this is
// populated after the repo is cloned/synced on the agent host.
LocalPath string `json:"local_path"`
// Provider fields describe the upstream source (e.g. github/gitlab) for future syncing.
Provider string `json:"provider"`
ProviderRepoID string `json:"provider_repo_id"`
ProviderHost string `json:"provider_host"`
ProviderScope string `json:"provider_scope"`
ProviderOwner string `json:"provider_owner"`
ProviderName string `json:"provider_name"`
RemoteURL string `json:"remote_url"`
DefaultBranch string `json:"default_branch"`
WorktreeBranchPrefix string `json:"worktree_branch_prefix"`
WorktreeBranchTemplate string `json:"worktree_branch_template"`
PullBeforeWorktree bool `json:"pull_before_worktree"`
SetupScript string `json:"setup_script"`
CleanupScript string `json:"cleanup_script"`
DevScript string `json:"dev_script"`
CopyFiles string `json:"copy_files"`
SecretBindings []RepositorySecretBinding `json:"secret_bindings,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
// DesktopDiscoveryRootState describes whether an install-wide discovery root
// can currently be read by the backend process.
type DesktopDiscoveryRootState string
const (
DesktopDiscoveryRootConnected DesktopDiscoveryRootState = "connected"
DesktopDiscoveryRootReconnectRequired DesktopDiscoveryRootState = "reconnect_required"
)
// DesktopDiscoveryRoot is an install-wide root selected for automatic local
// repository discovery. It is deliberately separate from a workspace-owned
// repository grant.
type DesktopDiscoveryRoot struct {
ID string `json:"id"`
Path string `json:"path"`
DisplayPath string `json:"display_path"`
State DesktopDiscoveryRootState `json:"state"`
LastScanAt *time.Time `json:"last_scan_at,omitempty"`
LastFailureAt *time.Time `json:"last_failure_at,omitempty"`
LastFailureCode string `json:"last_failure_code,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// DesktopDiscoveryMigration records upgrade-only state without turning the
// old implicit Home fallback into a new automatic scan.
type DesktopDiscoveryMigration struct {
HomeConfirmationRequired bool `json:"home_confirmation_required"`
UpdatedAt time.Time `json:"updated_at"`
}
// RepositoryBranchPolicy is a reusable branch workflow owned by one repository.
// It is configuration, not task history: task repositories copy these fields
// into their snapshot columns when a policy is selected.
type RepositoryBranchPolicy struct {
ID string `json:"id"`
RepositoryID string `json:"repository_id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
BaseBranch string `json:"base_branch"`
BranchTemplate string `json:"branch_template"`
PullRequestTarget string `json:"pull_request_target"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// RepositorySet is a named, reusable group of workspace repositories that fills
// the task-creation repository picker in one action.
//
// A set stores an optional base branch for each member. The task draft copies
// that preference when the set is applied; checkout state remains task-owned.
type RepositorySet struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description"`
// Items is the ordered membership. Slice order is authoritative on write:
// positions are assigned from it, and reads return items sorted by position.
Items []RepositorySetItem `json:"repositories"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// RepositorySetItem is one repository's membership in a set.
type RepositorySetItem struct {
ID string `json:"id"`
RepositorySetID string `json:"repository_set_id"`
RepositoryID string `json:"repository_id"`
Position int `json:"position"`
BaseBranch string `json:"base_branch"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// RepositoryIDs returns the set's membership in position order.
func (s *RepositorySet) RepositoryIDs() []string {
ids := make([]string, 0, len(s.Items))
for _, item := range s.Items {
ids = append(ids, item.RepositoryID)
}
return ids
}
// ProviderRepositoryIdentity is the durable provider lookup key. New plugin
// providers supply Scope + RepositoryID; legacy built-ins use Host/Owner/Name.
type ProviderRepositoryIdentity struct {
WorkspaceID string
Provider string
Scope string
RepositoryID string
Host string
Owner string
Name string
}
// RepositorySecretBinding maps an environment key to a secret reference. The
// value is never persisted or returned; a missing secret intentionally leaves
// this row dangling so launches can report a broken binding.
type RepositorySecretBinding struct {
RepositoryID string `json:"repository_id,omitempty"`
Key string `json:"key"`
SecretID string `json:"secret_id"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
// RepositoryScript represents a custom script for a repository
type RepositoryScript struct {
ID string `json:"id"`
RepositoryID string `json:"repository_id"`
Name string `json:"name"`
Command string `json:"command"`
Position int `json:"position"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ExecutorType represents the executor runtime type.
type ExecutorType string
const (
ExecutorTypeLocal ExecutorType = "local"
ExecutorTypeWorktree ExecutorType = "worktree"
ExecutorTypeLocalDocker ExecutorType = "local_docker"
ExecutorTypeRemoteDocker ExecutorType = "remote_docker"
ExecutorTypeSprites ExecutorType = "sprites"
ExecutorTypeSSH ExecutorType = "ssh"
ExecutorTypeKubernetes ExecutorType = "k8s"
ExecutorTypeMockRemote ExecutorType = "mock_remote"
)
// IsRemoteExecutorType reports whether the given executor type represents
// a remote execution environment (including containerized environments like Docker).
// These environments run shells inside the container/VM, not on the host.
func IsRemoteExecutorType(t ExecutorType) bool {
switch t {
case ExecutorTypeSprites, ExecutorTypeRemoteDocker, ExecutorTypeLocalDocker, ExecutorTypeSSH, ExecutorTypeKubernetes, ExecutorTypeMockRemote:
return true
default:
return false
}
}
// Runtime maps an ExecutorType to the runtime backend that hosts the
// agent subprocess. Mirrors executor.ExecutorTypeToBackend so callers
// outside the executor package can ask the property without importing
// it. The two are kept in lock-step intentionally — keep them in sync
// when adding a new ExecutorType.
func (t ExecutorType) Runtime() agentruntime.Runtime {
switch t {
case ExecutorTypeLocal, ExecutorTypeWorktree, ExecutorTypeMockRemote:
return agentruntime.RuntimeStandalone
case ExecutorTypeLocalDocker:
return agentruntime.RuntimeDocker
case ExecutorTypeRemoteDocker:
return agentruntime.RuntimeRemoteDocker
case ExecutorTypeSprites:
return agentruntime.RuntimeSprites
case ExecutorTypeSSH:
return agentruntime.RuntimeSSH
case ExecutorTypeKubernetes:
return agentruntime.RuntimeKubernetes
default:
return agentruntime.RuntimeStandalone
}
}
// IsContainerizedExecutorType reports whether the given executor type runs
// in a container/sandbox where shells must be executed inside the container
// via agentctl, not on the host. Single source of truth is
// Runtime().IsContainerized() so a new ExecutorType only has to declare its
// runtime once.
func IsContainerizedExecutorType(t ExecutorType) bool {
return t.Runtime().IsContainerized()
}
// IsAlwaysResumableRuntime reports whether the given runtime represents
// an executor that can always be resumed even without an explicit resume token.
func IsAlwaysResumableRuntime(runtime agentruntime.Runtime) bool {
return runtime == agentruntime.RuntimeSprites || runtime == agentruntime.RuntimeSSH || runtime == agentruntime.RuntimeKubernetes
}
const (
ExecutorIDLocal = "exec-local"
ExecutorIDWorktree = "exec-worktree"
ExecutorIDLocalDocker = "exec-local-docker"
ExecutorIDSprites = "exec-sprites"
)
// ExecutorStatus represents executor availability.
type ExecutorStatus string
const (
ExecutorStatusActive ExecutorStatus = "active"
ExecutorStatusDisabled ExecutorStatus = "disabled"
)
// Executor represents an execution target.
type Executor struct {
ID string `json:"id"`
Name string `json:"name"`
Type ExecutorType `json:"type"`
Status ExecutorStatus `json:"status"`
IsSystem bool `json:"is_system"`
Resumable bool `json:"resumable"`
Config map[string]string `json:"config,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
// ExecutorRunning tracks an active executor instance for a session.
type ExecutorRunning struct {
ID string `json:"id"`
SessionID string `json:"session_id"`
TaskID string `json:"task_id"`
ExecutionProfileID string `json:"execution_profile_id"`
ExecutorID string `json:"executor_id"`
Runtime agentruntime.Runtime `json:"runtime,omitempty"`
Status string `json:"status"`
Resumable bool `json:"resumable"`
ResumeToken string `json:"resume_token,omitempty"`
LastMessageUUID string `json:"last_message_uuid,omitempty"`
AgentExecutionID string `json:"agent_execution_id,omitempty"`
ContainerID string `json:"container_id,omitempty"`
AgentctlURL string `json:"agentctl_url,omitempty"`
AgentctlPort int `json:"agentctl_port,omitempty"`
// PID is SSH-only: the agentctl PID on the *remote* host, used by the SSH
// executor's remote-pid stop path. It is 0 for local/standalone rows.
PID int `json:"pid,omitempty"`
// LocalPID is a host-local liveness handle: the PID of the standalone
// agentctl control-server process Kandev spawned on this host. Populated for
// local/standalone runtimes so a dead row is distinguishable from a live one
// without external context. 0 for SSH/remote rows (their process lives on
// another host — see PID). Never probe LocalPID for SSH rows.
LocalPID int `json:"local_pid,omitempty"`
WorktreeID string `json:"worktree_id,omitempty"`
WorktreePath string `json:"worktree_path,omitempty"`
WorktreeBranch string `json:"worktree_branch,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ProfileEnvVar represents an environment variable for an executor profile.
// Either Value (plain text) or SecretID (reference to a secret) should be set, not both.
type ProfileEnvVar struct {
Key string `json:"key"`
Value string `json:"value,omitempty"`
SecretID string `json:"secret_id,omitempty"`
}
// ExecutorProfile represents a named configuration preset for an executor.
type ExecutorProfile struct {
ID string `json:"id"`
ExecutorID string `json:"executor_id"`
Name string `json:"name"`
McpPolicy string `json:"mcp_policy,omitempty"`
Config map[string]string `json:"config,omitempty"`
PrepareScript string `json:"prepare_script,omitempty"`
CleanupScript string `json:"cleanup_script,omitempty"`
EnvVars []ProfileEnvVar `json:"env_vars,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// EnvironmentKind represents the runtime type for environments.
type EnvironmentKind string
const (
EnvironmentKindLocalPC EnvironmentKind = "local_pc"
EnvironmentKindDockerImage EnvironmentKind = "docker_image"
)
const (
EnvironmentIDLocal = "env-local"
)
// Environment represents a runtime environment configuration.
type Environment struct {
ID string `json:"id"`
Name string `json:"name"`
Kind EnvironmentKind `json:"kind"`
IsSystem bool `json:"is_system"`
WorktreeRoot string `json:"worktree_root,omitempty"`
ImageTag string `json:"image_tag,omitempty"`
Dockerfile string `json:"dockerfile,omitempty"`
BuildConfig map[string]string `json:"build_config,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
// TaskEnvironmentStatus represents the lifecycle state of a task execution environment.
type TaskEnvironmentStatus string
const (
TaskEnvironmentStatusCreating TaskEnvironmentStatus = "creating"
TaskEnvironmentStatusReady TaskEnvironmentStatus = "ready"
TaskEnvironmentStatusStopped TaskEnvironmentStatus = "stopped"
TaskEnvironmentStatusFailed TaskEnvironmentStatus = "failed"
)
// TaskEnvironment represents a per-task execution environment instance.
// It owns the workspace (worktree/container/sandbox) and the agentctl control server.
// Multiple sessions can share the same TaskEnvironment.
type TaskEnvironment struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
OwnershipGeneration int64 `json:"ownership_generation"`
ExecutorType string `json:"executor_type"`
ExecutorID string `json:"executor_id"`
ExecutorProfileID string `json:"executor_profile_id"`
// AgentExecutionID was removed: executors_running owns the execution<->session
// mapping now. Read it via repo.GetExecutorRunningBySessionID(sessionID) when
// needed (the orchestrator does this in service_turns.go for WorkspaceInfo).
ControlPort int `json:"control_port"` // agentctl control port
Status TaskEnvironmentStatus `json:"status"`
// MaterializationSessionID durably identifies the one session allowed to
// turn a creating environment into a physical workspace. It is empty once
// the environment is ready; sibling sessions must attach only.
MaterializationSessionID string `json:"-"`
// WorkspacePath points at the agent workspace root (the task root when
// TaskDirName is set, otherwise the single repo's worktree path).
// Physical worktree identity lives on Repos, never on the environment row.
WorkspacePath string `json:"workspace_path,omitempty"`
ContainerID string `json:"container_id,omitempty"`
// ContainerBootstrapNonceSecretID is an environment-scoped encrypted secret
// reference used only to establish a new agentctl control connection to an
// already-owned Docker container. It is deliberately not exposed in API
// responses and is distinct from session runtime/auth metadata.
ContainerBootstrapNonceSecretID string `json:"-"`
// ContainerControlAuthTokenSecretID is the environment-scoped encrypted
// agentctl control-token reference for a running Docker container. It is
// deliberately separate from a session's agent runtime/auth metadata.
ContainerControlAuthTokenSecretID string `json:"-"`
SandboxID string `json:"sandbox_id,omitempty"`
// TaskDirName is the semantic directory name for the task (e.g. "fix-bug_ab12").
// Set when the task uses the multi-repo task-directory layout
// (~/.kandev/tasks/{TaskDirName}/{RepoName}/).
TaskDirName string `json:"task_dir_name,omitempty"`
// Repos contains one entry per repository associated with this environment.
// Each row is the single source of physical-worktree truth for that
// repository slot. Populated by repository getters.
Repos []*TaskEnvironmentRepo `json:"repos,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// RepoFor returns the per-repo environment row for repositoryID, or nil if not present.
func (te *TaskEnvironment) RepoFor(repositoryID string) *TaskEnvironmentRepo {
for _, r := range te.Repos {
if r.RepositoryID == repositoryID {
return r
}
}
return nil
}
// TaskEnvironmentRepo represents the per-repository state of a task environment.
// One row per repository associated with the task: each carries its own worktree
// reference and any per-repo preparation error. The row is the single source of
// physical-worktree truth — identity, path, branch, status, and lifecycle
// timestamps.
type TaskEnvironmentRepo struct {
ID string `json:"id"`
TaskEnvironmentID string `json:"task_environment_id"`
RepositoryID string `json:"repository_id"`
BranchSlug string `json:"branch_slug,omitempty"`
WorktreeID string `json:"worktree_id,omitempty"`
WorktreePath string `json:"worktree_path,omitempty"`
WorktreeBranch string `json:"worktree_branch,omitempty"`
WorktreeBranchOwner string `json:"-"`
WorktreeIntegrationRef string `json:"-"`
WorktreeRecoveryHeadSHA string `json:"-"`
WorktreeBranchCompactedAt *time.Time `json:"-"`
Position int `json:"position"`
ErrorMessage string `json:"error_message,omitempty"`
Status string `json:"status,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MergedAt *time.Time `json:"merged_at,omitempty"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
// TaskEnvironmentRecoveryClaimRequest identifies the environment authority
// required while an automatic host-worktree recovery is in progress.
type TaskEnvironmentRecoveryClaimRequest struct {
TaskEnvironmentID string
OwnerTaskID string
OwnershipGeneration int64
SessionID string
OperationID string
ExecutorType string
}
// TaskEnvironmentRecoveryClaim is the durable authority held from recovery
// preflight through the external workspace-start boundary.
type TaskEnvironmentRecoveryClaim struct {
TaskEnvironmentID string `json:"task_environment_id"`
OwnerTaskID string `json:"owner_task_id"`
OwnershipGeneration int64 `json:"ownership_generation"`
SessionID string `json:"session_id"`
OperationID string `json:"operation_id"`
ExecutorType string `json:"executor_type"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ToAPI converts internal TaskEnvironment to API map.
func (te *TaskEnvironment) ToAPI() map[string]interface{} {
result := map[string]interface{}{
"id": te.ID,
"task_id": te.TaskID,
"executor_type": te.ExecutorType,
"executor_id": te.ExecutorID,
"executor_profile_id": te.ExecutorProfileID,
"status": string(te.Status),
"workspace_path": te.WorkspacePath,
createdAtField: te.CreatedAt,
"updated_at": te.UpdatedAt,
}
// agent_execution_id is no longer carried on TaskEnvironment — see executors_running.
if te.ControlPort != 0 {
result["control_port"] = te.ControlPort
}
if te.ContainerID != "" {
result["container_id"] = te.ContainerID
}
if te.SandboxID != "" {
result["sandbox_id"] = te.SandboxID
}
if te.TaskDirName != "" {
result["task_dir_name"] = te.TaskDirName
}
if len(te.Repos) > 0 {
repos := make([]map[string]interface{}, 0, len(te.Repos))
for _, repo := range te.Repos {
repos = append(repos, repo.ToAPI())
}
result["repos"] = repos
}
return result
}
// ToAPI converts internal TaskEnvironmentRepo to API map.
func (r *TaskEnvironmentRepo) ToAPI() map[string]interface{} {
out := map[string]interface{}{
"id": r.ID,
"task_environment_id": r.TaskEnvironmentID,
"repository_id": r.RepositoryID,
"position": r.Position,
createdAtField: r.CreatedAt,
"updated_at": r.UpdatedAt,
}
if r.WorktreeID != "" {
out["worktree_id"] = r.WorktreeID
}
if r.BranchSlug != "" {
out["branch_slug"] = r.BranchSlug
}
if r.WorktreePath != "" {
out["worktree_path"] = r.WorktreePath
}
if r.WorktreeBranch != "" {
out["worktree_branch"] = r.WorktreeBranch
}
if r.ErrorMessage != "" {
out["error_message"] = r.ErrorMessage
}
return out
}
// TaskPlan represents a plan associated with a task
type TaskPlan struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
Title string `json:"title"`
Content string `json:"content"`
CreatedBy string `json:"created_by"` // "agent" or "user"
// WriteVersion changes on every committed title/content write. It is an
// internal optimistic-concurrency token and is not part of browser DTOs.
WriteVersion string `json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CommentsRevision int64 `json:"comments_revision"`
ImplementationStartedAt *time.Time `json:"implementation_started_at,omitempty"`
ImplementationStartedSessionID *string `json:"implementation_started_session_id,omitempty"`
ImplementationStartedBy *string `json:"implementation_started_by,omitempty"`
}
// TaskPlanComment is pending user feedback attached to the current task plan.
type TaskPlanComment struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
PlanID string `json:"plan_id"`
Body string `json:"body"`
SelectedText string `json:"selected_text"`
AnchorFrom int `json:"anchor_from"`
AnchorTo int `json:"anchor_to"`
Version int64 `json:"version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TaskPlanCommentSnapshot is the authoritative pending-comment collection for a plan.
type TaskPlanCommentSnapshot struct {
TaskID string `json:"task_id" db:"task_id"`
PlanID string `json:"plan_id" db:"plan_id"`
Revision int64 `json:"revision" db:"revision"`
Comments []*TaskPlanComment `json:"comments"`
}
// TaskPlanCommentRef identifies the exact pending-comment version a delivery includes.
type TaskPlanCommentRef struct {
ID string `json:"id"`
Version int64 `json:"version"`
}
// TaskPlanRevision is one immutable snapshot in the revision history of a task plan.
// Revisions are the source of truth for history; TaskPlan stores the latest revision's content as HEAD.
type TaskPlanRevision struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
RevisionNumber int `json:"revision_number"`
Title string `json:"title"`
Content string `json:"content"`
// ContentBytes is populated by bounded metadata reads. Full revision reads
// leave it zero because callers can derive the size from Content.
ContentBytes int `json:"-"`
AuthorKind string `json:"author_kind"` // "agent" | "user"
AuthorName string `json:"author_name"` // display snapshot (agent profile name or user identifier)
RevertOfRevisionID *string `json:"revert_of_revision_id,omitempty"`
// WorkflowStepID/Name/Color snapshot the task's workflow step at write
// time, same display-snapshot pattern as AuthorName. Empty for revisions
// written before this stamping existed, and preserved as-is (not
// re-stamped) when a later write coalesces into this row.
WorkflowStepID string `json:"workflow_step_id,omitempty"`
WorkflowStepName string `json:"workflow_step_name,omitempty"`
WorkflowStepColor string `json:"workflow_step_color,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` // bumps on coalesce merge
}
// TaskWalkthrough is an agent-authored guided code tour attached to a task.
// It is the "what & where" of a review narration: an ordered list of Steps,
// each anchored to a concrete repo/file/line, rendered as popovers over the
// review diff. Mirrors the TaskPlan artifact pattern (one per task, agent-authored).
type TaskWalkthrough struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
Title string `json:"title"`
Steps []WalkthroughStep `json:"steps"`
CreatedBy string `json:"created_by"` // always "agent"
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// WalkthroughStep is a single anchored stop in a TaskWalkthrough. Text is
// markdown shown in the popover; File/Line locate the anchor inside the diff
// (Repo disambiguates in multi-repo reviews, LineEnd optionally spans a range).
type WalkthroughStep struct {
Title string `json:"title,omitempty"`
Repo string `json:"repo,omitempty"`
File string `json:"file"`
Line int `json:"line"`
LineEnd int `json:"line_end,omitempty"`
Text string `json:"text"`
}
// ReviewRunStatus is the lifecycle state of a native code-review pass.
type ReviewRunStatus string
// Review run statuses. A run only leaves pending/running once, and every
// terminal state is final — an interrupted run is cancelled, never resumed.
const (
ReviewRunPending ReviewRunStatus = "pending"
ReviewRunRunning ReviewRunStatus = "running"
ReviewRunCompleted ReviewRunStatus = "completed"
ReviewRunFailed ReviewRunStatus = "failed"
ReviewRunCancelled ReviewRunStatus = "cancelled"
)
// IsTerminal reports whether the run has finished and cannot change again.
func (s ReviewRunStatus) IsTerminal() bool {
return s == ReviewRunCompleted || s == ReviewRunFailed || s == ReviewRunCancelled
}
// ReviewRunTrigger records what started a review pass.
type ReviewRunTrigger string
// Review run triggers.
const (
ReviewTriggerManual ReviewRunTrigger = "manual"
ReviewTriggerWorkflowStep ReviewRunTrigger = "workflow_step"
ReviewTriggerAgent ReviewRunTrigger = "agent"
)
// ReviewSeverity ranks how much a finding should worry the reader.
type ReviewSeverity string
// Review severities, most severe first.
const (
ReviewSeverityBlocker ReviewSeverity = "blocker"
ReviewSeverityMajor ReviewSeverity = "major"
ReviewSeverityMinor ReviewSeverity = "minor"
ReviewSeverityNit ReviewSeverity = "nit"
)
// ValidReviewSeverity reports whether s is a known severity.
func ValidReviewSeverity(s ReviewSeverity) bool {
switch s {
case ReviewSeverityBlocker, ReviewSeverityMajor, ReviewSeverityMinor, ReviewSeverityNit:
return true
default:
return false
}
}
// ReviewFindingStatus is the human disposition of a finding. Findings are
// advisory: the human resolves or dismisses, kandev never does it for them.
type ReviewFindingStatus string
// Review finding statuses.
const (
ReviewFindingOpen ReviewFindingStatus = "open"
ReviewFindingResolved ReviewFindingStatus = "resolved"
ReviewFindingDismissed ReviewFindingStatus = "dismissed"
)
// ValidReviewFindingStatus reports whether s is a known finding status.
func ValidReviewFindingStatus(s ReviewFindingStatus) bool {
switch s {
case ReviewFindingOpen, ReviewFindingResolved, ReviewFindingDismissed:
return true
default:
return false
}
}
// ReviewAnnotationSide selects which side of the diff a finding anchors to,
// matching the frontend annotation sides.
const (
ReviewSideAdditions = "additions"
ReviewSideDeletions = "deletions"
)
// TaskReviewRun is one native code-review pass over a task's changed files.
// A task keeps a bounded history of runs; findings reference the run that
// produced them so the UI can attribute and supersede them.
type TaskReviewRun struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
SessionID string `json:"session_id"`
Trigger ReviewRunTrigger `json:"trigger"`
WorkflowStepID string `json:"workflow_step_id"`
// EntryID is the step-transition ledger row identifier of the step entry
// that requested this run, when the run was requested by the
// run_code_review step-entry action. Empty for manual/MCP-triggered runs.
// Durable dedup key for AC-OFFICE-STEP-ENTRY-001.10: a redelivery of the
// same entry must rejoin this run rather than start a second one.
EntryID string `json:"entry_id,omitempty"`
AgentID string `json:"agent_id"`
Model string `json:"model"`
Status ReviewRunStatus `json:"status"`
ErrorCode string `json:"error_code"`
ErrorMessage string `json:"error_message"`
Summary string `json:"summary"`
FindingCount int `json:"finding_count"`
FileCount int `json:"file_count"`
RepositoryCount int `json:"repository_count"`
PromptTokens int `json:"prompt_tokens"`
ResponseTokens int `json:"response_tokens"`
DurationMs int `json:"duration_ms"`
CreatedAt time.Time `json:"created_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
}
// TaskReviewFinding is one anchored, advisory review comment produced by a
// review run. It renders in the Changes/Review diff at File/StartLine..EndLine
// of Repository, and carries FileDiffHash so a client can tell whether the
// diff has moved under it (see ../../../../docs/specs/agents/requirements/native-code-review.md).
type TaskReviewFinding struct {
ID string `json:"id"`
RunID string `json:"run_id"`
TaskID string `json:"task_id"`
RepositoryID string `json:"repository_id"`
RepositoryName string `json:"repository_name"`
FilePath string `json:"file_path"`
StartLine int `json:"start_line"`
EndLine int `json:"end_line"`
Side string `json:"side"`
Severity ReviewSeverity `json:"severity"`
Category string `json:"category"`
Title string `json:"title"`
Body string `json:"body"`
Suggestion string `json:"suggestion"`
AnchorText string `json:"anchor_text"`
FileDiffHash string `json:"file_diff_hash"`
Status ReviewFindingStatus `json:"status"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ReviewFindingKey identifies a finding's anchor for supersede matching: a new
// run that reports the same issue at the same place replaces the older row
// instead of listing it twice.
type ReviewFindingKey struct {
RepositoryName string
FilePath string
StartLine int
EndLine int
Title string
}
// SupersedeKey returns the finding's supersede identity.
func (f *TaskReviewFinding) SupersedeKey() ReviewFindingKey {
return ReviewFindingKey{
RepositoryName: f.RepositoryName,
FilePath: f.FilePath,
StartLine: f.StartLine,
EndLine: f.EndLine,
Title: f.Title,
}
}
// TaskDocument represents a named document (plan, spec, notes, etc.) associated with a task.
// The key uniquely identifies the document within a task (e.g., "plan", "spec", "notes").
type TaskDocument struct {
ID string `json:"id" db:"id"`
TaskID string `json:"task_id" db:"task_id"`
Key string `json:"key" db:"key"`
Type string `json:"type" db:"type"`
Title string `json:"title" db:"title"`
Content string `json:"content" db:"content"`
AuthorKind string `json:"author_kind" db:"author_kind"`
AuthorName string `json:"author_name" db:"author_name"`
Filename string `json:"filename,omitempty" db:"filename"`
MimeType string `json:"mime_type,omitempty" db:"mime_type"`
SizeBytes int64 `json:"size_bytes,omitempty" db:"size_bytes"`
DiskPath string `json:"-" db:"disk_path"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// TaskDocumentRevision is one immutable snapshot in the revision history of a task document.
// Revisions are the source of truth for history; TaskDocument stores the latest revision's content as HEAD.
type TaskDocumentRevision struct {
ID string `json:"id" db:"id"`
TaskID string `json:"task_id" db:"task_id"`
DocumentKey string `json:"document_key" db:"document_key"`
RevisionNumber int `json:"revision_number" db:"revision_number"`
Title string `json:"title" db:"title"`
Content string `json:"content" db:"content"`
AuthorKind string `json:"author_kind" db:"author_kind"`
AuthorName string `json:"author_name" db:"author_name"`
RevertOfRevisionID *string `json:"revert_of_revision_id,omitempty" db:"revert_of_revision_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// TaskMessageAttachment is the durable registry row for a prompt attachment.
// Message and queue metadata carry only the public descriptor; the bytes stay
// in private backend storage addressed by StorageKey.
type TaskMessageAttachment struct {
ID string `json:"id" db:"id"`
OwnerID string `json:"owner_id" db:"owner_id"`
WorkspaceID string `json:"workspace_id" db:"workspace_id"`
TaskID string `json:"task_id,omitempty" db:"task_id"`
SessionID string `json:"session_id,omitempty" db:"session_id"`
MessageID string `json:"message_id,omitempty" db:"message_id"`
QueueID string `json:"queue_id,omitempty" db:"queue_id"`
Name string `json:"name" db:"name"`
MimeType string `json:"mime_type" db:"mime_type"`
Kind string `json:"kind" db:"kind"`
DeliveryMode string `json:"delivery_mode" db:"delivery_mode"`
SizeBytes int64 `json:"size_bytes" db:"size_bytes"`
StorageKey string `json:"-" db:"storage_key"`
State string `json:"state" db:"state"`
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
const (
AttachmentStateStaged = "staged"
AttachmentStateClaimed = "claimed"
AttachmentStateExpired = "expired"
AttachmentStateDeleted = "deleted"
)
// SessionFileReview tracks per-file review state within a session
type SessionFileReview struct {
ID string `json:"id"`
SessionID string `json:"session_id"`
FilePath string `json:"file_path"`
Reviewed bool `json:"reviewed"`
DiffHash string `json:"diff_hash"`
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ToAPI converts internal Task to API type
func (t *Task) ToAPI() *v1.Task {
// Convert TaskRepository models to API types
var repositories []v1.TaskRepository
for _, repo := range t.Repositories {
repositories = append(repositories, v1.TaskRepository{
CheckoutOptions: PublicRepositoryCheckoutOptions(repo.Metadata),
ID: repo.ID,
TaskID: repo.TaskID,
RepositoryID: repo.RepositoryID,
BaseBranch: repo.BaseBranch,
Position: repo.Position,
Metadata: repo.Metadata,
CreatedAt: repo.CreatedAt,
UpdatedAt: repo.UpdatedAt,
})
}
result := &v1.Task{
ID: t.ID,
WorkspaceID: t.WorkspaceID,
WorkflowID: t.WorkflowID,
Title: t.Title,
Description: t.Description,
State: t.State,
Priority: t.Priority,
Repositories: repositories,
CreatedAt: t.CreatedAt,
UpdatedAt: t.UpdatedAt,
Metadata: PublicTaskMetadata(t.Metadata),
Interrupted: t.Metadata[MetaKeyInterruptedAt] != nil,
AutoStartFailed: t.Metadata[MetaKeyAutoStartFailed] != nil,
WorkspaceOrphaned: WorkspaceOrphaned(t.Metadata),
IsEphemeral: t.IsEphemeral,
ParentID: t.ParentID,
Autopilot: t.Autopilot,
}
if t.Identifier != "" {
result.Identifier = t.Identifier
}
return result
}