alissa-tools-github-devloop 0.8.41__tar.gz → 0.8.43__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_devloop-0.8.41/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.43}/PKG-INFO +73 -1
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/README.md +72 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa/tools/github/devloop/fleet_vitals.py +13 -3
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa/tools/github/devloop/loop.py +287 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa/tools/github/devloop/parking.py +112 -15
- alissa_tools_github_devloop-0.8.43/src/main/alissa/tools/github/devloop/version +1 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa/tools/github/devloop/webui/sources.py +12 -4
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +73 -1
- alissa_tools_github_devloop-0.8.41/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/setup.py +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa/tools/github/devloop/bows.py +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa/tools/github/devloop/config.py +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa/tools/github/devloop/hubs.py +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa/tools/github/devloop/loop_events.py +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa/tools/github/devloop/prompts.py +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa/tools/github/devloop/state.py +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.8.41 → alissa_tools_github_devloop-0.8.43}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: alissa-tools-github-devloop
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.43
|
|
4
4
|
Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
|
|
5
5
|
Home-page: https://alissa.app
|
|
6
6
|
Author: Fahera
|
|
@@ -148,6 +148,78 @@ issue immediately before every enqueue: a closed issue is `SKIPPED` "issue
|
|
|
148
148
|
closed between search and spawn", nothing is claimed, and the reserved slot
|
|
149
149
|
is handed back. The respawn-with-resume on an open draft PR is unchanged.
|
|
150
150
|
|
|
151
|
+
## The discovery round (issue #164)
|
|
152
|
+
|
|
153
|
+
A discovery issue is a no-code lane in the fleet's **plans repository**
|
|
154
|
+
(design `alissa-build-discovery.md` §3.2, lane DS-7a). Studio's `feed_plan`
|
|
155
|
+
renders it with the trailers `Alissa-Kind: discovery`, `Alissa-Reads:
|
|
156
|
+
<owner>/<repo>@<40-hex sha>` (the code repository it reads, at a pinned SHA)
|
|
157
|
+
and `Autonomous-Scope: knowledge-base/<YYYY>/<MM>/<DD>/TASK-<n>/**`. The
|
|
158
|
+
issue edge spawns the ordinary develop session — same name, same caps, same
|
|
159
|
+
liveness rules — with `DISCOVERY_DIRECTIVE` (in `loop.py`, byte-pinned in
|
|
160
|
+
`test_discovery.py`) instead of `DEV_DIRECTIVE`. Every other issue gets
|
|
161
|
+
today's directive byte for byte; a resume of a dead discovery session keeps
|
|
162
|
+
`RESUME_DIRECTIVE`.
|
|
163
|
+
|
|
164
|
+
The directive has the worker:
|
|
165
|
+
|
|
166
|
+
1. create its `TASK-<n>-<DESC>` branch in the plans repository, the branch
|
|
167
|
+
grammar unchanged;
|
|
168
|
+
2. read the code at the pinned SHA, read-only — a `git -C <hub> worktree add
|
|
169
|
+
--detach <scratch> <sha>` when the workspace has a hub of the read
|
|
170
|
+
repository (no branch, removed at the end), otherwise `gh api
|
|
171
|
+
repos/<repo>/contents/<path>?ref=<sha>` and `gh search code`; never a
|
|
172
|
+
clone, never a commit or push there. The read is bounded by the SHA, not a
|
|
173
|
+
file count; the output by the findings schema's limits;
|
|
174
|
+
3. write exactly one directory, `knowledge-base/<YYYY>/<MM>/<DD>/TASK-<n>/`,
|
|
175
|
+
holding only `findings.md` (findings schema v1, `schema/findings.v1.json`
|
|
176
|
+
in the plans repository);
|
|
177
|
+
4. run `node lint/plan-lint.ts --kb <dir>` locally with its own
|
|
178
|
+
`ALISSA_API_TOKEN` (K6/K7) and never push findings it refuses;
|
|
179
|
+
5. open a ready pull request with `Closes #<n>`, both task refs and the
|
|
180
|
+
marker `<!-- alissa-discovery v1 -->` on a line of its own, attach a
|
|
181
|
+
`Findings` evidence item, move its task to `pending_validation`, request
|
|
182
|
+
**no** reviewer (revloop skips such PRs; orcloop merges on the lint of
|
|
183
|
+
record) and end its session.
|
|
184
|
+
|
|
185
|
+
**The push guard.** Before it enqueues a discovery session (fresh or
|
|
186
|
+
resumed), the daemon writes `${ALISSA_DISCOVERY_DIR:-/workspace/.discovery}/
|
|
187
|
+
ali-<session>.json` naming the plans repository; a marker that cannot be
|
|
188
|
+
written stands the spawn down for the pass. The container's shell guard
|
|
189
|
+
(`docker/claude/hooks/guard-shell.py`) finds the marker through
|
|
190
|
+
`$ALISSA_TMUX_SESSION` and refuses, with a PreToolUse deny and a reason, any
|
|
191
|
+
`git push` from that session whose destination is not the plans repository —
|
|
192
|
+
resolved from the remote name or URL in the directory the push runs in
|
|
193
|
+
(`-C`, `cd`), and refused outright when it cannot be resolved. Sessions with
|
|
194
|
+
no marker are untouched. `ALISSA_SHELL_GUARD=off` disables this guard with
|
|
195
|
+
the rest.
|
|
196
|
+
|
|
197
|
+
## Seat parking: what counts as managed (issue #166)
|
|
198
|
+
|
|
199
|
+
The idle block on `GET /api/state` (`parking.idle_block`, issue #153) is what
|
|
200
|
+
orcloop's D2 invariant reads before it drains and parks the seat: idle only
|
|
201
|
+
when `live == managed == queued == owed == 0` with no timer and a fresh pass.
|
|
202
|
+
`live` and `managed` come from `alissa tmux ls --json`:
|
|
203
|
+
|
|
204
|
+
- a row is **live** unless it says otherwise (its `live` flag, else
|
|
205
|
+
`status != "gone"`);
|
|
206
|
+
- a row is **managed** when it is live, **or** when it is dead but its
|
|
207
|
+
session still backs a lane the seat holds — the session named by a stage
|
|
208
|
+
of the latest poll snapshot that is owed or in flight (`spawned` /
|
|
209
|
+
`in-flight`), any session the spawn ledgers record on such a stage's lane
|
|
210
|
+
(an earlier attempt the lane may still resume), or a session the alissa
|
|
211
|
+
queue holds an undispatched item for;
|
|
212
|
+
- a dead row backing none of these is an **orphan**: it is not managed, and
|
|
213
|
+
is reported in the block's `gone` field as `{count, sessions}` so the
|
|
214
|
+
console and orcloop's logs still show it.
|
|
215
|
+
|
|
216
|
+
Unknown is never idle: a failed listing leaves `live`, `managed` and `gone`
|
|
217
|
+
null, and before the first completed pass (no snapshot to read lanes from)
|
|
218
|
+
every dead row stays managed. The console only reports an orphan — it never
|
|
219
|
+
kills or reaps one. The fleet-vitals push applies the same rule to
|
|
220
|
+
`sessions.managed` and `idle.managed` (Studio's park guard reads both) and
|
|
221
|
+
leaves `gone` out, since Studio's idle schema is strict.
|
|
222
|
+
|
|
151
223
|
## License
|
|
152
224
|
|
|
153
225
|
Licensed under the
|
|
@@ -126,6 +126,78 @@ issue immediately before every enqueue: a closed issue is `SKIPPED` "issue
|
|
|
126
126
|
closed between search and spawn", nothing is claimed, and the reserved slot
|
|
127
127
|
is handed back. The respawn-with-resume on an open draft PR is unchanged.
|
|
128
128
|
|
|
129
|
+
## The discovery round (issue #164)
|
|
130
|
+
|
|
131
|
+
A discovery issue is a no-code lane in the fleet's **plans repository**
|
|
132
|
+
(design `alissa-build-discovery.md` §3.2, lane DS-7a). Studio's `feed_plan`
|
|
133
|
+
renders it with the trailers `Alissa-Kind: discovery`, `Alissa-Reads:
|
|
134
|
+
<owner>/<repo>@<40-hex sha>` (the code repository it reads, at a pinned SHA)
|
|
135
|
+
and `Autonomous-Scope: knowledge-base/<YYYY>/<MM>/<DD>/TASK-<n>/**`. The
|
|
136
|
+
issue edge spawns the ordinary develop session — same name, same caps, same
|
|
137
|
+
liveness rules — with `DISCOVERY_DIRECTIVE` (in `loop.py`, byte-pinned in
|
|
138
|
+
`test_discovery.py`) instead of `DEV_DIRECTIVE`. Every other issue gets
|
|
139
|
+
today's directive byte for byte; a resume of a dead discovery session keeps
|
|
140
|
+
`RESUME_DIRECTIVE`.
|
|
141
|
+
|
|
142
|
+
The directive has the worker:
|
|
143
|
+
|
|
144
|
+
1. create its `TASK-<n>-<DESC>` branch in the plans repository, the branch
|
|
145
|
+
grammar unchanged;
|
|
146
|
+
2. read the code at the pinned SHA, read-only — a `git -C <hub> worktree add
|
|
147
|
+
--detach <scratch> <sha>` when the workspace has a hub of the read
|
|
148
|
+
repository (no branch, removed at the end), otherwise `gh api
|
|
149
|
+
repos/<repo>/contents/<path>?ref=<sha>` and `gh search code`; never a
|
|
150
|
+
clone, never a commit or push there. The read is bounded by the SHA, not a
|
|
151
|
+
file count; the output by the findings schema's limits;
|
|
152
|
+
3. write exactly one directory, `knowledge-base/<YYYY>/<MM>/<DD>/TASK-<n>/`,
|
|
153
|
+
holding only `findings.md` (findings schema v1, `schema/findings.v1.json`
|
|
154
|
+
in the plans repository);
|
|
155
|
+
4. run `node lint/plan-lint.ts --kb <dir>` locally with its own
|
|
156
|
+
`ALISSA_API_TOKEN` (K6/K7) and never push findings it refuses;
|
|
157
|
+
5. open a ready pull request with `Closes #<n>`, both task refs and the
|
|
158
|
+
marker `<!-- alissa-discovery v1 -->` on a line of its own, attach a
|
|
159
|
+
`Findings` evidence item, move its task to `pending_validation`, request
|
|
160
|
+
**no** reviewer (revloop skips such PRs; orcloop merges on the lint of
|
|
161
|
+
record) and end its session.
|
|
162
|
+
|
|
163
|
+
**The push guard.** Before it enqueues a discovery session (fresh or
|
|
164
|
+
resumed), the daemon writes `${ALISSA_DISCOVERY_DIR:-/workspace/.discovery}/
|
|
165
|
+
ali-<session>.json` naming the plans repository; a marker that cannot be
|
|
166
|
+
written stands the spawn down for the pass. The container's shell guard
|
|
167
|
+
(`docker/claude/hooks/guard-shell.py`) finds the marker through
|
|
168
|
+
`$ALISSA_TMUX_SESSION` and refuses, with a PreToolUse deny and a reason, any
|
|
169
|
+
`git push` from that session whose destination is not the plans repository —
|
|
170
|
+
resolved from the remote name or URL in the directory the push runs in
|
|
171
|
+
(`-C`, `cd`), and refused outright when it cannot be resolved. Sessions with
|
|
172
|
+
no marker are untouched. `ALISSA_SHELL_GUARD=off` disables this guard with
|
|
173
|
+
the rest.
|
|
174
|
+
|
|
175
|
+
## Seat parking: what counts as managed (issue #166)
|
|
176
|
+
|
|
177
|
+
The idle block on `GET /api/state` (`parking.idle_block`, issue #153) is what
|
|
178
|
+
orcloop's D2 invariant reads before it drains and parks the seat: idle only
|
|
179
|
+
when `live == managed == queued == owed == 0` with no timer and a fresh pass.
|
|
180
|
+
`live` and `managed` come from `alissa tmux ls --json`:
|
|
181
|
+
|
|
182
|
+
- a row is **live** unless it says otherwise (its `live` flag, else
|
|
183
|
+
`status != "gone"`);
|
|
184
|
+
- a row is **managed** when it is live, **or** when it is dead but its
|
|
185
|
+
session still backs a lane the seat holds — the session named by a stage
|
|
186
|
+
of the latest poll snapshot that is owed or in flight (`spawned` /
|
|
187
|
+
`in-flight`), any session the spawn ledgers record on such a stage's lane
|
|
188
|
+
(an earlier attempt the lane may still resume), or a session the alissa
|
|
189
|
+
queue holds an undispatched item for;
|
|
190
|
+
- a dead row backing none of these is an **orphan**: it is not managed, and
|
|
191
|
+
is reported in the block's `gone` field as `{count, sessions}` so the
|
|
192
|
+
console and orcloop's logs still show it.
|
|
193
|
+
|
|
194
|
+
Unknown is never idle: a failed listing leaves `live`, `managed` and `gone`
|
|
195
|
+
null, and before the first completed pass (no snapshot to read lanes from)
|
|
196
|
+
every dead row stays managed. The console only reports an orphan — it never
|
|
197
|
+
kills or reaps one. The fleet-vitals push applies the same rule to
|
|
198
|
+
`sessions.managed` and `idle.managed` (Studio's park guard reads both) and
|
|
199
|
+
leaves `gone` out, since Studio's idle schema is strict.
|
|
200
|
+
|
|
129
201
|
## License
|
|
130
202
|
|
|
131
203
|
Licensed under the
|
|
@@ -257,7 +257,8 @@ def build_snapshot(
|
|
|
257
257
|
`queued` (the undispatched alissa queue items, None when unread) and
|
|
258
258
|
`drain` (the holding drain row, or None) feed the `idle` block, whose
|
|
259
259
|
owed / timers come from the latest poll snapshot and whose live /
|
|
260
|
-
managed come from `roster` -- the same reading as `sessions
|
|
260
|
+
managed come from `roster` -- the same reading as `sessions`, with a dead
|
|
261
|
+
row backing no held lane left out of `managed` (issue #166).
|
|
261
262
|
|
|
262
263
|
`config` is the seat's `config_block`, or None to leave the key out
|
|
263
264
|
(the console's push, which has no derived answer to give)."""
|
|
@@ -275,13 +276,20 @@ def build_snapshot(
|
|
|
275
276
|
if session and session not in repo_by_session:
|
|
276
277
|
repo_by_session[str(session)] = str(row["repo_slug"])
|
|
277
278
|
|
|
279
|
+
# A dead row counts as managed only while it backs a lane the seat still
|
|
280
|
+
# holds (issue #166): Studio's park guard reads `sessions.managed` as well
|
|
281
|
+
# as the idle block's, so both follow the console's rule.
|
|
282
|
+
roster_live, roster_managed, _gone = parking.roster_counts(
|
|
283
|
+
roster, parking.lane_sessions(latest, ledgers=ledgers),
|
|
284
|
+
)
|
|
285
|
+
|
|
278
286
|
sessions_block: "dict | None" = None
|
|
279
287
|
session_list: "list[dict] | None" = None
|
|
280
288
|
if roster is not None:
|
|
281
289
|
records = sources.sessions(listing=[dict(r) for r in roster])
|
|
282
290
|
sessions_block = {
|
|
283
291
|
"live": sum(1 for r in records if r.get("live")),
|
|
284
|
-
"managed":
|
|
292
|
+
"managed": roster_managed,
|
|
285
293
|
}
|
|
286
294
|
session_list = [
|
|
287
295
|
_session_row(r, repo_by_session=repo_by_session, parse_session=parse)
|
|
@@ -307,11 +315,13 @@ def build_snapshot(
|
|
|
307
315
|
+ int(latest.get("revert_candidates") or 0)
|
|
308
316
|
)
|
|
309
317
|
|
|
310
|
-
roster_live, roster_managed = parking.roster_counts(roster)
|
|
311
318
|
idle = parking.idle_block(
|
|
312
319
|
latest, live=roster_live, managed=roster_managed, queued=queued,
|
|
313
320
|
drain=drain, now=now_ts,
|
|
314
321
|
)
|
|
322
|
+
# `gone` is the console's observation; Studio's idle schema is strict and
|
|
323
|
+
# does not carry it.
|
|
324
|
+
idle.pop("gone", None)
|
|
315
325
|
|
|
316
326
|
drift = sources.drift()
|
|
317
327
|
snapshot = {
|
|
@@ -451,6 +451,7 @@ import calendar
|
|
|
451
451
|
import dataclasses
|
|
452
452
|
import json
|
|
453
453
|
import logging
|
|
454
|
+
import os
|
|
454
455
|
import re
|
|
455
456
|
import sqlite3
|
|
456
457
|
import time
|
|
@@ -916,6 +917,117 @@ RESUME_DIRECTIVE = (
|
|
|
916
917
|
"(best-effort — the daemon's reaper sweep collects stragglers)."
|
|
917
918
|
)
|
|
918
919
|
|
|
920
|
+
# The discovery round's directive (issue #164, design
|
|
921
|
+
# alissa-build-discovery.md §3.2, lane DS-7). Sent INSTEAD of DEV_DIRECTIVE
|
|
922
|
+
# when the issue carries the trailer `Alissa-Kind: discovery` (rendered by
|
|
923
|
+
# Studio's feed_plan since DS-1): a no-code round in the PLANS repository
|
|
924
|
+
# that reads a code repository at the SHA pinned by `Alissa-Reads:` and writes
|
|
925
|
+
# one knowledge-base directory. What differs from the develop contract, and
|
|
926
|
+
# why each difference is load-bearing:
|
|
927
|
+
#
|
|
928
|
+
# - The read is DETACHED and read-only (a scratch `--detach` worktree off a
|
|
929
|
+
# hub of the read repo, else `gh api …?ref=<sha>`): no branch exists that
|
|
930
|
+
# could be pushed, and the push guard (docker/claude/hooks/guard-shell.py,
|
|
931
|
+
# armed by `write_discovery_marker`) refuses any push that is not to the
|
|
932
|
+
# plans repository anyway.
|
|
933
|
+
# - The read is bounded by the SHA, not by a file count (D1: no rationing);
|
|
934
|
+
# only the OUTPUT is bounded, with the findings schema's own limits.
|
|
935
|
+
# - The local `--kb` lint gates the push: findings the linter refuses never
|
|
936
|
+
# leave the machine, so the run of record never sees them.
|
|
937
|
+
# - The PR is opened READY, carries the discovery marker on a line of its
|
|
938
|
+
# own, and requests NO reviewer: revloop skips a marked PR confined to
|
|
939
|
+
# knowledge-base/ (§3.3), orcloop merges it on the lint of record (§3.5),
|
|
940
|
+
# and a draft would sit unmergeable until the orphan sweep flipped it.
|
|
941
|
+
# - No delivery note: the findings ARE the narrative, and their reader is a
|
|
942
|
+
# linter and a plan, not a human validator deciding on a merge.
|
|
943
|
+
#
|
|
944
|
+
# The {trailer_note} slot renders to nothing when the trailers parse; see
|
|
945
|
+
# render_discovery_directive for what it says when they do not.
|
|
946
|
+
DISCOVERY_DIRECTIVE = (
|
|
947
|
+
"You are a DISCOVERY worker, not an implementer and not a reviewer: this "
|
|
948
|
+
"round writes NO code. Answer the questions of GitHub issue {issue_url} "
|
|
949
|
+
"(attempt {attempt} of {cap}) by READING code, and record the answers as "
|
|
950
|
+
"findings — the issue is your self-contained spec, its numbered "
|
|
951
|
+
"questions are what you answer, and its acceptance criteria are "
|
|
952
|
+
"authoritative. {task_instruction} {trailer_note}"
|
|
953
|
+
"Golden path: your cwd is the worktree hub ROOT of the PLANS repository "
|
|
954
|
+
"{plans_repo} — create the task worktree there with `git worktree add "
|
|
955
|
+
"TASK-<n>-<DESC>`, where <n> is YOUR IMPLEMENTATION task's number (the "
|
|
956
|
+
"downstream task YOU created, NOT the origin task's), from there (one "
|
|
957
|
+
"task = one branch = one worktree; never clone, never work directly on "
|
|
958
|
+
"main). "
|
|
959
|
+
+ _BRANCH_RULE
|
|
960
|
+
+ _SHELL_RULE
|
|
961
|
+
+ "READ the code at the pinned SHA and nowhere else, read-only: "
|
|
962
|
+
"`{read_repo}` at `{read_sha}` (the issue's `Alissa-Reads:` trailer). "
|
|
963
|
+
"If this workspace holds a hub of {read_repo} (a sibling of your hub "
|
|
964
|
+
"root with a `.source/`), read through a DETACHED scratch worktree: `git "
|
|
965
|
+
"-C <hub> worktree add --detach <scratch> {read_sha}` (fetch that SHA "
|
|
966
|
+
"into the hub first if it is missing) — no branch, never pushed — and "
|
|
967
|
+
"remove it with `git -C <hub> worktree remove --force <scratch>` when "
|
|
968
|
+
"you are done. Otherwise read through `gh api "
|
|
969
|
+
"repos/{read_repo}/contents/<path>?ref={read_sha}` and `gh search "
|
|
970
|
+
"code`. Never clone {read_repo}, and never commit or push to it: this "
|
|
971
|
+
"session's push guard refuses any `git push` to a repository other than "
|
|
972
|
+
"{plans_repo}. Never open a deny-listed path (`.env*`, `*.pem`, `*.key`, "
|
|
973
|
+
"`*.p12`, `id_rsa*`, `*secret*`, `*credential*`, `.npmrc`, `.git/**`, "
|
|
974
|
+
"`**/node_modules/**`). The read is bounded by the SHA, not by a file "
|
|
975
|
+
"count — read whatever the questions need; what is bounded is the "
|
|
976
|
+
"OUTPUT. "
|
|
977
|
+
"WRITE exactly one directory, `{kb_dir}/`, holding `findings.md` and "
|
|
978
|
+
"nothing else; no other file anywhere in the repository changes. "
|
|
979
|
+
"`findings.md` follows the findings schema v1 (`schema/findings.v1.json` "
|
|
980
|
+
"in the plans repository, and the knowledge-base section of its "
|
|
981
|
+
"README): YAML frontmatter with `schema: alissa-findings/v1`, `task` = "
|
|
982
|
+
"the origin task, `reads` = `{read_repo}` at `{read_sha}`, every "
|
|
983
|
+
"question either answered with cites (repo, path, line range and the "
|
|
984
|
+
"file's git blob SHA at the pinned SHA) or marked `unanswerable` with a "
|
|
985
|
+
"reason; then a `## Summary` and one section per question. Stay inside "
|
|
986
|
+
"the schema's limits: at most 32 KB, 1–12 questions, 12 risks, 8 "
|
|
987
|
+
"suggested tasks and 24 cites per entry, 400 lines per cite, 1,200 "
|
|
988
|
+
"characters of Summary and 6,000 per question's section. "
|
|
989
|
+
"LINT it locally before anything leaves the machine: `node "
|
|
990
|
+
"lint/plan-lint.ts --kb {kb_dir}` from your worktree (`npm ci` first if "
|
|
991
|
+
"`node_modules` is missing), with this seat's own `ALISSA_API_TOKEN` in "
|
|
992
|
+
"the environment for the K6 task check and the K7 cite check. Findings "
|
|
993
|
+
"the local run refuses are NEVER pushed — fix them and rerun until it "
|
|
994
|
+
"passes; `plan-lint could not run` is a refusal, not a pass. "
|
|
995
|
+
"Then commit, push the branch to {plans_repo}, and open a READY (not "
|
|
996
|
+
"draft) pull request whose body contains `Closes #{number}`, BOTH task "
|
|
997
|
+
"refs — the origin task AND your implementation task — and the marker "
|
|
998
|
+
"`<!-- alissa-discovery v1 -->` on a line of its own. That is the "
|
|
999
|
+
"body's single closing claim: never put a closing keyword (`close`, "
|
|
1000
|
+
"`fix`, `resolve` and their forms) before any other issue reference. "
|
|
1001
|
+
+ _DEFAULT_BASE
|
|
1002
|
+
+ "Attach the PR URL to your implementation task as evidence, and attach "
|
|
1003
|
+
"a `Findings` evidence item to the origin task — the `## Summary` "
|
|
1004
|
+
"section; one line per question (`q1 · answered · high`); the risk "
|
|
1005
|
+
"count; the PR URL and the blob URL of `findings.md` at the head commit "
|
|
1006
|
+
"— or, if the origin task refuses that write, to your implementation "
|
|
1007
|
+
"task, saying so in the PR body. Then move your implementation task to "
|
|
1008
|
+
"pending_validation. Do NOT request a reviewer — no `gh pr edit … "
|
|
1009
|
+
"--add-reviewer …`, no review skill: a discovery PR is checked by the "
|
|
1010
|
+
"plan-lint run of record and merged on it. NEVER merge, never push to "
|
|
1011
|
+
"main, and do NOT create further ali-* sessions — this loop is depth-1. "
|
|
1012
|
+
"Once your task is pending_validation and the PR is open, your last "
|
|
1013
|
+
"act: run `alissa tmux kill {session}` to end this session and free the "
|
|
1014
|
+
"machine (best-effort — the daemon's reaper sweep collects stragglers)."
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
# Part of DISCOVERY_DIRECTIVE's {trailer_note} when the issue's `Alissa-Reads:`
|
|
1018
|
+
# trailer is absent or not `<owner>/<repo>@<40-hex sha>`. There is then no
|
|
1019
|
+
# pinned SHA to read, and a worker that picked one (the default branch's
|
|
1020
|
+
# head, say) would write findings K6 refuses at the run of record -- so the
|
|
1021
|
+
# round stops instead, and the defect goes where the plan author sees it.
|
|
1022
|
+
DISCOVERY_READS_MISSING_NOTE = (
|
|
1023
|
+
"This issue's `Alissa-Reads:` trailer is missing or malformed, so there "
|
|
1024
|
+
"is NO pinned SHA: do not pick one yourself. Comment on the issue naming "
|
|
1025
|
+
"the defect (it must read `Alissa-Reads: <owner>/<repo>@<40-hex sha>`), "
|
|
1026
|
+
"leave your implementation task in_progress with that note, open no "
|
|
1027
|
+
"pull request, and run `alissa tmux kill {session}` to end this "
|
|
1028
|
+
"session. "
|
|
1029
|
+
)
|
|
1030
|
+
|
|
919
1031
|
# The handover clause (issue #121), shared by FIX_DIRECTIVE and
|
|
920
1032
|
# FIX_RESUME_DIRECTIVE. When a later review round lands on the head a fix
|
|
921
1033
|
# session is still working (the 2026-09-15 studio#1243 shape: a phantom round
|
|
@@ -1709,6 +1821,139 @@ def marker_file_name(tmux_session: str) -> str:
|
|
|
1709
1821
|
return safe + ".json"
|
|
1710
1822
|
|
|
1711
1823
|
|
|
1824
|
+
# -- discovery rounds (issue #164) ---------------------------------------
|
|
1825
|
+
#
|
|
1826
|
+
# The trailers Studio's feed_plan renders on a discovery issue (design
|
|
1827
|
+
# alissa-build-discovery.md §2.2). Line-anchored and MULTILINE like
|
|
1828
|
+
# `Alissa-Task:`: a trailer sits on a line of its own, so a body QUOTING one
|
|
1829
|
+
# mid-sentence (a design issue explaining discovery) is not a discovery issue.
|
|
1830
|
+
_DISCOVERY_KIND_RE = re.compile(r"^Alissa-Kind:[ \t]*discovery[ \t]*\r?$", re.MULTILINE)
|
|
1831
|
+
_ALISSA_READS_RE = re.compile(
|
|
1832
|
+
r"^Alissa-Reads:[ \t]*(?P<repo>[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)"
|
|
1833
|
+
r"@(?P<sha>[0-9a-f]{40})[ \t]*\r?$",
|
|
1834
|
+
re.MULTILINE,
|
|
1835
|
+
)
|
|
1836
|
+
_KB_SCOPE_RE = re.compile(
|
|
1837
|
+
r"^Autonomous-Scope:[ \t]*(?P<dir>knowledge-base/\d{4}/\d{2}/\d{2}/TASK-\d+)"
|
|
1838
|
+
r"/\*\*[ \t]*\r?$",
|
|
1839
|
+
re.MULTILINE,
|
|
1840
|
+
)
|
|
1841
|
+
|
|
1842
|
+
# The knowledge-base directory a discovery directive names when the issue's
|
|
1843
|
+
# `Autonomous-Scope:` does not spell one (feed_plan always derives it), and
|
|
1844
|
+
# the sentence telling the worker how to fill it in.
|
|
1845
|
+
DISCOVERY_KB_DIR_FALLBACK = "knowledge-base/<YYYY>/<MM>/<DD>/TASK-<n>"
|
|
1846
|
+
DISCOVERY_KB_DIR_NOTE = (
|
|
1847
|
+
"This issue's `Autonomous-Scope:` does not name the knowledge-base "
|
|
1848
|
+
"directory: in `knowledge-base/<YYYY>/<MM>/<DD>/TASK-<n>/` below, <n> is "
|
|
1849
|
+
"the ORIGIN task's number and the date its creation day (UTC). "
|
|
1850
|
+
)
|
|
1851
|
+
|
|
1852
|
+
# Where the daemon leaves the marker that arms the push guard for one
|
|
1853
|
+
# discovery session, and the env var both sides read it from. The daemon and
|
|
1854
|
+
# every worker run in one container with one environment, so the hook
|
|
1855
|
+
# (docker/claude/hooks/guard-shell.py, which spells the same default) and
|
|
1856
|
+
# this module agree on the directory without a config key.
|
|
1857
|
+
DISCOVERY_DIR_ENV = "ALISSA_DISCOVERY_DIR"
|
|
1858
|
+
DEFAULT_DISCOVERY_DIR = "/workspace/.discovery"
|
|
1859
|
+
|
|
1860
|
+
|
|
1861
|
+
@dataclass(frozen=True)
|
|
1862
|
+
class Discovery:
|
|
1863
|
+
"""What a discovery issue's trailers say. `read_repo`/`read_sha` are None
|
|
1864
|
+
when `Alissa-Reads:` is absent or malformed, `kb_dir` when
|
|
1865
|
+
`Autonomous-Scope:` is not one knowledge-base directory."""
|
|
1866
|
+
|
|
1867
|
+
read_repo: "str | None"
|
|
1868
|
+
read_sha: "str | None"
|
|
1869
|
+
kb_dir: "str | None"
|
|
1870
|
+
|
|
1871
|
+
|
|
1872
|
+
def discovery_of(body: "str | None") -> "Discovery | None":
|
|
1873
|
+
"""The discovery trailers of an issue body, or None when it does not
|
|
1874
|
+
carry `Alissa-Kind: discovery` -- the one signal that selects
|
|
1875
|
+
DISCOVERY_DIRECTIVE. The first `Alissa-Reads:` wins."""
|
|
1876
|
+
text = body or ""
|
|
1877
|
+
if not _DISCOVERY_KIND_RE.search(text):
|
|
1878
|
+
return None
|
|
1879
|
+
reads = _ALISSA_READS_RE.search(text)
|
|
1880
|
+
scope = _KB_SCOPE_RE.search(text)
|
|
1881
|
+
return Discovery(
|
|
1882
|
+
read_repo=reads.group("repo") if reads else None,
|
|
1883
|
+
read_sha=reads.group("sha") if reads else None,
|
|
1884
|
+
kb_dir=scope.group("dir") if scope else None,
|
|
1885
|
+
)
|
|
1886
|
+
|
|
1887
|
+
|
|
1888
|
+
def render_discovery_directive(
|
|
1889
|
+
*,
|
|
1890
|
+
issue_url: str,
|
|
1891
|
+
number: int,
|
|
1892
|
+
attempt: int,
|
|
1893
|
+
cap: int,
|
|
1894
|
+
task_instruction: str,
|
|
1895
|
+
plans_repo: str,
|
|
1896
|
+
session: str,
|
|
1897
|
+
discovery: Discovery,
|
|
1898
|
+
) -> str:
|
|
1899
|
+
"""DISCOVERY_DIRECTIVE for one issue. A missing `Alissa-Reads:` renders
|
|
1900
|
+
the read target as placeholders plus DISCOVERY_READS_MISSING_NOTE (stop,
|
|
1901
|
+
do not guess a SHA); a missing knowledge-base scope renders
|
|
1902
|
+
DISCOVERY_KB_DIR_FALLBACK plus DISCOVERY_KB_DIR_NOTE."""
|
|
1903
|
+
notes = ""
|
|
1904
|
+
if discovery.read_repo is None or discovery.read_sha is None:
|
|
1905
|
+
notes += DISCOVERY_READS_MISSING_NOTE.format(session=session)
|
|
1906
|
+
if discovery.kb_dir is None:
|
|
1907
|
+
notes += DISCOVERY_KB_DIR_NOTE
|
|
1908
|
+
return DISCOVERY_DIRECTIVE.format(
|
|
1909
|
+
issue_url=issue_url,
|
|
1910
|
+
number=number,
|
|
1911
|
+
attempt=attempt,
|
|
1912
|
+
cap=cap,
|
|
1913
|
+
task_instruction=task_instruction,
|
|
1914
|
+
trailer_note=notes,
|
|
1915
|
+
plans_repo=plans_repo,
|
|
1916
|
+
read_repo=discovery.read_repo or "<owner>/<repo>",
|
|
1917
|
+
read_sha=discovery.read_sha or "<sha>",
|
|
1918
|
+
kb_dir=discovery.kb_dir or DISCOVERY_KB_DIR_FALLBACK,
|
|
1919
|
+
session=session,
|
|
1920
|
+
)
|
|
1921
|
+
|
|
1922
|
+
|
|
1923
|
+
def discovery_dir() -> Path:
|
|
1924
|
+
return Path(os.environ.get(DISCOVERY_DIR_ENV, "").strip() or DEFAULT_DISCOVERY_DIR)
|
|
1925
|
+
|
|
1926
|
+
|
|
1927
|
+
def write_discovery_marker(session: str, plans_repo: str, number: int, discovery: Discovery) -> Path:
|
|
1928
|
+
"""Arm the push guard for the managed session `session` (its tmux name is
|
|
1929
|
+
`ali-<session>`, the name the hook reads from `$ALISSA_TMUX_SESSION`):
|
|
1930
|
+
`<discovery_dir>/<tmux name>.json`, spelled like the waiting marker
|
|
1931
|
+
(`marker_file_name`), naming the one repository the session may push to.
|
|
1932
|
+
Written atomically (temp file + rename) so the hook never reads a torn
|
|
1933
|
+
file. Raises OSError; the caller stands the spawn down rather than start a
|
|
1934
|
+
discovery session the guard does not know about. Never removed: a
|
|
1935
|
+
session name is unique per issue and attempt, so a stale marker can only
|
|
1936
|
+
ever guard a later session on the same discovery issue."""
|
|
1937
|
+
tmux_name = f"{MANAGED_PREFIX}{session}"
|
|
1938
|
+
folder = discovery_dir()
|
|
1939
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
1940
|
+
path = folder / marker_file_name(tmux_name)
|
|
1941
|
+
payload = {
|
|
1942
|
+
"session": tmux_name,
|
|
1943
|
+
"plansRepo": plans_repo,
|
|
1944
|
+
"issue": number,
|
|
1945
|
+
"reads": (
|
|
1946
|
+
f"{discovery.read_repo}@{discovery.read_sha}"
|
|
1947
|
+
if discovery.read_repo is not None
|
|
1948
|
+
else None
|
|
1949
|
+
),
|
|
1950
|
+
}
|
|
1951
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
1952
|
+
tmp.write_text(json.dumps(payload, sort_keys=True) + "\n", encoding="utf-8")
|
|
1953
|
+
os.replace(tmp, path)
|
|
1954
|
+
return path
|
|
1955
|
+
|
|
1956
|
+
|
|
1712
1957
|
# The first-run-dialog wedge's once-per-episode marker (issue #123; see
|
|
1713
1958
|
# first_run_dialog_kind). Like ESCALATION_WEDGED an ACT, not a page: the
|
|
1714
1959
|
# daemon killed a session whose pane showed one of Claude Code's first-run
|
|
@@ -4243,6 +4488,11 @@ class DevWatcher:
|
|
|
4243
4488
|
"resolution"
|
|
4244
4489
|
)
|
|
4245
4490
|
regenerate_rule = self._regenerate_rule(issue.owner, issue.repo)
|
|
4491
|
+
# A discovery issue (issue #164) gets DISCOVERY_DIRECTIVE on a fresh
|
|
4492
|
+
# spawn; a resume keeps RESUME_DIRECTIVE (it finishes a PR that
|
|
4493
|
+
# already exists). Both are discovery SESSIONS, so both get the
|
|
4494
|
+
# push-guard marker below.
|
|
4495
|
+
discovery = discovery_of(issue.body)
|
|
4246
4496
|
if resume_pr is not None:
|
|
4247
4497
|
directive = RESUME_DIRECTIVE.format(
|
|
4248
4498
|
issue_url=issue.url,
|
|
@@ -4258,6 +4508,17 @@ class DevWatcher:
|
|
|
4258
4508
|
dead_session=dead_session,
|
|
4259
4509
|
regenerate_rule=regenerate_rule,
|
|
4260
4510
|
)
|
|
4511
|
+
elif discovery is not None:
|
|
4512
|
+
directive = render_discovery_directive(
|
|
4513
|
+
issue_url=issue.url,
|
|
4514
|
+
number=issue.number,
|
|
4515
|
+
attempt=attempt,
|
|
4516
|
+
cap=self.config.attempt_cap,
|
|
4517
|
+
task_instruction=task_instruction,
|
|
4518
|
+
plans_repo=issue.full_name,
|
|
4519
|
+
session=name,
|
|
4520
|
+
discovery=discovery,
|
|
4521
|
+
)
|
|
4261
4522
|
else:
|
|
4262
4523
|
directive = DEV_DIRECTIVE.format(
|
|
4263
4524
|
issue_url=issue.url,
|
|
@@ -4308,6 +4569,32 @@ class DevWatcher:
|
|
|
4308
4569
|
attempt - 1,
|
|
4309
4570
|
)
|
|
4310
4571
|
|
|
4572
|
+
# The push guard's marker (issue #164), before the assignment and the
|
|
4573
|
+
# enqueue: a discovery session must never exist without it, because
|
|
4574
|
+
# the marker is the only thing that tells the shell guard to refuse
|
|
4575
|
+
# its pushes to the read repository. A marker that cannot be written
|
|
4576
|
+
# stands the spawn down exactly like an unreadable issue -- nothing
|
|
4577
|
+
# claimed, the slot handed back, retried next poll. The issue
|
|
4578
|
+
# edge's own repository is the plans repository: Autonomous-Dev of a
|
|
4579
|
+
# discovery issue is the fleet's plans repo (design §2.2).
|
|
4580
|
+
if discovery is not None and not self.config.dry_run:
|
|
4581
|
+
try:
|
|
4582
|
+
write_discovery_marker(name, issue.full_name, issue.number, discovery)
|
|
4583
|
+
except OSError as exc:
|
|
4584
|
+
if budget is not None:
|
|
4585
|
+
budget.release()
|
|
4586
|
+
log.warning(
|
|
4587
|
+
"%s: could not write the discovery push-guard marker for "
|
|
4588
|
+
"%s (%s) — standing the spawn down this pass",
|
|
4589
|
+
issue.issue_slug, name, exc,
|
|
4590
|
+
)
|
|
4591
|
+
return Decision(
|
|
4592
|
+
Action.SKIPPED,
|
|
4593
|
+
f"discovery push-guard marker could not be written ({exc}) "
|
|
4594
|
+
f"— retried next poll",
|
|
4595
|
+
attempt - 1,
|
|
4596
|
+
)
|
|
4597
|
+
|
|
4311
4598
|
# assign_self FIRST: the assignment is the server-side in-flight
|
|
4312
4599
|
# marker, so it must land before anything is enqueued -- a crash
|
|
4313
4600
|
# between the two leaves an assigned-but-sessionless issue (safe:
|
|
@@ -8,8 +8,8 @@ the devloop half of that contract, shared by the daemon (which computes the
|
|
|
8
8
|
verdict per pass and pushes it with the fleet vitals) and the console (which
|
|
9
9
|
serves it on ``GET /api/state`` and runs the drain handshake):
|
|
10
10
|
|
|
11
|
-
* **The idle block.** ``{asOf, passAt, owed, live, managed, queued,
|
|
12
|
-
drainedAt}``. `owed` counts the candidates whose LAST evaluation was an
|
|
11
|
+
* **The idle block.** ``{asOf, passAt, owed, live, managed, queued, gone,
|
|
12
|
+
timers, drainedAt}``. `owed` counts the candidates whose LAST evaluation was an
|
|
13
13
|
action or a timer the seat owns (a spawn, a fresh spawn row in its stale
|
|
14
14
|
window, an owed respawn, a deferral, a pending stall ping, a grace the seat
|
|
15
15
|
is waiting out, an evaluation that failed); a candidate the seat has
|
|
@@ -17,7 +17,9 @@ serves it on ``GET /api/state`` and runs the drain handshake):
|
|
|
17
17
|
owed. Each pass records the verdict per candidate on its poll snapshot's
|
|
18
18
|
stage (`owed`, `timer`), so the block is read back from the snapshot and
|
|
19
19
|
costs no GitHub call. `live` / `managed` / `queued` are read from tmux and
|
|
20
|
-
the alissa queue by whoever builds the block.
|
|
20
|
+
the alissa queue by whoever builds the block. `managed` is the live rows
|
|
21
|
+
plus the dead rows whose session backs a lane the seat still holds; a dead
|
|
22
|
+
row backing nothing is reported under `gone` instead (`roster_counts`).
|
|
21
23
|
* **Unknown is never idle.** No snapshot yet → `owed` is null; a listing that
|
|
22
24
|
failed → `live` / `managed` / `queued` are null. `idle_refusals` reads every
|
|
23
25
|
null as a refusal.
|
|
@@ -29,7 +31,7 @@ serves it on ``GET /api/state`` and runs the drain handshake):
|
|
|
29
31
|
from __future__ import annotations
|
|
30
32
|
|
|
31
33
|
from datetime import datetime, timezone
|
|
32
|
-
from typing import Iterable, Mapping, Sequence
|
|
34
|
+
from typing import Collection, Iterable, Mapping, Sequence
|
|
33
35
|
|
|
34
36
|
# The timer kinds of the design's idle block (§2.1). devloop owns the first
|
|
35
37
|
# three; the last two are revloop's and are listed so the union is stated once.
|
|
@@ -111,19 +113,107 @@ def queued_items(queues: "object") -> "int | None":
|
|
|
111
113
|
return count
|
|
112
114
|
|
|
113
115
|
|
|
116
|
+
# The tmux prefix `alissa tmux` puts on a managed session's real name
|
|
117
|
+
# (`alissa.MANAGED_PREFIX`, restated so this module imports nothing of the
|
|
118
|
+
# daemon's). A listing row carries both spellings (`name` bare, `session`
|
|
119
|
+
# prefixed), a snapshot stage the bare one, a queue the prefixed one.
|
|
120
|
+
MANAGED_PREFIX = "ali-"
|
|
121
|
+
|
|
122
|
+
# The spawn ledgers (`Sources.ledgers` keys) with the snapshot edge each one
|
|
123
|
+
# feeds, the column holding its issue / PR number, and the slug separator the
|
|
124
|
+
# poll snapshot uses for it (`owner/repo#7`, `owner/repo#pr16`).
|
|
125
|
+
LEDGER_LANES = (
|
|
126
|
+
("spawns", "issue", "issue", "#"),
|
|
127
|
+
("fix_spawns", "fix", "number", "#pr"),
|
|
128
|
+
("maintain_spawns", "maintain", "number", "#pr"),
|
|
129
|
+
("revert_spawns", "revert", "number", "#pr"),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# A stage holding one of these actions has a worker in flight on it whatever
|
|
133
|
+
# its owed verdict says: its session is never an orphan.
|
|
134
|
+
IN_FLIGHT_ACTIONS = frozenset({"spawned", "in-flight"})
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def bare_session(name: "object") -> str:
|
|
138
|
+
"""A session name without its `ali-` tmux prefix ("" for a non-name)."""
|
|
139
|
+
text = str(name or "")
|
|
140
|
+
return text[len(MANAGED_PREFIX):] if text.startswith(MANAGED_PREFIX) else text
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def lane_sessions(
|
|
144
|
+
snapshot: "Mapping | None",
|
|
145
|
+
*,
|
|
146
|
+
ledgers: "Mapping | None" = None,
|
|
147
|
+
queues: "object" = None,
|
|
148
|
+
) -> "frozenset[str] | None":
|
|
149
|
+
"""The bare names of the sessions that back a lane the seat still holds
|
|
150
|
+
(issue #166): the session a stage that is owed or in flight names; every
|
|
151
|
+
session the spawn ledgers record on such a stage's lane (an earlier
|
|
152
|
+
attempt the lane may still resume); and every session the alissa queue
|
|
153
|
+
holds an undispatched item for. None before the first pass -- with no
|
|
154
|
+
snapshot nothing can be called an orphan, so every row stays managed."""
|
|
155
|
+
if snapshot is None:
|
|
156
|
+
return None
|
|
157
|
+
names: "set[str]" = set()
|
|
158
|
+
held: "set[tuple[str, str]]" = set()
|
|
159
|
+
for stage in snapshot.get("stages") or ():
|
|
160
|
+
if not isinstance(stage, Mapping):
|
|
161
|
+
continue
|
|
162
|
+
if not (stage_owed(stage) or stage.get("stage") in IN_FLIGHT_ACTIONS):
|
|
163
|
+
continue
|
|
164
|
+
held.add((str(stage.get("edge") or ""), str(stage.get("slug") or "")))
|
|
165
|
+
if stage.get("session"):
|
|
166
|
+
names.add(bare_session(stage["session"]))
|
|
167
|
+
for table, edge, column, sep in LEDGER_LANES:
|
|
168
|
+
for row in (ledgers or {}).get(table) or ():
|
|
169
|
+
lane = (edge, f"{row.get('repo_slug')}{sep}{row.get(column)}")
|
|
170
|
+
if lane in held and row.get("session"):
|
|
171
|
+
names.add(bare_session(row["session"]))
|
|
172
|
+
if isinstance(queues, list):
|
|
173
|
+
for queue in queues:
|
|
174
|
+
if not isinstance(queue, Mapping):
|
|
175
|
+
continue
|
|
176
|
+
if any(
|
|
177
|
+
isinstance(item, Mapping) and not item.get("lastDispatchedAt")
|
|
178
|
+
for item in queue.get("items") or ()
|
|
179
|
+
):
|
|
180
|
+
names.add(bare_session(queue.get("session")))
|
|
181
|
+
names.discard("")
|
|
182
|
+
return frozenset(names)
|
|
183
|
+
|
|
184
|
+
|
|
114
185
|
def roster_counts(
|
|
115
186
|
roster: "Sequence[Mapping] | None",
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
187
|
+
lanes: "Collection[str] | None" = None,
|
|
188
|
+
) -> "tuple[int | None, int | None, list[str] | None]":
|
|
189
|
+
"""`(live, managed, gone)` from an ``alissa tmux ls --json`` listing.
|
|
190
|
+
|
|
191
|
+
A row is live unless it says otherwise (the fleet-vitals `sessions`
|
|
192
|
+
rule: its `live` flag, else `status != "gone"`). A row is MANAGED when it
|
|
193
|
+
is live or when its session backs a lane the seat still holds -- `lanes`,
|
|
194
|
+
the bare names `lane_sessions` returns. A dead row matching no lane is an
|
|
195
|
+
orphan: it is not managed, and its name is reported in `gone` instead
|
|
196
|
+
(issue #166 -- one such row kept orcloop's `managed == 0` from ever
|
|
197
|
+
holding). `lanes=None` (no completed pass to read lanes from) keeps every
|
|
198
|
+
row managed: unknown is never idle. `(None, None, None)` when the listing
|
|
199
|
+
failed."""
|
|
120
200
|
if roster is None:
|
|
121
|
-
return None, None
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
201
|
+
return None, None, None
|
|
202
|
+
live = managed = 0
|
|
203
|
+
gone: "list[str]" = []
|
|
204
|
+
for row in roster:
|
|
205
|
+
if not isinstance(row, Mapping):
|
|
206
|
+
continue
|
|
207
|
+
if bool(row.get("live", row.get("status") != "gone")):
|
|
208
|
+
live += 1
|
|
209
|
+
managed += 1
|
|
210
|
+
continue
|
|
211
|
+
name = bare_session(row.get("name") or row.get("session"))
|
|
212
|
+
if lanes is None or name in lanes or bare_session(row.get("session")) in lanes:
|
|
213
|
+
managed += 1
|
|
214
|
+
else:
|
|
215
|
+
gone.append(name)
|
|
216
|
+
return live, managed, sorted(gone)
|
|
127
217
|
|
|
128
218
|
|
|
129
219
|
def idle_block(
|
|
@@ -134,10 +224,13 @@ def idle_block(
|
|
|
134
224
|
queued: "int | None",
|
|
135
225
|
drain: "Mapping | None",
|
|
136
226
|
now: "float | int",
|
|
227
|
+
gone: "Sequence[str] | None" = None,
|
|
137
228
|
) -> dict:
|
|
138
229
|
"""The seat's idle block from its latest poll snapshot (None before the
|
|
139
230
|
first pass) and the caller's live readings. `drain` is the holding drain
|
|
140
|
-
row (`State.drain`) or None.
|
|
231
|
+
row (`State.drain`) or None. `gone` is `roster_counts`' orphaned rows,
|
|
232
|
+
reported as `{count, sessions}` (null when the listing was not read) --
|
|
233
|
+
an observation for the operator, never a refusal."""
|
|
141
234
|
stages: "Iterable[Mapping]" = (snapshot or {}).get("stages") or ()
|
|
142
235
|
owed: "int | None" = None
|
|
143
236
|
timers: "list[dict]" = []
|
|
@@ -157,6 +250,10 @@ def idle_block(
|
|
|
157
250
|
"live": live,
|
|
158
251
|
"managed": managed,
|
|
159
252
|
"queued": queued,
|
|
253
|
+
"gone": (
|
|
254
|
+
{"count": len(gone), "sessions": list(gone)}
|
|
255
|
+
if gone is not None else None
|
|
256
|
+
),
|
|
160
257
|
"timers": timers,
|
|
161
258
|
"drainedAt": iso(drain.get("drained_at")) if drain else None,
|
|
162
259
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.8.43
|
|
@@ -438,17 +438,25 @@ class Sources:
|
|
|
438
438
|
a drain holds. A caller that already holds the listing (None when it
|
|
439
439
|
failed) or the latest snapshot (None before the first pass) hands it
|
|
440
440
|
in; anything not handed in is read here. A failed listing is null
|
|
441
|
-
counts, never zeros.
|
|
441
|
+
counts, never zeros. A dead row backing no lane the seat holds is
|
|
442
|
+
reported under `gone`, not counted as managed (issue #166)."""
|
|
442
443
|
if latest is _UNREAD:
|
|
443
444
|
snaps = self.snapshots(1)
|
|
444
445
|
latest = snaps[0] if snaps else None
|
|
445
446
|
if listing is _UNREAD:
|
|
446
447
|
listing = self._safe_json(["alissa", "tmux", "ls", "--json"])
|
|
447
448
|
roster = listing if isinstance(listing, list) else None
|
|
448
|
-
|
|
449
|
+
snapshot = latest if isinstance(latest, dict) else None
|
|
450
|
+
queues = self._safe_json(["alissa", "tmux", "queue", "ls", "--json"])
|
|
451
|
+
# A dead row counts as managed only while it backs a lane the seat
|
|
452
|
+
# still holds (issue #166).
|
|
453
|
+
lanes = parking.lane_sessions(
|
|
454
|
+
snapshot, ledgers=self.ledgers(), queues=queues,
|
|
455
|
+
)
|
|
456
|
+
live, managed, gone = parking.roster_counts(roster, lanes)
|
|
449
457
|
return parking.idle_block(
|
|
450
|
-
|
|
451
|
-
|
|
458
|
+
snapshot, live=live, managed=managed,
|
|
459
|
+
queued=parking.queued_items(queues), gone=gone,
|
|
452
460
|
drain=self.drain(), now=self._wall(),
|
|
453
461
|
)
|
|
454
462
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: alissa-tools-github-devloop
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.43
|
|
4
4
|
Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
|
|
5
5
|
Home-page: https://alissa.app
|
|
6
6
|
Author: Fahera
|
|
@@ -148,6 +148,78 @@ issue immediately before every enqueue: a closed issue is `SKIPPED` "issue
|
|
|
148
148
|
closed between search and spawn", nothing is claimed, and the reserved slot
|
|
149
149
|
is handed back. The respawn-with-resume on an open draft PR is unchanged.
|
|
150
150
|
|
|
151
|
+
## The discovery round (issue #164)
|
|
152
|
+
|
|
153
|
+
A discovery issue is a no-code lane in the fleet's **plans repository**
|
|
154
|
+
(design `alissa-build-discovery.md` §3.2, lane DS-7a). Studio's `feed_plan`
|
|
155
|
+
renders it with the trailers `Alissa-Kind: discovery`, `Alissa-Reads:
|
|
156
|
+
<owner>/<repo>@<40-hex sha>` (the code repository it reads, at a pinned SHA)
|
|
157
|
+
and `Autonomous-Scope: knowledge-base/<YYYY>/<MM>/<DD>/TASK-<n>/**`. The
|
|
158
|
+
issue edge spawns the ordinary develop session — same name, same caps, same
|
|
159
|
+
liveness rules — with `DISCOVERY_DIRECTIVE` (in `loop.py`, byte-pinned in
|
|
160
|
+
`test_discovery.py`) instead of `DEV_DIRECTIVE`. Every other issue gets
|
|
161
|
+
today's directive byte for byte; a resume of a dead discovery session keeps
|
|
162
|
+
`RESUME_DIRECTIVE`.
|
|
163
|
+
|
|
164
|
+
The directive has the worker:
|
|
165
|
+
|
|
166
|
+
1. create its `TASK-<n>-<DESC>` branch in the plans repository, the branch
|
|
167
|
+
grammar unchanged;
|
|
168
|
+
2. read the code at the pinned SHA, read-only — a `git -C <hub> worktree add
|
|
169
|
+
--detach <scratch> <sha>` when the workspace has a hub of the read
|
|
170
|
+
repository (no branch, removed at the end), otherwise `gh api
|
|
171
|
+
repos/<repo>/contents/<path>?ref=<sha>` and `gh search code`; never a
|
|
172
|
+
clone, never a commit or push there. The read is bounded by the SHA, not a
|
|
173
|
+
file count; the output by the findings schema's limits;
|
|
174
|
+
3. write exactly one directory, `knowledge-base/<YYYY>/<MM>/<DD>/TASK-<n>/`,
|
|
175
|
+
holding only `findings.md` (findings schema v1, `schema/findings.v1.json`
|
|
176
|
+
in the plans repository);
|
|
177
|
+
4. run `node lint/plan-lint.ts --kb <dir>` locally with its own
|
|
178
|
+
`ALISSA_API_TOKEN` (K6/K7) and never push findings it refuses;
|
|
179
|
+
5. open a ready pull request with `Closes #<n>`, both task refs and the
|
|
180
|
+
marker `<!-- alissa-discovery v1 -->` on a line of its own, attach a
|
|
181
|
+
`Findings` evidence item, move its task to `pending_validation`, request
|
|
182
|
+
**no** reviewer (revloop skips such PRs; orcloop merges on the lint of
|
|
183
|
+
record) and end its session.
|
|
184
|
+
|
|
185
|
+
**The push guard.** Before it enqueues a discovery session (fresh or
|
|
186
|
+
resumed), the daemon writes `${ALISSA_DISCOVERY_DIR:-/workspace/.discovery}/
|
|
187
|
+
ali-<session>.json` naming the plans repository; a marker that cannot be
|
|
188
|
+
written stands the spawn down for the pass. The container's shell guard
|
|
189
|
+
(`docker/claude/hooks/guard-shell.py`) finds the marker through
|
|
190
|
+
`$ALISSA_TMUX_SESSION` and refuses, with a PreToolUse deny and a reason, any
|
|
191
|
+
`git push` from that session whose destination is not the plans repository —
|
|
192
|
+
resolved from the remote name or URL in the directory the push runs in
|
|
193
|
+
(`-C`, `cd`), and refused outright when it cannot be resolved. Sessions with
|
|
194
|
+
no marker are untouched. `ALISSA_SHELL_GUARD=off` disables this guard with
|
|
195
|
+
the rest.
|
|
196
|
+
|
|
197
|
+
## Seat parking: what counts as managed (issue #166)
|
|
198
|
+
|
|
199
|
+
The idle block on `GET /api/state` (`parking.idle_block`, issue #153) is what
|
|
200
|
+
orcloop's D2 invariant reads before it drains and parks the seat: idle only
|
|
201
|
+
when `live == managed == queued == owed == 0` with no timer and a fresh pass.
|
|
202
|
+
`live` and `managed` come from `alissa tmux ls --json`:
|
|
203
|
+
|
|
204
|
+
- a row is **live** unless it says otherwise (its `live` flag, else
|
|
205
|
+
`status != "gone"`);
|
|
206
|
+
- a row is **managed** when it is live, **or** when it is dead but its
|
|
207
|
+
session still backs a lane the seat holds — the session named by a stage
|
|
208
|
+
of the latest poll snapshot that is owed or in flight (`spawned` /
|
|
209
|
+
`in-flight`), any session the spawn ledgers record on such a stage's lane
|
|
210
|
+
(an earlier attempt the lane may still resume), or a session the alissa
|
|
211
|
+
queue holds an undispatched item for;
|
|
212
|
+
- a dead row backing none of these is an **orphan**: it is not managed, and
|
|
213
|
+
is reported in the block's `gone` field as `{count, sessions}` so the
|
|
214
|
+
console and orcloop's logs still show it.
|
|
215
|
+
|
|
216
|
+
Unknown is never idle: a failed listing leaves `live`, `managed` and `gone`
|
|
217
|
+
null, and before the first completed pass (no snapshot to read lanes from)
|
|
218
|
+
every dead row stays managed. The console only reports an orphan — it never
|
|
219
|
+
kills or reaps one. The fleet-vitals push applies the same rule to
|
|
220
|
+
`sessions.managed` and `idle.managed` (Studio's park guard reads both) and
|
|
221
|
+
leaves `gone` out, since Studio's idle schema is strict.
|
|
222
|
+
|
|
151
223
|
## License
|
|
152
224
|
|
|
153
225
|
Licensed under the
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.8.41
|
|
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
|
|
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
|