instar 1.3.1312 → 1.3.1313
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.
- package/dist/data/standards-guard-index.json +1 -1
- package/dist/data/standards-guard-index.meta.json +2 -2
- package/dist/data/standards-registry.meta.json +1 -1
- package/dist/feedback-factory/drain/FeedbackDrainService.d.ts.map +1 -1
- package/dist/feedback-factory/drain/FeedbackDrainService.js +7 -3
- package/dist/feedback-factory/drain/FeedbackDrainService.js.map +1 -1
- package/dist/feedback-factory/drain/FeedbackDrainStore.d.ts +20 -0
- package/dist/feedback-factory/drain/FeedbackDrainStore.d.ts.map +1 -1
- package/dist/feedback-factory/drain/FeedbackDrainStore.js +73 -20
- package/dist/feedback-factory/drain/FeedbackDrainStore.js.map +1 -1
- package/dist/feedback-factory/store/FeedbackSourceGenerations.d.ts.map +1 -1
- package/dist/feedback-factory/store/FeedbackSourceGenerations.js +4 -0
- package/dist/feedback-factory/store/FeedbackSourceGenerations.js.map +1 -1
- package/dist/feedback-factory/store/JsonlFeedbackStore.d.ts.map +1 -1
- package/dist/feedback-factory/store/JsonlFeedbackStore.js +8 -1
- package/dist/feedback-factory/store/JsonlFeedbackStore.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +2 -2
- package/src/data/standards-guard-index.json +1 -1
- package/src/data/standards-guard-index.meta.json +2 -2
- package/src/data/standards-registry.meta.json +1 -1
- package/src/scaffold/templates/jobs/instar/feedback-factory-process.md +113 -20
- package/upgrades/1.3.1313.md +65 -0
- package/upgrades/feedback-drain-end-to-end.eli16.md +42 -0
- package/upgrades/side-effects/feedback-drain-end-to-end.md +245 -0
|
@@ -16,29 +16,122 @@ toolAllowlist: "*"
|
|
|
16
16
|
unrestrictedTools: true
|
|
17
17
|
mcpAccess: none
|
|
18
18
|
---
|
|
19
|
-
Run one feedback-factory operating-drain tick. This is a near-silent operated cadence — do NOT message the user. The server owns every transition; this job only triggers and
|
|
19
|
+
Run one feedback-factory operating-drain tick. This is a near-silent operated cadence — do NOT message the user. The server owns every transition; this job only triggers one bounded run and records an honest outcome.
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
AGENT_ID="${INSTAR_AGENT_ID:-$(python3 -c "import json; print(json.load(open('.instar/config.json')).get('projectName',''))" 2>/dev/null)}"
|
|
23
|
-
PORT="${INSTAR_PORT:-4042}"
|
|
24
|
-
NONCE="feedback-drain-$(date +%s)-$$"
|
|
21
|
+
Run the command below ONCE, exactly as written, in a single Bash call with `timeout: 300000`. Do not rewrite it, split it, or re-type it as shell code: the script carries every rule of this job, and an improvised shell loop has broken it before (zsh refuses a variable named `status`, and the run was recorded as a success).
|
|
25
22
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
- `live` → go to step 2.
|
|
30
|
-
- `dark` → exit silently. The drain is switched off on purpose (fleet default, or an operator switch-off).
|
|
31
|
-
- `unavailable` → FAIL THE RUN with reason `drain posture unavailable: <posture.reason>`.
|
|
32
|
-
- Anything else — no JSON, no `posture` field, connection refused, 401, 403, any other code → if `developmentAgent` is true, FAIL THE RUN with reason `drain status unreadable: HTTP <code or unreachable>`; otherwise exit silently.
|
|
23
|
+
```bash
|
|
24
|
+
python3 - <<'FEEDBACK_DRAIN_TICK'
|
|
25
|
+
import json, os, sys, time, urllib.error, urllib.request
|
|
33
26
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
27
|
+
def config():
|
|
28
|
+
try:
|
|
29
|
+
with open('.instar/config.json') as fh:
|
|
30
|
+
return json.load(fh)
|
|
31
|
+
except Exception:
|
|
32
|
+
return {}
|
|
37
33
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
CONFIG = config()
|
|
35
|
+
TOKEN = os.environ.get('INSTAR_AUTH_TOKEN') or (CONFIG.get('authToken') if isinstance(CONFIG.get('authToken'), str) else '')
|
|
36
|
+
AGENT_ID = os.environ.get('INSTAR_AGENT_ID') or str(CONFIG.get('projectName') or '')
|
|
37
|
+
BASE = 'http://localhost:' + str(os.environ.get('INSTAR_PORT') or CONFIG.get('port') or 4042)
|
|
38
|
+
DEVELOPMENT_AGENT = CONFIG.get('developmentAgent') is True
|
|
39
|
+
POLL_SECONDS = float(os.environ.get('FEEDBACK_DRAIN_POLL_SECONDS') or 60)
|
|
41
40
|
|
|
42
|
-
|
|
41
|
+
def fail(reason):
|
|
42
|
+
path = os.environ.get('INSTAR_JOB_FAILURE_FILE')
|
|
43
|
+
if path:
|
|
44
|
+
with open(path, 'w') as fh:
|
|
45
|
+
fh.write(reason[:480])
|
|
46
|
+
print('FEEDBACK_DRAIN_RESULT failed: ' + reason)
|
|
47
|
+
sys.exit(0)
|
|
43
48
|
|
|
44
|
-
|
|
49
|
+
def done(message):
|
|
50
|
+
print('FEEDBACK_DRAIN_RESULT ' + message)
|
|
51
|
+
sys.exit(0)
|
|
52
|
+
|
|
53
|
+
def call(method, route, extra=None):
|
|
54
|
+
headers = {'Authorization': 'Bearer ' + TOKEN, 'X-Instar-AgentId': AGENT_ID}
|
|
55
|
+
headers.update(extra or {})
|
|
56
|
+
request = urllib.request.Request(BASE + route, method=method, headers=headers, data=b'' if method == 'POST' else None)
|
|
57
|
+
try:
|
|
58
|
+
with urllib.request.urlopen(request, timeout=10) as response:
|
|
59
|
+
code, raw = response.status, response.read()
|
|
60
|
+
except urllib.error.HTTPError as error:
|
|
61
|
+
code, raw = error.code, error.read()
|
|
62
|
+
except Exception as error:
|
|
63
|
+
return None, 'unreachable (' + type(error).__name__ + ')'
|
|
64
|
+
try:
|
|
65
|
+
return code, json.loads(raw.decode('utf8') or 'null')
|
|
66
|
+
except Exception:
|
|
67
|
+
return code, None
|
|
68
|
+
|
|
69
|
+
def drain_status():
|
|
70
|
+
code, body = call('GET', '/feedback-factory/drain/status')
|
|
71
|
+
posture = body.get('posture') if isinstance(body, dict) else None
|
|
72
|
+
state = posture.get('state') if isinstance(posture, dict) else None
|
|
73
|
+
return code, body, posture, state
|
|
74
|
+
|
|
75
|
+
def count(value):
|
|
76
|
+
try:
|
|
77
|
+
return int(value or 0)
|
|
78
|
+
except (TypeError, ValueError):
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
def work_counts(body):
|
|
82
|
+
work = ((body or {}).get('drain') or {}).get('work') or {}
|
|
83
|
+
return count(work.get('claimed')) + count(work.get('completed'))
|
|
84
|
+
|
|
85
|
+
# 1. Posture: the JSON posture decides, whatever the HTTP code.
|
|
86
|
+
code, before, posture, state = drain_status()
|
|
87
|
+
if state == 'dark':
|
|
88
|
+
done('dark')
|
|
89
|
+
if state == 'unavailable':
|
|
90
|
+
fail('drain posture unavailable: ' + str(posture.get('reason') or 'unknown'))
|
|
91
|
+
if state != 'live':
|
|
92
|
+
if DEVELOPMENT_AGENT:
|
|
93
|
+
fail('drain status unreadable: HTTP ' + (str(code) if isinstance(code, int) else str(before)))
|
|
94
|
+
done('status unreadable on a fleet agent')
|
|
95
|
+
|
|
96
|
+
# 2. One bounded tick. A refused tick is a failed run (for example a demoted authority).
|
|
97
|
+
nonce = 'feedback-drain-' + str(int(time.time())) + '-' + str(os.getpid())
|
|
98
|
+
code, tick = call('POST', '/feedback-factory/drain/tick', {'X-Instar-Request': '1', 'X-Instar-Request-Nonce': nonce})
|
|
99
|
+
if code != 202 or not isinstance(tick, dict) or not tick.get('runId'):
|
|
100
|
+
detail = tick.get('error') if isinstance(tick, dict) else tick
|
|
101
|
+
fail('drain tick refused: HTTP ' + str(code) + ' ' + str(detail or ''))
|
|
102
|
+
run_id = tick['runId']
|
|
103
|
+
if tick.get('proxied'):
|
|
104
|
+
done('proxied to the owner machine, run ' + run_id + '; the owner records its outcome')
|
|
105
|
+
|
|
106
|
+
# 3. Poll this run to a terminal state (60 s). Still running at the limit is not a failure.
|
|
107
|
+
deadline = time.time() + POLL_SECONDS
|
|
108
|
+
last = None
|
|
109
|
+
after = None
|
|
110
|
+
while time.time() < deadline:
|
|
111
|
+
code, after, _, _ = drain_status()
|
|
112
|
+
last = (after or {}).get('lastRun') if isinstance(after, dict) else None
|
|
113
|
+
if isinstance(last, dict) and last.get('runId') == run_id and last.get('state') not in ('accepted', 'running'):
|
|
114
|
+
break
|
|
115
|
+
time.sleep(3)
|
|
116
|
+
else:
|
|
117
|
+
done('run ' + run_id + ' still in flight; the next cadence observes it')
|
|
118
|
+
|
|
119
|
+
# 4. Tier-1 supervision of the terminal run.
|
|
120
|
+
run_state = last.get('state')
|
|
121
|
+
reason = str(last.get('reason') or '').strip()
|
|
122
|
+
if run_state in ('succeeded', 'no-op'):
|
|
123
|
+
if isinstance(after, dict) and after.get('consumerLive') is False and work_counts(after) > work_counts(before):
|
|
124
|
+
fail('invariant broken: claimed/completed work advanced while the consumer is in simulation (run ' + run_id + ')')
|
|
125
|
+
done(run_state + ' ' + run_id)
|
|
126
|
+
fail('drain run ' + str(run_state) + ': ' + (reason or 'no reason given') + ' (run ' + run_id + ')')
|
|
127
|
+
FEEDBACK_DRAIN_TICK
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The script prints one `FEEDBACK_DRAIN_RESULT` line. It records a failed run itself by writing the reason to `$INSTAR_JOB_FAILURE_FILE` (the scheduler reads that file when the run ends); saying "failed" in your output does NOT record a failure. Outcomes it enforces:
|
|
131
|
+
- Posture `dark` → silent exit. `unavailable` → failed run. Unreadable status → failed run on a development agent, silent exit on the fleet.
|
|
132
|
+
- A refused tick (any HTTP code other than 202, e.g. `current registered readiness agent required` after the authority was demoted) → failed run.
|
|
133
|
+
- A tick proxied to the owner machine → success here; its outcome lives in the owner's run history and status.
|
|
134
|
+
- Terminal `succeeded` / `no-op` → success. Terminal `degraded`, `failed` or `abandoned` → failed run carrying the drain's reason.
|
|
135
|
+
- In simulation, claimed/completed work counts must not advance; if they do → failed run.
|
|
136
|
+
|
|
137
|
+
Never retry in the same run; the durable queue and the next cadence own retry. Then exit silently. Do NOT relay anything to Telegram and do NOT summarize. A failed run reaches the operator through the scheduler's consecutive-failure alert.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- assembled-by: assemble-next-md -->
|
|
4
|
+
<!-- bump: patch -->
|
|
5
|
+
|
|
6
|
+
## What Changed
|
|
7
|
+
|
|
8
|
+
The operated feedback drain failed every run with `source record checksum conflicts with
|
|
9
|
+
its projection`. The canonical `feedback.jsonl` is an append-only, last-write-wins log:
|
|
10
|
+
when processing flips a report from unprocessed to processing it re-appends the report's
|
|
11
|
+
full row with the same `sourceRecordId`. The drain read its own re-append as a conflict,
|
|
12
|
+
and one conflict failed the whole run, so 742 clusters never left `collecting`.
|
|
13
|
+
|
|
14
|
+
- The processing writer now appends each update as a new source record (fresh
|
|
15
|
+
`sourceRecordId`), as the spec requires. The 1,000 lines the old writer appended under the
|
|
16
|
+
original ids are accepted as superseded versions only because they change `status` and
|
|
17
|
+
`clusterId` alone. Any other same-id change, or an id bound to another report, is
|
|
18
|
+
corruption: that line alone is held in `source_conflicts` (audited, integrity hold set), the
|
|
19
|
+
cursor moves on, and every run ends `degraded` with `source-record-quarantined` until an
|
|
20
|
+
operator repairs it.
|
|
21
|
+
- Self-heal: the conflict the earlier build recorded for a legacy update line is cleared
|
|
22
|
+
(audited) when the cursor re-reads that line.
|
|
23
|
+
- Compaction: the cursor now starts after the checksum-verified copied prefix of a new
|
|
24
|
+
generation instead of re-reading the whole corpus, and an already-compact generation
|
|
25
|
+
is no longer copied into a byte-identical new file every interval.
|
|
26
|
+
- A projected batch replayed after a crash mid-pass no longer clusters (and counts) a
|
|
27
|
+
report the store already processed.
|
|
28
|
+
- The `feedback-factory-process` job body is now one fixed script. The runner model used to
|
|
29
|
+
improvise a zsh loop (`status=$(...)` fails: `status` is read-only in zsh), and a degraded
|
|
30
|
+
run was recorded as a success. The script fails the job run (via
|
|
31
|
+
`$INSTAR_JOB_FAILURE_FILE`) on a refused tick and on a `degraded`, `failed` or
|
|
32
|
+
`abandoned` run, with the drain's reason. A tick proxied to the owner machine finishes at
|
|
33
|
+
once (its outcome lives in the owner's run history and status).
|
|
34
|
+
|
|
35
|
+
Existing stores heal on the next run. The job body reaches existing agents through the
|
|
36
|
+
normal built-in job refresh on update. No operator action.
|
|
37
|
+
|
|
38
|
+
## What to Tell Your User
|
|
39
|
+
|
|
40
|
+
The feedback sorter was stuck: it tripped over its own bookkeeping on every run, so no
|
|
41
|
+
feedback reached the task list. It now runs end to end on the real backlog, and if a run
|
|
42
|
+
goes wrong the scheduled job says so instead of reporting success. Nothing for you to do.
|
|
43
|
+
|
|
44
|
+
## Summary of New Capabilities
|
|
45
|
+
|
|
46
|
+
- The feedback drain completes full passes on the live store (reports processed, clusters judged, ready work handed to Initiative tasks).
|
|
47
|
+
- One bad source record is quarantined instead of stopping every run.
|
|
48
|
+
- The drain job fails honestly when a run is degraded or refused.
|
|
49
|
+
|
|
50
|
+
## Evidence
|
|
51
|
+
|
|
52
|
+
Reproduced on a read-only copy (`sqlite3 .backup`) of the Mac Studio's live store, taken
|
|
53
|
+
2026-10-01 16:02 PDT, driving the real drain code:
|
|
54
|
+
- Before (origin/main): 3 of 3 ticks threw `source record checksum conflicts with its
|
|
55
|
+
projection` at the first processing re-append (`feedback-source:7720c778-…`); 742 clusters
|
|
56
|
+
stayed `collecting`, 0 work rows.
|
|
57
|
+
- After: 18 ticks, 0 failed or degraded; 1,004/1,004 reports processed, all 746 clusters
|
|
58
|
+
evaluated, 45 ready → queued → claimed → completed Initiative tasks (read back), the stale
|
|
59
|
+
conflict row cleared with an audit entry, 0 lines quarantined, 2 compacted generations
|
|
60
|
+
(6 before), projection lag 0.
|
|
61
|
+
- Real arbiter (gpt-6-astra via codex-cli): 5 generic clusters → `collecting`, 5 specific
|
|
62
|
+
multi-report bugs → `ready` (0.86–0.94); both replies replayed in the e2e.
|
|
63
|
+
- Job: the live 11:00 PDT run died on `read-only variable: status` and was recorded as a
|
|
64
|
+
success for a degraded drain run; the new script records that run as failed
|
|
65
|
+
(`drain run degraded: readiness-authority-failed`).
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# ELI16 — the feedback sorter tripped over its own notes
|
|
2
|
+
|
|
3
|
+
## The problem
|
|
4
|
+
|
|
5
|
+
Fleet agents send bug reports. The feedback sorter ("drain") reads them from a log file,
|
|
6
|
+
groups similar ones, asks a model which groups are ready to become a task, and creates
|
|
7
|
+
those tasks.
|
|
8
|
+
|
|
9
|
+
The log file is append-only: nothing is ever edited in place. When the sorter files a
|
|
10
|
+
report into a group, it writes a new copy of that report's line at the end of the file,
|
|
11
|
+
marked "processing". The newest copy wins.
|
|
12
|
+
|
|
13
|
+
The sorter also keeps its own index of every line it has read, with a fingerprint of each.
|
|
14
|
+
When it reached its own "processing" copy, it saw the same report id with a different
|
|
15
|
+
fingerprint and decided the file had been tampered with. It stopped the whole run. Every
|
|
16
|
+
run after that stopped at the same line, so no group ever became a task.
|
|
17
|
+
|
|
18
|
+
The scheduled job that drives the sorter reported "success" anyway. It runs on a small
|
|
19
|
+
model that wrote its own shell loop, and that loop crashed on a variable name the shell
|
|
20
|
+
reserves (`status`).
|
|
21
|
+
|
|
22
|
+
## What this change does
|
|
23
|
+
|
|
24
|
+
- The sorter's own updates now get a new record id each time, as the design always said
|
|
25
|
+
they should. So a new copy is never mistaken for a changed old one.
|
|
26
|
+
- The 1,000 old copies already in the file changed only the "processing" mark and the group
|
|
27
|
+
name. Those are accepted as updates. Any other change under an old id is still treated as
|
|
28
|
+
tampering.
|
|
29
|
+
- Tampering no longer stops everything. Only the bad line is set aside and logged, and the
|
|
30
|
+
rest of the run continues. Every run is then marked "degraded", so the scheduled job keeps
|
|
31
|
+
failing and alerting until a person repairs that line.
|
|
32
|
+
- The false alarm already stored on the live machine clears itself on the next run, with
|
|
33
|
+
an audit entry.
|
|
34
|
+
- Two waste fixes found on the way: after the log is compacted, the sorter no longer
|
|
35
|
+
re-reads everything from the start, and an unchanged log is no longer copied again.
|
|
36
|
+
- The scheduled job now runs one fixed script. A refused, degraded, or failed run fails
|
|
37
|
+
the job, with the reason.
|
|
38
|
+
|
|
39
|
+
## What you need to do
|
|
40
|
+
|
|
41
|
+
Nothing. The next run heals the stored state, and the job update arrives with the
|
|
42
|
+
normal instar update.
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# Side-Effects Review — feedback drain: a full pass completes on real data
|
|
2
|
+
|
|
3
|
+
**Version / slug:** `feedback-drain-end-to-end`
|
|
4
|
+
**Date:** `2026-10-01`
|
|
5
|
+
**Author:** `Echo`
|
|
6
|
+
**Second-pass reviewer:** `independent reviewer subagent (see below)`
|
|
7
|
+
|
|
8
|
+
## Summary of the change
|
|
9
|
+
|
|
10
|
+
Seventh first-run blocker on the operated drain (Mac Studio). After authority generation 2
|
|
11
|
+
went active (epoch 22681), both runs (15:58 and 16:00 PDT) failed with `source record
|
|
12
|
+
checksum conflicts with its projection`; `source_conflicts` held one row
|
|
13
|
+
(`feedback-source:7720c778-…`, report `fb-1145e5c3-4a2`). Per the standing "three failed
|
|
14
|
+
rounds → structural review" rule, this change drove the REAL drain code against a copy of
|
|
15
|
+
the live store until a full pass completed, fixing every blocker met on the way:
|
|
16
|
+
|
|
17
|
+
1. **Root cause (checksum conflict) — the writer.** The spec (§5, source discovery) says a
|
|
18
|
+
later last-write-wins update is a NEW source record with its own projection sequence, and a
|
|
19
|
+
duplicate `sourceRecordId` with different bytes is corruption that holds. The processing
|
|
20
|
+
writer broke the first half: `JsonlFeedbackStore.markProcessed` re-appended each processed
|
|
21
|
+
report under its ORIGINAL `sourceRecordId`. The first live runs appended 1,000 such lines;
|
|
22
|
+
the cursor then reached the first one (byte 2,992,509) and the drain, correctly by the
|
|
23
|
+
spec's second half, called it corruption, and threw for the whole run. Fix: the processing
|
|
24
|
+
update is appended without the old id, so `FeedbackSourceGenerations.append` mints a fresh
|
|
25
|
+
one. Updates are now projected as their own records; clustering ignores them (only
|
|
26
|
+
`unprocessed` rows are clustered).
|
|
27
|
+
2. **Legacy lines and quarantine.** The 1,000 lines already on disk keep their reused ids. A
|
|
28
|
+
same-id line is accepted as a superseded version only when it differs from the projected
|
|
29
|
+
record in the LWW fields alone (`status`, `clusterId`) — exactly what the old writer
|
|
30
|
+
produced (all 1,000 live lines pass; 0 quarantined on the copy). Any other difference
|
|
31
|
+
(`source-record-content-conflict`) or the id bound to another report
|
|
32
|
+
(`source-record-identity-conflict`) is corruption: that line alone is held in
|
|
33
|
+
`source_conflicts` with an audit row and `source_integrity_hold`, the cursor moves on, and
|
|
34
|
+
EVERY run ends `degraded` / `source-record-quarantined` until an operator repairs the
|
|
35
|
+
held line (spec §207: a persistent mismatch raises operator attention). One record no
|
|
36
|
+
longer fails the whole run. **Self-heal:** when the cursor re-reads a legacy LWW line that
|
|
37
|
+
the earlier build recorded as `source-record-checksum-conflict`, that row is deleted with
|
|
38
|
+
an audit entry (`cleared-misclassified-conflict`); the hold is lifted only if it is a
|
|
39
|
+
`source-record-*` hold and no conflict of any kind remains. The live store heals on its
|
|
40
|
+
next run.
|
|
41
|
+
3. **Compaction re-read (found on the copy).** `acceptSourceHandoff` reset the cursor to
|
|
42
|
+
byte 0 of the new generation, so every compaction re-read the whole corpus at 500 rows
|
|
43
|
+
per run. It now takes the handoff's `startOffset` (the end of the copied prefix, already
|
|
44
|
+
checksum-verified by `readManifest`). Optional parameter; callers without it keep 0.
|
|
45
|
+
4. **Byte-identical generations (found on the copy).** `compact()` on a generation with
|
|
46
|
+
nothing superseded wrote a new 3 MB copy each interval (5 identical files in the replay).
|
|
47
|
+
It now returns null when a manifest already exists and the output equals the input; the
|
|
48
|
+
legacy file still always moves to a generation. The service restarts the compaction
|
|
49
|
+
interval either way.
|
|
50
|
+
5. **Replay double-count.** A projected batch replayed after a crash mid-pass (process
|
|
51
|
+
killed between clustering and `acknowledgeProcessedProjection`) re-clustered reports the
|
|
52
|
+
store already marked processing, inflating `reportCount`; with updates now projected, the
|
|
53
|
+
same batch could also hold a report and its update ("duplicated id" throw, every run).
|
|
54
|
+
`withProjectedFeedbackScope` skips a row unless both the row and the store's folded state
|
|
55
|
+
are `unprocessed`, before the duplicate check.
|
|
56
|
+
6. **Job honesty.** The `feedback-factory-process` body is now one fixed python script the
|
|
57
|
+
haiku runner executes verbatim. The live run (transcript 8e49dd8d, 11:00 PDT) improvised
|
|
58
|
+
`status=$(curl …)` in zsh (read-only variable), and the old body also accepted a
|
|
59
|
+
`degraded` run as success. The script writes `$INSTAR_JOB_FAILURE_FILE` on: posture
|
|
60
|
+
`unavailable`; unreadable status on a development agent; any tick response other than 202
|
|
61
|
+
(e.g. 403 after an authority demotion); terminal `degraded` / `failed` / `abandoned`; and
|
|
62
|
+
simulation work counts that advance. `dark` exits silently. A tick proxied to the owner
|
|
63
|
+
machine finishes at once (its outcome lives in the owner's run history). Still in flight
|
|
64
|
+
at 60 s is not a failure. Worst case ~93 s (10 s per HTTP call), inside the
|
|
65
|
+
`timeout: 300000` the body asks the runner to set.
|
|
66
|
+
|
|
67
|
+
## Decision-point inventory
|
|
68
|
+
|
|
69
|
+
- Processing update writer — modify — original sourceRecordId → fresh sourceRecordId (spec).
|
|
70
|
+
- Source projection: same sourceRecordId + same report + only status/clusterId differ — modify — throw (whole run) → superseded version (legacy lines).
|
|
71
|
+
- Source projection: same sourceRecordId + other content or another report — modify — throw (whole run) → quarantine that line; every run degraded until repaired.
|
|
72
|
+
- Compaction handoff cursor start — modify — 0 → manifest `startOffset`.
|
|
73
|
+
- Compaction of an already-compact generation — modify — new identical generation → no-op.
|
|
74
|
+
- Projected clustering scope — modify — includes already-processed rows → skips them (before the duplicate-id check).
|
|
75
|
+
- Job outcome classification — modify — model-improvised → fixed script; `degraded` now fails the job.
|
|
76
|
+
- Reconciliation (`reconcileSourceProjection`) — unchanged: projected byte ranges are still re-hashed and mismatches still recorded.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## 1. Over-block
|
|
81
|
+
|
|
82
|
+
The old code failed whole runs for one line; the live trigger was not even corruption but
|
|
83
|
+
the drain's own writer. Now only a corrupt line is held, and only that line. The job now
|
|
84
|
+
fails on any `degraded` run. Every degraded reason is something an operator should see
|
|
85
|
+
(authority unavailable or failed, spend brake, readiness source missing, cancellation,
|
|
86
|
+
quarantine). The job is priority `low`, so the scheduler alerts after 3 consecutive
|
|
87
|
+
failures: a spend brake or an unrepaired quarantine fails every 30-minute run and alerts
|
|
88
|
+
within ~90 minutes; a single transient degraded run does not alert.
|
|
89
|
+
|
|
90
|
+
## 2. Under-block
|
|
91
|
+
|
|
92
|
+
- A legacy same-id line that changes only `status`/`clusterId` is accepted without an alarm.
|
|
93
|
+
That is the documented LWW update shape; it is never re-projected, so it cannot re-enter
|
|
94
|
+
clustering. A same-id line with any other change is still held.
|
|
95
|
+
- "Hold the entity family" (spec §6, corrupt source): the held line never enters the
|
|
96
|
+
projection; later updates of that report under their own ids are projected but are never
|
|
97
|
+
clustered (not `unprocessed`). The report's first, valid line stays the projected record.
|
|
98
|
+
- Repair of a held line is operator work (no route added); until then every run is degraded
|
|
99
|
+
and `pruneOperationalHistory` is skipped (existing degraded behaviour), so run/audit
|
|
100
|
+
history grows while the job alerts.
|
|
101
|
+
- The job script still depends on the runner executing it verbatim. The body says so and
|
|
102
|
+
the script is one heredoc; a `script`-type job would need a manifest migration for every
|
|
103
|
+
agent, which this does not justify while the prompt route works.
|
|
104
|
+
- A proxied tick's outcome is not supervised by any job: it is visible only in the owner's
|
|
105
|
+
run history and `/feedback-factory/drain/status` on the owner.
|
|
106
|
+
|
|
107
|
+
## 3. Level-of-abstraction fit
|
|
108
|
+
|
|
109
|
+
The projection rule sits in the store that owns the cursor and the conflict table. The
|
|
110
|
+
compaction fix sits in the generation writer, and the cursor fix in the store's handoff
|
|
111
|
+
acceptance. The job fix is in the shipped template that `installBuiltinJobs` overwrites
|
|
112
|
+
on every update.
|
|
113
|
+
|
|
114
|
+
## 4. Signal vs authority compliance
|
|
115
|
+
|
|
116
|
+
Deterministic integrity checks over ids and checksums; no judgment. Quarantine is a
|
|
117
|
+
signal (conflict row, hold, audit, degraded run) and no longer blocks unrelated reports.
|
|
118
|
+
|
|
119
|
+
## 4b. Judgment-point check
|
|
120
|
+
|
|
121
|
+
No new heuristic at a competing-signals point. The readiness model's prompt and parsing are
|
|
122
|
+
untouched; the e2e replays two real gpt-6-astra replies through the unchanged parser.
|
|
123
|
+
|
|
124
|
+
## 5. Interactions
|
|
125
|
+
|
|
126
|
+
- `reconcileSourceProjection` re-hashes only projected byte ranges, which still hold their
|
|
127
|
+
original bytes (append-only). Unaffected.
|
|
128
|
+
- `acceptSourceHandoff` keeps the "target generation already projected" refusal.
|
|
129
|
+
- `pruneOperationalHistory` is skipped on degraded runs, as before; quarantine is the only
|
|
130
|
+
new degraded case, and it persists until repaired.
|
|
131
|
+
- `source_integrity_hold` is also set by the generation-missing path and by reconciliation;
|
|
132
|
+
this path lifts only a `source-record-*` hold, and only when the conflict table is empty.
|
|
133
|
+
Nothing reads the hold today.
|
|
134
|
+
- Projection rows grow by one per processing update (the spec's model); compaction's copied
|
|
135
|
+
prefix is skipped by `startOffset`.
|
|
136
|
+
|
|
137
|
+
## 6. External surfaces
|
|
138
|
+
|
|
139
|
+
`/feedback-factory/drain/status`: `sourceChecksumConflicts` drops to 0 on the live store
|
|
140
|
+
after one run. The job's run history now records failures it previously hid.
|
|
141
|
+
|
|
142
|
+
## 6b. Operator-surface quality
|
|
143
|
+
|
|
144
|
+
The failure reasons the job records are one line with the drain's own reason and run id.
|
|
145
|
+
|
|
146
|
+
## 7. Multi-machine posture (Cross-Machine Coherence)
|
|
147
|
+
|
|
148
|
+
Machine-local by design: the source log and drain DB live only on the operated host. The job
|
|
149
|
+
on a non-owner proxies the tick as before and now finishes at once. The proxied run's outcome
|
|
150
|
+
is visible only in the owner's run history and status; no job supervises it.
|
|
151
|
+
|
|
152
|
+
## 8. Rollback cost
|
|
153
|
+
|
|
154
|
+
A code revert. A healed store keeps working on the old code until the next processing
|
|
155
|
+
re-append is read, then fails as before. No data is rewritten; the only deletions are the
|
|
156
|
+
misclassified conflict rows, each audited.
|
|
157
|
+
|
|
158
|
+
## Conclusion
|
|
159
|
+
|
|
160
|
+
Root cause: the processing writer re-appended updates under the original source id, which
|
|
161
|
+
the spec calls corruption, and one such line failed every run. The writer now mints a new id,
|
|
162
|
+
the 1,000 legacy lines are accepted only because they change status/clusterId alone, real
|
|
163
|
+
corruption holds just its line while runs stay loudly degraded, and the live store heals on
|
|
164
|
+
its next run. Driving
|
|
165
|
+
the real code over a copy of the live store until a full pass completed found and fixed two
|
|
166
|
+
compaction costs and a replay double-count on the way, and the job now reports degraded and
|
|
167
|
+
failed runs honestly.
|
|
168
|
+
|
|
169
|
+
## Operator repair of a held source line
|
|
170
|
+
|
|
171
|
+
Only needed if a run reports `source-record-quarantined` (none on the live store today).
|
|
172
|
+
1. Read the held rows: `sqlite3 <stateDir>/state/feedback-factory/store/feedback-drain.db
|
|
173
|
+
"SELECT * FROM source_conflicts"` and the matching `drain_audit` rows
|
|
174
|
+
(`kind='source-record'`). Compare the held line in `feedback.jsonl` (search its
|
|
175
|
+
`sourceRecordId`) with the projected record (`SELECT record_json FROM source_projection
|
|
176
|
+
WHERE source_record_id=?`).
|
|
177
|
+
2. Decide: the projected record stays authoritative (the default — nothing else to do), or
|
|
178
|
+
the report needs a fresh correct line, appended through the normal intake.
|
|
179
|
+
3. Clear the hold with the server stopped:
|
|
180
|
+
`DELETE FROM source_conflicts WHERE source_record_id='<id>';`
|
|
181
|
+
`INSERT INTO drain_audit(kind,entity_id,from_state,to_state,reason,created_at) VALUES
|
|
182
|
+
('source-record','<id>','<reason>','operator-cleared','operator-repair:<decision-ref>',<epoch-ms>);`
|
|
183
|
+
and, if the table is now empty, `DELETE FROM drain_meta WHERE key='source_integrity_hold';`.
|
|
184
|
+
The next run is no longer degraded by it.
|
|
185
|
+
|
|
186
|
+
## Spec alignment
|
|
187
|
+
|
|
188
|
+
`docs/specs/feedback-factory-operating-drain.md` §5 (source discovery, reconciliation) and §6
|
|
189
|
+
(corrupt source):
|
|
190
|
+
- "Later LWW updates are new source records and receive a new projection sequence" — the
|
|
191
|
+
writer now does this; the defect was the writer reusing the id.
|
|
192
|
+
- "Duplicate `sourceRecordId` with a different checksum is corruption and holds" — kept for
|
|
193
|
+
every change except the legacy status/clusterId-only update the old writer produced, which
|
|
194
|
+
is the LWW update the spec describes, written under the wrong id. Those lines are
|
|
195
|
+
superseded, not deleted; the source file is never rewritten.
|
|
196
|
+
- "Conflicting rows are … never auto-deleted" — the only deletion is of rows the earlier
|
|
197
|
+
build recorded for those legacy LWW lines, each audited (`cleared-misclassified-conflict`).
|
|
198
|
+
Real conflicts (content or identity) are never deleted.
|
|
199
|
+
- "A persistent mismatch … raises one operator attention" — every run stays degraded while a
|
|
200
|
+
held line exists (projection content/identity conflicts and reconciliation checksum
|
|
201
|
+
conflicts), the job fails, and the scheduler's consecutive-failure alert fires. Before this
|
|
202
|
+
change a reconciliation conflict never degraded a run.
|
|
203
|
+
- "Source-record uniqueness makes copied live rows idempotent" — unchanged; the compaction
|
|
204
|
+
cursor additionally skips the verified copied prefix.
|
|
205
|
+
|
|
206
|
+
## Second-pass review (if required)
|
|
207
|
+
|
|
208
|
+
**Reviewer:** independent reviewer subagent (general-purpose, read-only)
|
|
209
|
+
**Independent read of the artifact (round 1):** concerns raised, all addressed
|
|
210
|
+
|
|
211
|
+
Round 1 raised: (a) a real quarantine alerted only once, against spec §207 → every run now
|
|
212
|
+
stays degraded until repair; (b) a proxied tick polled a run it cannot see for 90 s and
|
|
213
|
+
recorded success → finishes at once; (c) worst-case script time (~150 s) exceeded the Bash
|
|
214
|
+
tool's 120 s default → 10 s HTTP timeouts, 60 s poll, body asks for `timeout: 300000`;
|
|
215
|
+
(d) a non-numeric work count crashed the script → safe parse; (e) the hold-clear removed holds
|
|
216
|
+
other paths own → only `source-record-*` holds; (f) the change contradicted spec §5
|
|
217
|
+
("later LWW updates are new source records") → redesigned: the writer mints a fresh id and
|
|
218
|
+
only legacy status/clusterId-only lines are tolerated; (g) wording in §1, §5, §7 → corrected.
|
|
219
|
+
**Round 2:** concur. Notes applied: legacy-update comparison also ignores the re-keyed id
|
|
220
|
+
fields (`feedbackId`/`feedback_id`/`id`); `reconciliation-checksum-conflict` rows now count as
|
|
221
|
+
held (runs degrade until repaired); the operator repair procedure is written above; proxied-run
|
|
222
|
+
wording corrected.
|
|
223
|
+
|
|
224
|
+
## Evidence pointers
|
|
225
|
+
|
|
226
|
+
- Live store copy (`sqlite3 .backup`, read-only) at 2026-10-01 16:02 PDT: origin/main fails
|
|
227
|
+
3/3 ticks with the live error. With this change: 1,004/1,004 reports processed, all 746
|
|
228
|
+
clusters evaluated, 45 ready → queued → claimed → completed → read-back Initiative tasks
|
|
229
|
+
(stubbed gpt-6-astra-shaped arbiter through the real parser), stale conflict cleared
|
|
230
|
+
(audit row), 2 generations after compaction (was 6), projection lag 0.
|
|
231
|
+
- Real arbiter (production `FeedbackReadinessArbiter` + codex-cli provider, gpt-6-astra
|
|
232
|
+
resolved and accepted): 5 generic clusters → all `collecting` (0.99, 17 s); 5 specific
|
|
233
|
+
multi-report bugs → all `ready` (0.86–0.94, 18–20 s). Both replies are fixtures.
|
|
234
|
+
- Tests: `tests/unit/feedback-drain-source-versions.test.ts`,
|
|
235
|
+
`tests/unit/feedback-drain-store.test.ts`, `tests/integration/feedback-factory-drain-service.test.ts`,
|
|
236
|
+
`tests/integration/feedback-factory-process-job-body.test.ts` (real script against a drain
|
|
237
|
+
server), `tests/e2e/feedback-drain-live-shapes.test.ts` (AgentServer + HTTP, real shapes).
|
|
238
|
+
All 14 new tests fail on the pre-fix source.
|
|
239
|
+
|
|
240
|
+
## Class-Closure Declaration (display-only mirror)
|
|
241
|
+
|
|
242
|
+
Runtime code defect. Class: a writer that breaks the source-record contract, met by an
|
|
243
|
+
integrity check that fails closed for the whole batch. Siblings in the same path:
|
|
244
|
+
compaction handoff (re-read), compaction no-op (copy), projected replay (double count) —
|
|
245
|
+
all closed here. Reconciliation already scopes to fixed byte ranges and is not a sibling.
|