process

package
v0.11.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 14, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

Documentation

Overview

Package process defines the Tools-owned long-running-command supervision domain: process identity, lifecycle state, the stable error taxonomy, and quota configuration (spec "docs/specs/long-running-command-supervision.md", sections "Identity and authorization", "State machine", "Stable errors", and "Quotas and retention"). This package has no dependency on github.com/looprig/harness; later tasks wire this domain to Harness's AsyncProcessRunner/PreparedProcess contracts.

Index

Constants

View Source
const (
	DefaultMaxRunningProcessesPerLoop              = 8
	DefaultMaxRunningProcessesPerSession           = 32
	DefaultMaxRetainedCompletedProcessesPerSession = 100

	// DefaultMaxProcessInMemoryBytes is the spec's 1 MiB per-process
	// in-memory rolling window default.
	DefaultMaxProcessInMemoryBytes int64 = 1 << 20 // 1 MiB (spec)
	// DefaultMaxAggregateInMemoryBytes derives from the per-process default
	// times the per-session concurrency default.
	DefaultMaxAggregateInMemoryBytes int64 = int64(DefaultMaxRunningProcessesPerSession) * DefaultMaxProcessInMemoryBytes

	// DefaultMaxProcessSpoolBytes is the spec's 64 MiB per-process disk
	// spool default.
	DefaultMaxProcessSpoolBytes int64 = 64 << 20 // 64 MiB (spec)
	// DefaultMaxAggregateSpoolBytes derives from the per-process default
	// times the per-session concurrency default.
	DefaultMaxAggregateSpoolBytes int64 = int64(DefaultMaxRunningProcessesPerSession) * DefaultMaxProcessSpoolBytes

	// DefaultMaxInlineResultBytes is the spec's 32 KiB inline model result
	// default.
	DefaultMaxInlineResultBytes int64 = 32 << 10 // 32 KiB (spec)

	DefaultMaxPendingWaiters    int   = 64
	DefaultMaxPendingInputBytes int64 = 1 << 20 // 1 MiB

	// DefaultGracefulShutdownPeriod is the spec's 5 second graceful
	// shutdown default.
	DefaultGracefulShutdownPeriod = 5 * time.Second // (spec)
)

Documented zero-value defaults. Values annotated "(spec)" are the exact numbers from the spec's "Output capture and storage" defaults table. The remaining defaults are conservative operational values chosen for this task, not spec-mandated, and any Config value may override them; the per-process/aggregate pairs are deliberately derived from each other so the defaults are self-consistent (a per-process default never exceeds its aggregate default) without hand-tuning a second number.

View Source
const ArtifactEncodingBase64 = "base64"

ArtifactEncodingBase64 is the only Encoding value Artifact currently carries: raw bytes retrieved through a base64 ProcessOutput read (spec "ProcessOutput API": `"artifact": {"id": "opaque", "encoding": "base64"}`).

View Source
const HandleEntropyBytes = 16

HandleEntropyBytes is the raw random byte length backing a Handle: 16 bytes = 128 bits, the spec's documented minimum ("Output capture and storage": "Process handle entropy | at least 128 bits").

View Source
const SupervisorResourceKey = "github.com/looprig/tools/process.supervisor"

SupervisorResourceKey names the ONE shared process.Supervisor session resource every supervised Bash call (bash/supervised.go's runSupervised) and every ProcessOutput/ProcessInput/ProcessStop call (this module's root definitions.go) obtain through tool.SessionResourceRegistry.GetOrCreate: "any of the four definitions may win get-or-create" requires all of them to key on this exact same string. Both consumer packages import this one constant rather than each keeping their own private copy, so there is a single authoritative source for the key.

Variables

View Source
var ErrSpoolClosed = errors.New("process: spool is closed")

ErrSpoolClosed reports that an operation was attempted against a Spool after Close or Remove. It is a plain sentinel, not a *Error, because it signals local misuse of the Spool value's lifetime rather than a model-facing supervision failure.

Functions

func NewSupervisorResource

func NewSupervisorResource(dir string) (tool.SessionResource, error)

NewSupervisorResource is the tool.SessionResourceRegistry.GetOrCreate factory for SupervisorResourceKey: it is runner-free (constructs no tool.AsyncProcessRunner and calls neither PrepareProcess nor Start), so any of the four process-backed definitions may win the get-or-create race for a session's shared supervisor. dir is the private per-session storage directory the registry reserves for this key.

Types

type AccessMode

type AccessMode string

AccessMode is the manifest's sanitized record of the effective workspace access a process was granted at spawn (spec "Workspace coordination", "Lease compatibility": read-only, scoped write, broad/workspace write). Tools does not import Harness's WorkspaceAccess type; this is a narrow, stable local mirror recorded for manifest/audit purposes only.

const (
	AccessReadOnly    AccessMode = "read_only"
	AccessScopedWrite AccessMode = "scoped_write"
	AccessBroadWrite  AccessMode = "broad_write"
)

The closed set of recorded access modes.

func (AccessMode) Valid

func (a AccessMode) Valid() bool

Valid reports whether a belongs to the closed AccessMode domain.

type Artifact

type Artifact struct {
	ProcessID   Handle
	StartCursor int64
	EndCursor   int64
	Encoding    string
}

Artifact is an opaque, path-free reference to a bounded window of a process's raw output (spec "Output capture and storage": "raw bytes remain only in the bounded spool and are exposed only through an opaque artifact descriptor plus owner-authorized ProcessOutput base64 reads"; "No filesystem path to a spool or manifest is returned to a model"). Every field is independently already safe to hand to a model: ProcessID is a Handle, which by construction carries no filesystem path, owner identifier, or OS PID (see identity.go's Handle doc); StartCursor and EndCursor are plain integers. There is no path, file descriptor, or other host detail anywhere in this type. A caller retrieves the bytes an Artifact describes with a base64 ProcessOutput read against ProcessID at StartCursor (spec: "callers retrieve its bytes with ProcessOutput and the original process handle, cursor, and base64 encoding").

func NewArtifact

func NewArtifact(handle Handle, startCursor, endCursor int64) Artifact

NewArtifact builds the opaque descriptor for the raw byte range [startCursor, endCursor) of handle's output.

type Base64Result

type Base64Result struct {
	// Data is the raw bytes Read returned, base64-encoded byte for byte
	// with no normalization applied.
	Data string
	// StartCursor is the cursor Read was called with (before any
	// gap adjustment).
	StartCursor int64
	// NextCursor is the exclusive cursor immediately after the bytes Read
	// actually returned.
	NextCursor int64
	// Gap reports whether StartCursor fell before the earliest retained
	// byte, exactly as Reader.Read documents.
	Gap bool
}

Base64Result is the base64 render outcome for one bounded, cursor-addressed read: the exact raw bytes from r, unmodified and unnormalized, base64-encoded (spec "ProcessOutput API": "`base64` reads the same owner-authorized raw spool bytes without exposing a host path").

func RenderBase64

func RenderBase64(r Reader, cursor int64, maxBytes int) (Base64Result, error)

