Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
0447c93
feat(agent): detector registry + incremental-inference delta core (ep…
pulzzejason Jul 3, 2026
1193a30
feat(agent): proposedManifest columns + detectedRequirements + propos…
pulzzejason Jul 3, 2026
5dd011b
feat(agent): apply-update + dismiss-update proposal actions (epic 583…
pulzzejason Jul 3, 2026
f987714
feat(agent): 'setup update proposed' driver state + review-then-use d…
pulzzejason Jul 3, 2026
2544148
Merge pull request #1758 from InteractorOSS/goal/repo-setup-evolution…
pulzzejason Jul 3, 2026
8bc9cf6
feat(agent): acceptGoal setup-drift trigger + reusable seams (epic 58…
pulzzejason Jul 3, 2026
59461f5
feat(agent): setupFailure wire contract + shared classification seam …
pulzzejason Jul 3, 2026
eace443
feat(agent): map setupFailure signal → setup-update proposal (epic 58…
pulzzejason Jul 3, 2026
00e5dc6
docs(agent): S2c hand-driven daemon-ship runbook + PM_MIN_DAEMON_VERS…
pulzzejason Jul 3, 2026
68db43d
docs(agent): link S2 daemon-ship runbook from design doc (epic 583e46…
pulzzejason Jul 3, 2026
97b0a79
Merge pull request #1766 from InteractorOSS/goal/repo-setup-evolution…
pulzzejason Jul 3, 2026
1083c86
Merge pull request #1765 from InteractorOSS/goal/repo-setup-evolution…
pulzzejason Jul 3, 2026
9961f15
feat(engine): surface pending setup proposals in the attention feed (…
pulzzejason Jul 3, 2026
f57bcc2
feat(engine): owner-scoped setup_update_proposed notification (epic 5…
pulzzejason Jul 3, 2026
60eaa47
Merge pull request #1779 from InteractorOSS/goal/repo-setup-evolution…
pulzzejason Jul 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,13 @@ VOYAGE_API_KEY=
# semver. When set, a daemon reporting a lower DAEMON_VERSION is refused all
# claims (returns { upgradeRequired }) until upgraded — preventing an out-of-date
# image from silently taking and failing jobs. Unset = disabled (no gate).
#
# Raise this at a gated fleet rollout that ships a new daemon capability — e.g.
# epic 583e469f S2 (failure-driven setup trigger), where the daemon starts
# emitting the structured `setupFailure` signal. Roll the new image FIRST, then
# raise the floor (raising it before the fleet is on the new image benches every
# machine on upgradeRequired). Hand-driven runbook:
# docs/design/repo-setup-evolution-s2-daemon-ship-runbook.md
# PM_MIN_DAEMON_VERSION=0.2.0

# -----------------------------------------------------------------------------
Expand Down
176 changes: 176 additions & 0 deletions docs/design/repo-setup-evolution-s2-daemon-ship-runbook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
<!-- Copyright (c) 2026 Interactor, Inc. -->
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->

# Runbook: S2 failure-driven trigger — hand-driven daemon-ship rollout

> Status: **Operational runbook** · Epic: `583e469f` (repo setup evolution) · Slice **S2** ·
> Author: hand-driven (2026-07-03)
> Depends on: [`repo-setup-evolution.md`](./repo-setup-evolution.md) §7A / §12 (the design) and the
> **already-merged** server slices S2a (`59461f54`, wire contract) + S2b (`eace443a`, signal→proposal).

## Why this is a runbook, not a code change

The daemon that runs agent work is **out of this repo** — it lives in the fleet image
(thin-daemon / fat-server; see [[goal-s2a-setupfailure-wire-seam]]). **No daemon source ships from
`product-manager`.** S2's server half (the `setupFailure` wire contract, the classifier seam, and
the signal→proposal binding) is already merged and **live behind the report field**: a report that
omits `setupFailure` validates exactly as today, so the field is dormant until a daemon starts
emitting it. Activating the failure-driven trigger end to end is therefore a **fleet rollout** —
build a daemon image that emits the signal, smoke it, and set the deploy-time version floor. That
work is gated and hand-driven; this runbook is the checklist for driving it.

The steps below are ordered. Do not set `PM_MIN_DAEMON_VERSION` (step 3) until the new image has
passed its smoke (step 2), or you bench the fleet on an image that can't yet do the thing you gated
for.

---

## 1. The exact `setupFailure` wire contract the daemon must emit

On a run that **fails**, the daemon classifies the failure **tail** for a setup signature and ships a
**structured, bounded signal — never raw logs**. The contract is the tagged union finalized in S2a
(`src/lib/agent/repo-readiness.ts`, `SetupFailureSchema` / `SetupFailure`). It is discriminated on
`kind`; an unknown `kind` is rejected and every string is length-capped.

```jsonc
// exactly one of the three kinds — the discriminant is `kind`
{ "kind": "service", "host": "<hostname>", "port": 6379 } // a backing service was unreachable
{ "kind": "tool", "bin": "<binary>" } // a required tool/binary is missing
{ "kind": "env", "name": "<VAR_NAME>" } // a required env var is unset
```

Field bounds (must hold or the server rejects the report):

| kind | field | type / bound |
|---|---|---|
| `service` | `host` | non-empty string, ≤ 200 chars |
| `service` | `port` | integer, `0 ≤ port ≤ 65535` (same `host:port` probe shape the manifest uses) |
| `tool` | `bin` | non-empty string, ≤ 200 chars |
| `env` | `name` | non-empty string, ≤ 200 chars |

**High-precision only.** The daemon should emit `setupFailure` **only** when the failed run's tail
matches one of the three exact signatures below — never on a fuzzy substring or a guess. Emitting on
a soft match produces false setup proposals; when in doubt, emit nothing (the run still escalates as
an ordinary failure). These are the same signatures the server's backstop classifier
(`classifySetupFailure` in `src/lib/engine/pause.ts`) recognises, so daemon and server agree:

| Signature in the failure tail | Emitted signal |
|---|---|
| `connection refused: <host>:<port>` | `{ kind: "service", host, port }` |
| `command not found: <bin>` | `{ kind: "tool", bin }` |
| `missing env <NAME>` | `{ kind: "env", name }` |

### Where the signal rides — two transports (both already live server-side)

The design routes the **structured** signal through the connection-scoped assess/report flow, and
the run-report path carries a **derived** legibility marker. Do not conflate them:

1. **Structured proposal transport** — `POST /api/v1/me/connected-repos/:connectionId/assess/report`
(`ReadinessReportSchema.setupFailure`). The daemon puts the structured union on the readiness
report; the server (S2b, `proposeSetupUpdateFromFailure`) maps it to a **reviewable manifest-update
proposal** on `RepoAgentProfile` — no LLM, no touch to the operative manifest. This is the field
the daemon image must start populating. A known service port short-circuits to a deterministic
`services:` delta; an unnameable port falls through to daemon-side inference (server no-op).
2. **Run-report legibility marker** — `POST /api/v1/me/engine/runs/:runId/report`. Here the daemon
ships only its usual `error` string; the server **derives** the signal from it
(`classifySetupFailure(body.error)`) and stamps a bounded `setupFailure` + `setupFailureReason`
one-liner onto `EnginePause.detail` (mirrors the `attachmentCredsFailure` precedent) so `/attention`
renders a human cause. The failure classifies as the existing retryable `infra` class — **not** a
new `RunFailureClass`. This transport needs **no daemon change** and already works today.

The daemon-ship work in this runbook is about transport **(1)** — teaching the daemon to emit the
structured `setupFailure` on the readiness report so a failed run raises a remediation proposal.

---

## 2. Daemon image change + smoke

1. **Emit the signal.** In the out-of-repo daemon, on a failed run, classify the failure tail with
the three high-precision signatures above and attach the structured `setupFailure` union to the
readiness report POSTed to `.../assess/report`. Bound every field exactly as the table in §1 —
the server rejects an out-of-range `port` or an over-length string, and rejects an unknown `kind`.
Ship raw findings, not decisions: no raw logs, no fuzzy matches.
2. **Bump `DAEMON_VERSION`.** The daemon reports its `DAEMON_VERSION` on register/heartbeat →
`Machine.daemonVersion`. Pick the new semver floor this image establishes; you set the server-side
gate to it in step 3.
3. **Smoke against a controlled failing run.** On a scratch repo/connection, force each signature and
confirm the whole path:
- **service** — point a required service at a dead port so the run fails with
`connection refused: <host>:<port>`; verify the report carries
`{ kind: "service", host, port }` and a `setup update is proposed` card appears on the
Fleet-setup driver naming the service (for a known port, e.g. `6379 → redis`).
- **tool** — remove a required binary so the run fails with `command not found: <bin>`; verify
`{ kind: "tool", bin }` → a `nix.packages` proposal.
- **env** — unset a required var so the run fails with `missing env <NAME>`; verify
`{ kind: "env", name }` → an env-required proposal.
- **negative** — an ordinary task failure that merely name-drops "connection"/"env" must emit
**nothing** and raise no proposal (guards against false setup proposals).
4. Publish/tag the image and roll it to the fleet.

---

## 3. Set `PM_MIN_DAEMON_VERSION` at deploy

`PM_MIN_DAEMON_VERSION` is a **deploy-time environment variable** (see `.env.example`, currently
commented/disabled — the OSS default is **unset = gate disabled**). It is **NOT a code constant** —
do not hardcode a floor in the app. The gate lives in `src/lib/engine/daemon-version.ts`
(`minDaemonVersion()` reads `process.env.PM_MIN_DAEMON_VERSION`) and is enforced at claim time: a
daemon reporting a `DAEMON_VERSION` **below** the floor is refused all claims (`upgradeRequired`)
until upgraded, while it keeps heartbeating.

At the S2 deploy, **after** the new image is rolled and smoked (step 2), set
`PM_MIN_DAEMON_VERSION` to the new floor from step 2 so only images that emit the structured
`setupFailure` can take work:

```
PM_MIN_DAEMON_VERSION=<new floor semver, e.g. the DAEMON_VERSION from step 2>
```

Notes on the gate's failure modes (from `daemon-version.ts`):
- **Unset/blank ⇒ disabled** (backward-compatible; no surprise lockouts).
- A daemon with a **missing/unparseable** version, when a valid floor is set, is **failed-closed**
(refused) — an unparseable version is itself a signal.
- An **unparseable floor** (operator typo) **fails open** (gate skipped) so a typo can't lock out the
whole fleet — so double-check the value parses as semver.

Sequencing: roll the new image to the fleet **first**, confirm machines report the new
`DAEMON_VERSION`, **then** raise `PM_MIN_DAEMON_VERSION`. Raising the floor before the fleet is on the
new image benches every machine on `upgradeRequired`.

---

## 4. Server side is already live behind the field — daemon rollout is the last step

S2a + S2b are **merged and live on this goal's branch** and behave as no-ops until a daemon emits the
signal:

- **S2a** (`59461f54`) — the `setupFailure` wire contract (`SetupFailureSchema`), the shared
`classifySetupFailure` classifier + `setupFailureReason`, and the `/attention` legibility marker on
the run-report path. The report schema field is **optional**, so a report omitting `setupFailure`
validates exactly as before.
- **S2b** (`eace443a`) — the binding: a reported `setupFailure` → `proposeSetupUpdateFromFailure` →
a reviewable manifest-update proposal on `RepoAgentProfile`, reusing S1's review-then-use +
accept-then-verify machinery. Deterministic known-port short-circuit; an unnameable port is a
server no-op (falls through to daemon-side inference).

Because the server half is already deployed behind the optional field, **the daemon rollout can be
driven independently** of the server and is the **last step that activates the failure-driven
trigger** end to end. There is no further server change to land for S2 — steps 1–3 above are the
whole activation. (S3, the goal-delivery-diff trigger, is a separate, pure server-side slice with no
daemon dependency — see the design doc §12.)

---

## Rollback

Set `PM_MIN_DAEMON_VERSION` back to its prior value (or unset it) to drop the version floor. Because
the `setupFailure` report field is optional and additive, an image that stops emitting it simply
raises no failure-driven proposals — nothing else regresses; the run-report `/attention` marker
(transport 2, server-derived) keeps working regardless.

## Agent-config note

**No `pm-agent` config / `TOOL_NAMES` change is needed for S2.** This slice adds no agent-visible
tool and no new agent-visible entity — the `setupFailure` signal is an internal daemon↔server wire
field, and the proposal it raises is the same `RepoAgentProfile` surface S1 already shipped. State
this one-liner in the PR description per the repo's agent-maintenance rule.
8 changes: 6 additions & 2 deletions docs/design/repo-setup-evolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,10 @@ extend the assess report payload (`ReadinessReportSchema` in `src/lib/agent/repo
- The server maps the signal to a delta (service unreachable → propose adding it; missing bin →
propose an install/nix addition) and raises a proposal.
- **Daemon dependency:** this requires a daemon image change (emit the signal) + `PM_MIN_DAEMON_VERSION`
bump. Server-side classification/proposal can ship first behind the report field.
bump. Server-side classification/proposal can ship first behind the report field. The gated,
hand-driven fleet rollout that lands this end to end — the exact wire contract the daemon must emit,
the image smoke, and the roll-image-then-raise-floor deploy step — is the
[S2 daemon-ship runbook](./repo-setup-evolution-s2-daemon-ship-runbook.md).

### 7B. Goal-delivery dependency/config diff (proactive; pure server-side)

Expand Down Expand Up @@ -246,7 +249,8 @@ full proposed YAML sits behind a "View details" disclosure (parity with the mach
the repo" PR for committed manifests (keeps S1's first cut off repo-write).
- **S2 — Failure-driven trigger.** Daemon emits `setupFailure` signals (image change +
`PM_MIN_DAEMON_VERSION`); server maps signal → proposal. *(Server side can land behind the field;
daemon rebuild is the gated ship step — hand-drive the fleet rollout.)*
daemon rebuild is the gated ship step — hand-drive the fleet rollout per the
[S2 daemon-ship runbook](./repo-setup-evolution-s2-daemon-ship-runbook.md).)*
- **S3 — Goal-delivery diff trigger.** `acceptGoal` post-merge hook → `fetchPrFiles` → setup-relevant
change → request detect+assess → diff → proposal. Debounced/idempotent. *(Pure server-side; no
daemon rebuild — likely the safest to validate first even though S2 is higher-signal.)*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- Repo-setup evolution S1: additive RepoAgentProfile manifest-update proposal fields (epic 583e469f).
--
-- The server-computed candidate manifest update surfaced when a detected requirement (from the
-- deterministic detector registry) reveals the operative setup is now INCOMPLETE. This is a
-- side-channel to the operative `manifest`/`manifestSource`, which keeps serving until the operator
-- ADOPTS the proposal — nothing here runs on the daemon. proposedManifest is the candidate body;
-- proposedManifestSummary is the computed display diff (added services/steps/env + reason + source);
-- proposedManifestAt is when it was raised; proposedManifestDismissedAt records an operator dismissal.
--
-- Purely additive + ALL NULLABLE: existing rows keep loading with no backfill. No EngineGoal is
-- created — a proposal is profile-level (design §9 DECIDED).
--
-- IF NOT EXISTS keeps this a no-op on any environment where a column already exists, matching the
-- fresh-idempotent pattern used elsewhere in this repo.

-- AlterTable
ALTER TABLE "RepoAgentProfile"
ADD COLUMN IF NOT EXISTS "proposedManifest" TEXT,
ADD COLUMN IF NOT EXISTS "proposedManifestSummary" JSONB,
ADD COLUMN IF NOT EXISTS "proposedManifestAt" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "proposedManifestDismissedAt" TIMESTAMP(3);
11 changes: 11 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -2753,6 +2753,17 @@ model RepoAgentProfile {
// distinguish "generating…" (pending, no attempt yet) from "couldn't draft — Regenerate"
// (attempted, failed). Cleared on a successful draft and on Regenerate.
manifestDraftFailedAt DateTime?
// ─── Manifest-update proposal (epic 583e469f S1 — additive, all nullable) ──
// The server-computed candidate manifest update, raised when a detected requirement (from the
// detector registry) reveals the operative setup is now INCOMPLETE. This is a side-channel: the
// operative `manifest`/`manifestSource` keeps serving until the operator ADOPTS the proposal, so
// nothing here ever runs on the daemon. A newer detection SUPERSEDES an un-acted proposal
// (overwrite). No EngineGoal is created — a proposal is profile-level (design §9 DECIDED).
// All nullable so existing rows keep loading with no backfill. null = no open proposal.
proposedManifest String? // candidate replacement pm-agent.yaml body (operative keeps serving until adopted)
proposedManifestSummary Json? // computed diff for display + reason + source ("failure" | "dep_change")
proposedManifestAt DateTime? // when the current proposal was raised
proposedManifestDismissedAt DateTime? // operator dismissed the open proposal; cleared when a fresh one is raised
// ─── Repo automation-readiness determination (S0 — additive, all nullable) ──
// Machine-independent, repo-scoped eligibility home (keyed by connectionId,
// not per-machine MachineRepo). Populated by later phases (S1/S2 assess
Expand Down
10 changes: 10 additions & 0 deletions src/app/(app)/o/[orgSlug]/digest/load-digest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export const ATTENTION_LABELS: Record<AttentionItem["type"], string> = {
goal_pr_dead: "PR closed",
discussion_waiting: "Discussion",
stalled: "Stalled",
setup_proposed: "Setup update",
risk: "Risk",
};

Expand All @@ -111,6 +112,9 @@ export function attentionTitle(item: AttentionItem): string {
return item.task?.title ?? item.reason ?? "Paused run";
case "approval":
return item.target?.label ?? "Approval requested";
case "setup_proposed":
// No `title` field — the repo is this item's subject.
return `Setup update for ${item.repo}`;
default:
return item.title;
}
Expand All @@ -124,6 +128,9 @@ export function attentionDisplayNumber(item: AttentionItem): number | null {
return null;
case "risk":
return null;
case "setup_proposed":
// Connection-scoped — no display number.
return null;
default:
return item.displayNumber;
}
Expand All @@ -149,6 +156,9 @@ export function attentionHref(item: AttentionItem, orgSlug: string, projectSlug:
return item.goalId ? `${base}/goals/${item.goalId}` : `${base}/goals`;
case "risk":
return `${base}/flags`;
case "setup_proposed":
// Deep-link to the Fleet-setup driver (integration settings), anchored to the connection.
return `${base}/settings/integrations#connection-${item.connectionId}`;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,7 @@ export function ApprovalsClient({
initialPrDeadGoals={attention.prDeadGoals}
initialDiscussions={attention.discussions}
initialStalled={attention.stalled}
initialSetupProposals={attention.setupProposals}
initialRisks={attention.risks}
currentUserId={currentUserId}
onCountChange={handleAttentionCount}
Expand Down
Loading