Skip to content

Commit 8d9cd99

Browse files
authored
Merge pull request #154 from compoundingtech/schickling-assistant/2026-08-03-149-ding-companion-lifecycle
fix: couple derived DING to agent lifecycle
2 parents 4262c19 + f7cbd4b commit 8d9cd99

9 files changed

Lines changed: 354 additions & 17 deletions

File tree

INVARIANTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ materialization, messaging, DING, or presence must preserve them.
1010
| **Transport-decoupled lifecycle** | Each task is isolated from a supervisor/transport process-group or cgroup cascade. | `tests/transport_isolation.rs`; `tests/transport_isolation_macos.rs` |
1111
| **Clean exec teardown** | Killing an exec task reaps its whole process group. | `tests/exec_backend.rs::exec_kill_reaps_the_whole_process_group_not_just_the_leader` |
1212
| **Bounded restart diagnostics** | Relaunching an exec task preserves the just-finished log as one prior generation while bounding retained diagnostics to current plus prior. Final retirement removes the PID and both logs. | `tests/exec_backend.rs::exec_restart_reap_keeps_bounded_diagnostics_and_final_remove_cleans_them`; `tests/run.rs::up_once_finally_removes_dead_retired_tasks_without_restarting_them` |
13+
| **Derived companion lifecycle** | A generated DING starts only with an eligible canonical agent, is suppressed or stopped while that target is held, unavailable, or terminally parked, and remains coupled without changing explicit sibling task behavior. Healthy compact startup still launches both tasks in one pass, retirement stops both, and targeted reconciliation holds a missing generated DING rather than broadening to its agent. | `tests/run.rs::fresh_compact_agent_launches_with_its_derived_ding`; `tests/run.rs::absent_adopt_only_compact_agent_holds_its_derived_ding`; `tests/run.rs::held_adopt_only_compact_agent_stops_its_live_derived_ding`; `tests/run.rs::failed_compact_agent_restart_stops_its_live_derived_ding`; `tests/run.rs::failed_compact_agent_reap_stops_its_live_derived_ding`; `tests/run.rs::parked_compact_agent_stops_its_live_derived_ding`; `tests/run.rs::parked_compact_agent_does_not_relaunch_its_exited_derived_ding`; `tests/run.rs::retired_compact_agent_stops_agent_and_derived_ding`; `tests/run.rs::selected_missing_derived_ding_is_held_without_broadening_to_its_agent`; `tests/run.rs::up_once_collects_spawn_errors_without_aborting` |
1314
| **Exactly-once-safe native bus** | Messages use stable `<unix-ms>-<rand6>.md` files. An archive filename is a durable receipt that shadows and cleans restored inbox replicas and makes repeated archive cleanup idempotent. | `src/message.rs::filename_grammar`; `src/message.rs::archive_receipt_suppresses_and_idempotently_cleans_a_restored_inbox_copy`; `tests/message.rs` |
1415
| **Fail-closed observed native DING** | Each unread message becomes one normalized `[DING]` frame. Fresh delivery records ownership, then preserves the one combined bracketed-paste, 0.5 second delay, and Return transaction. PTY and Return success are transport only: `Delivered` additionally requires adapter classification of the expected notice text in a submitted-prompt or queued-message pattern while the lowest live composer is empty or an accepted idle placeholder. Retry never re-pastes and may send one bare Return only after two adjacent `RetainedSafe` observations. A maintained adapter's positive `NotRetained` observation releases only an already archived staged head; unread, blocked, timed-out, errored, unknown, and unrecognized states retain ownership and later FIFO work remains blocked. Ownership prevents duplicate paste across command failures, receipt ambiguity, archive races, and restart adoption without letting a vanished archived head block FIFO indefinitely. Startup backlog otherwise becomes one generic recovery DING; new arrivals remain FIFO; `busy` delivers immediately; only fresh `dnd` defers. | `src/ding/mod.rs::poke_text_normalizes_and_bounds_untrusted_fields`; `src/ding/mod.rs::malicious_controls_cannot_escape_the_single_paste_frame`; `src/ding/mod.rs::pty_delivery_uses_face607_delay_order_and_seconds`; `src/ding/mod.rs::maintained_composer_classifiers_require_exact_idle_state`; `src/ding/mod.rs::successful_transport_with_retained_or_unproven_pixels_is_not_delivered`; `src/ding/mod.rs::ambiguous_transport_receipt_and_retry_errors_retain_staged_ownership`; `src/ding/mod.rs::adapter_recognized_notice_with_an_empty_live_composer_is_a_positive_receipt`; `src/ding/mod.rs::staged_retry_submits_only_retained_safe_and_requires_a_receipt`; `src/ding/mod.rs::staged_retry_keeps_unproven_and_retained_blocked_owned`; `src/ding/mod.rs::staged_ownership_survives_archive_and_never_repastes`; `src/ding/mod.rs::archived_not_retained_releases_fifo_without_repasting_owned_notice`; `src/ding/mod.rs::unread_not_retained_keeps_fifo_ownership_without_repasting`; `src/ding/mod.rs::pty_commands_have_a_real_outer_timeout`; `src/ding/mod.rs::session_watch_has_startup_grace_debounce_and_live_reset`; `src/ding/mod.rs::new_arrivals_is_fifo_and_archive_receipts_prevent_reding`; `src/ding/mod.rs::pending_delivery_ignores_busy_but_respects_fresh_dnd_archive_and_retry`; `src/ding/mod.rs::startup_recovery_notice_retries_in_memory`; `src/ding/mod.rs::startup_backlog_gets_one_generic_recovery_then_new_arrivals_poke` |
1516
| **Mutation-only filesystem wakeups** | Supervisor and DING filesystem watchers ignore read/open access events and wake early only for create, modify, rename, or remove events. Their own catalog and inbox reads therefore cannot bypass the bounded timer cadence or form a Linux inotify CPU loop. | `src/watch.rs::only_mutations_wake_watch_loops`; `src/watch.rs::linux_reads_are_silent_but_real_mutations_wake`; `src/ding/mod.rs::idle_ding_does_not_spin_on_its_own_inbox_reads`; `src/run.rs::idle_supervisor_does_not_spin_on_its_own_catalog_reads` |

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,9 @@ agent "<identity>" {
174174
For a zero-interruption migration, add `lifecycle "adopt-only"` to the compact
175175
agent or to an explicit `pty`/`exec` task. st2 adopts that task when its current
176176
generation is alive. If the generation is dead or absent, st2 reports the task
177-
as `held` and does not remove or launch anything:
177+
as `held` and does not remove or launch that task. A compact `ding` is derived
178+
from the generated agent task: while that agent is held, st2 suppresses a
179+
missing DING and stops an exact generated DING proved live:
178180

179181
```kdl
180182
agent "<identity>" {
@@ -301,6 +303,9 @@ st2 up --catalog "$CATALOG" --host <host> --once --task <host.agent.task>
301303
```
302304

303305
Unknown, ambiguous, and wrong-host task selectors refuse before workspace writes or PTY inspection.
306+
A selected generated DING that is dead or absent is reported as `held`, because starting its
307+
canonical agent would broaden the exact-task operation. Explicit sibling tasks remain independently
308+
selectable.
304309

305310
`st2 doctor` accepts the absence of a live host lock as the normal manual/`--once` mode. For a
306311
resident `st2 up` deployment, use `st2 doctor --require-supervisor` to make a missing loop fail the

docs/vrs/02-agent-spec/spec.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,12 @@ evidence: [materializer](../../../src/materialize.rs).
113113
<h3 id="f09">F09 Task set: <code>pty</code>, <code>exec</code>, or compact <code>ding</code></h3>
114114

115115
Add only the unique missing child. Remove and clean only an old child with exact
116-
ownership proof. A compact DING task uses the same rule. Do not change siblings.
116+
ownership proof. A compact DING is a derived child: it starts only after its
117+
canonical agent is already live or starts successfully in the same pass. When
118+
that target is held, fails to restart, or is terminally parked, do not launch
119+
the derived child and stop an exact generated child proved live. Explicit
120+
sibling tasks, including an authored `exec "ding"`, remain independent. Do not
121+
change unrelated siblings.
117122

118123
Authoring: [pinned compact and explicit tasks][evals-tasks]. st2 source:
119124
[`Task` and `TaskKind`](../../../crates/agent-spec/src/spec.rs). Evidence:
@@ -148,8 +153,10 @@ Evidence: [spawn construction](../../../src/run.rs).
148153

149154
Agent or task `keep`, restart `attempts`, `interval`, `delay`, and `mode`, and
150155
task `lifecycle` are future policy. Adopt healthy work. `adopt-only` holds absent
151-
or dead work; `service` reconciles it normally. Invalid policy refuses changes
152-
to the related agent and tasks.
156+
or dead work; `service` reconciles it normally. A generated companion follows
157+
the canonical agent's effective eligibility: `adopt-only` holds it, and
158+
exhausting a fail-mode restart policy stops or suppresses it. Invalid policy
159+
refuses changes to the related agent and tasks.
153160

154161
Authoring: [pinned complete declaration][evals-fields]. The
155162
[pinned explicit-task list][evals-task-fields] and st2 `9887b28` predate task
@@ -170,8 +177,9 @@ Authoring: [pinned complete declaration][evals-fields]. st2 source:
170177
<h3 id="f14">F14 Compact agent fields</h3>
171178

172179
Compact `command`, `argv`, `env`, `lifecycle`, and `ding` convert to the
173-
generated agent PTY and sidecar. The tasks use F09, F11, and F12. Compact syntax
174-
adds no other behavior.
180+
generated agent PTY and derived sidecar. The tasks use F09, F11, and F12;
181+
`ding` carries the dependency on the generated agent task described there.
182+
Compact syntax adds no other behavior.
175183

176184
Authoring: [pinned compact tasks][evals-tasks]. That document and st2 `9887b28`
177185
predate compact `argv` and `lifecycle`. Current st2 source:

docs/vrs/spec.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,12 @@ validate ──► materialize ──► host-local st2 scheduler/reconciler
430430
loop is deterministic; exactly one declared root agent provides intelligent
431431
host-local supervision, bounded recovery, and escalation. Filesystem reads
432432
never wake reconciliation; only create, modify, rename, or remove events may
433-
wake it before the bounded timer.
433+
wake it before the bounded timer. A generated companion is eligible only
434+
while its canonical agent task is eligible. Healthy startup launches the
435+
agent first and then its missing companions in the same pass. Holding or
436+
failing to restart the agent, or terminally parking it, suppresses companion
437+
launch and stops an exact generated companion proved live; explicitly
438+
authored sibling tasks remain independent.
434439
- **R06:** st2 passes the complete effective task definition to the underlying
435440
launcher so manual and supervised restarts are equivalent. Harness readiness
436441
that depends on a dynamically selected account belongs to that declared
@@ -613,7 +618,10 @@ task before writing and renders only its owning agent. `st2 up --once --task
613618
PTY/exec state and executes a plan containing only that task. Unknown,
614619
ambiguous, and wrong-host selectors refuse before writes or runner inspection;
615620
unrelated discovery diagnostics remain visible without preventing the selected
616-
owner/task path.
621+
owner/task path. A live generated companion may be adopted or retired as that
622+
exact selected task. An active dead or absent generated companion is held: the
623+
bounded pass cannot start its canonical agent without broadening the selector.
624+
Explicitly authored sibling tasks retain ordinary selected-task behavior.
617625

618626
`st2 up --materialize-only --agent <id>` remains the agent-wide rendering
619627
selector. Targeted task reconciliation is intentionally bounded to `--once`;

src/exec_backend.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -933,6 +933,7 @@ mod generation_observation_tests {
933933
pty_id: id.to_string(),
934934
bus_id: "host.test".to_string(),
935935
name: "probe".to_string(),
936+
derived: false,
936937
launch: TaskLaunch::Shell("sleep 30".to_string()),
937938
cwd: None,
938939
workspace: None,

src/reconcile.rs

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
//!
55
//! Pure and side-effect-free, so it is exhaustively unit-testable; execution lives behind backends
66
//! (the `pty` CLI for `pty` tasks, direct process supervision for terminal-free `exec` tasks). st2
7-
//! reconciles at the **task** level — an agent declares several tasks (its harness pty + its ding
8-
//! exec) and each is kept running independently; a spec with one live task and one dead task is a
9-
//! launch of just the missing one.
7+
//! reconciles at the **task** level — explicitly authored sibling tasks remain independent. A
8+
//! generated companion is instead eligible only with its canonical agent task and is stopped when
9+
//! that target is held or terminally parked.
1010
1111
use std::collections::BTreeMap;
1212
use std::collections::HashMap;
@@ -46,6 +46,8 @@ pub struct TaskTarget {
4646
pub bus_id: String,
4747
/// The task name (`agent`, `ding`, …).
4848
pub name: String,
49+
/// Generated from another task rather than authored as an independent sibling.
50+
pub derived: bool,
4951
/// How to launch the task: shell source or a direct program argument vector.
5052
pub launch: TaskLaunch,
5153
/// Declared working dir; `None` → default to `workspace`, else the spec dir (resolved at spawn).
@@ -131,9 +133,12 @@ pub enum TaskLaunch {
131133
pub struct Launch<'a> {
132134
pub spec: &'a AgentSpec,
133135
pub tasks: Vec<TaskTarget>,
136+
/// Exact derived task IDs proved live in the same inventory snapshot. Execution stops these if
137+
/// the canonical agent becomes terminal while applying this launch.
138+
pub live_derived: Vec<String>,
134139
}
135140

136-
/// An agent to tear down (retired) — the live task ids to kill.
141+
/// Exact live task IDs to stop because their owner retired or their derived target is ineligible.
137142
#[derive(Debug, Clone, PartialEq, Eq)]
138143
pub struct Teardown<'a> {
139144
pub spec: &'a AgentSpec,
@@ -213,6 +218,10 @@ pub fn reconcile_selected<'a>(
213218
}
214219
return Ok(plan);
215220
}
221+
if task.derived && !actual.is_some_and(|session| session.alive) {
222+
plan.held.push(runtime);
223+
return Ok(plan);
224+
}
216225
let launch = match (&task.command, &task.argv) {
217226
(Some(command), None) => TaskLaunch::Shell(command.clone()),
218227
(None, Some(argv)) => TaskLaunch::Argv(argv.clone()),
@@ -236,6 +245,7 @@ pub fn reconcile_selected<'a>(
236245
pty_id: runtime.clone(),
237246
bus_id: bus_id.clone(),
238247
name: task.name.clone(),
248+
derived: task.derived,
239249
launch,
240250
cwd: task.cwd.clone(),
241251
workspace: owner.workspace.clone(),
@@ -263,17 +273,20 @@ pub fn reconcile_selected<'a>(
263273
plan.launch.push(Launch {
264274
spec: owner,
265275
tasks: vec![target],
276+
live_derived: Vec::new(),
266277
});
267278
}
268279
_ => plan.launch.push(Launch {
269280
spec: owner,
270281
tasks: vec![target],
282+
live_derived: Vec::new(),
271283
}),
272284
}
273285
Ok(plan)
274286
}
275287

276288
/// The state of a declared task's session in the ACTUAL world.
289+
#[derive(Clone, Copy)]
277290
enum SessionState {
278291
Alive,
279292
Dead,
@@ -372,6 +385,7 @@ pub fn reconcile<'a>(
372385
pty_id: pty_id.clone(),
373386
bus_id: bus_id.clone(),
374387
name: t.name.clone(),
388+
derived: t.derived,
375389
launch,
376390
cwd: t.cwd.clone(),
377391
workspace: spec.workspace.clone(),
@@ -387,11 +401,42 @@ pub fn reconcile<'a>(
387401

388402
debug_assert!(!targets.is_empty());
389403

404+
let agent_eligible = targets
405+
.iter()
406+
.find(|(target, _)| target.name == "agent" && !target.derived)
407+
.is_some_and(|(target, lifecycle)| match session_state(&by_id, &target.pty_id) {
408+
SessionState::Alive => true,
409+
SessionState::Dead => {
410+
!target.keep && *lifecycle == TaskLifecycle::Service
411+
}
412+
SessionState::Absent => *lifecycle == TaskLifecycle::Service,
413+
});
390414
let mut to_launch = Vec::new();
415+
let mut live_derived = Vec::new();
416+
let mut ineligible_derived = Vec::new();
417+
let mut derived_cleanup = false;
391418
let held_before = plan.held.len();
392419
for (target, lifecycle) in targets {
393-
match session_state(&by_id, &target.pty_id) {
420+
let state = session_state(&by_id, &target.pty_id);
421+
if target.derived && !agent_eligible {
422+
match state {
423+
SessionState::Alive => {
424+
ineligible_derived.push(target.pty_id);
425+
derived_cleanup = true;
426+
}
427+
SessionState::Dead if !target.keep => {
428+
plan.gc.push(target.pty_id);
429+
derived_cleanup = true;
430+
}
431+
SessionState::Dead | SessionState::Absent => {}
432+
}
433+
continue;
434+
}
435+
match state {
394436
SessionState::Alive => {
437+
if target.derived {
438+
live_derived.push(target.pty_id.clone());
439+
}
395440
let actual = sessions_by_id
396441
.get(target.pty_id.as_str())
397442
.expect("alive state has a session");
@@ -417,13 +462,20 @@ pub fn reconcile<'a>(
417462
SessionState::Absent => to_launch.push(target),
418463
}
419464
}
465+
if !ineligible_derived.is_empty() {
466+
plan.teardown.push(Teardown {
467+
spec,
468+
pty_ids: ineligible_derived,
469+
});
470+
}
420471

421-
if to_launch.is_empty() && plan.held.len() == held_before {
472+
if to_launch.is_empty() && plan.held.len() == held_before && !derived_cleanup {
422473
plan.adopt.push(spec);
423474
} else if !to_launch.is_empty() {
424475
plan.launch.push(Launch {
425476
spec,
426477
tasks: to_launch,
478+
live_derived,
427479
});
428480
}
429481
}

0 commit comments

Comments
 (0)