RenderBase64 reads up to maxBytes of retained output from r starting at cursor and returns it base64-encoded, byte for byte, with no normalization applied. It reuses exactly the same Reader/cursor/maxBytes plumbing as RenderSafeText -- "the same owner check and byte limits as safe text" from the task doc means both render modes share this one read path; only RenderSafeText additionally normalizes and caps. RenderBase64 performs no owner authorization itself (see Reader's doc); that is Task 8's supervisor's responsibility before either render function is called.

type Buffer

type Buffer struct {
	// contains filtered or unexported fields
}

Buffer is the in-memory rolling window over one process's combined stdout+stderr byte stream (spec "Output capture and storage": "The in-memory window is optimized for recent polling. The spool is the bounded source of truth for completed output and cursor recovery"). Buffer and Spool (spool.go) share one cursor addressing scheme -- raw, monotonically increasing byte offsets into a single combined append-ordered stream, never rune or codepoint indexes -- but are two independent stores: Buffer is a fixed-capacity ring kept entirely in memory for cheap recent-output polling. It never persists to disk and never reads from or delegates to a Spool; a Buffer alone is not the source of truth for anything older than its own capacity.

A Buffer is safe for concurrent use by multiple goroutines.

func NewBuffer

func NewBuffer(capacity int64) *Buffer

NewBuffer returns a Buffer with the given capacity in bytes. A non-positive capacity defaults to DefaultMaxProcessInMemoryBytes (config.go), mirroring OpenSpool's non-positive-ceiling default.

func (*Buffer) Append

func (b *Buffer) Append(chunk []byte) (int64, error)

Append durably (in the in-memory sense -- see the type doc for what "durable" does not mean here) adds data to the end of the combined output stream and returns the resulting global cursor (equal to TotalBytes after the append). Append order determines the global cursor, exactly as for Spool.Append.

Once the retained window would exceed capacity, Append overwrites the oldest retained bytes in place (true ring wraparound: no reallocation, no growth beyond capacity) rather than blocking or failing; TotalBytes keeps counting every byte ever appended, independent of what remains retained. A single Append whose data is itself longer than capacity is handled the same way an equivalent sequence of smaller Appends would be: only the last capacity bytes of data end up retained.

func (*Buffer) Capacity

func (b *Buffer) Capacity() int64

Capacity reports the maximum number of retained bytes b was constructed with.

func (*Buffer) Read

func (b *Buffer) Read(cursor int64, maxBytes int) (data []byte, nextCursor int64, gap bool, err error)

Read returns up to maxBytes of retained output starting at cursor, the exclusive cursor immediately after the returned data (nextCursor), and whether cursor fell before the earliest retained byte (gap). This mirrors Spool.Read's exact signature and semantics: when gap is true, the returned data begins at the earliest retained byte rather than at cursor. A non-positive maxBytes returns every retained byte from the (possibly gap-adjusted) start.

A cursor beyond TotalBytes reports CodeCursorAhead. A negative cursor reports CodeInvalidArguments.

func (*Buffer) RetainedFrom

func (b *Buffer) RetainedFrom() int64

RetainedFrom reports the cursor of the earliest byte currently retained in memory.

func (*Buffer) TotalBytes

func (b *Buffer) TotalBytes() int64

TotalBytes reports the monotonically increasing count of every byte ever appended, independent of what remains retained.

type Code

type Code string

Code is a stable, model-facing process-supervision failure classification (spec "Stable errors"). The set is closed; render only Code — never Cause — at an untrusted or model-facing boundary, so host paths, OS PIDs, and cross-owner details never leak.

const (
	CodeInvalidArguments                Code = "invalid_arguments"
	CodeInvalidSettings                 Code = "invalid_settings"
	CodeProcessQuotaExceeded            Code = "process_quota_exceeded"
	CodeOutputQuotaExceeded             Code = "output_quota_exceeded"
	CodeLifetimeEnforcementUnavailable  Code = "lifetime_enforcement_unavailable"
	CodeProcessNotificationsUnsupported Code = "process_notifications_unsupported"
	CodeSpawnFailed                     Code = "spawn_failed"
	CodeProcessSetupFailed              Code = "process_setup_failed"
	CodePTYUnavailable                  Code = "pty_unavailable"
	CodeNotFound                        Code = "not_found"
	CodeStdinClosed                     Code = "stdin_closed"
	CodeInputBackpressure               Code = "input_backpressure"
	CodeCursorGap                       Code = "cursor_gap"
	CodeCursorAhead                     Code = "cursor_ahead"
	CodeTimedOut                        Code = "timed_out"
	CodeInterrupted                     Code = "interrupted"
	CodeTerminated                      Code = "terminated"
	CodeKilled                          Code = "killed"
	CodeSupervisorShuttingDown          Code = "supervisor_shutting_down"
	CodeManifestCorrupt                 Code = "manifest_corrupt"
	CodeSpoolCorrupt                    Code = "spool_corrupt"
	CodeLostOnRestore                   Code = "lost_on_restore"
	CodeTeardownFailed                  Code = "teardown_failed"
)

The closed stable error taxonomy (spec "Stable errors").

func (Code) Valid

func (c Code) Valid() bool

Valid reports whether c belongs to the closed stable-error-code domain.

type CollisionError

type CollisionError struct{ Attempts int }

CollisionError reports that handle generation exhausted maxGenerateAttempts candidates that all reported as already in use by the supplied HandleExists check.

func (*CollisionError) Error

func (e *CollisionError) Error() string

type CommandMetadata

type CommandMetadata struct {
	// Command is the shell command line as supplied to Bash.
	Command string
	// WorkDir is the resolved working directory, when the call requested
	// one.
	WorkDir string
}

CommandMetadata is the sanitized, non-secret description of the command a manifest records (spec "Manifests and durability": "sanitized command metadata"). It never carries environment, stdin content, or captured output.

type Config

type Config struct {
	// MaxRunningProcessesPerLoop bounds concurrently running processes
	// belonging to one loop.
	MaxRunningProcessesPerLoop int
	// MaxRunningProcessesPerSession bounds concurrently running processes
	// across an entire session (every loop in it).
	MaxRunningProcessesPerSession int
	// MaxRetainedCompletedProcessesPerSession bounds how many completed
	// process manifests a session keeps queryable before least-recently-used
	// eviction (spec "Quotas and retention": "Completed metadata is evicted
	// by least-recently-used order only after the retention limit is
	// reached").
	MaxRetainedCompletedProcessesPerSession int

	// MaxProcessInMemoryBytes bounds one process's in-memory rolling output
	// window (spec "Output capture and storage" default: 1 MiB per
	// process).
	MaxProcessInMemoryBytes int64
	// MaxAggregateInMemoryBytes bounds the sum of every process's
	// in-memory rolling window across a session.
	MaxAggregateInMemoryBytes int64

	// MaxProcessSpoolBytes bounds one process's disk spool retention window
	// (spec default: 64 MiB per process). This is a bounded retention
	// window, not a hard cap on total process output (spec "Output capture
	// and storage": "The spool is a bounded retention window, not a hard
	// cap on the process").
	MaxProcessSpoolBytes int64
	// MaxAggregateSpoolBytes bounds the sum of every process's disk spool
	// across a session.
	MaxAggregateSpoolBytes int64

	// MaxInlineResultBytes bounds the output returned inline in a
	// model-facing result before a caller must page through ProcessOutput
	// (spec default: 32 KiB).
	MaxInlineResultBytes int64

	// MaxPendingWaiters bounds the outstanding ProcessOutput wait: any|all
	// waiters a session admits concurrently.
	MaxPendingWaiters int

	// MaxPendingInputBytes bounds unconsumed ProcessInput data queued for
	// one process, so a call can never block indefinitely behind a process
	// that does not read its stdin (spec "ProcessInput API": "Writes are
	// serialized per process and bounded").
	MaxPendingInputBytes int64

	// GracefulShutdownPeriod is how long supervisor shutdown and
	// ProcessStop's terminate mode wait before escalating to kill (spec
	// default: 5 seconds).
	GracefulShutdownPeriod time.Duration
}

Config is the process-supervisor quota and retention surface described by the spec's "Quotas and retention" section, plus the numeric defaults from its "Output capture and storage" defaults table. The zero Config is valid: Normalize fills every zero field with its documented default.

func (Config) Normalize

func (c Config) Normalize() (Config, error)

Normalize validates c and, if valid, returns it with every zero field replaced by its documented default (withDefaults). The zero Config normalizes to every documented default with no error. A negative field, or an explicit value that exceeds an explicit counterpart, returns the zero Config and a *Error with CodeInvalidSettings.

Validation runs on c before defaulting, not on the defaulted result: Validate's cross-field checks are documented to reject only explicit inconsistencies between two nonzero fields, and that "zero means unset" contract only holds against the caller's original input. Two independent per-field defaults are proven self-consistent with each other (TestDefaultsAreSelfConsistent), but a default chosen for one field is not guaranteed to stay under a smaller *explicit* value the caller set for its counterpart (e.g. an explicit, low MaxRunningProcessesPerSession with MaxRunningProcessesPerLoop left at zero) -- validating post-default would reject that legitimate partial override merely because defaulting filled in the other side.

func (Config) Validate

func (c Config) Validate() error

Validate rejects a negative limit and any explicit per-process limit that exceeds its explicit aggregate (or broader-scope) counterpart. A zero field is untouched by Validate — it means "unset, use the documented default" (see Normalize) — so only strictly negative values and explicit inconsistencies between two nonzero fields are rejected.

type Error

type Error struct {
	Code  Code
	Cause error
}

Error reports one classified process-supervision failure. Cause carries implementation detail for programmatic inspection and trusted logs; Code is the stable, model-safe classification (spec "Stable errors": "Errors must support errors.Is or typed inspection and render to stable model-facing codes without exposing host paths, OS PIDs, or cross-owner details").

func New

func New(code Code) *Error

New returns an *Error with the given code and no cause.

func Wrap

func Wrap(code Code, cause error) *Error

Wrap returns an *Error with the given code and cause.

func (*Error) Error

func (e *Error) Error() string

func (*Error) Is

func (e *Error) Is(target error) bool

Is matches *Error values by stable Code, independent of Cause, so errors.Is(err, process.New(process.CodeNotFound)) works regardless of which concrete cause produced err.

func (*Error) Unwrap

func (e *Error) Unwrap() error

Unwrap returns the underlying cause, if any, so errors.Is/errors.As can see through an *Error to a wrapped sentinel or typed error.

type GenerateError

type GenerateError struct{ Err error }

GenerateError reports a failure to read randomness while minting a process Handle. It wraps the underlying source error so callers can errors.As to a *GenerateError and errors.Unwrap (or read .Err) to inspect the cause.

func (*GenerateError) Error

func (e *GenerateError) Error() string

func (*GenerateError) Unwrap

func (e *GenerateError) Unwrap() error

type Handle

type Handle string

Handle is a process's opaque, URL-safe capability identifier. It is cryptographically random with at least HandleEntropyBytes of entropy and carries no owner, filesystem path, timestamp, or OS process identifier: nothing about its bytes can be inspected to recover any of those (spec "Identity and authorization": "ProcessID is an opaque, cryptographically random handle with at least 128 bits of entropy. It must not encode an OS PID, filesystem path, owner identifier, or creation timestamp").

func NewHandle

func NewHandle(exists HandleExists) (Handle, error)

NewHandle mints a new Handle sourced from crypto/rand, retrying against exists (which may be nil to skip the check) up to maxGenerateAttempts times before returning a *CollisionError.

func (Handle) Valid

func (h Handle) Valid() bool

Valid reports whether h decodes as a well-formed Handle: exactly HandleEntropyBytes of unpadded URL-safe base64.

type HandleExists

type HandleExists func(Handle) bool

HandleExists reports whether a candidate Handle is already in use, so GenerateHandle/NewHandle can retry on collision without this package depending on any supervisor registry type.

type Identity

type Identity struct {
	Handle Handle
	Owner  Owner
	Origin Origin
}

Identity groups the three immutable facts that exist for a supervised process from the moment it is admitted: its opaque capability Handle, its authority Owner (SessionID + LoopID), and its audit-only Origin (the creating tool execution). None of these fields ever change after admission; only a process's State (state.go) transitions over its lifetime.

type ImmutableIdentityChangedError

type ImmutableIdentityChangedError struct {
	Handle Handle
}

ImmutableIdentityChangedError reports an attempted manifest update that would change the immutable Handle, Owner, or Origin recorded for a process (identity.go: "None of these fields ever change after admission").

func (*ImmutableIdentityChangedError) Error

type Lease

type Lease interface {
	Release() error
}

Lease is Task 8's minimal placeholder for the Harness lifetime workspace lease a caller acquires before calling Start (spec "Workspace coordination": "Tools acquires the matching Harness lifetime workspace lease" between PrepareProcess and Start). Task 8 does not wire Harness end-to-end and does not import any concrete Harness lease type; Start only needs "the resource the caller already acquired that must be released on every exit path". The method is named Release, not Close, so a call site reads unambiguously against PreparedProcess.Close (which releases the preparation, not the lease) -- Task 15/19 replaces this interface with (or adapts it to) the real Harness lease type.

type LifecycleEventIDChangedError

type LifecycleEventIDChangedError struct {
	Handle Handle
	Field  string
	Had    uuid.UUID
	Got    uuid.UUID
}

LifecycleEventIDChangedError reports an attempted manifest update that would reassign an already-allocated stable lifecycle EventID or completion CommandID (spec "Manifests and durability": these IDs are "allocated and persisted before publication", so retries must always reuse the same ID).

func (*LifecycleEventIDChangedError) Error

type LifecycleEventIDs

type LifecycleEventIDs struct {
	Started      uuid.UUID
	Backgrounded uuid.UUID
	Completed    uuid.UUID
	Lost         uuid.UUID
	CommandID    uuid.UUID
}

LifecycleEventIDs are the stable, per-kind identifiers a manifest allocates and persists before the corresponding Harness lifecycle event or completion notification may be published (spec "Manifests and durability": "stable lifecycle EventIDs and completion-notification CommandID allocated before publication"). The zero uuid.UUID means "not yet allocated"; once a field is non-zero, ManifestStore.Save rejects any attempt to change it (LifecycleEventIDChangedError), so retries always reuse the same ID.

type Manifest

type Manifest struct {
	Identity

	Command CommandMetadata
	Access  AccessMode
	TTY     bool

	State State

	CreatedAt  time.Time
	StartedAt  *time.Time
	FinishedAt *time.Time
	Deadline   *time.Time

	Cursors SpoolCursors
	Result  Result

	Events LifecycleEventIDs

	// CompletionPublished is a monotonically increasing marker of the last
	// successfully attempted completion publication. It exists purely to
	// skip a redundant republish attempt; it is never the deduplication
	// boundary (spec "Manifests and durability": "the completion-published
	// marker avoids needless retries but is not required for at-most-once
	// journal state" — that boundary is the Harness durable journal, added
	// in a later phase).
	CompletionPublished int64
	// contains filtered or unexported fields
}

Manifest is the durable record Tools atomically persists for one supervised process before returning its handle, and updates for the rest of the process's lifetime (spec "Manifests and durability"). Its exported fields are a plain, ergonomic value type in the style of Config; a ManifestStore is responsible for enforcing the monotonic and terminal-immutability invariants across successive Save calls — Manifest itself only validates that a single value is internally well-formed (Validate).

func NewManifest

func NewManifest(id Identity, cmd CommandMetadata, access AccessMode, tty bool, createdAt time.Time, deadline *time.Time) Manifest

NewManifest builds the initial Manifest for a newly admitted process: State StateStarting, no started/finished/deadline timestamps unless deadline is non-zero, and zero cursors, result, and lifecycle IDs.

func (Manifest) Validate

func (m Manifest) Validate() error

Validate reports whether m is internally well-formed: required identity fields are present, State/Access belong to their closed domains, and the timestamp/result fields are consistent with State (spec "Manifests and durability" invariants; a violation is exactly what Load reports as CodeManifestCorrupt for a value read from disk, and what Save rejects for a value a caller is trying to persist for the first time).

type ManifestStore

type ManifestStore struct {
	// contains filtered or unexported fields
}

ManifestStore persists one Manifest per process Handle as versioned JSON beneath a private resource-root directory (spec "Manifests and durability"). It never writes inside the workspace: root is an explicit, caller-supplied directory dedicated to process resource storage (spec "Supervisor lifetime": "The resource root is never the workspace").

func NewManifestStore

func NewManifestStore(root string) *ManifestStore

NewManifestStore returns a ManifestStore rooted at root. root must already exist; ManifestStore never creates it.

func (*ManifestStore) Delete

func (s *ManifestStore) Delete(h Handle) error

Delete removes the on-disk manifest for h, if any (spec "Eviction deletes the manifest and spool atomically from the supervisor's perspective" -- Task 8D's terminal-LRU retention is Delete's only caller today). Delete is idempotent: deleting a handle that has no manifest file -- already deleted, or never written -- is a no-op rather than an error, mirroring Spool.Remove's idempotence (spool.go).

func (*ManifestStore) Load

func (s *ManifestStore) Load(h Handle) (Manifest, error)

Load reads and validates the manifest for h. A missing file reports CodeNotFound. Malformed JSON, an unrecognized format version, or a value that fails Manifest.Validate all report CodeManifestCorrupt.

func (*ManifestStore) Save

func (s *ManifestStore) Save(m Manifest) error

Save validates m, then atomically persists it (spec "Manifests and durability": "write-new, sync, and atomic replace semantics"). If a manifest already exists for m.Handle, Save additionally enforces that the update does not change immutable identity, move State outside an approved state-machine edge, change the Result of an already-terminal manifest, move Cursors or CompletionPublished backward, or reassign an already-allocated lifecycle EventID/CommandID. Save refuses to overwrite an existing manifest that itself fails to load (CodeManifestCorrupt) rather than silently discarding evidence of the corruption.

type NonMonotonicUpdateError

type NonMonotonicUpdateError struct {
	Handle Handle
	Field  string
	Had    int64
	Got    int64
}

NonMonotonicUpdateError reports a manifest update that would move a monotonic field backward (spec "Manifests and durability": "State and cursor metadata never move backward"). Field names the offending value using its JSON key.

func (*NonMonotonicUpdateError) Error

func (e *NonMonotonicUpdateError) Error() string

type Origin

type Origin struct {
	ToolExecutionID uuid.UUID
}

Origin is the immutable, audit-only provenance of the tool execution that created a process. It is recorded for traceability but deliberately carries no authority: every follow-up tool invocation necessarily has its own new ToolExecutionID, so comparing Origin against a follow-up call would reject every legitimate one. Authorization always compares Owner, never Origin (spec "Identity and authorization": "The originating Bash ToolExecutionID is stored immutably as audit provenance, but it is not compared to the execution ID of ProcessOutput, ProcessInput, or ProcessStop").

type Owner

type Owner struct {
	SessionID uuid.UUID
	LoopID    uuid.UUID
}

Owner is the immutable authority over a supervised process: the session and loop that created it (spec "Identity and authorization": "Each process has an immutable authority owner: SessionID + LoopID + ProcessID"). Every follow-up ProcessOutput, ProcessInput, or ProcessStop call is authorized by an exact match against both fields — never against Origin. See Origin for why the two are kept separate.

func (Owner) Equal

func (o Owner) Equal(other Owner) bool

Equal reports whether o and other name the same session and loop. A cross-owner lookup must be indistinguishable from a missing handle (spec "Identity and authorization"), so callers compare ownership with Equal rather than inspecting SessionID/LoopID separately.

func (Owner) IsZero

func (o Owner) IsZero() bool

IsZero reports whether o carries no session or loop identity.

type ProcessInputTool

type ProcessInputTool struct {
	// contains filtered or unexported fields
}

ProcessInputTool implements the mutating ProcessInput tool over a session's shared Supervisor. supervisor and owner are resolved once, by the caller that constructs this value (mirrors ProcessOutputTool) -- ProcessInputTool itself never touches a tool.SessionResourceRegistry.

func NewProcessInput

func NewProcessInput(supervisor *Supervisor, owner Owner) *ProcessInputTool

NewProcessInput constructs a ProcessInputTool bound to the session's shared supervisor and this tool's immutable process-authority owner. A nil supervisor is retained as a construction error and fails every call closed, mirroring NewProcessOutput's initErr convention.

func (*ProcessInputTool) Info

Info returns ProcessInput's self-description. Name MUST equal "ProcessInput".

func (*ProcessInputTool) InvokableRun

func (t *ProcessInputTool) InvokableRun(ctx context.Context, _ string) (*tool.ToolResult, error)

InvokableRun executes the PREPARED artifact bound to this call. It never reparses argsJSON. A missing/cross-owner handle, a terminal target process, or a nil live process all render the single bare-error shape (ProcessID + Error) without ever reaching applyOperations; every other outcome -- success or a mid-operation failure -- goes through applyOperations under this handle's serialization lock and then renders the same cursor-addressed snapshot shape ProcessOutput renders (readOne, reused verbatim from output_tool.go).

func (*ProcessInputTool) PrepareCall

func (t *ProcessInputTool) PrepareCall(_ context.Context, _ uuid.UUID, argsJSON string) (tool.Request, tool.PreparedArtifact, error)

PrepareCall decodes, validates, and normalizes one ProcessInput call and freezes the result into a sealed processInputArtifact. Every argument is validated HERE; InvokableRun never re-parses argsJSON. The emitted Request carries no Requirements: writing to a process the caller already owns needs no new gate decision (spec "Identity and authorization": follow-up operations do not re-run the original Bash gate).

type ProcessOutputTool

type ProcessOutputTool struct {
	// contains filtered or unexported fields
}

ProcessOutputTool implements the read-only ProcessOutput tool over a session's shared Supervisor. supervisor and owner are resolved once, by the caller that constructs this value (see this file's package doc comment) -- ProcessOutputTool itself never touches a tool.SessionResourceRegistry.

