// The activator's half of `carlos logs` (spec 2026-08-10-carlos-logs-design // §5): the seam the agent injects its applog sink through, and the // member-safe rendering of wake failures. The reason a wake failed is the // whole diagnosis a member gets for a 503 (#131), and it is composed HERE, // at the emit site — never relayed from the journal, and never carrying box // filesystem layout. package activator import ( "errors" "fmt" "regexp" "strings" "github.com/carlosframework/platform/internal/cgroup" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/updatewords" ) // EventSink is where the activator publishes member-visible log lines. // applog.Sink satisfies it; the interface lives here so this package // depends on the contract, not the buffer. Nil disables everything — // tests and non-shipping deployments change nothing. type EventSink interface { // App records one line of the app's own output. App(acct, app, host, line string) // Event records one platform event with member-safe text. Event(acct, app, host, level, msg string) } // emitEvent publishes one platform event for rt's app, tolerating a nil // sink. level is an applog.Level* value ("info"/"warn"/"error" — string // here to keep the dependency one-way). func emitEvent(sink EventSink, rt registry.Route, level, msg string) { if sink == nil { return } sink.Event(release.RouteAccount(rt), rt.App, rt.Host, level, msg) } // absPathToken matches an absolute-path token: box filesystem layout, // which a member-safe message must not carry (#131). Replacement keeps the // base name — "…/ledgerpay.bde.oncarlos.com.sock" still tells the member WHICH // socket without telling them where the box keeps it. var absPathToken = regexp.MustCompile(`(^|[\s"(=])(/[\w@.\-/]+)`) // memberSafeWakeReason renders a wake error for the member-facing platform // stream. Known failure shapes get a diagnosis in the member's terms; the // rest pass through with absolute paths reduced to base names. The error // text itself originates in our own code (backends, materialize, lease) — // this is a rendering pass, not a secrecy boundary. func memberSafeWakeReason(err error) string { // The seed fallback's refusals are composed for the member (SD-N14 to // SD-N19) and pass through whole, without the wrapping around them. var seed *SeedPlacementError if errors.As(err, &seed) { return "wake failed: " + seed.Sentence } // The held wake check's refusals (settings spec §4.3 rule 3; ruling // R-WAKEWORDS). Unknown and failed need the member's action, so they get // the settings words (approved copy, settings plan 4). A wait // for root that ran out, or a repoint after it, is the platform's own // work and reopens by itself: the fence's existing wording. if errors.Is(err, errBuildUnknown) { return updatewords.Text("settings.box.reason.unknown") } var failed *targetFailedError if errors.As(err, &failed) { return fmt.Sprintf(updatewords.Text("settings.box.reason.failed"), failed.build) } if errors.Is(err, errStaleLink) { err = ErrFenced } if errors.Is(err, errStagedBuildNotPublished) { // A staged instance wake refused because its build could not be // published first (final review F9, flag on only). The platform // holds the host closed until its own write succeeds (the sweep // retries it) and then it reopens by itself, which is the // maintenance fence's existing wording; the write error is the // operator's (persistGenerations logs it). err = ErrFenced } if errors.Is(err, errBuildStateUnwritten) { // A spawn refused because its build state could not be written // first (settings spec §4.2; ruling R-WAKEWORDS): the platform holds // the host until its own write succeeds, the fence's existing // wording. The write error is the operator's (recordBuild logs it). err = ErrFenced } if errors.Is(err, cgroup.ErrLegacyParentPopulated) { // A generation start refused because the host's parent cgroup still // holds a process directly (amadan #272). It is the box's state to // clear: the platform does not lift it by itself; the process must // exit or be stopped (or the flag go off). The cgroup detail (the // host, the count) is the operator's: the agent logs it. The member // gets the maintenance fence's existing text, the box's condition // in the words the platform already uses for it. err = ErrFenced } if errors.Is(err, cgroup.ErrLimitNotApplied) { // A generation start refused because a limit it would run under // could not be applied, or it could only be started outside its // cgroup and moved after (zdd readiness round 2, Task 7). Like the // populated parent above it is the box's condition, and the cgroup // detail is the operator's (the manager logs it), so the member // gets the maintenance fence's existing text. err = ErrFenced } if errors.Is(err, cgroup.ErrKilledCgroupKept) { // A start refused because the cgroup it would spawn into took // cgroup.kill and could not be made fresh (amadan #272, Task 19). // Like the populated parent above it is the box's condition, and the // cgroup detail is the operator's (the manager logs it), so the // member gets the maintenance fence's existing text. err = ErrFenced } if errors.Is(err, errHostUnproven) { // A flag-off wake refused because its host cgroup holds processes // not proven gone (Task 8 fix round 1, finding 4): the platform holds // the host closed until its own proof holds, and it reopens by // itself. The fence's existing text; the cgroup detail is the // operator's (the wake logs it). err = ErrFenced } if errors.Is(err, errWakeUnproven) || genStartUnproven(err) || errors.Is(err, errOutcomeUndelivered) { // A wake refused while the host's last failed wake is not proven // stopped (zero-downtime spec §7.4), or that failed wake itself: a // start whose generation kill was not proven (errGenUnproven, // instancectl.ErrGenUnproven), which containment produces with // generations on or off. Members get the maintenance fence's // existing text, the state that is like it: the platform holds the // host closed until its own work is done, and it reopens by itself. // The generation detail stays in the operator log (wake). The same // for a wake refused while an earlier planned wake's outcome is // still being delivered (errOutcomeUndelivered, final review wave 2 // N1). err = ErrFenced } if errors.Is(err, ErrBuildNotPublished) { // A worker wake refused because its build could not be published // before the exec (plan 4's ErrBuildNotPublished, flag on or off). // The member gets a reason in their terms; the write error is the // operator's (the sweep retries the write). errStagedBuildNotPublished // is a distinct sentinel and was mapped to the fence above. return "wake failed: the box could not record which build this instance runs, so it did not start it. the next request tries again" } s := err.Error() if errors.Is(err, errClaimsAdmission) { // claimAdmission's wrapper is the classifier's, not the member's // (zero-downtime plan 3, Task 10): the event reads exactly as its // cause did, alone or joined. Every other mapping is unchanged. s = strings.ReplaceAll(s, claimAdmissionPrefix, "") } switch { case strings.Contains(s, "address already in use"): // The stale-socket wake trap (#129): every later wake fails until // the leftover socket goes away. Say that, not the bind syscall. return "wake failed: instance socket already in use — a previous run's socket was left behind (this wake will keep failing until the platform clears it)" case strings.Contains(s, release.StaticHeadUnderInstance): // The one wake failure `carlos channels` appears to CONTRADICT: the // channel does have a head, it is just a static one under a route // that execs a binary (skills#29). materialize's sentence already // names the app, the version, the channel and both commands out of // it — it is composed for a member, so it is passed through whole // rather than summarised into something less actionable. return "wake failed: " + RedactPaths(s) case strings.Contains(s, "no adoptable head"): return "wake failed: nothing is promoted for this instance's channel (or the head is still bake-held) — promote a build, or check `carlos channels`" } return "wake failed: " + RedactPaths(s) } // RedactPaths reduces absolute-path tokens to their base names. Exported // for the host package's schedule wiring, whose token and delivery errors // reach the bucket status object and `carlos schedule ls` (#131's rule: // no box filesystem layout in a member-facing string). func RedactPaths(s string) string { return absPathToken.ReplaceAllStringFunc(s, func(m string) string { i := strings.Index(m, "/") prefix, path := m[:i], m[i:] if j := strings.LastIndexByte(path, '/'); j >= 0 && j+1 < len(path) { return prefix + path[j+1:] } return prefix + path }) }