feat(jobs): a job may emit content, as a sibling of progress #739

Merged
coilyco-ops merged 1 commit from feat/a-job-may-emit-content into main 2026-08-13 21:50:00 +00:00
Member

The seam issue 356 asks for, and nothing else. Kai said "make it so" on issue 236 and the design there is settled; this is the piece the rest sits on.

What lands

JobContentReporter is a sibling of JobProgressReporter, not a change to it. Progress keeps its single editable line, its 20-second rate limit, and the notice alphabet. That was the acceptance row easiest to break by widening the existing interface, so the existing interface is untouched.

  • contentFor returns an emitter that returns an error rather than dropping. A dropped status line costs nothing; a dropped paragraph is a hole in an answer.
  • EmitJobContent on the Discord sink always posts, never edits. Editing is what makes progress one line, and it is what would overwrite part one with part two.
  • The bound is maxJobContentMessages = 10, which is the ceiling Kai decided on 236. Per job, and it refuses with ErrJobContentExhausted.

Fail closed, deliberately

ValidateContent is nil-refuses, not nil-allows. An unwired validator that silently becomes an unchecked path to a member is the exact shape issue 621 records, and this is a new path to a member.

validateJobContent runs ParseReply, ValidateNoToolCallMarkup, the identifier guard, ValidateIdentityClaim and ValidateResponseStyle, in runReplyChecks order. Grounding and self-attribution are absent by construction, not oversight: both read a turn's supplied context and executed tools, and a job has neither. That is stated in the code rather than left to be discovered.

Acceptance

Row Where
ordered sequence, all arrive TestEveryContentMessageArrivesInOrder
refused as a reply, refused here TestContentIsRefusedForTheSameReasonAReplyIs
progress unchanged TestContentAndProgressDoNotShareABound
a job emitting nothing is unchanged TestAJobThatEmitsNothingTouchesNothing
flood bound stated and tested TestTheFloodBoundRefusesRatherThanDropping

ward exec gate PASS on all six steps.

Two mutations, both in runs where the package compiles:

  • nil validator allowing instead of refusing: fails TestContentWithoutAValidatorIsRefused
  • the bound never refusing: fails TestTheFloodBoundRefusesRatherThanDropping

Not in this change, and named so nobody assumes it

The ceiling's ten-minute half, threading, and file upload from 236. Each is a small change on top of this seam and none was buildable before it.

Idempotency. 621 records that RecordEffect and EffectApplied have no production caller, so job.Effects is empty and resume redoes everything. A job that emits ten ordered messages and then resumes emits some of them twice. This seam does not pretend otherwise, and 621 is a precondition for the ceiling rather than unrelated cleanup.

closes #356

The seam [issue 356](https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/356) asks for, and nothing else. Kai said *"make it so"* on [issue 236](https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/236) and the design there is settled; this is the piece the rest sits on. ## What lands `JobContentReporter` is a **sibling** of `JobProgressReporter`, not a change to it. Progress keeps its single editable line, its 20-second rate limit, and the notice alphabet. That was the acceptance row easiest to break by widening the existing interface, so the existing interface is untouched. - `contentFor` returns an emitter that **returns an error rather than dropping**. A dropped status line costs nothing; a dropped paragraph is a hole in an answer. - `EmitJobContent` on the Discord sink **always posts**, never edits. Editing is what makes progress one line, and it is what would overwrite part one with part two. - The bound is `maxJobContentMessages = 10`, which is the ceiling Kai decided on 236. Per job, and it refuses with `ErrJobContentExhausted`. ## Fail closed, deliberately `ValidateContent` is nil-refuses, not nil-allows. An unwired validator that silently becomes an unchecked path to a member is the exact shape [issue 621](https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/621) records, and this is a new path to a member. `validateJobContent` runs `ParseReply`, `ValidateNoToolCallMarkup`, the identifier guard, `ValidateIdentityClaim` and `ValidateResponseStyle`, in `runReplyChecks` order. **Grounding and self-attribution are absent by construction, not oversight**: both read a turn's supplied context and executed tools, and a job has neither. That is stated in the code rather than left to be discovered. ## Acceptance | Row | Where | | --- | --- | | ordered sequence, all arrive | `TestEveryContentMessageArrivesInOrder` | | refused as a reply, refused here | `TestContentIsRefusedForTheSameReasonAReplyIs` | | progress unchanged | `TestContentAndProgressDoNotShareABound` | | a job emitting nothing is unchanged | `TestAJobThatEmitsNothingTouchesNothing` | | flood bound stated and tested | `TestTheFloodBoundRefusesRatherThanDropping` | `ward exec gate` PASS on all six steps. Two mutations, both in runs where the package compiles: - nil validator allowing instead of refusing: fails `TestContentWithoutAValidatorIsRefused` - the bound never refusing: fails `TestTheFloodBoundRefusesRatherThanDropping` ## Not in this change, and named so nobody assumes it **The ceiling's ten-minute half, threading, and file upload** from 236. Each is a small change on top of this seam and none was buildable before it. **Idempotency.** 621 records that `RecordEffect` and `EffectApplied` have no production caller, so `job.Effects` is empty and resume redoes everything. A job that emits ten ordered messages and then resumes emits some of them twice. This seam does not pretend otherwise, and 621 is a precondition for the ceiling rather than unrelated cleanup. closes #356
feat(jobs): a job may emit content, as a sibling of progress
All checks were successful
ci / image-build (pull_request) Successful in 22s
ci / test (pull_request) Successful in 37s
ci / publish-echo-image (pull_request) Has been skipped
ci / publish-observed (pull_request) Has been skipped
e02dc774c9
JobContentReporter is a new interface rather than a widening of
JobProgressReporter, so progress keeps its single editable line, its rate
limit, and the notice alphabet. Content always posts and never edits, because
editing is what would overwrite part one with part two.

The emitter returns an error rather than dropping: a dropped status line costs
nothing and a dropped paragraph is a hole in an answer. The bound is ten per
job, the ceiling decided on 236, and it refuses rather than silently stopping.

ValidateContent is nil-refuses. An unwired validator that becomes an unchecked
path to a member is the shape 621 records, and this is a new path to a member.
Grounding and self-attribution are absent by construction: both read a turn's
supplied context and executed tools, which a job does not have.

closes #356

Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-gaming/sirens-echo!739
No description provided.