func NewProcessOutput

func NewProcessOutput(supervisor *Supervisor, owner Owner) *ProcessOutputTool

NewProcessOutput constructs a ProcessOutputTool bound to the session's shared supervisor and this tool's immutable process-authority owner. A nil supervisor is retained as a construction error and fails every call closed, mirroring bash.BashTool's initErr convention -- ProcessOutput never panics on a missing dependency.

func (*ProcessOutputTool) Info

Info returns ProcessOutput's self-description. Name MUST equal "ProcessOutput".

func (*ProcessOutputTool) InvokableRun

func (t *ProcessOutputTool) InvokableRun(ctx context.Context, _ string) (*tool.ToolResult, error)

InvokableRun executes the PREPARED artifact bound to this call. It never reparses argsJSON. A wait: any|all call first blocks (bounded by timeout_ms, if any, and always by ctx) until its combinator's condition is already satisfied or becomes satisfied; the wait's own outcome (including a timeout or ctx cancellation) is never surfaced as a call failure -- "the wait timeout affects only the output call, never the process" (spec) -- InvokableRun always proceeds to render whatever is available once the wait attempt returns. Every per-process failure (a missing/cross-owner handle, a cursor beyond total_bytes) renders as that one entry's own "error" field, never as a whole-call failure or a Go error.

