Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,15 @@ which stay local and are never rendered into a comment.
your open-item limit.
5. **Write the ledger before ending the turn. Always** — including turns where
nothing moved, because "checked at 20:44, still waiting on CI round 3" is the
difference between a working crew and a crew that looks asleep.
difference between a working crew and a crew that looks asleep. When you took
nothing at all — the queue held nothing in your scope, or every candidate was
already claimed or already skipped — record that too, as a crew-level step:
call the record tool with **no** `number` and `event_kind: "sweep"`. It is the
one write that belongs to no issue, so it is the only way an empty cycle
becomes visible instead of looking like a crew that stopped running.
Consecutive sweeps coalesce, so calling it on every idle turn does not grow
the log — the answer tells you whether your report was appended or folded into
the open stretch.

Also write the ledger at any natural checkpoint inside a turn — before a long
build, before a push, before anything that might hit the 2-hour ceiling.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -276,8 +276,46 @@ a lock and was caught in review — take its `_LedgerLock` too).
"crew_id":"c_7f3a","number":2251,"kind":"ci","text":"CI round 3 — 41/47 green, 6 inherited from main"}
```

`number` is OMITTED on a **crew-level** line — a step that belongs to no issue,
which today is only `kind: "sweep"` (the crew checked the queue and took
nothing). The key is absent rather than `null` or `0`: a reader tells "this line
is about no issue" from its absence, and a `0` would be indistinguishable from a
real issue number in every filter and join that keys on it. In the id, an absent
number renders as the empty string, so the formula for a numbered line is
unchanged and the two families cannot collide.

The pairing is enforced in both directions, in the write route and in the store:
a numberless line must carry a crew-level kind, and a crew-level kind must not
carry a number. Neither shape can drift into the other's meaning.

**Consecutive sweeps coalesce.** "Checked, took nothing" is a recurring
latest-value fact, not an event, and a crew is nudged on a timer — so one line per
idle cycle would be an unbounded run. Reads here are capped and discard the OLDEST
line first, so a crew idling a couple of hundred cycles would push its real work
history out of its own work log. The FIRST sweep after real work is written; a
sweep whose crew already has one as its newest line is answered with that existing
line, and the response says `coalesced`. The surviving timestamp therefore marks
when the idle stretch BEGAN, which is the more useful reading. This is the same
record-the-transition discipline `phase` already follows — stamped only when an
item is created or actually moves. The tail check and the append happen under ONE
hold of the events lock, or two crew turns waking together would both see no
trailing sweep and both append.

**What the surviving timestamp does not mean.** It records when the crew last
REPORTED an empty queue, and nothing about the present. Consecutive reports fold,
and a crew that stops — an operator pause, a crash, a lost nudge timer — stops
reporting without saying so, because nothing on the crew record evidences liveness.
So the ledger cannot tell a crew that is still checking from one that quietly died
mid-stretch, and the crew page renders the line as the past instant it is rather
than as a claim about now. An earlier revision qualified it as "checking since …",
which read as present-tense activity and so masked exactly the failure an operator
opens that page to notice. Closing that properly needs a real last-seen datum, and
that is a separate change.

One log feeds **two** surfaces: the work-log table on the crew page, and the
`<details>` progress list inside the public claim comment.
`<details>` progress list inside the public claim comment. A crew-level line
reaches only the first — it has no issue, so there is no claim comment to render
it into.

That dual use imposes the stricter constraint on both: **`text` becomes public**,
so it must never contain an absolute path, a host name, or anything from the
Expand Down Expand Up @@ -396,7 +434,7 @@ five findings fields the same way). Empty fields are dropped, so a partial patch
preserves what an earlier write stored.

```
number required int
number optional int — omit ONLY with `event_kind: sweep`
phase optional enum
outcome optional enum
next optional str
Expand All @@ -414,9 +452,18 @@ skip_scope optional enum — why a pass was recorded, including
`needs-decision` / `needs-investigation` when the next
step belongs to a human
event optional str — the public progress line
event_kind optional enum (claim|investigate|reply|implement|ci|review|conflict|merge|handback|skip|yield)
event_kind optional enum (claim|investigate|reply|implement|ci|review|conflict|merge|handback|skip|yield|sweep)
```

Nothing is unconditionally required. `number` was, which left a crew that swept
an empty queue no way to record the cycle without inventing an issue number. The
coupling that replaced the requirement is a relation between two fields, which a
per-field schema cannot express, so it lives on the write route and in the store:
a missing `number` is valid ONLY with `sweep`, `sweep` is invalid WITH one, and a
numberless call takes none of the work-item fields (they patch an item this call
does not create, so they are refused rather than dropped). A present-but-invalid
number stays a 400 and is never reinterpreted as "no issue".

Validation lives in `validation.py` alongside the existing schemas. The handler
sends `owner`/`repo` explicitly so a same-numbered issue in another repo cannot
overwrite this record, and refuses a second item entering an editing phase.
84 changes: 75 additions & 9 deletions src/kiro_crew/apps/builtins/issue_radar/backend/crew_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -828,14 +828,22 @@ async def _revoke_execution(


async def _handle_crew_work(request: web.Request) -> web.Response:
"""PUT /crew/work {"owner","repo","crew_id","number", ...patch, "event",
"event_kind"} -> {"item","event"}.
"""PUT /crew/work {"owner","repo","crew_id","number"?, ...patch, "event",
"event_kind"} -> {"item","event","skip"}.

THE route a crew writes its progress through, and the one the MCP write tool
targets. It upserts the work item AND appends one ledger line in a single
call, which is the whole point: a phase cannot change without a logged reason,
because there is no route that changes one without the other.

``number`` IS OPTIONAL, and omitting it is how a crew records a step that
belongs to no issue — today only a queue sweep that took nothing. That case
exists because the alternative was worse: a crew with an empty queue had no
way to report the cycle except by attributing it to an issue it never touched,
so the record either lied or was not written at all. A numberless write takes
the crew-level path below: one ledger line, no work item, no skip entry, and
no work-item fields accepted.

ORDER MATTERS and is: validate the log line -> write the item -> index a skip
-> append the line. Validating the kind and text FIRST means the store's own
refusals, including the second-editing-item 409, all happen before anything is
Expand Down Expand Up @@ -887,13 +895,12 @@ async def _handle_crew_work(request: web.Request) -> web.Response:
return early
crew_id = routes._str_field(body, "crew_id")

# Reusing the PR field parser rather than copying its bound: the bound is the
# point (the number becomes a FILENAME), and a second copy is how one of them
# ships without it. Its out-of-range text says "pull-request number", which is
# cosmetically wrong here and only reachable past 1e9.
number, number_error = routes._pr_number_field(body)
if number_error is not None:
return number_error
# `number` is OPTIONAL, and its ABSENCE is what selects a crew-level line: a
# step that belongs to no issue, which today is only "swept the queue and took
# nothing". Presence, not truthiness -- a present-but-invalid number is still a
# 400 below rather than being silently reinterpreted as "no issue", because a
# crew that meant an issue must not have a typo read as a queue sweep.
crew_level = "number" not in body

event_text, too_long = routes._pr_body_field(body, "event")
if too_long is not None:
Expand All @@ -911,11 +918,70 @@ async def _handle_crew_work(request: web.Request) -> web.Response:
"code": "invalid_event_kind"},
status=400,
)
# The vocabulary and the shape are paired in BOTH directions, so neither can
# drift into the other's meaning. A missing number with an item kind is the
# fabricated-number bug this route is being opened up to avoid, and accepting
# it would only move the fabrication into the store; a crew-level kind WITH a
# number would file a queue sweep under an issue it never touched, which is the
# same lie in the other direction.
_sweep = crew_store.CREW_LEVEL_EVENT_KIND
if crew_level and event_kind != _sweep:
return web.json_response(
{"error": (f"'number' is required for event_kind {event_kind!r} — only "
f"{_sweep} records a step with no issue"),
"code": "number_required"},
status=400,
)
if not crew_level and event_kind == _sweep:
return web.json_response(
{"error": (f"event_kind {event_kind!r} is crew-level and takes no "
"'number' — it records a step that belongs to no issue"),
"code": "unexpected_number"},
status=400,
)

_crew, missing = await _require_crew(key, crew_id, must_be_live=True)
if missing is not None:
return missing

if crew_level:
# Refused rather than dropped. Every field named here patches a WORK ITEM,
# and this call creates none, so honouring the write while discarding them
# would report success for a phase move or a CI reading that was never
# stored anywhere. `skip_scope` is named alongside them because a pass is a
# decision about one issue and is indexed by that issue's number.
stray = sorted(
field for field in (*_WORK_PATCH_FIELDS, "skip_scope") if field in body
)
if stray:
return web.json_response(
{"error": (f"{', '.join(repr(f) for f in stray)} belong to a work item, "
"so they cannot be sent without a 'number'"),
"code": "item_fields_without_number"},
status=400,
)
checkpoint = await routes._st(
key,
crew_store.record_crew_checkpoint,
key.owner,
key.repo,
crew_id,
event_text,
)
return web.json_response(checkpoint)

# Parsed here rather than above so the log line is validated FIRST, which is
# the order this route's docstring prescribes, and so the numbered path holds a
# plain ``int`` with no sentinel standing in for the crew-level case.
#
# Reusing the PR field parser rather than copying its bound: the bound is the
# point (the number becomes a FILENAME), and a second copy is how one of them
# ships without it. Its out-of-range text says "pull-request number", which is
# cosmetically wrong here and only reachable past 1e9.
number, number_error = routes._pr_number_field(body)
if number_error is not None:
return number_error

patch = {field: body[field] for field in _WORK_PATCH_FIELDS if field in body}
# The route derives the pass's PROSE (only it has the request body); the store
# owns the coupling — a non-`None` reason is what makes the transaction index
Expand Down
Loading
Loading