Skip to documentation
Documentation navigation

Documentation navigation

Documentation / guides

Process notifications

Deliver supervised process lifecycle notifications through the Session.

developer

Process completion is a metadata-only notification from a supervised process runner back to the owning native loop. It is not a second process API: the notification contains a stable command ID, session and loop ownership, an opaque process handle, and a closed terminal state/reason pair. It never carries the command, output, stdin, environment, host path, or OS PID.

Public notifier and DTO

Session resources receive this capability through tool.ProcessCompletionNotifier:

type ProcessCompletionNotifier interface {
	NotifyProcessCompletion(context.Context, ProcessCompletionNotification) error
}

type ProcessCompletionNotification struct {
	CommandID     uuid.UUID             `json:"command_id,omitzero"`
	SessionID     uuid.UUID             `json:"session_id,omitzero"`
	LoopID        uuid.UUID             `json:"loop_id,omitzero"`
	ProcessHandle string                `json:"process_handle"`
	State         ProcessLifecycleState `json:"state"`
	Reason        ProcessTerminalReason `json:"reason"`
}

func (n ProcessCompletionNotification) Validate() error

The process runner allocates CommandID before publishing completion. Harness copies that exact ID to both the nested DTO and the command envelope; it never mints a replacement. Validate requires non-zero command, session, and loop IDs, a URL-safe handle of at most MaxProcessHandleBytes, and one of the closed terminal state/reason pairs.

The durable command adds only the generic envelope and a transient disposition channel:

type ProcessNotificationResult uint8

const (
	ProcessNotificationAccepted ProcessNotificationResult = iota + 1
	ProcessNotificationDuplicate
	ProcessNotificationCollision
	ProcessNotificationStopped
)

type ProcessNotification struct {
	Header
	Notification tool.ProcessCompletionNotification `json:"notification"`
	Result       chan<- ProcessNotificationResult   `json:"-"`
}

Header.CommandID == Notification.CommandID is a hard invariant. The nested DTO also validates its own lifecycle tuple. The command’s live Result is nil when reconstructed from a journal; restore seeds the owning loop directly.

Delivery dispositions

DispositionMeaningCaller action
Accepteda new durable frame was appended (or headless mode has no journal) and the loop took ownershipfinish the supervisor’s notification path
Duplicatethe same command ID and identical payload were already appended or accepted livetreat as success; do not create a new ID
Collisionthe ID names a different persisted payloadfail closed and investigate ID reuse or forgery
Stoppedthe owner exited, the bounded live set is full, or the loop cannot accept notificationsretry with the same command ID; the durable frame remains authoritative
%%{init: {"theme":"dark"}}%%
sequenceDiagram
    participant P as Process supervisor
    participant S as Session
    participant J as Journal
    participant L as Native loop

    P->>S: NotifyProcessCompletion(n)
    S->>S: validate ownership and closed tuple
    S->>J: append command using n.CommandID
    alt new or identical retry
        J-->>S: appended or deduplicated
        S->>L: ProcessNotification
        L-->>S: Accepted, Duplicate, or Stopped
    else different payload reuses ID
        J-->>S: IdempotencyCollisionError
    end
    S-->>P: error/disposition

The append happens before dispatch. A failed non-collision append is returned and nothing is sent to the loop. If dispatch is stopped after a successful append, a retry checks the same idempotency index and does not write a second frame. The loop also has a bounded live de-dup guard, so a headless session can recognize an at-least-once retry.

func publishCompletion(ctx context.Context, n tool.ProcessCompletionNotification,
	notifier tool.ProcessCompletionNotifier) error {
	if err := notifier.NotifyProcessCompletion(ctx, n); err != nil {
		var invalid *tool.ProcessLifecycleValidationError
		if errors.As(err, &invalid) {
			return fmt.Errorf("bad process completion field %s: %w", invalid.Field, err)
		}
		return err
	}
	return nil
}

For a native session, the concrete error types also distinguish owner mismatch, unsupported engine, delivery stopped, and journal idempotency collision. The caller should retry only stopped delivery, and only with the same command ID and identical DTO.

Durable lifecycle relationship

ProcessNotification is the terminal hand-off to the loop. The process itself publishes separate enduring lifecycle events (ProcessStarted, ProcessBackgrounded, ProcessCompleted, ProcessStopRequested, and ProcessLost) through the session resource services. Those events contain bounded metadata and no host secrets. Restore can reconstruct undelivered notifications but cannot resurrect a process from the notification alone.

The source is pkg/command/process_notification.go and the DTO is pkg/tool/process.go. The checked append and retry behavior is proved by internal/sessionruntime/process_notification_test.go, including duplicate, collision, inbox-full, and foreign-engine cases.

Source and proof

← back to documentation