func (*ProcessOutputTool) PrepareCall

func (t *ProcessOutputTool) PrepareCall(_ context.Context, _ uuid.UUID, argsJSON string) (tool.Request, tool.PreparedArtifact, error)

PrepareCall decodes, validates, and normalizes one ProcessOutput call and freezes the result into a sealed processOutputArtifact. Every argument -- process_id/process_ids exclusivity and shape, cursor, limit_bytes, encoding, wait, and timeout_ms -- is validated HERE; InvokableRun never re-parses argsJSON. The emitted Request carries no Requirements: reading a process the caller already owns needs no new gate decision (spec "Identity and authorization": follow-up operations do not re-run the original Bash gate).

type ProcessStopTool

type ProcessStopTool struct {
	// contains filtered or unexported fields
}

ProcessStopTool implements the mutating ProcessStop tool over a session's shared Supervisor. supervisor and owner are resolved once, by the caller that constructs this value (mirrors ProcessOutputTool/ ProcessInputTool) -- ProcessStopTool itself never touches a tool.SessionResourceRegistry.

func NewProcessStop

func NewProcessStop(supervisor *Supervisor, owner Owner) *ProcessStopTool

NewProcessStop constructs a ProcessStopTool bound to the session's shared supervisor and this tool's immutable process-authority owner. A nil supervisor is retained as a construction error and fails every call closed, mirroring NewProcessOutput/NewProcessInput's initErr convention.

