alissa-tools-github-orcloop 0.3.7__tar.gz → 0.3.8__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {alissa_tools_github_orcloop-0.3.7/src/main/alissa_tools_github_orcloop.egg-info → alissa_tools_github_orcloop-0.3.8}/PKG-INFO +1 -1
- {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/src/main/alissa/tools/github/orcloop/alissa_client.py +123 -0
- {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/src/main/alissa/tools/github/orcloop/loop.py +677 -23
- {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/src/main/alissa/tools/github/orcloop/state.py +10 -6
- alissa_tools_github_orcloop-0.3.8/src/main/alissa/tools/github/orcloop/version +1 -0
- {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8/src/main/alissa_tools_github_orcloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_orcloop-0.3.7/src/main/alissa/tools/github/orcloop/version +0 -1
- {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/MANIFEST.in +0 -0
- {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/README.md +0 -0
- {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/requirements.txt +0 -0
- {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/setup.cfg +0 -0
- {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/setup.py +0 -0
- {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/src/main/alissa/tools/github/orcloop/__init__.py +0 -0
- {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/src/main/alissa/tools/github/orcloop/__main__.py +0 -0
- {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/src/main/alissa/tools/github/orcloop/config.py +0 -0
- {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/src/main/alissa/tools/github/orcloop/ghclient.py +0 -0
- {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/src/main/alissa/tools/github/orcloop/markers.py +0 -0
- {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/src/main/alissa/tools/github/orcloop/proc.py +0 -0
- {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/src/main/alissa/tools/github/orcloop/version.py +0 -0
- {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/src/main/alissa_tools_github_orcloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/src/main/alissa_tools_github_orcloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/src/main/alissa_tools_github_orcloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/src/main/alissa_tools_github_orcloop.egg-info/top_level.txt +0 -0
|
@@ -127,6 +127,37 @@ would then hold a task whose only dependency is fully validated -- forever,
|
|
|
127
127
|
eventually paging ``release-blocked``. Deduplicating matches what the resolved
|
|
128
128
|
view actually does.
|
|
129
129
|
|
|
130
|
+
TASK CONTEXT ATTACHMENTS (O14)
|
|
131
|
+
==============================
|
|
132
|
+
An operator's decisions about a task do not all fit in the description that was
|
|
133
|
+
written before the work started. Alissa's answer is the task's **context
|
|
134
|
+
attachments** -- ``GET /v1/tasks/<ref>/context`` -> ``{"context": [...]}`` --
|
|
135
|
+
and O14 makes them travel: orcloop renders them into the issue it files, so a
|
|
136
|
+
decision recorded after authoring reaches the implementing worker (repo issue
|
|
137
|
+
#34). Verified live against ``api.alissa.app``:
|
|
138
|
+
|
|
139
|
+
* Each row carries ``_id``, ``title``, ``createdAt`` (ms), ``actorId``/
|
|
140
|
+
``authorName``, and then AT MOST ONE payload of ``markdownContent`` /
|
|
141
|
+
``link`` / a vault reference, plus an optional one-line ``note``. The
|
|
142
|
+
OpenAPI schema pins only ``_id`` and ``title`` (everything else is
|
|
143
|
+
``additionalProperties``), so `get_task_context` treats every payload field
|
|
144
|
+
as optional and an item with none of them still renders as a titled,
|
|
145
|
+
empty subsection rather than being dropped -- a decision the operator
|
|
146
|
+
attached and orcloop silently discarded is the one outcome #34 exists to
|
|
147
|
+
prevent.
|
|
148
|
+
* The task DETAIL payload carries ``contextCount`` (an int), which is what
|
|
149
|
+
lets the release path skip this read entirely for the common zero-context
|
|
150
|
+
task -- see `Task.context_count`.
|
|
151
|
+
|
|
152
|
+
Note for a future reader: the detail payload ALSO embeds the attachments
|
|
153
|
+
themselves under an undocumented ``contextAttachments`` key, so the second
|
|
154
|
+
call could in principle be avoided. orcloop deliberately does not read it. The
|
|
155
|
+
dedicated route is the documented, access-gated one (the OpenAPI ``Task``
|
|
156
|
+
schema pins neither the key nor its item shape), and the release path's
|
|
157
|
+
conservatism depends on being able to tell "there are N attachments" from "I
|
|
158
|
+
read them": a silently-absent embedded array would read as "no context" on
|
|
159
|
+
exactly the payload that declares some.
|
|
160
|
+
|
|
130
161
|
WHO AM I (O8)
|
|
131
162
|
=============
|
|
132
163
|
``GET /v1/ping`` -- ``{"pong", "timestamp", "userId", "actorId",
|
|
@@ -266,6 +297,29 @@ class Dependency:
|
|
|
266
297
|
status: str
|
|
267
298
|
|
|
268
299
|
|
|
300
|
+
@dataclass(frozen=True)
|
|
301
|
+
class ContextItem:
|
|
302
|
+
"""One knowledge/reference attachment on a task (O14).
|
|
303
|
+
|
|
304
|
+
The operator's decision channel: whatever was decided AFTER the task was
|
|
305
|
+
authored lands here rather than being rewritten into the description. Every
|
|
306
|
+
field but `id` is optional in the payload, so every one but `id` defaults
|
|
307
|
+
to empty -- an attachment is identified by its title and rendered from
|
|
308
|
+
whichever payload it carries.
|
|
309
|
+
|
|
310
|
+
`markdown` is the write-up, `link` a bare URL, `note` the one-line "why
|
|
311
|
+
this is relevant"; an attachment may carry any combination, including none
|
|
312
|
+
(a vault reference the REST payload does not inline). `created_at` is ms
|
|
313
|
+
since epoch, 0 when the payload omits it -- the renderer orders on it."""
|
|
314
|
+
|
|
315
|
+
id: str
|
|
316
|
+
title: str = ""
|
|
317
|
+
markdown: str = ""
|
|
318
|
+
link: str = ""
|
|
319
|
+
note: str = ""
|
|
320
|
+
created_at: int = 0
|
|
321
|
+
|
|
322
|
+
|
|
269
323
|
@dataclass(frozen=True)
|
|
270
324
|
class Task:
|
|
271
325
|
"""A task as the DETAIL endpoint reports it -- full description and DoD,
|
|
@@ -293,6 +347,13 @@ class Task:
|
|
|
293
347
|
# len(dependencies) when an upstream is invisible to this actor -- the gate
|
|
294
348
|
# treats that mismatch as "not provably clear".
|
|
295
349
|
depends_on_count: int = 0
|
|
350
|
+
# How many context attachments the DETAIL payload declares (`contextCount`,
|
|
351
|
+
# O14). Purely a "is the second read worth making" hint for the release
|
|
352
|
+
# path: 0/absent skips `get_task_context` entirely, which is the common
|
|
353
|
+
# case. It is NOT a length contract -- attachments can be added or deleted
|
|
354
|
+
# between the two reads -- so nothing keys on it agreeing with what the
|
|
355
|
+
# context endpoint returns. Only "declares some" vs "declares none" matters.
|
|
356
|
+
context_count: int = 0
|
|
296
357
|
|
|
297
358
|
|
|
298
359
|
def _query(params: dict) -> str:
|
|
@@ -625,6 +686,17 @@ class AlissaClient:
|
|
|
625
686
|
created_raw = task.get("createdAt")
|
|
626
687
|
created_at = int(created_raw) if isinstance(created_raw, (int, float)) else 0
|
|
627
688
|
|
|
689
|
+
# A bool IS an int in Python; `contextCount: true` would read as 1 and
|
|
690
|
+
# buy a context read on a payload that declared nothing countable, so
|
|
691
|
+
# it is excluded explicitly. Negatives clamp to 0 -- "fewer than none"
|
|
692
|
+
# is not a reason to make the read.
|
|
693
|
+
count_raw = task.get("contextCount")
|
|
694
|
+
context_count = (
|
|
695
|
+
max(0, int(count_raw))
|
|
696
|
+
if isinstance(count_raw, (int, float)) and not isinstance(count_raw, bool)
|
|
697
|
+
else 0
|
|
698
|
+
)
|
|
699
|
+
|
|
628
700
|
number = task.get("taskNumber")
|
|
629
701
|
return Task(
|
|
630
702
|
ref=f"TASK-{number}" if isinstance(number, int) else str(ref),
|
|
@@ -638,7 +710,58 @@ class AlissaClient:
|
|
|
638
710
|
created_at=created_at,
|
|
639
711
|
dependencies=tuple(dependencies),
|
|
640
712
|
depends_on_count=depends_on_count,
|
|
713
|
+
context_count=context_count,
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
def get_task_context(self, ref: str) -> list[ContextItem]:
|
|
717
|
+
"""The task's context attachments, in the order the endpoint returns
|
|
718
|
+
them (`GET /v1/tasks/<ref>/context`).
|
|
719
|
+
|
|
720
|
+
Ordering is the CALLER's to impose (the renderer sorts on
|
|
721
|
+
`created_at`); this stays a faithful read of the payload. Rows without
|
|
722
|
+
an `_id` are dropped -- an attachment that cannot be addressed cannot be
|
|
723
|
+
cited or edited -- but a row with an id and nothing else is KEPT: the
|
|
724
|
+
payload pins only `_id` and `title`, so treating a shape this adapter
|
|
725
|
+
does not recognise as "no context" would drop the very decisions the
|
|
726
|
+
feature exists to carry.
|
|
727
|
+
|
|
728
|
+
A 2xx whose `context` key is not an array is a contract violation, not
|
|
729
|
+
an empty list, and surfaces as the base `AlissaError` -- the same way
|
|
730
|
+
`get_task` refuses to fabricate a Task. That distinction is load-
|
|
731
|
+
bearing on the release path: it holds the release instead of filing an
|
|
732
|
+
issue that silently lost the operator's decisions.
|
|
733
|
+
"""
|
|
734
|
+
payload = self._request(
|
|
735
|
+
"GET", f"/v1/tasks/{urllib.parse.quote(str(ref), safe='')}/context"
|
|
641
736
|
)
|
|
737
|
+
rows = payload.get("context") if isinstance(payload, dict) else None
|
|
738
|
+
if not isinstance(rows, list):
|
|
739
|
+
raise AlissaError(200, f"no context array in response for {ref!r}")
|
|
740
|
+
|
|
741
|
+
out: list[ContextItem] = []
|
|
742
|
+
for row in rows:
|
|
743
|
+
if not isinstance(row, dict):
|
|
744
|
+
continue
|
|
745
|
+
item_id = row.get("_id")
|
|
746
|
+
if not isinstance(item_id, str) or not item_id:
|
|
747
|
+
continue
|
|
748
|
+
created_raw = row.get("createdAt")
|
|
749
|
+
out.append(
|
|
750
|
+
ContextItem(
|
|
751
|
+
id=item_id,
|
|
752
|
+
title=str(row.get("title") or ""),
|
|
753
|
+
markdown=str(row.get("markdownContent") or ""),
|
|
754
|
+
link=str(row.get("link") or ""),
|
|
755
|
+
note=str(row.get("note") or ""),
|
|
756
|
+
created_at=(
|
|
757
|
+
int(created_raw)
|
|
758
|
+
if isinstance(created_raw, (int, float))
|
|
759
|
+
and not isinstance(created_raw, bool)
|
|
760
|
+
else 0
|
|
761
|
+
),
|
|
762
|
+
)
|
|
763
|
+
)
|
|
764
|
+
return out
|
|
642
765
|
|
|
643
766
|
def add_evidence(self, ref: str, title: str, markdown: str, *, item_type: str = "evidence") -> None:
|
|
644
767
|
"""Attach an evidence/deliverable item (outputs surface, not context).
|
|
@@ -46,10 +46,40 @@ issues the develop daemon can pick up:
|
|
|
46
46
|
* **Ordering** is priority-then-age: the highest-priority ready task goes
|
|
47
47
|
first, oldest breaking ties, so the queue drains deterministically.
|
|
48
48
|
* **Rendering** is mechanical: the issue body is the task description with its
|
|
49
|
-
control trailers stripped, then
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
control trailers stripped, then -- since O14 -- the origin task's CONTEXT
|
|
50
|
+
ATTACHMENTS, then the Definition of Done, an acceptance-criteria checklist,
|
|
51
|
+
and a canonical trailer block (`Autonomous-Dev`, `Autonomous-Scope` if any,
|
|
52
|
+
`Alissa-Task:`). The `Autonomous-*` trailers are re-emitted so lane occupancy
|
|
53
|
+
stays derivable from the open issues themselves.
|
|
54
|
+
* **Context attachments travel** (O14, issue #34). An operator's decisions
|
|
55
|
+
recorded on a task AFTER it was authored used to reach nobody: the issue body
|
|
56
|
+
was rendered from description + DoD + criteria, and a worker cannot read the
|
|
57
|
+
origin task cross-partition. So the release path now reads
|
|
58
|
+
`GET /v1/tasks/<ref>/context` (skipped entirely when the detail's
|
|
59
|
+
`contextCount` is 0) and renders a `## Context` section between the
|
|
60
|
+
description and the DoD. Three properties make it safe to put operator- and
|
|
61
|
+
third-party-authored text into a body the daemon itself parses:
|
|
62
|
+
|
|
63
|
+
- **No line of it can be a trailer.** The canonical block sits at the END of
|
|
64
|
+
the body while every trailer parser is line-anchored and FIRST-match-wins,
|
|
65
|
+
so a context line beginning with a control key would silently win over the
|
|
66
|
+
real one -- retargeting the repo, the scope, or (worst) the origin task the
|
|
67
|
+
close edge advances on merge. Every such line is blockquoted before it is
|
|
68
|
+
rendered; see `escape_control_lines` and `control_keys`.
|
|
69
|
+
- **Loud caps.** Per attachment and per section, both marked in the body with
|
|
70
|
+
`[context truncated: ...]` and counted onto the release log line, with the
|
|
71
|
+
section's ceiling computed from what is LEFT under GitHub's 65,536-char
|
|
72
|
+
issue-body limit.
|
|
73
|
+
- **Hold, don't guess -- then page.** A task that declares attachments but
|
|
74
|
+
whose context read fails is HELD for the pass and retried next poll; the
|
|
75
|
+
rest of the pass proceeds. EVERY read failure holds, not only the transient
|
|
76
|
+
ones, because a permanent one is equally a release whose decisions did not
|
|
77
|
+
travel -- and because that widening admits conditions that never
|
|
78
|
+
self-clear, the hold carries the floor-then-page shape `_dedupe_unreadable`
|
|
79
|
+
uses rather than holding quietly forever. See `_context_unreadable`.
|
|
80
|
+
|
|
81
|
+
Context is a SNAPSHOT taken at release time -- attachments added afterwards
|
|
82
|
+
do not re-render an open issue.
|
|
53
83
|
* **Create -> ledger -> label**, in that order, with idempotent crash
|
|
54
84
|
completion: the issue is created WITHOUT the trigger label, the ledger row is
|
|
55
85
|
written (`labeled=0`), then the label is stamped (`labeled=1`). The label is
|
|
@@ -250,7 +280,8 @@ import time
|
|
|
250
280
|
from collections import Counter
|
|
251
281
|
from dataclasses import dataclass
|
|
252
282
|
from enum import Enum
|
|
253
|
-
from
|
|
283
|
+
from functools import lru_cache
|
|
284
|
+
from typing import Callable, Sequence
|
|
254
285
|
|
|
255
286
|
from .alissa_client import (
|
|
256
287
|
AlissaClient,
|
|
@@ -258,6 +289,7 @@ from .alissa_client import (
|
|
|
258
289
|
AlissaTransient,
|
|
259
290
|
BodyOfWork,
|
|
260
291
|
BowMember,
|
|
292
|
+
ContextItem,
|
|
261
293
|
Task,
|
|
262
294
|
)
|
|
263
295
|
from .config import FEED_BOW, Config
|
|
@@ -292,6 +324,60 @@ DEP_CLEAR_STATES = frozenset({"pending_validation", "validated"})
|
|
|
292
324
|
TASK_TODO_STATE = "committed"
|
|
293
325
|
TASK_IN_FLIGHT_STATE = "in_progress"
|
|
294
326
|
|
|
327
|
+
# --- the rendered context section (O14, issue #34) -------------------------
|
|
328
|
+
#
|
|
329
|
+
# The third trailer key of the canonical block. Unlike `marker_key`/`scope_key`
|
|
330
|
+
# it is NOT configurable -- `ghclient.TASK_TRAILER_RE` hardcodes it, and both
|
|
331
|
+
# daemons parse it -- so it is a constant here and the renderer emits it from
|
|
332
|
+
# this one place rather than a literal.
|
|
333
|
+
TASK_TRAILER_KEY = "Alissa-Task"
|
|
334
|
+
|
|
335
|
+
# GitHub refuses an issue body longer than this. Everything the context section
|
|
336
|
+
# adds is bounded against what is left of it.
|
|
337
|
+
GITHUB_BODY_LIMIT = 65536
|
|
338
|
+
|
|
339
|
+
# The two context caps. Deliberately CONSTANTS, not config keys: issue #34 asks
|
|
340
|
+
# for documented caps, not tunable ones, and a config key is a compatibility
|
|
341
|
+
# commitment. Sized so the common case never notices (a decision write-up is
|
|
342
|
+
# hundreds of chars, not thousands) while a pathological attachment cannot eat
|
|
343
|
+
# the issue: three full-size attachments still leave ~40k for the operator's own
|
|
344
|
+
# description, DoD and criteria. Widening them later is additive.
|
|
345
|
+
CONTEXT_ITEM_MAX_CHARS = 8000
|
|
346
|
+
CONTEXT_SECTION_MAX_CHARS = 24000
|
|
347
|
+
|
|
348
|
+
CONTEXT_HEADING = "## Context"
|
|
349
|
+
# The two headings `render_issue` emits AFTER the context section. Constants
|
|
350
|
+
# rather than literals because `strip_context_section` terminates the excision
|
|
351
|
+
# on them: renaming a heading at the render site and not the excision site
|
|
352
|
+
# would silently re-open the O13 warning this PR closed, and that is a failure
|
|
353
|
+
# nothing else would catch.
|
|
354
|
+
DOD_HEADING = "## Definition of Done"
|
|
355
|
+
CRITERIA_HEADING = "## Acceptance criteria"
|
|
356
|
+
# The per-attachment subsection heading. Also the structural signature
|
|
357
|
+
# `strip_context_section` anchors the START of the excision on.
|
|
358
|
+
CONTEXT_SUBHEADING = "### "
|
|
359
|
+
# Every truncation marker starts with this, at both levels, so one grep over an
|
|
360
|
+
# issue body answers "did anything get cut".
|
|
361
|
+
CONTEXT_TRUNCATED_PREFIX = "[context truncated:"
|
|
362
|
+
CONTEXT_ESCAPE_PREFIX = "> "
|
|
363
|
+
CONTEXT_UNTITLED = "(untitled attachment)"
|
|
364
|
+
CONTEXT_EMPTY = "_(no inline content — see the attachment on the task)_"
|
|
365
|
+
|
|
366
|
+
# Marks the one attachment the section may cut mid-block (the first, and only
|
|
367
|
+
# when nothing fits whole). Carries its own separator so the room arithmetic can
|
|
368
|
+
# subtract exactly what appending it costs.
|
|
369
|
+
CONTEXT_PARTIAL_MARKER = (
|
|
370
|
+
f"\n\n{CONTEXT_TRUNCATED_PREFIX} attachment cut to fit the remaining "
|
|
371
|
+
f"section budget]"
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# The floor under a partial first attachment. When even the first block will not
|
|
375
|
+
# fit whole, it is cut to the room that IS left rather than rendering an empty
|
|
376
|
+
# section -- but only if this much of it survives; below that the "attachment"
|
|
377
|
+
# would be a title and a stub, which tells the worker less than the explicit
|
|
378
|
+
# "0 of N rendered" notice does.
|
|
379
|
+
CONTEXT_MIN_PARTIAL_CHARS = 200
|
|
380
|
+
|
|
295
381
|
# The close edge only mutates a task the loop itself left in one of these two
|
|
296
382
|
# states. `in_progress` is the NORMAL one since issue #30 -- the release edge
|
|
297
383
|
# sets it the moment the issue exists, so that is how a task in flight reads.
|
|
@@ -340,6 +426,18 @@ ESCALATION_DEDUPE_UNREADABLE = "dedupe-unreadable"
|
|
|
340
426
|
# reach of every prefix read of a page kind.
|
|
341
427
|
DEDUPE_UNREADABLE_SINCE = "marker:dedupe-unreadable"
|
|
342
428
|
|
|
429
|
+
# The O14 context read could not answer. Same floor-then-page shape, and for the
|
|
430
|
+
# same reason `_dedupe_unreadable` gives: `_release` holds on EVERY `AlissaError`
|
|
431
|
+
# rather than only the transient ones (an issue filed without the operator's
|
|
432
|
+
# decisions cannot be taken back), and both permanent shapes are reachable -- a
|
|
433
|
+
# 403 on the context route for this actor, and the `no context array` contract
|
|
434
|
+
# violation. Neither self-clears, so without a page the task holds forever on an
|
|
435
|
+
# INFO line. The marker measures THE FAILURE, not the task age, for the same
|
|
436
|
+
# reason again: this is a single I/O outcome, so an old task would page on
|
|
437
|
+
# contact with a self-clearing blip and burn the kind.
|
|
438
|
+
ESCALATION_CONTEXT_UNREADABLE = "context-unreadable"
|
|
439
|
+
CONTEXT_UNREADABLE_SINCE = "marker:context-unreadable"
|
|
440
|
+
|
|
343
441
|
# How many CLOSED trailer matches the pre-create dedupe will walk looking for a
|
|
344
442
|
# merged closing PR. Each check is a bounded timeline walk (see
|
|
345
443
|
# `GitHub.closing_merged_pr`), and the matches are walked newest-first, so this
|
|
@@ -478,6 +576,17 @@ DEDUPE_UNREADABLE_COMMENT = (
|
|
|
478
576
|
"work by hand) to clear it."
|
|
479
577
|
)
|
|
480
578
|
|
|
579
|
+
CONTEXT_UNREADABLE_COMMENT = (
|
|
580
|
+
"**Orchestrator: context unreadable** — this task declares "
|
|
581
|
+
"{count} context attachment(s), and the orchestrator has been unable to "
|
|
582
|
+
"read them continuously for over {floor_days} day(s): {reason}. The "
|
|
583
|
+
"release is held rather than file a develop issue WITHOUT the decisions "
|
|
584
|
+
"recorded on this task, and it will keep retrying. Usual causes: the "
|
|
585
|
+
"orchestrator's token lost its role on this task, or the context endpoint "
|
|
586
|
+
"is returning an unexpected shape. Restore the read (or delete the "
|
|
587
|
+
"attachments if they are no longer needed) to clear it."
|
|
588
|
+
)
|
|
589
|
+
|
|
481
590
|
ALREADY_MERGED_ISSUE_COMMENT = (
|
|
482
591
|
"**Orchestrator: already merged** — this closed issue carries the "
|
|
483
592
|
"`Alissa-Task:` trailer of a task that is still queued for release. No "
|
|
@@ -752,11 +861,18 @@ class Orchestrator:
|
|
|
752
861
|
# malformed trailer -- and the two are indistinguishable in
|
|
753
862
|
# every later line, because from here on the lane only carries
|
|
754
863
|
# `[]`. Said once per issue per pass, here, where the difference
|
|
755
|
-
# is still visible. (A released body keeps the operator's
|
|
864
|
+
# is still visible. (A released body keeps the operator's PROSE
|
|
756
865
|
# verbatim, mid-line fragment and all: `_strip_control_lines`
|
|
757
866
|
# drops trailers by the same anchored rule, so what was missed
|
|
758
|
-
# in the task is missed in the issue too.
|
|
759
|
-
|
|
867
|
+
# in the task is missed in the issue too. It does NOT keep only
|
|
868
|
+
# the operator's prose -- since O14 the body also carries the
|
|
869
|
+
# rendered context section, whose escaped lines are mid-line
|
|
870
|
+
# keys orcloop wrote itself. Hence the excision: the warning
|
|
871
|
+
# must only ever name text a human wrote.)
|
|
872
|
+
note = self._malformed_trailer(
|
|
873
|
+
strip_context_section(issue.body, self.config),
|
|
874
|
+
self.config.scope_key,
|
|
875
|
+
)
|
|
760
876
|
if note:
|
|
761
877
|
log.warning(
|
|
762
878
|
"%s declares no `%s:` trailer, so it occupies the WHOLE "
|
|
@@ -1809,8 +1925,12 @@ class Orchestrator:
|
|
|
1809
1925
|
# to `_dedupe_result`'s crash-recovery branch rather than back here.
|
|
1810
1926
|
# What recurs in the malformed-trailer case is the lane hold above,
|
|
1811
1927
|
# which does carry it.
|
|
1928
|
+
# Context section excised for the same reason as in `_scan_released`:
|
|
1929
|
+
# the issue being adopted may well be one orcloop rendered (a crash
|
|
1930
|
+
# between create and the ledger write leaves exactly that), so its
|
|
1931
|
+
# escaped context lines are mid-line keys the daemon wrote, not a human.
|
|
1812
1932
|
issue_note = "" if issue_scope else self._malformed_trailer(
|
|
1813
|
-
issue.body, self.config.scope_key
|
|
1933
|
+
strip_context_section(issue.body, self.config), self.config.scope_key
|
|
1814
1934
|
)
|
|
1815
1935
|
labeled = self.config.label in issue.labels
|
|
1816
1936
|
if not labeled:
|
|
@@ -2083,6 +2203,136 @@ class Orchestrator:
|
|
|
2083
2203
|
if self.state.escalated(task_number, kind):
|
|
2084
2204
|
self.state.clear_escalation(task_number, kind)
|
|
2085
2205
|
|
|
2206
|
+
def _context_unreadable(self, task: Task, exc: AlissaError) -> tuple[str, Decision]:
|
|
2207
|
+
"""The context read could not answer, so the release is HELD rather
|
|
2208
|
+
than degraded -- with the floor-then-page shape `_dedupe_unreadable`
|
|
2209
|
+
uses, and for the identical reason.
|
|
2210
|
+
|
|
2211
|
+
Why hold at all: the attachments are where the operator's
|
|
2212
|
+
post-authoring decisions live (issue #34), so an issue filed without
|
|
2213
|
+
them briefs the worker on a superseded plan. A held pass is recoverable
|
|
2214
|
+
next poll; that is not.
|
|
2215
|
+
|
|
2216
|
+
Why hold on EVERY `AlissaError` and not just `AlissaTransient` as #34
|
|
2217
|
+
specifies: a permanent failure is still a release whose decisions did
|
|
2218
|
+
not travel. Both permanent shapes are reachable -- a 403 on the context
|
|
2219
|
+
route for this actor, and the `no context array` contract violation --
|
|
2220
|
+
and narrowing the catch would put them on the release path with no
|
|
2221
|
+
route at all, which is the worse of the two.
|
|
2222
|
+
|
|
2223
|
+
Why that widening OBLIGES this method: a permanent failure does not
|
|
2224
|
+
self-clear, so without a page the task holds on an INFO line forever
|
|
2225
|
+
and no operator is ever told. Every other unbounded hold in this loop
|
|
2226
|
+
(`release-blocked`, `dedupe-unreadable`) pages past the floor.
|
|
2227
|
+
|
|
2228
|
+
The floor measures THE FAILURE, not the task age -- `_dedupe_unreadable`
|
|
2229
|
+
argues this at length and the argument transfers exactly: this is a
|
|
2230
|
+
single I/O outcome, so an old task would page on contact with a
|
|
2231
|
+
self-clearing blip and burn the kind for the genuine outage later. So
|
|
2232
|
+
the first failure records `marker:context-unreadable` from the loop's
|
|
2233
|
+
own clock and holds quietly; only once THAT is older than
|
|
2234
|
+
`release_blocked_floor_days` does one `context-unreadable` escalation
|
|
2235
|
+
land. `_read_context` clears both rows the moment a read answers.
|
|
2236
|
+
|
|
2237
|
+
The floor knob is shared with the other two on purpose: all three ask
|
|
2238
|
+
"how long may a release be quietly held before an operator is told",
|
|
2239
|
+
and a fourth knob for the same question is a config surface (and a
|
|
2240
|
+
container floor bump) that buys nothing.
|
|
2241
|
+
"""
|
|
2242
|
+
floor_seconds = self.config.release_blocked_floor_days * 86400
|
|
2243
|
+
held = (
|
|
2244
|
+
f"context unreadable ({task.context_count} attachment(s) declared): "
|
|
2245
|
+
f"{exc}; held rather than file an issue without the operator's "
|
|
2246
|
+
f"decisions"
|
|
2247
|
+
)
|
|
2248
|
+
rows = self.state.escalations_matching(task.task_number, CONTEXT_UNREADABLE_SINCE)
|
|
2249
|
+
now = int(self._now())
|
|
2250
|
+
if not rows:
|
|
2251
|
+
if not self.config.dry_run:
|
|
2252
|
+
self.state.record_escalation(
|
|
2253
|
+
task.task_number, CONTEXT_UNREADABLE_SINCE, at=now,
|
|
2254
|
+
)
|
|
2255
|
+
return self._logged(task.ref, Decision(
|
|
2256
|
+
Action.HELD,
|
|
2257
|
+
held + "; first failure, retries next poll",
|
|
2258
|
+
task.task_number,
|
|
2259
|
+
))
|
|
2260
|
+
|
|
2261
|
+
failing_for = now - int(rows[0]["escalated_at"])
|
|
2262
|
+
if failing_for < floor_seconds:
|
|
2263
|
+
return self._logged(task.ref, Decision(
|
|
2264
|
+
Action.HELD,
|
|
2265
|
+
held + f"; failing for {failing_for // 3600}h, within the "
|
|
2266
|
+
f"{self.config.release_blocked_floor_days}d floor",
|
|
2267
|
+
task.task_number,
|
|
2268
|
+
))
|
|
2269
|
+
|
|
2270
|
+
self._escalate(
|
|
2271
|
+
task.task_number,
|
|
2272
|
+
ESCALATION_CONTEXT_UNREADABLE,
|
|
2273
|
+
CONTEXT_UNREADABLE_COMMENT.format(
|
|
2274
|
+
count=task.context_count,
|
|
2275
|
+
reason=str(exc),
|
|
2276
|
+
floor_days=self.config.release_blocked_floor_days,
|
|
2277
|
+
),
|
|
2278
|
+
)
|
|
2279
|
+
return self._logged(task.ref, Decision(
|
|
2280
|
+
Action.ESCALATED,
|
|
2281
|
+
held + f", and failing for {failing_for // 86400}d — past the "
|
|
2282
|
+
f"{self.config.release_blocked_floor_days}d floor",
|
|
2283
|
+
task.task_number,
|
|
2284
|
+
))
|
|
2285
|
+
|
|
2286
|
+
def _context_answered(self, task_number: int) -> None:
|
|
2287
|
+
"""The context read answered, so any recorded failure is OVER: drop the
|
|
2288
|
+
first-seen marker and the page kind together.
|
|
2289
|
+
|
|
2290
|
+
Clearing the page kind is the point, exactly as in `_dedupe_answered`:
|
|
2291
|
+
`_escalate` dedupes per (task, kind), so without a clear a SECOND,
|
|
2292
|
+
unrelated outage would never page. Guarded by a read so the common
|
|
2293
|
+
(working) path does not write on every release."""
|
|
2294
|
+
if self.config.dry_run:
|
|
2295
|
+
return
|
|
2296
|
+
for kind in (CONTEXT_UNREADABLE_SINCE, ESCALATION_CONTEXT_UNREADABLE):
|
|
2297
|
+
if self.state.escalated(task_number, kind):
|
|
2298
|
+
self.state.clear_escalation(task_number, kind)
|
|
2299
|
+
|
|
2300
|
+
def _read_context(self, task: Task) -> list[ContextItem]:
|
|
2301
|
+
"""The origin task's context attachments, or [] when it declares none.
|
|
2302
|
+
|
|
2303
|
+
`contextCount` is the skip: a task with 0 (or a payload that omits the
|
|
2304
|
+
key) costs no extra call, which is every task that predates O14 and
|
|
2305
|
+
most that follow. The count is only ever compared against zero -- it is
|
|
2306
|
+
a denormalised counter, the two reads are not atomic, and keying
|
|
2307
|
+
anything on it AGREEING with the context payload would turn an operator
|
|
2308
|
+
deleting an attachment mid-poll into a held release.
|
|
2309
|
+
|
|
2310
|
+
Raises whatever `get_task_context` raises; the caller holds the release
|
|
2311
|
+
on it (`_context_unreadable`).
|
|
2312
|
+
|
|
2313
|
+
Every path that RETURNS clears the failure marker, including the
|
|
2314
|
+
zero-count skip: the condition the marker records is "the release path
|
|
2315
|
+
could not get what it needed", and a task whose attachments were all
|
|
2316
|
+
deleted no longer needs anything. Leaving the row behind would make a
|
|
2317
|
+
much later, unrelated failure inherit an old clock and page immediately.
|
|
2318
|
+
"""
|
|
2319
|
+
if task.context_count <= 0:
|
|
2320
|
+
self._context_answered(task.task_number)
|
|
2321
|
+
return []
|
|
2322
|
+
items = self.alissa.get_task_context(task.ref)
|
|
2323
|
+
self._context_answered(task.task_number)
|
|
2324
|
+
if not items:
|
|
2325
|
+
# Not an error and not a hold: the count is denormalised, so this
|
|
2326
|
+
# is what "the last attachment was deleted since the detail read"
|
|
2327
|
+
# looks like. Worth a line, since it is also what a contextCount
|
|
2328
|
+
# that has drifted looks like.
|
|
2329
|
+
log.debug(
|
|
2330
|
+
"%s: declares %d context attachment(s) but the context read "
|
|
2331
|
+
"returned none — releasing without a context section",
|
|
2332
|
+
task.ref, task.context_count,
|
|
2333
|
+
)
|
|
2334
|
+
return items
|
|
2335
|
+
|
|
2086
2336
|
def _release(
|
|
2087
2337
|
self,
|
|
2088
2338
|
task: Task,
|
|
@@ -2104,7 +2354,10 @@ class Orchestrator:
|
|
|
2104
2354
|
|
|
2105
2355
|
Nothing is created until the TRAILER dedupe answers: the pass-level
|
|
2106
2356
|
scan is label-scoped and cannot see an unlabeled issue that already
|
|
2107
|
-
carries this task's trailer (see `_trailer_dedupe`).
|
|
2357
|
+
carries this task's trailer (see `_trailer_dedupe`). The context read
|
|
2358
|
+
(O14) comes AFTER it, in the one branch that renders a body: a
|
|
2359
|
+
candidate that turns out to be a duplicate adopts an existing issue and
|
|
2360
|
+
never renders, so it must not spend the read either.
|
|
2108
2361
|
|
|
2109
2362
|
`note` is the caller's missed-trailer warning (empty unless this is a
|
|
2110
2363
|
SCOPELESS release with trailer-like text in the body). It rides on the
|
|
@@ -2112,13 +2365,22 @@ class Orchestrator:
|
|
|
2112
2365
|
the record of a task taking the whole lane -- possibly on the strength
|
|
2113
2366
|
of a trailer nobody parsed. It is passed in rather than recomputed so
|
|
2114
2367
|
one candidate can never produce two of them; the dedupe path returns
|
|
2115
|
-
above with its own note, computed on the ISSUE body it acts on.
|
|
2368
|
+
above with its own note, computed on the ISSUE body it acts on. The
|
|
2369
|
+
renderer's own context note (what was rendered, cut or escaped) is
|
|
2370
|
+
appended to it, so one release still produces exactly one line."""
|
|
2116
2371
|
owner, _, repo = target.partition("/")
|
|
2117
2372
|
preexisting = self._trailer_dedupe(task, target, occupancy, bow=bow)
|
|
2118
2373
|
if preexisting is not None:
|
|
2119
2374
|
return preexisting
|
|
2120
2375
|
|
|
2121
|
-
|
|
2376
|
+
try:
|
|
2377
|
+
context = self._read_context(task)
|
|
2378
|
+
except AlissaError as exc:
|
|
2379
|
+
return self._context_unreadable(task, exc)
|
|
2380
|
+
|
|
2381
|
+
rendered = render_issue(task, target, scope, self.config, context)
|
|
2382
|
+
body = rendered.body
|
|
2383
|
+
note += rendered.context_note
|
|
2122
2384
|
|
|
2123
2385
|
if self.config.dry_run:
|
|
2124
2386
|
occupancy.setdefault(target, []).append(scope)
|
|
@@ -2424,7 +2686,7 @@ def _strip_control_lines(text: str, config: Config) -> str:
|
|
|
2424
2686
|
`render_issue_body`, so this only removes the operator's raw form."""
|
|
2425
2687
|
marker_re = re.compile(rf"^{re.escape(config.marker_key)}\s*:", re.IGNORECASE)
|
|
2426
2688
|
scope_re = re.compile(rf"^{re.escape(config.scope_key)}\s*:", re.IGNORECASE)
|
|
2427
|
-
task_re = re.compile(
|
|
2689
|
+
task_re = re.compile(rf"^{re.escape(TASK_TRAILER_KEY)}\s*:", re.IGNORECASE)
|
|
2428
2690
|
kept: list[str] = []
|
|
2429
2691
|
for line in text.splitlines():
|
|
2430
2692
|
stripped = line.strip()
|
|
@@ -2436,30 +2698,422 @@ def _strip_control_lines(text: str, config: Config) -> str:
|
|
|
2436
2698
|
return "\n".join(kept).strip()
|
|
2437
2699
|
|
|
2438
2700
|
|
|
2439
|
-
def
|
|
2701
|
+
def control_keys(config: Config) -> tuple[str, ...]:
|
|
2702
|
+
"""Every trailer key a line in the rendered body must not begin with.
|
|
2703
|
+
|
|
2704
|
+
All THREE of the canonical block's keys, not just the two the marker
|
|
2705
|
+
contract owns. `Alissa-Task` is here because it is parsed exactly like the
|
|
2706
|
+
others -- `ghclient.TASK_TRAILER_RE` is line-anchored and first-match-wins
|
|
2707
|
+
-- and a body remapped to a stranger's task number is the worst of the
|
|
2708
|
+
three outcomes: the close edge would drive that task forward on merge.
|
|
2709
|
+
"""
|
|
2710
|
+
return (config.marker_key, config.scope_key, TASK_TRAILER_KEY)
|
|
2711
|
+
|
|
2712
|
+
|
|
2713
|
+
@lru_cache(maxsize=None)
|
|
2714
|
+
def _control_line_re(keys: tuple[str, ...]) -> re.Pattern[str]:
|
|
2715
|
+
"""A line-start match for any of `keys` followed by a colon.
|
|
2716
|
+
|
|
2717
|
+
Deliberately WIDER than the parsers it defends: case-insensitive (the
|
|
2718
|
+
parsers split on this -- `parse_marker` is case-sensitive,
|
|
2719
|
+
`_strip_control_lines` is not) and tolerant of space before the colon
|
|
2720
|
+
(`_strip_control_lines` allows it, `parse_marker` does not). Escaping a
|
|
2721
|
+
line no parser would have read costs one `> ` in an issue body; missing one
|
|
2722
|
+
a parser WOULD have read costs a retargeted release.
|
|
2723
|
+
"""
|
|
2724
|
+
alternation = "|".join(re.escape(key) for key in keys)
|
|
2725
|
+
return re.compile(rf"^(?:{alternation})[ \t]*:", re.IGNORECASE)
|
|
2726
|
+
|
|
2727
|
+
|
|
2728
|
+
def escape_control_lines(text: str, keys: tuple[str, ...]) -> tuple[str, int]:
|
|
2729
|
+
"""`(text, n)` with every line that begins with one of `keys` blockquoted.
|
|
2730
|
+
|
|
2731
|
+
Split on `"\\n"` and nothing else: that is exactly the set of line starts
|
|
2732
|
+
`re.MULTILINE`'s `^` recognises, and `str.splitlines()` is not -- it also
|
|
2733
|
+
breaks on `\\v`, `\\f`, `\\x1c`-`\\x1e` and `\\u2028`/`\\u2029`, so it would
|
|
2734
|
+
report a "line" the parsers cannot see and, worse, JOIN back on `\\n` and
|
|
2735
|
+
thereby CREATE one. The escape must not be able to manufacture the
|
|
2736
|
+
condition it exists to remove.
|
|
2737
|
+
|
|
2738
|
+
Blockquoting (`> `) rather than a backslash escape: `\\A` is not a
|
|
2739
|
+
CommonMark escape (only ASCII punctuation is), so it would render as a
|
|
2740
|
+
literal backslash, while a blockquote renders as quoted text and reads as
|
|
2741
|
+
what it is. The prefix is applied even inside a fenced code block -- a
|
|
2742
|
+
fence means nothing to a line-anchored regex, so a code block is exactly
|
|
2743
|
+
where an injected trailer would hide.
|
|
2744
|
+
"""
|
|
2745
|
+
pattern = _control_line_re(keys)
|
|
2746
|
+
escaped = 0
|
|
2747
|
+
out: list[str] = []
|
|
2748
|
+
for line in text.split("\n"):
|
|
2749
|
+
if pattern.match(line):
|
|
2750
|
+
line = CONTEXT_ESCAPE_PREFIX + line
|
|
2751
|
+
escaped += 1
|
|
2752
|
+
out.append(line)
|
|
2753
|
+
return "\n".join(out), escaped
|
|
2754
|
+
|
|
2755
|
+
|
|
2756
|
+
def count_escaped_lines(text: str, keys: tuple[str, ...]) -> int:
|
|
2757
|
+
"""How many lines of `text` are escaped control lines.
|
|
2758
|
+
|
|
2759
|
+
Counted on RENDERED text rather than accumulated as blocks are built, so
|
|
2760
|
+
the number describes the body on every path -- including the partial cut,
|
|
2761
|
+
which truncates a block after it was escaped and would otherwise report
|
|
2762
|
+
escapes that were cut away with the tail.
|
|
2763
|
+
|
|
2764
|
+
The one over-count: an attachment whose own write-up literally contains
|
|
2765
|
+
`> Autonomous-Dev: ...` is indistinguishable from a line this renderer
|
|
2766
|
+
escaped, and is counted. That line is trailer-like text sitting inert in an
|
|
2767
|
+
issue body either way, so naming it in the log is not misleading.
|
|
2768
|
+
"""
|
|
2769
|
+
pattern = _control_line_re(keys)
|
|
2770
|
+
return sum(
|
|
2771
|
+
1
|
|
2772
|
+
for line in text.split("\n")
|
|
2773
|
+
if line.startswith(CONTEXT_ESCAPE_PREFIX)
|
|
2774
|
+
and pattern.match(line[len(CONTEXT_ESCAPE_PREFIX):])
|
|
2775
|
+
)
|
|
2776
|
+
|
|
2777
|
+
|
|
2778
|
+
def strip_context_section(body: str, config: Config) -> str:
|
|
2779
|
+
"""An ISSUE body with the rendered `## Context` section removed.
|
|
2780
|
+
|
|
2781
|
+
For the O13 missed-trailer warning only, and only on issue bodies. That
|
|
2782
|
+
warning means "a human wrote a trailer where the parser cannot see it", and
|
|
2783
|
+
after O14 an issue body contains text no human wrote: every escaped context
|
|
2784
|
+
line (`> Autonomous-Scope: ...`) is, by construction, a key that is not at a
|
|
2785
|
+
line start -- exactly what `find_mid_line_trailer` reports. Left alone, a
|
|
2786
|
+
scopeless release whose attachments merely MENTIONED a trailer warns every
|
|
2787
|
+
poll, for the life of the issue, pointing the operator at a task body with
|
|
2788
|
+
nothing in it to fix.
|
|
2789
|
+
|
|
2790
|
+
Delimited excision rather than "skip lines starting with the escape
|
|
2791
|
+
prefix": a human who hand-writes `> Autonomous-Scope: src/**` into an issue
|
|
2792
|
+
HAS written a trailer the parser cannot see, and silencing that is exactly
|
|
2793
|
+
the signal O13 exists to give. This removes only what orcloop itself
|
|
2794
|
+
generated, and a body whose section a human edited away has nothing to
|
|
2795
|
+
remove.
|
|
2796
|
+
|
|
2797
|
+
THE START ANCHOR. `## Context` is not orcloop's to claim on the way IN any
|
|
2798
|
+
more than `## ` was on the way out: a task description is operator markdown
|
|
2799
|
+
and "Context" is one of the headings an operator most likely writes (this
|
|
2800
|
+
repo's README has one). So the section is the LAST `## Context` before the
|
|
2801
|
+
first terminator that is also STRUCTURALLY one -- first non-blank line
|
|
2802
|
+
under it is a `### ` subsection or the truncation notice, which is what
|
|
2803
|
+
`render_context_section` emits in all three of its shapes. The `last` is
|
|
2804
|
+
what picks orcloop's over an operator's when a body has both; the
|
|
2805
|
+
structural test is what stops a ZERO-attachment body (no section rendered
|
|
2806
|
+
at all -- every task predating O14) from having the operator's own heading
|
|
2807
|
+
excised instead.
|
|
2808
|
+
|
|
2809
|
+
The rule behind both: **when the anchor is ambiguous, do nothing.** Not
|
|
2810
|
+
excising costs a spurious warning an operator can dismiss; excising wrongly
|
|
2811
|
+
costs a warning they never see, and a lost diagnostic is the failure this
|
|
2812
|
+
function's own callers exist to prevent. Every tie breaks toward keeping
|
|
2813
|
+
the signal. Residual: an operator heading `## Context` followed by their own
|
|
2814
|
+
`### ` subheading on a task with no attachments.
|
|
2815
|
+
|
|
2816
|
+
THE TERMINATORS, and why there are two. A bare `## ` test is wrong: an
|
|
2817
|
+
attachment is operator markdown and operator markdown has headings, so the
|
|
2818
|
+
first heading in a write-up would end the excision and hand every escaped
|
|
2819
|
+
line after it back to the warning (PR #35 review, round 2).
|
|
2820
|
+
|
|
2821
|
+
* **A line that begins with a control key** -- the canonical trailer block.
|
|
2822
|
+
Attachment content CANNOT spell this one, and the guarantee comes from
|
|
2823
|
+
the escape itself: `escape_control_lines` leaves no line inside the
|
|
2824
|
+
section beginning with a control key, so a line-start `Autonomous-Dev:`
|
|
2825
|
+
can only be the block `render_issue` emitted. The property added for
|
|
2826
|
+
injection doubles as an unspoofable delimiter, which is why no sentinel
|
|
2827
|
+
markup is needed in the body.
|
|
2828
|
+
* **`DOD_HEADING` / `CRITERIA_HEADING`** -- needed because the trailer
|
|
2829
|
+
terminator alone would swallow those two sections, and they are
|
|
2830
|
+
human-written text where O13 should still warn. These ARE spellable by an
|
|
2831
|
+
attachment; the residual is that a write-up reproducing one of the two
|
|
2832
|
+
verbatim ends the excision early and the warning returns for that issue.
|
|
2833
|
+
The cost is log noise: this function feeds only `_malformed_trailer`, and
|
|
2834
|
+
both call sites still take `parse_scope` from the RAW body, so no lane,
|
|
2835
|
+
dedupe or trailer parse can be moved by anything here.
|
|
2836
|
+
"""
|
|
2837
|
+
terminator = _control_line_re(control_keys(config))
|
|
2838
|
+
lines = body.split("\n")
|
|
2839
|
+
|
|
2840
|
+
def ends_the_section(index: int) -> bool:
|
|
2841
|
+
line = lines[index]
|
|
2842
|
+
return (
|
|
2843
|
+
line.strip() in (DOD_HEADING, CRITERIA_HEADING) or bool(terminator.match(line))
|
|
2844
|
+
)
|
|
2845
|
+
|
|
2846
|
+
def renders_a_section(index: int) -> bool:
|
|
2847
|
+
"""Whether the `## Context` at `index` is followed by what this
|
|
2848
|
+
renderer puts under one: a `### ` subsection (the normal and
|
|
2849
|
+
partial-cut shapes) or the truncation notice (the nothing-fit shape)."""
|
|
2850
|
+
for line in lines[index + 1:]:
|
|
2851
|
+
if not line.strip():
|
|
2852
|
+
continue
|
|
2853
|
+
return line.startswith(CONTEXT_SUBHEADING) or line.startswith(
|
|
2854
|
+
CONTEXT_TRUNCATED_PREFIX
|
|
2855
|
+
)
|
|
2856
|
+
return False
|
|
2857
|
+
|
|
2858
|
+
start = -1
|
|
2859
|
+
for index in range(len(lines)):
|
|
2860
|
+
if ends_the_section(index):
|
|
2861
|
+
break
|
|
2862
|
+
if lines[index].strip() == CONTEXT_HEADING and renders_a_section(index):
|
|
2863
|
+
start = index # LAST valid candidate wins: see below
|
|
2864
|
+
if start < 0:
|
|
2865
|
+
return body
|
|
2866
|
+
|
|
2867
|
+
end = len(lines)
|
|
2868
|
+
for index in range(start + 1, len(lines)):
|
|
2869
|
+
if ends_the_section(index):
|
|
2870
|
+
end = index
|
|
2871
|
+
break
|
|
2872
|
+
return "\n".join(lines[:start] + lines[end:])
|
|
2873
|
+
|
|
2874
|
+
|
|
2875
|
+
def _context_item_block(item: ContextItem, cap: int) -> tuple[str, bool]:
|
|
2876
|
+
"""One attachment as `(markdown, was_truncated)`, before escaping.
|
|
2877
|
+
|
|
2878
|
+
Title, then the `note` (the operator's one-line "why this is relevant") in
|
|
2879
|
+
italics, then the payload: the markdown write-up AND the link. Both, not
|
|
2880
|
+
the first one present -- `markdownContent` and `link` are independent fields
|
|
2881
|
+
and an attachment may carry both, so picking one would silently discard
|
|
2882
|
+
operator content, which is the failure mode this whole feature exists to
|
|
2883
|
+
remove. The link is rendered bare (GitHub autolinks it; angle brackets would
|
|
2884
|
+
break on a `>` inside the URL) and is never truncated: a URL is bounded by
|
|
2885
|
+
nature and it is the pointer back to the full source when the write-up was
|
|
2886
|
+
cut. An attachment whose payload this adapter does not inline (a vault
|
|
2887
|
+
reference) still gets its titled subsection -- the title and the note are
|
|
2888
|
+
themselves the decision often enough.
|
|
2889
|
+
|
|
2890
|
+
Only the write-up is capped, at `cap`. `cap` is the per-attachment ceiling
|
|
2891
|
+
the caller can afford, which is `CONTEXT_ITEM_MAX_CHARS` unless the section
|
|
2892
|
+
budget is tighter than that.
|
|
2893
|
+
"""
|
|
2894
|
+
lines = [f"{CONTEXT_SUBHEADING}{item.title.strip() or CONTEXT_UNTITLED}", ""]
|
|
2895
|
+
if item.note.strip():
|
|
2896
|
+
lines.extend([f"_{item.note.strip()}_", ""])
|
|
2897
|
+
|
|
2898
|
+
content = item.markdown.strip()
|
|
2899
|
+
# Captured BEFORE the reassignment below: recomputing it from `item` would
|
|
2900
|
+
# keep reporting the right number only for as long as `content` is derived
|
|
2901
|
+
# exactly one way, and the marker's job is to describe what was actually cut.
|
|
2902
|
+
original = len(content)
|
|
2903
|
+
truncated = original > cap
|
|
2904
|
+
if truncated:
|
|
2905
|
+
content = (
|
|
2906
|
+
content[:cap].rstrip()
|
|
2907
|
+
+ f"\n\n{CONTEXT_TRUNCATED_PREFIX} {cap} of {original} chars]"
|
|
2908
|
+
)
|
|
2909
|
+
link = item.link.strip()
|
|
2910
|
+
if link:
|
|
2911
|
+
content = f"{content}\n\n{link}" if content else link
|
|
2912
|
+
lines.append(content or CONTEXT_EMPTY)
|
|
2913
|
+
return "\n".join(lines), truncated
|
|
2914
|
+
|
|
2915
|
+
|
|
2916
|
+
def _omitted_notice(rendered: int, total: int, budget: int) -> str:
|
|
2917
|
+
return (
|
|
2918
|
+
f"{CONTEXT_TRUNCATED_PREFIX} {rendered} of {total} attachments "
|
|
2919
|
+
f"rendered; the section is capped at {budget} chars]"
|
|
2920
|
+
)
|
|
2921
|
+
|
|
2922
|
+
|
|
2923
|
+
def render_context_section(
|
|
2924
|
+
items: Sequence[ContextItem], config: Config, budget: int
|
|
2925
|
+
) -> tuple[str, str]:
|
|
2926
|
+
"""`(section_markdown, note)` for the origin task's context attachments.
|
|
2927
|
+
|
|
2928
|
+
`note` is a one-line summary for the release log -- empty only when there
|
|
2929
|
+
was nothing to render. Nothing here is silent: every attachment that was
|
|
2930
|
+
cut, dropped or escaped is counted into it AND marked in the body.
|
|
2931
|
+
|
|
2932
|
+
ORDER is chronological on `created_at`, sorted stably, so a payload with no
|
|
2933
|
+
timestamps at all (a shape this adapter must not assume away) keeps the
|
|
2934
|
+
endpoint's own order, which is creation order.
|
|
2935
|
+
|
|
2936
|
+
`budget` is the hard character ceiling for everything returned, computed by
|
|
2937
|
+
the caller from what is LEFT under GitHub's issue-body limit -- see
|
|
2938
|
+
`render_issue`. Fitting is by whole attachment: a half-rendered decision
|
|
2939
|
+
reads as a whole one, whereas "2 of 5 rendered" sends the operator to the
|
|
2940
|
+
task. Per-attachment truncation (`CONTEXT_ITEM_MAX_CHARS`) is the one place
|
|
2941
|
+
a single attachment is cut, and it says so where it was cut.
|
|
2942
|
+
"""
|
|
2943
|
+
if not items:
|
|
2944
|
+
return "", ""
|
|
2945
|
+
|
|
2946
|
+
keys = control_keys(config)
|
|
2947
|
+
blocks: list[tuple[str, bool]] = []
|
|
2948
|
+
for item in sorted(items, key=lambda entry: entry.created_at):
|
|
2949
|
+
block, truncated = _context_item_block(item, CONTEXT_ITEM_MAX_CHARS)
|
|
2950
|
+
block, _ = escape_control_lines(block, keys)
|
|
2951
|
+
blocks.append((block, truncated))
|
|
2952
|
+
|
|
2953
|
+
total = len(blocks)
|
|
2954
|
+
sep = "\n\n"
|
|
2955
|
+
# An upper bound on the notice: its two counts are both <= `total`, and the
|
|
2956
|
+
# limit it finally reports is <= `budget`, so rendering it with `total`
|
|
2957
|
+
# twice and `budget` can only be at least as wide as the one emitted.
|
|
2958
|
+
reserve = len(sep) + len(_omitted_notice(total, total, budget))
|
|
2959
|
+
if len(CONTEXT_HEADING) + reserve > budget:
|
|
2960
|
+
# No room even to SAY that nothing fit. Reachable only when the rest of
|
|
2961
|
+
# the body has all but exhausted GitHub's limit on its own; the issue
|
|
2962
|
+
# still gets filed, so this line is the only record.
|
|
2963
|
+
log.error(
|
|
2964
|
+
"context section dropped entirely: %d attachment(s) did not fit in "
|
|
2965
|
+
"the %d chars left under GitHub's %d-char issue-body limit — the "
|
|
2966
|
+
"issue is filed WITHOUT them; shorten the task description or the "
|
|
2967
|
+
"definition of done",
|
|
2968
|
+
total, budget, GITHUB_BODY_LIMIT,
|
|
2969
|
+
)
|
|
2970
|
+
return "", f"; NOTE: {total} context attachment(s) dropped — no room under the body limit"
|
|
2971
|
+
|
|
2972
|
+
def fit(limit: int) -> int:
|
|
2973
|
+
used = len(CONTEXT_HEADING)
|
|
2974
|
+
count = 0
|
|
2975
|
+
for block, _ in blocks:
|
|
2976
|
+
cost = len(sep) + len(block)
|
|
2977
|
+
if used + cost > limit:
|
|
2978
|
+
break
|
|
2979
|
+
used += cost
|
|
2980
|
+
count += 1
|
|
2981
|
+
return count
|
|
2982
|
+
|
|
2983
|
+
kept = fit(budget)
|
|
2984
|
+
limit = budget
|
|
2985
|
+
if kept < total:
|
|
2986
|
+
# Something is being omitted, so the notice is going to be emitted and
|
|
2987
|
+
# has to be paid for. Re-fit against the smaller ceiling -- and REPORT
|
|
2988
|
+
# that ceiling, since it is the one that was applied.
|
|
2989
|
+
limit = budget - reserve
|
|
2990
|
+
kept = fit(limit)
|
|
2991
|
+
|
|
2992
|
+
rendered = [block for block, _ in blocks[:kept]]
|
|
2993
|
+
partial = ""
|
|
2994
|
+
if kept == 0 and total:
|
|
2995
|
+
# Nothing fits whole, but there is room to say something. Rendering the
|
|
2996
|
+
# first decision beats rendering none, and the per-attachment cap
|
|
2997
|
+
# already establishes that a marked partial is an acceptable shape --
|
|
2998
|
+
# so the whole-attachment rule yields exactly here, at the point where
|
|
2999
|
+
# keeping it would mean an empty section. Cutting the ESCAPED block from
|
|
3000
|
+
# the end is safe: truncation only removes text, so it cannot produce a
|
|
3001
|
+
# line start the escape has not already seen.
|
|
3002
|
+
room = limit - len(CONTEXT_HEADING) - len(sep) - len(CONTEXT_PARTIAL_MARKER)
|
|
3003
|
+
if room >= CONTEXT_MIN_PARTIAL_CHARS:
|
|
3004
|
+
partial = blocks[0][0][:room].rstrip() + CONTEXT_PARTIAL_MARKER
|
|
3005
|
+
rendered = [partial]
|
|
3006
|
+
|
|
3007
|
+
parts = [CONTEXT_HEADING] + rendered
|
|
3008
|
+
if kept < total:
|
|
3009
|
+
parts.append(_omitted_notice(kept, total, limit))
|
|
3010
|
+
section = sep.join(parts)
|
|
3011
|
+
|
|
3012
|
+
# What actually REACHED the body, which on the partial path is not
|
|
3013
|
+
# `blocks[:kept]` -- that branch renders `blocks[0]` while deliberately
|
|
3014
|
+
# leaving `kept` at 0 for the notice's sake. Counting the slice alone made
|
|
3015
|
+
# the one attachment in the body the one attachment neither counter could
|
|
3016
|
+
# see, which is precisely what this function's docstring promises cannot
|
|
3017
|
+
# happen. `kept` keeps its meaning; only the counters widen.
|
|
3018
|
+
counted = blocks[:1] if partial else blocks[:kept]
|
|
3019
|
+
truncations = sum(1 for _, truncated in counted if truncated)
|
|
3020
|
+
escapes = count_escaped_lines(section, keys)
|
|
3021
|
+
summary = f"{kept} of {total}" if kept < total else str(total)
|
|
3022
|
+
note = f"; context: {summary} attachment(s) rendered"
|
|
3023
|
+
if partial:
|
|
3024
|
+
note += ", 1 rendered partially to fit"
|
|
3025
|
+
if truncations:
|
|
3026
|
+
note += f", {truncations} truncated"
|
|
3027
|
+
if escapes:
|
|
3028
|
+
note += f", {escapes} trailer-like line(s) escaped"
|
|
3029
|
+
return section, note
|
|
3030
|
+
|
|
3031
|
+
|
|
3032
|
+
@dataclass(frozen=True)
|
|
3033
|
+
class RenderedIssue:
|
|
3034
|
+
"""The issue body plus what rendering it had to say out loud."""
|
|
3035
|
+
|
|
3036
|
+
body: str
|
|
3037
|
+
# The `; context: ...` suffix for the release line -- empty when the task
|
|
3038
|
+
# had no context attachments (the common case, and the only one that
|
|
3039
|
+
# renders byte-identically to pre-O14).
|
|
3040
|
+
context_note: str = ""
|
|
3041
|
+
|
|
3042
|
+
|
|
3043
|
+
def render_issue(
|
|
3044
|
+
task: Task,
|
|
3045
|
+
target: str,
|
|
3046
|
+
scope: list[str],
|
|
3047
|
+
config: Config,
|
|
3048
|
+
context: Sequence[ContextItem] = (),
|
|
3049
|
+
) -> RenderedIssue:
|
|
2440
3050
|
"""The mechanical issue body: task description (control trailers stripped)
|
|
2441
|
-
+
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
3051
|
+
+ the origin task's context attachments + Definition of Done +
|
|
3052
|
+
acceptance-criteria checklist + a canonical trailer block. The
|
|
3053
|
+
`Autonomous-*` trailers are re-emitted (line-anchored, git-trailer style) so
|
|
3054
|
+
lane occupancy stays derivable from the open issues, and the `Alissa-Task:`
|
|
3055
|
+
trailer links the issue back to its origin task for the develop daemon and
|
|
3056
|
+
the close loop.
|
|
3057
|
+
|
|
3058
|
+
`context` (O14, issue #34) is a SNAPSHOT taken at release time: the section
|
|
3059
|
+
sits after the description and before the Definition of Done, because it is
|
|
3060
|
+
the operator amending the brief, not adding a requirement. With no
|
|
3061
|
+
attachments -- the common case, and every case before O14 -- the body is
|
|
3062
|
+
byte-identical to what shipped through 0.3.7 (the last release before this
|
|
3063
|
+
section existed).
|
|
3064
|
+
|
|
3065
|
+
The context section's ceiling is what is LEFT under GitHub's issue-body
|
|
3066
|
+
limit after everything else, capped further at `CONTEXT_SECTION_MAX_CHARS`.
|
|
3067
|
+
It yields to the rest of the body rather than the other way around: the
|
|
3068
|
+
description, the DoD and the criteria come from the operator's own task and
|
|
3069
|
+
are the issue's contract, while the context is the part orcloop is adding.
|
|
3070
|
+
"""
|
|
2446
3071
|
sections: list[str] = []
|
|
2447
3072
|
|
|
2448
3073
|
prose = _strip_control_lines(task.description, config)
|
|
2449
3074
|
if prose:
|
|
2450
3075
|
sections.append(prose)
|
|
3076
|
+
# Where the context section goes: straight after the description, or first
|
|
3077
|
+
# when there is none.
|
|
3078
|
+
context_at = len(sections)
|
|
2451
3079
|
|
|
2452
3080
|
if task.definition_of_done.strip():
|
|
2453
|
-
sections.append(
|
|
3081
|
+
sections.append(DOD_HEADING + "\n\n" + task.definition_of_done.strip())
|
|
2454
3082
|
|
|
2455
3083
|
if task.criteria:
|
|
2456
3084
|
checklist = "\n".join(f"- [ ] {c.description}" for c in task.criteria)
|
|
2457
|
-
sections.append(
|
|
3085
|
+
sections.append(CRITERIA_HEADING + "\n\n" + checklist)
|
|
2458
3086
|
|
|
2459
3087
|
trailers = [f"{config.marker_key}: {target}"]
|
|
2460
3088
|
if scope:
|
|
2461
3089
|
trailers.append(f"{config.scope_key}: {', '.join(scope)}")
|
|
2462
|
-
trailers.append(f"
|
|
3090
|
+
trailers.append(f"{TASK_TRAILER_KEY}: {task.ref}")
|
|
2463
3091
|
sections.append("\n".join(trailers))
|
|
2464
3092
|
|
|
2465
|
-
|
|
3093
|
+
body = "\n\n".join(sections) + "\n"
|
|
3094
|
+
if not context:
|
|
3095
|
+
return RenderedIssue(body)
|
|
3096
|
+
|
|
3097
|
+
section, note = render_context_section(
|
|
3098
|
+
context,
|
|
3099
|
+
config,
|
|
3100
|
+
# `body` already accounts for the trailing newline; the section costs
|
|
3101
|
+
# itself plus the "\n\n" that joins it in.
|
|
3102
|
+
min(CONTEXT_SECTION_MAX_CHARS, GITHUB_BODY_LIMIT - len(body) - 2),
|
|
3103
|
+
)
|
|
3104
|
+
if not section:
|
|
3105
|
+
return RenderedIssue(body, note)
|
|
3106
|
+
sections.insert(context_at, section)
|
|
3107
|
+
return RenderedIssue("\n\n".join(sections) + "\n", note)
|
|
3108
|
+
|
|
3109
|
+
|
|
3110
|
+
def render_issue_body(
|
|
3111
|
+
task: Task,
|
|
3112
|
+
target: str,
|
|
3113
|
+
scope: list[str],
|
|
3114
|
+
config: Config,
|
|
3115
|
+
context: Sequence[ContextItem] = (),
|
|
3116
|
+
) -> str:
|
|
3117
|
+
"""`render_issue(...).body` -- the body alone, for callers with nothing to
|
|
3118
|
+
report about how it was rendered."""
|
|
3119
|
+
return render_issue(task, target, scope, config, context).body
|
|
@@ -67,12 +67,16 @@ CREATE TABLE IF NOT EXISTS meta (
|
|
|
67
67
|
"""
|
|
68
68
|
|
|
69
69
|
|
|
70
|
-
# The only `escalations` kinds `clear_escalation` will remove:
|
|
71
|
-
#
|
|
72
|
-
# can END (see `clear_escalation`).
|
|
73
|
-
# imported from `loop`, which imports this
|
|
74
|
-
# loop's constants and this set stay the same
|
|
75
|
-
|
|
70
|
+
# The only `escalations` kinds `clear_escalation` will remove: two outage clocks
|
|
71
|
+
# and the two pages they raise -- the O9 dedupe check and the O14 context read --
|
|
72
|
+
# all four of which describe a condition that can END (see `clear_escalation`).
|
|
73
|
+
# Spelled as literals here rather than imported from `loop`, which imports this
|
|
74
|
+
# module; `test_loop` pins that the loop's constants and this set stay the same
|
|
75
|
+
# four strings.
|
|
76
|
+
CLEARABLE_KINDS = frozenset({
|
|
77
|
+
"marker:dedupe-unreadable", "dedupe-unreadable",
|
|
78
|
+
"marker:context-unreadable", "context-unreadable",
|
|
79
|
+
})
|
|
76
80
|
|
|
77
81
|
|
|
78
82
|
class State:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.3.8
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.3.7
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|