func (*ProcessStopTool) Info

Info returns ProcessStop's self-description. Name MUST equal "ProcessStop".

func (*ProcessStopTool) InvokableRun

func (t *ProcessStopTool) InvokableRun(ctx context.Context, _ string) (*tool.ToolResult, error)

InvokableRun executes the PREPARED artifact bound to this call. It never reparses argsJSON. A missing/cross-owner handle or a nil live process on a non-terminal entry both render the single bare-error shape (ProcessID + Error) without ever calling Signal. An already-terminal entry is rendered from its existing manifest without ever calling Signal (idempotence). Otherwise the requested mode's signal/escalation/confirm sequence runs, and the result is either a bare teardown_failed error (a Signal call itself failed) or this process's current snapshot -- terminal only if this call's own wait actually observed the confirmed terminal manifest.

func (*ProcessStopTool) PrepareCall

func (t *ProcessStopTool) PrepareCall(_ context.Context, _ uuid.UUID, argsJSON string) (tool.Request, tool.PreparedArtifact, error)

PrepareCall decodes, validates, and normalizes one ProcessStop call and freezes the result into a sealed processStopArtifact. Every argument is validated HERE; InvokableRun never re-parses argsJSON. The emitted Request carries no Requirements: stopping a process the caller already owns needs no new gate decision (spec "Identity and authorization": follow-up operations do not re-run the original Bash gate).

An omitted grace_ms defaults to t.supervisor.cfg.GracefulShutdownPeriod -- the exact same duration Supervisor.Shutdown's own escalation uses (config.go's GracefulShutdownPeriod doc comment: "how long supervisor shutdown and ProcessStop's terminate mode wait before escalating to kill") -- rather than a fixed constant, so a session configured with a different grace period gets a consistent default across both paths.

type Reader

type Reader interface {
	Read(cursor int64, maxBytes int) (data []byte, nextCursor int64, gap bool, err error)
}

Reader is the minimal cursor-addressed read surface render.go needs from a byte store. Both *Buffer (buffer.go) and *Spool (spool.go) already implement this exact method signature, so render.go depends on neither concretely: a caller passes whichever store currently holds the requested cursor range. Routing between the in-memory window and the disk spool (and any owner authorization) is Task 8's supervisor's job, not this package's rendering logic.

type RestoreError

type RestoreError struct {
	Handle Handle
	Err    error
}

RestoreError reports one persisted process resource that Restore could not reconcile -- most commonly a manifest that fails to load (manifest.go's CodeManifestCorrupt) or whose disk spool fails to reopen (CodeSpoolCorrupt). Restore isolates and reports every such failure rather than aborting its whole scan, so one corrupt entry never hides every other healthy one (spec combined-acceptance: "corrupt entries are isolated and reported without hiding healthy entries").

func (*RestoreError) Error

func (e *RestoreError) Error() string

func (*RestoreError) Unwrap

func (e *RestoreError) Unwrap() error

Unwrap returns the underlying reconciliation failure, so errors.As/errors.Is can see through a RestoreError to a wrapped *Error (e.g. CodeManifestCorrupt).

type RestoreReport

type RestoreReport struct {
	Reconciled []Handle
	Errors     []RestoreError
}

RestoreReport is Restore's summary of one scan of the resource root: every Handle it successfully reconciled and registered into s.entries, and every Handle it could not (see RestoreError).

type Result

type Result struct {
	// ExitCode is set only when State == StateExited (spec "Durable events
	// and notifications": "only completed/exited requires an exit code").
	ExitCode *int
	// Reason is the closed lifecycle-event reason (spec "Durable events and
	// notifications" table), e.g. "exited", "failed", "timed-out",
	// "interrupted", "terminated", "killed", "lost-on-restore".
	Reason string
}

Result carries the terminal outcome fields recorded once a process reaches a terminal State (spec "Manifests and durability": "terminal result fields when complete"). It is the zero value for any non-terminal manifest.

func (Result) Equal

func (r Result) Equal(other Result) bool

Equal reports whether r and other carry the same terminal outcome.

type SafeTextResult

type SafeTextResult struct {
	// Output is the normalized, capped, model-visible text.
	Output string
	// StartCursor is the cursor Read was called with (before any
	// gap adjustment).
	StartCursor int64
	// NextCursor is the exclusive cursor immediately after the bytes Read
	// actually returned (Reader's own nextCursor, unaffected by the
	// safe-text cap below).
	NextCursor int64
	// Gap reports whether StartCursor fell before the earliest retained
	// byte; when true, Output begins at the earliest retained byte rather
	// than at StartCursor, exactly as Reader.Read documents.
	Gap bool
	// Normalized reports whether normalization changed anything: invalid
	// UTF-8 was replaced, or a control/escape sequence was removed. Safe
	// pass-through text (Step 2's "safe text unchanged" case) reports
	// false.
	Normalized bool
	// Binary reports whether the pre-normalization bytes looked like
	// binary data (safetext.LooksBinary), so a caller can prefer routing
	// this read through base64/Artifact instead of showing Output inline.
	Binary bool
	// Artifact is always populated, independent of Binary, so a caller can
	// retrieve the exact raw bytes of this same cursor range later via a
	// base64 read, even for output that rendered safely as inline text.
	Artifact Artifact
}

SafeTextResult is the safe-text render outcome for one bounded, cursor-addressed read (spec "ProcessOutput API" result shape, the subset render.go owns: output/start_cursor/next_cursor/gap/normalized/binary/ artifact). Manifest-derived fields such as total_bytes, status, and exit_code belong to the future supervisor-facing ProcessOutput tool (Task 16), not to this task's render/encode logic.

func RenderSafeText

func RenderSafeText(r Reader, handle Handle, cursor int64, maxBytes int, capBytes int64) (SafeTextResult, error)

RenderSafeText reads up to maxBytes of retained output from r starting at cursor -- sharing Reader's exact gap and cursor-ahead Read semantics -- normalizes it through a one-shot safetext.Normalizer, and caps the normalized text at capBytes (a non-positive capBytes defaults to DefaultMaxInlineResultBytes) without splitting a replacement sequence (safetext.Truncate).

RenderSafeText normalizes each call's byte window independently: a terminal escape sequence that happens to be split exactly at this read's byte boundary is safely dropped (never leaked as raw bytes) but not reconstructed across separate RenderSafeText calls, unlike safetext.Normalizer's own cross-call carry-over (normalize_test.go), which this function does not use across calls. Reconstructing a sequence split across two different ProcessOutput polls of the same process would require a per-process *safetext.Normalizer threaded through the supervisor across polls; that is out of this task's scope (see the task doc: render.go accepts already-resolved byte sources and does not wire supervisor plumbing) and is flagged here for the phase-gate reviewer to weigh in on for Task 8/16.

type Spool

type Spool struct {
	// contains filtered or unexported fields
}

Spool is the bounded, durable, cursor-addressed disk retention window for one process's combined stdout+stderr byte stream (spec "Output capture and storage"). It stores only the currently retained window plus a monotonically increasing TotalBytes counter; an append that would exceed the configured ceiling drops the oldest retained bytes rather than blocking or failing (spec: "The spool is a bounded retention window, not a hard cap on the process"). Every Append durably persists the new retained window via atomicfile.Replace before returning success, so the spool file is never left in a partially-written state that a concurrent reader could observe.

A Spool is safe for concurrent use by multiple goroutines.

func OpenSpool

func OpenSpool(root string, h Handle, ceiling int64) (*Spool, error)

OpenSpool opens (or, if none exists yet, prepares to create) the spool for process h beneath root. ceiling bounds the retained window in bytes; a non-positive ceiling defaults to DefaultMaxProcessSpoolBytes (process/ config.go). OpenSpool never creates the on-disk file itself — the file is created by the first Append.

A truncated or otherwise inconsistent on-disk spool (malformed JSON, an unrecognized version, impossible cursor bounds, or a payload whose length does not match its declared retained window) is reported as CodeSpoolCorrupt.

func (*Spool) Append

func (s *Spool) Append(data []byte) (int64, error)

Append durably adds data to the end of the combined output stream and returns the resulting global cursor (equal to TotalBytes after the append). Append order determines the global cursor: cursors are assigned strictly in the order Append is called, regardless of whether the bytes originated from stdout or stderr.

If retaining data would exceed the configured ceiling, Append drops exactly enough of the oldest retained bytes to fit and still succeeds; TotalBytes keeps counting every byte ever appended, independent of what remains retained. Append never fails, blocks, or refuses data because of volume — a process is never terminated for output volume (spec).

func (*Spool) Close

func (s *Spool) Close() error

Close marks the Spool closed, rejecting further Append/Read calls. It does not remove the on-disk file. Close is idempotent: calling it more than once, including after Remove, is a no-op.

func (*Spool) Read

func (s *Spool) Read(cursor int64, maxBytes int) (data []byte, nextCursor int64, gap bool, err error)

Read returns up to maxBytes of retained output starting at cursor, the exclusive cursor immediately after the returned data (nextCursor), and whether cursor fell before the earliest retained byte (gap). When gap is true, the returned data begins at the earliest retained byte rather than at cursor, exactly as the spec describes for both the in-memory window and the disk spool. A non-positive maxBytes returns every retained byte from the (possibly gap-adjusted) start.

A cursor beyond TotalBytes reports CodeCursorAhead. A negative cursor reports CodeInvalidArguments.

func (*Spool) Remove

func (s *Spool) Remove() error

Remove closes the Spool and deletes its on-disk file, if any. Remove is idempotent: calling it more than once, or calling it when no file was ever written (e.g. a process that produced no output), is a no-op rather than an error.

func (*Spool) RetainedFrom

func (s *Spool) RetainedFrom() int64

RetainedFrom reports the cursor of the earliest byte currently retained on disk.

func (*Spool) TotalBytes

func (s *Spool) TotalBytes() int64

TotalBytes reports the monotonically increasing count of every byte ever appended to the spool, independent of what remains retained.

type SpoolCursors

type SpoolCursors struct {
	// TotalBytes is the monotonically increasing count of every byte ever
	// appended to the spool, independent of what remains retained.
	TotalBytes int64
	// RetainedFrom is the cursor of the earliest byte still retained on
	// disk; bytes before it have been dropped by ceiling truncation.
	RetainedFrom int64
}

SpoolCursors is a manifest's durable snapshot of its disk spool's cursor bounds (spec "Manifests and durability": "spool metadata and cursor bounds"), mirroring what a live Spool (spool.go) tracks so a manifest alone reports accurate bounds without reopening the spool file.

type State

type State string

State is one of the externally visible supervised-process lifecycle states (spec "State machine").

const (
	StateStarting      State = "starting"
	StateRunning       State = "running"
	StateExited        State = "exited"
	StateFailed        State = "failed"
	StateTimedOut      State = "timed_out"
	StateInterrupted   State = "interrupted"
	StateTerminated    State = "terminated"
	StateKilled        State = "killed"
	StateLostOnRestore State = "lost_on_restore"
)

The closed set of externally visible states.

func (State) Terminal

func (s State) Terminal() bool

Terminal reports whether s is a terminal state that accepts no further transitions.

func (State) Valid

func (s State) Valid() bool

Valid reports whether s belongs to the closed state domain.

type StorageCeiling

type StorageCeiling struct {
	InMemoryBytes int64
	SpoolBytes    int64
}

StorageCeiling bounds one supervised process's in-memory rolling window and disk spool retention window (mirrors Config's MaxProcessInMemoryBytes/MaxProcessSpoolBytes pair, and buffer.go's NewBuffer / spool.go's OpenSpool ceiling parameters). It is supplied per Start call rather than read from the Supervisor's Config, so a caller (Bash) can request a smaller window for one process; a non-positive field falls back to the Supervisor's configured per-process default (reserveQuota). Reserving InMemoryBytes/SpoolBytes against the Supervisor's aggregate ceilings before Start is this task's realization of the combined-acceptance text's "memory" and "spool" quotas.

type Supervisor

type Supervisor struct {
	// contains filtered or unexported fields
}

Supervisor is the runner-free process supervisor described by the spec's "Supervisor lifetime" and "Workspace coordination" sections: it never calls tool.AsyncProcessRunner.PrepareProcess itself, and NewSupervisor deliberately does not accept a tool.AsyncProcessRunner at all. A caller (Task 8 does not implement this caller; it is the Bash tool built in Phase 4, per the spec's "Only Bash owns execution authority") resolves a runner, calls PrepareProcess, acquires the matching workspace Lease, and hands the resulting tool.PreparedProcess to Start. Supervisor then owns admission -- reserving quotas and consuming the single-use preparation (this microtask, 8A) -- and, starting in Task 8B, durable handoff, stream drain, terminal arbitration, and retention.

A Supervisor is safe for concurrent use by multiple goroutines.

func NewSupervisor

func NewSupervisor(cfg Config, manifests *ManifestStore, spoolRoot string, lifecycle lifecycleSink, notifications completionNotifier) (*Supervisor, error)

NewSupervisor returns a Supervisor governed by cfg (normalized; see Config.Normalize), persisting manifests to manifests and, from Task 8B on, opening spools beneath spoolRoot. lifecycle and notifications are the two narrow SessionResourceServices capabilities described by the spec's "Supervisor lifetime" section; see the Supervisor.lifecycle/notifications field doc for why they are accepted here even though 8A never calls either. NewSupervisor deliberately does not accept a tool.AsyncProcessRunner: the supervisor is runner-free by construction, unable to call PrepareProcess even if some future caller wanted it to.

func (*Supervisor) Restore

func (s *Supervisor) Restore(ctx context.Context) (RestoreReport, error)

Restore is the session-restore entrypoint (spec "Manifests and durability": "On normal session restore ..."). It scans every manifest durably persisted beneath the Supervisor's ManifestStore root and reconciles each one into a queryable, non-running s.entries record:

  • a manifest already in a terminal State (state.go's State.Terminal()) is reopened as-is: its completed output remains readable through its Spool, with no live tool.Process, goroutine, lease, or quota reservation (TestRestoreCompletedOutput);
  • a manifest still in StateStarting or StateRunning means the supervisor process that was running it no longer exists -- a session restore implies exactly that (spec: "a manifest marked running or starting becomes lost_on_restore"). It is durably transitioned to the terminal StateLostOnRestore first (markLostOnRestore), publishing the lost lifecycle event and completion notification through the manifest's already-persisted stable LifecycleEventIDs.Lost/CommandID -- never a freshly minted ID (TestRestoreRunningBecomesLost, TestRestorePublicationCrashRetriesStableID) -- and only then reopened the same way as an already-terminal manifest.

Restore is intended to be called once, immediately after NewSupervisor and before any Start/Wait call, as the caller's session-restore step; it is not a periodic reconciliation loop, and it never touches quota bookkeeping (runningByLoop/runningBySession/reservedMemoryBytes/reservedSpoolBytes): nothing here was ever actually reserved by this process instance, and nothing will ever call releaseQuota for a restored entry.

Restore never constructs, obtains, or references a tool.Process, tool.PreparedProcess, or tool.AsyncProcessRunner: there is no live process handle for a restored entry, by construction, so there is no signal-capable value anywhere in this reconciliation path that could ever be handed a persisted PID (spec: "Restore never signals a PID recovered from persisted metadata"; TestRestoreNeverSignalsPersistedPID). The manifest's unexported osMetadata field is never read here at all -- see manifest.go's osMetadata doc comment.

A manifest that fails to load, or whose disk spool fails to reopen, does not abort the scan: it is recorded in the returned RestoreReport.Errors and every other manifest is still reconciled normally.

func (*Supervisor) Shutdown

func (s *Supervisor) Shutdown(ctx context.Context) error

Shutdown implements the spec's "Supervisor lifetime" coordinated shutdown sequence: close admission to new processes, concurrently request graceful termination of every currently running process tree, escalate to a forceful kill for any tree still running once cfg.GracefulShutdownPeriod elapses, confirm every tree has exited, and return any teardown failure to the caller.

Shutdown closes admission (closeAdmission) as its very first step -- before requesting termination of a single process -- so a concurrent Start call is rejected immediately with CodeSupervisorShuttingDown even while Shutdown's later termination/escalation steps are still in flight (TestShutdownClosesAdmissionBeforeStop).

Every currently running entry (snapshotRunningEntries) is signaled tool.ProcessSignalTerminate concurrently, one goroutine per entry; an entry that has not confirmed exit within cfg.GracefulShutdownPeriod of that request is then signaled tool.ProcessSignalKill (TestShutdownEscalatesAndConfirmsTrees). Shutdown waits on each entry's exited channel -- not done -- for that confirmation: see entry.go's exited field doc comment for why a slow or backpressured completion notification must never delay this step (combined-acceptance: "notification backpressure cannot block terminalization"). A signaled entry's actual terminal-state computation and manifest write are never bypassed or duplicated here -- run's existing natural Wait()-return path (entry.terminalize) is what every signal ultimately drives, exactly as it already does for an unforced exit.

Shutdown is idempotent and safe to call concurrently: only the first call's invocation actually runs the shutdown sequence (sync.Once); every concurrent or later caller receives that exact same result (TestShutdownConcurrentCallersShareResult). Only the first caller's ctx is ever used -- every other caller's ctx argument is ignored, exactly like every other input to a no-op Do call.

A teardown failure -- a Signal call that itself returns an error -- doesn't stop the affected entry from reaching a terminal state: its run goroutine still classifies whatever outcome its live tool.Process.Wait ultimately reports and terminalizes normally. Shutdown only aggregates every such Signal failure into its own returned error, a *Error wrapping CodeTeardownFailed (TestShutdownTeardownFailureRetainsAuthority) -- Shutdown retains authority over every process regardless of a teardown hiccup; it never loses track of one.

Steps the ordered sequence describes but that need no new code here: terminal manifests are already flushed and workspace leases are already released by entry.doTerminalize (Task 8C), which every signaled entry still reaches through its own run goroutine; any caller still blocked in Wait is already released once an entry's done channel closes (Task 9A's existing generation/done wake mechanism), no new mechanism required. Closing storage handles is a deliberate no-op: neither ManifestStore nor Spool has (or should have) a Supervisor-wide Close of its own -- a process's completed output must remain readable through its Spool after Shutdown returns, exactly as Restore already relies on for a clean (non-lost) reconciliation of a shutdown-terminated process (TestSupervisorIntegrationShutdownAndRestore).

func (*Supervisor) Start

func (s *Supervisor) Start(
	ctx context.Context,
	owner Owner,
	origin Origin,
	prepared tool.PreparedProcess,
	lease Lease,
	sink lifecycleSink,
	observations observationInvalidator,
	ceiling StorageCeiling,
	yield YieldSettings,
) (Handle, error)

Start begins supervising one already-prepared process. The caller has already resolved an AsyncProcessRunner, called PrepareProcess, and acquired the matching workspace lease (spec "Workspace coordination"); Start only ever consumes what it is handed -- it never calls PrepareProcess itself. owner and origin are recorded verbatim as the new process's Identity (identity.go); prepared is the single-use preparation this call consumes; lease is released on every path that does not end with a registered entry owning it; sink, observations, ceiling, and yield are recorded on the entry for later microtasks to use.

Start reserves every quota reserveQuota enforces before calling prepared.Start (TestSupervisorReservesQuotaBeforeStart), at most once, exactly matching PreparedProcess.Start's own single-use contract. It then atomically persists this process's manifest in state StateStarting -- via s.manifests, a real durable ManifestStore -- before ever calling prepared.Start, so a Handle can never be returned for a process that has no durable record (TestSupervisorPersistsBeforeReturningHandle; spec "Manifests and durability": "Before returning a process handle, Tools atomically persists a manifest"). This requires minting the Handle (and therefore checking handleExists) before prepared.Start is called, not after, unlike a Handle-agnostic admission flow would.

If prepared.Start fails, Start releases every reservation it made, releases lease, closes prepared -- idempotent per the Harness PreparedProcess contract -- and transitions the already-persisted StateStarting manifest directly to StateFailed (there is no live entry on this path, so this is a plain synchronous Save, not entry.terminalize's compare-and-set: Start's own call sequence is already the only writer of this manifest at this point, and no concurrent caller can race it) before returning a *Error wrapping CodeSpawnFailed (TestSupervisorStartFailureReleasesQuota). If a quota is already exhausted, Start returns a *Error wrapping CodeProcessQuotaExceeded without ever calling prepared.Start or prepared.Close, and without minting a Handle or writing any manifest (TestSupervisorRejectsSessionAndLoopQuota).

Once prepared.Start succeeds, Start persists the manifest's StateRunning transition, opens this process's in-memory Buffer and durable Spool (both sized from the exact quota reservation -- see reservation's doc comment), registers the entry, and starts the entry's single wait/activity/drain goroutine (entry.go's run) on a lifetime-scoped context that is deliberately independent of ctx (see run's doc comment for why). Start returns the freshly minted Handle only after the entry is registered and its goroutine has been started (TestSupervisorDrainsOrderedStreams, TestSupervisorSpoolCeilingDropsOldest).

Start allocates this process's stable LifecycleEventIDs (newLifecycleEventIDs) and records them on the very first manifest it persists, so they are already durably in place before the process can ever reach a terminal state (TestSupervisorPublishesStableLifecycleIDs; spec "Manifests and durability"). Start itself does not arbitrate a terminal state for a process that reaches one after prepared.Start succeeds -- that is entry.terminalize's one-shot compare-and-set (entry.go), driven by run's natural Wait() return today and, starting in Task 9C, also by an explicit stop request, a deadline timeout, or supervisor shutdown.

If closeAdmission has already been called, Start rejects admission immediately with a *Error wrapping CodeSupervisorShuttingDown, before reserving any quota or touching prepared at all (TestSupervisorShutdownRejectsAdmission).

A prepared.Start failure is classified by classifyStartError, below, rather than unconditionally reported as CodeSpawnFailed: a real AsyncProcessRunner adapter (e.g. the product composition root's process_adapter.go) reports a more specific tool.ProcessError classification for a handful of documented, distinct failure modes (lifetime containment unavailable, PTY unavailable), and that specific reason must survive through this layer for bash/supervised.go's own classifyProcessError to see it rather than a generic spawn failure that already lost it here.

func (*Supervisor) Wait

func (s *Supervisor) Wait(ctx context.Context, owner Owner, kind WaitKind, targets []WaitTarget) ([]WaitStatus, error)

Wait reports (poll) or waits for (any/all) new output or a terminal transition across targets, scoped to entries owner can see.

WaitPoll returns the current WaitStatus for every target immediately: it never blocks and never even inspects ctx (TestWaitPollReturnsImmediately).

WaitAny and WaitAll block until their respective condition is satisfied or ctx.Done() fires, returning ctx.Err() on the latter (TestWaitCancelRemovesWaiter). Neither mode polls or sleeps to notice a change: both are woken directly by appendChunk's generation bump (entry.go's bumpGeneration) or by an entry's done channel closing at its terminal transition (TestWaitAnyWakesOnAppend, TestWaitAllRequiresEveryEntry).

A blocking call (any/all) first reserves one waiter slot against cfg.MaxPendingWaiters for owner.SessionID (acquireWaiterSlot), releasing it unconditionally on every return path -- including cancellation, so a canceled wait never leaks its slot (TestWaitCancelRemovesWaiter). If the quota is already exhausted, Wait returns a *Error wrapping CodeOutputQuotaExceeded immediately, before registering any watcher. Poll-mode calls never touch the quota: they neither reserve nor require a slot.

type SupervisorResource

type SupervisorResource struct {
	Supervisor *Supervisor
	Manifests  *ManifestStore
}

SupervisorResource adapts the shared *Supervisor (and its backing *ManifestStore) to tool.SessionResource, so it can be obtained through tool.SessionResourceRegistry.GetOrCreate by any of the four process-backed tool definitions (Bash's supervised path, ProcessOutput, ProcessInput, ProcessStop). GetOrCreate's factory runs exactly once per key: whichever caller's GetOrCreate reaches SupervisorResourceKey first determines the concrete tool.SessionResource value every later caller with that key receives back, including a caller in a different package. Exporting one shared type/factory here (rather than each consumer package keeping its own private wrapper) is what makes that safe: every caller, regardless of which one wins the race, type-asserts the resource to the same *SupervisorResource.

func (*SupervisorResource) Activate

Activate wires the real, validated tool.SessionResourceServices Harness's live session construction supplies into the shared Supervisor this resource wraps, and then performs this resource's session-restore reconciliation step. NewSupervisorResource itself constructs the Supervisor notification-free (nil lifecycle/notifications at NewSupervisor time, below) precisely because these real capabilities do not exist yet at factory time -- SessionResource's own contract late-binds them here, after the live session, hub, durable publisher, and notifier are ready (pkg/tool.SessionResource's doc comment). Activate adapts services' validated tool.ProcessLifecyclePublisher/tool.ProcessCompletionNotifier into this package's private lifecycleSink/completionNotifier shapes (lifecycle_bridge.go) and installs them on the live Supervisor (activateServices), which every subsequent Start call -- and so every admitted process's Start-time and terminal lifecycle publish and completion notify -- reads from that point on (supervisor.go's servicesLocked). services.Validate() rejects a nil or typed-nil service before either is installed, so a caller that mishandles construction can never leave the Supervisor half-wired.

Activate then calls Supervisor.Restore over this resource's own directory, exactly matching restore.go's own documented contract ("intended to be called once, immediately after NewSupervisor and before any Start/Wait call, as the caller's session-restore step"). Nothing else in this package or in bash ever called it: NewSupervisorResource always constructs a fresh, empty in-memory Supervisor regardless of whether its directory already holds manifests a PRIOR process durably left there (persisted sessions reuse the identical resource root across a real restart), so a real session restore silently discarded every previously known process -- completed output became permanently unreadable and a still-running manifest was never marked lost. This is Activate's own natural, single, guaranteed-once-before-any-use hook for that reconciliation (SessionResource's own contract: "late-binds live session services after construction AND RESTORE PLANNING" -- pkg/tool's doc comment), so it needs no new "is this a restore" signal of its own: Restore's own directory scan is a harmless no-op (an empty Reconciled list, no error) when this resource's directory has nothing to reconcile yet, i.e. for a genuinely new session -- see listManifestHandles' own os.IsNotExist handling. Restore runs AFTER activateServices, not before: a still-running manifest's lost-on-restore reconciliation (markLostOnRestore/publishLostOnRestore, restore.go) publishes through these exact services, and must not silently no-op against the construction-time nils.

func (*SupervisorResource) Shutdown

func (r *SupervisorResource) Shutdown(ctx context.Context) error

Shutdown releases every resource the shared Supervisor still holds.

type TerminalResultChangedError

type TerminalResultChangedError struct {
	Handle Handle
	State  State
	Had    Result
	Got    Result
}

TerminalResultChangedError reports an attempted manifest update that would change the terminal Result of a manifest already in the same terminal State (spec "Manifests and durability": terminal result is immutable once set). This is a programming-invariant violation, not a stable model-facing code, so it is a plain typed Go error in the style of state.go's TransitionError rather than a *Error.

func (*TerminalResultChangedError) Error

type TransitionError

type TransitionError struct {
	From State
	To   State
}

TransitionError reports an attempted state transition that is not an approved edge in the supervision state machine (including any transition out of a terminal state, or into an unrecognized state).

func (*TransitionError) Error

func (e *TransitionError) Error() string

type WaitKind

type WaitKind string

WaitKind selects how Supervisor.Wait observes a set of process handles (spec "ProcessOutput API": `"wait": "poll | any | all"`). This is Task 9A's generic waiter primitive: the future ProcessOutput tool (Task 16) translates its own `wait`/`cursor` arguments into a WaitKind and a set of WaitTarget values and calls Wait through the Supervisor; 9A does not build ProcessOutput itself.

const (
	// WaitPoll returns every target's current status immediately, never
	// blocking and never consulting ctx.
	WaitPoll WaitKind = "poll"
	// WaitAny blocks until at least one target has advanced past its
	// supplied generation or become terminal, or ctx is done.
	WaitAny WaitKind = "any"
	// WaitAll blocks until every target has advanced past its supplied
	// generation or become terminal, or ctx is done.
	WaitAll WaitKind = "all"
)

The closed set of wait kinds.

func (WaitKind) Valid

func (k WaitKind) Valid() bool

Valid reports whether k belongs to the closed WaitKind domain.

type WaitStatus

type WaitStatus struct {
	Handle Handle
	// Generation is the entry's current generation counter.
	Generation uint64
	// Terminal reports whether the entry has reached a terminal state.
	Terminal bool
	// Found reports whether Handle named a live entry visible to the
	// calling Owner. A Handle that does not exist and a Handle owned by a
	// different Owner are deliberately indistinguishable here -- both
	// report Found false and every other field at its zero value -- so a
	// cross-owner probe can never be told apart from a missing one (spec
	// "Identity and authorization"; mirrors Owner.Equal's doc comment).
	Found bool
}

WaitStatus is Wait's per-target report.

type WaitTarget

type WaitTarget struct {
	Handle     Handle
	Generation uint64
}

WaitTarget is one process a Wait call watches, paired with the generation the caller last observed for it. Generation is this package's own append-driven counter (entry.go's entry.generation), not a byte cursor: a caller with a byte cursor (Task 16's ProcessOutput) derives "have I already seen everything as of this cursor" itself, from a WaitStatus's Generation together with its own last-read output cursor, and only needs Wait to tell it "something changed, go look again" versus "nothing changed yet". The zero value (a target never observed before) blocks until the entry has appended at least once or is already terminal.

type YieldSettings

type YieldSettings struct {
	// Yield requests that the process detach to the background immediately
	// rather than staying attached to the foreground Bash call.
	Yield bool
}

YieldSettings carries one Start call's explicit backgrounding preference (spec "Bash API": a foreground call may explicitly yield to the background rather than staying attached and returning inline). Task 8 does not implement yield/backgrounded-handoff behavior yet -- that is part of Task 8B/8C's lifecycle-event work and, ultimately, the Bash tool built in Phase 4; this struct only reserves the field Start's signature commits to.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL