@rulemetric/local 0.12.3 → 0.12.5
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/meta.json +2 -2
- package/dist/server.mjs +292 -255
- package/dist/supabase/migrations/00206_reconcile_attribution_producer.sql +156 -0
- package/dist/supabase/migrations/00207_repair_project_paths.sql +183 -0
- package/dist/supabase/migrations/00208_auto_accept_candidate_view.sql +131 -0
- package/dist/supabase/migrations/00209_suggestion_pipeline_conformance.sql +296 -0
- package/dist/supabase/migrations/00210_shared_eval_target_health_gates.sql +237 -0
- package/dist/supabase/migrations/00211_training_candidate_view.sql +119 -0
- package/dist/supabase/migrations/00212_reconcile_junk_session_paths.sql +393 -0
- package/package.json +2 -2
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
-- A routine that looks for sessions nobody counted.
|
|
2
|
+
--
|
|
3
|
+
-- THE DEFECT CLASS. The proxy auto-bootstraps a session from LLM traffic before
|
|
4
|
+
-- any hook has run, so it has the wire id but no cwd, and the row lands with
|
|
5
|
+
-- project_path and project_id NULL. Events keep flowing. Nothing errors. But
|
|
6
|
+
-- BOTH insights consumers filter on attribution --
|
|
7
|
+
-- `regenerate_insights_jobs()` selects `where s.project_id is not null`, and
|
|
8
|
+
-- routes/insights/jobs.ts requires it too -- so the session is never counted.
|
|
9
|
+
--
|
|
10
|
+
-- The only symptom is silence: a project simply has no insights. Measured
|
|
11
|
+
-- 2026-08-26 on the cloud DB, 354 sessions / 39,193 events were orphaned this
|
|
12
|
+
-- way, one repo having lost every session it ever had across four days of work,
|
|
13
|
+
-- and it surfaced only because a human asked why that project looked empty.
|
|
14
|
+
-- `create.ts:repairAttribution` stops new sessions from orphaning; this
|
|
15
|
+
-- producer is what finds the ones already on the books, and anything a future
|
|
16
|
+
-- gap lets through.
|
|
17
|
+
--
|
|
18
|
+
-- WHY A WORKER JOB AND NOT A SQL BACKFILL. This function deliberately does no
|
|
19
|
+
-- repairing itself. Repair needs a git remote or root commit, and only 6 of
|
|
20
|
+
-- those 354 rows carried ANY git metadata -- the server has nothing to match
|
|
21
|
+
-- on. Matching on project_path alone is precisely the cwd-only fallback Phase
|
|
22
|
+
-- 12 removed for causing misassignment, and a routine running unattended every
|
|
23
|
+
-- hour must not reintroduce it. The evidence lives on the machine that ran the
|
|
24
|
+
-- session: the checkout is still on disk and `git remote get-url origin` still
|
|
25
|
+
-- answers. So the handler reads the anchors there and POSTs them to
|
|
26
|
+
-- POST /api/sessions, which routes into the same `repairAttribution` the live
|
|
27
|
+
-- hook uses -- one implementation of the match order, two callers.
|
|
28
|
+
--
|
|
29
|
+
-- SCHEDULE. Hourly at :20, off the nightly loop slots (04:30 suggestions ->
|
|
30
|
+
-- 05:00 auto-accept -> 05:30 refine -> 05:45 retire -> 06:10 autorun -> 07:30
|
|
31
|
+
-- evolution). Attribution repair is cheap, reads no LLM, and wants to happen
|
|
32
|
+
-- BEFORE the 6-hourly insights producer at 00/06/12/18 so a session repaired
|
|
33
|
+
-- this hour is counted in the next insights pass rather than waiting a day.
|
|
34
|
+
-- ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
create or replace function public.enqueue_reconcile_attribution()
|
|
37
|
+
returns integer
|
|
38
|
+
language plpgsql
|
|
39
|
+
security definer
|
|
40
|
+
set search_path = public
|
|
41
|
+
as $$
|
|
42
|
+
declare
|
|
43
|
+
queued integer;
|
|
44
|
+
begin
|
|
45
|
+
with eligible as (
|
|
46
|
+
select s.user_id
|
|
47
|
+
from public.sessions s
|
|
48
|
+
-- INVISIBLE TO INSIGHTS is wider than project_id IS NULL. This function
|
|
49
|
+
-- INNER JOINs project_canonical_checkouts, which is built from
|
|
50
|
+
-- project_checkouts -- so a project with no checkout row is skipped no
|
|
51
|
+
-- matter how well its sessions are attributed, and a project whose every
|
|
52
|
+
-- session was orphaned has never had one recorded (the insert path only
|
|
53
|
+
-- upserts on a match). Chasing only the NULL half is how the first real
|
|
54
|
+
-- reconcile pass attributed Thrakopontia and left it exactly as unable to
|
|
55
|
+
-- produce insights as before.
|
|
56
|
+
where (
|
|
57
|
+
s.project_id is null
|
|
58
|
+
or not exists (
|
|
59
|
+
select 1 from public.project_checkouts pc where pc.project_id = s.project_id
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
-- Only chase orphans a worker could plausibly still repair. A session
|
|
63
|
+
-- whose checkout was deleted years ago is permanently unrepairable, and
|
|
64
|
+
-- re-walking it every hour forever is pure noise. 90 days is well past
|
|
65
|
+
-- Claude Code's transcript retention, which is the recovery source for
|
|
66
|
+
-- the pathless shape.
|
|
67
|
+
and s.started_at > now() - interval '90 days'
|
|
68
|
+
-- Sessions with no external id cannot be addressed through
|
|
69
|
+
-- POST /api/sessions at all (it matches on external_session_id), so the
|
|
70
|
+
-- handler would only count them as unrepairable. Do not enqueue for a
|
|
71
|
+
-- user who has nothing BUT those.
|
|
72
|
+
and s.external_session_id is not null
|
|
73
|
+
-- Temp fixtures and home directories are orphaned CORRECTLY. The handler
|
|
74
|
+
-- refuses them; not enqueuing on their account keeps a guaranteed refusal
|
|
75
|
+
-- out of the hourly feed.
|
|
76
|
+
and (
|
|
77
|
+
s.project_path is null
|
|
78
|
+
or (
|
|
79
|
+
s.project_path !~ '^/(private/)?(tmp|var/folders)'
|
|
80
|
+
and s.project_path !~ '^/(Users|home)/[^/]+/?$'
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
group by s.user_id
|
|
84
|
+
), fresh as (
|
|
85
|
+
select e.user_id
|
|
86
|
+
from eligible e
|
|
87
|
+
-- One in flight at a time, per the 00083/00086 pattern.
|
|
88
|
+
where not exists (
|
|
89
|
+
select 1 from public.agent_jobs j
|
|
90
|
+
where j.task_kind = 'cron_reconcile_attribution'
|
|
91
|
+
and j.status in ('pending', 'claimed', 'running')
|
|
92
|
+
and j.user_id = e.user_id
|
|
93
|
+
)
|
|
94
|
+
-- Do not re-run within the hour for a user whose last pass just completed.
|
|
95
|
+
-- The orphan set only changes when new sessions land or a checkout appears.
|
|
96
|
+
and not exists (
|
|
97
|
+
select 1 from public.agent_jobs j
|
|
98
|
+
where j.task_kind = 'cron_reconcile_attribution'
|
|
99
|
+
and j.user_id = e.user_id
|
|
100
|
+
and j.status = 'completed'
|
|
101
|
+
and j.completed_at > now() - interval '50 minutes'
|
|
102
|
+
)
|
|
103
|
+
), ins as (
|
|
104
|
+
insert into agent_jobs (user_id, task_kind, status, payload, dedupe_key)
|
|
105
|
+
select f.user_id,
|
|
106
|
+
'cron_reconcile_attribution',
|
|
107
|
+
'pending',
|
|
108
|
+
jsonb_build_object('limit', 200),
|
|
109
|
+
'reconcile-attribution:' || f.user_id
|
|
110
|
+
from fresh f
|
|
111
|
+
returning 1
|
|
112
|
+
)
|
|
113
|
+
select count(*)::int into queued from ins;
|
|
114
|
+
|
|
115
|
+
return queued;
|
|
116
|
+
end;
|
|
117
|
+
$$;
|
|
118
|
+
|
|
119
|
+
comment on function public.enqueue_reconcile_attribution() is
|
|
120
|
+
'Hourly producer for cron_reconcile_attribution (:20). Enqueues one run per user holding sessions with project_id NULL that a worker could still repair (under 90 days old, has an external_session_id, not a temp/home path). Does NOT repair anything itself: attribution needs git anchors that live on the machine that ran the session, and matching on project_path alone is the cwd-only fallback Phase 12 removed. The handler reads those anchors from disk and POSTs them to POST /api/sessions, where repairAttribution performs the write.';
|
|
121
|
+
|
|
122
|
+
-- Observability: the residual count, so a rising floor of unrepairable orphans
|
|
123
|
+
-- is visible instead of being smoothed away. `repairable_recent` is what the
|
|
124
|
+
-- producer actually chases; `total` is every orphan on the books.
|
|
125
|
+
create or replace view public.session_attribution_health as
|
|
126
|
+
select
|
|
127
|
+
s.user_id,
|
|
128
|
+
count(*)::int as total_unattributed,
|
|
129
|
+
count(*) filter (
|
|
130
|
+
where s.started_at > now() - interval '90 days'
|
|
131
|
+
and s.external_session_id is not null
|
|
132
|
+
and (
|
|
133
|
+
s.project_path is null
|
|
134
|
+
or (
|
|
135
|
+
s.project_path !~ '^/(private/)?(tmp|var/folders)'
|
|
136
|
+
and s.project_path !~ '^/(Users|home)/[^/]+/?$'
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
)::int as repairable_recent,
|
|
140
|
+
sum(s.event_count)::bigint as orphaned_events,
|
|
141
|
+
max(s.started_at) as newest_orphan
|
|
142
|
+
from public.sessions s
|
|
143
|
+
where s.project_id is null
|
|
144
|
+
or not exists (
|
|
145
|
+
select 1 from public.project_checkouts pc where pc.project_id = s.project_id
|
|
146
|
+
)
|
|
147
|
+
group by s.user_id;
|
|
148
|
+
|
|
149
|
+
comment on view public.session_attribution_health is
|
|
150
|
+
'Per-user count of sessions invisible to the insights pipeline (project_id IS NULL). repairable_recent is the subset cron_reconcile_attribution will attempt; a total that keeps climbing while repairable_recent stays near zero means the orphans are structurally unrepairable, not that the reconciler is failing.';
|
|
151
|
+
|
|
152
|
+
select cron.schedule(
|
|
153
|
+
'enqueue_reconcile_attribution',
|
|
154
|
+
'20 * * * *',
|
|
155
|
+
$$select public.enqueue_reconcile_attribution();$$
|
|
156
|
+
);
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
-- Repair the two data shapes that leave a correctly-attributed project unable
|
|
2
|
+
-- to produce insights.
|
|
3
|
+
--
|
|
4
|
+
-- Both are consequences of the same root cause fixed in code on 2026-08-26: the
|
|
5
|
+
-- subdir-consolidation normalizer in POST /sessions collapsed any path under a
|
|
6
|
+
-- known session path to that parent, treated `/Users/<user>` as a valid parent,
|
|
7
|
+
-- and picked an ARBITRARY first match. So sessions landed with the HOME
|
|
8
|
+
-- DIRECTORY as their project_path, and `upsertProjectCheckout` (rightly)
|
|
9
|
+
-- refuses to record a home dir as a checkout. The project_id was correct the
|
|
10
|
+
-- whole time -- it comes from the git remote at capture -- but with no row in
|
|
11
|
+
-- project_checkouts, `project_canonical_checkouts` is empty for that project
|
|
12
|
+
-- and `regenerate_insights_jobs()` INNER JOINs that view. Attributed, and
|
|
13
|
+
-- invisible.
|
|
14
|
+
--
|
|
15
|
+
-- Measured on prod 2026-08-26:
|
|
16
|
+
-- * kc-content-workspace: 191 sessions, 181 of them at `/Users/kylechalmers`,
|
|
17
|
+
-- ZERO checkouts. Same for kc-ops-workspace (41 sessions, 28 at home).
|
|
18
|
+
-- This is also what 00205's canonical-checkout gate blocks: that user's
|
|
19
|
+
-- auto-accept lane is dead until his checkouts are registered. Registering
|
|
20
|
+
-- them is the fix; widening the gate is not.
|
|
21
|
+
-- * context-engineering-intro exists TWICE -- one row with a git remote and
|
|
22
|
+
-- root commit (4 sessions, no checkout) and one anchorless legacy row (no
|
|
23
|
+
-- remote, no root commit, 1 session) that HOLDS the checkout for the path.
|
|
24
|
+
-- Because project_checkouts has a GLOBAL unique index on `path` while the
|
|
25
|
+
-- upsert conflicts on the (project_id, path) primary key, the real project
|
|
26
|
+
-- could never record its own checkout: 23505, swallowed, forever.
|
|
27
|
+
--
|
|
28
|
+
-- Both halves are written as idempotent FUNCTIONS rather than one-shot DO
|
|
29
|
+
-- blocks so they can be tested, re-run, and called again if the shape recurs.
|
|
30
|
+
-- ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
-- ── 1. Merge anchorless duplicate project rows ──────────────────────────────
|
|
33
|
+
--
|
|
34
|
+
-- A project row with NEITHER a git_remote_url NOR a git_root_commit has no
|
|
35
|
+
-- identity anchor at all -- it predates git-context-as-identity and can never
|
|
36
|
+
-- be matched again by capture. When such a row shares a name with a project
|
|
37
|
+
-- that DOES have an anchor, it is the same repo, and everything hanging off it
|
|
38
|
+
-- belongs to the anchored row.
|
|
39
|
+
--
|
|
40
|
+
-- STRICT: only merges when exactly ONE candidate keeper exists for the name.
|
|
41
|
+
-- Two anchored rows for one name is a different problem (a real fork, or a
|
|
42
|
+
-- rename) and must not be resolved by guessing.
|
|
43
|
+
create or replace function public.merge_anchorless_duplicate_projects()
|
|
44
|
+
returns integer
|
|
45
|
+
language plpgsql
|
|
46
|
+
security definer
|
|
47
|
+
set search_path = public
|
|
48
|
+
as $$
|
|
49
|
+
declare
|
|
50
|
+
merged integer := 0;
|
|
51
|
+
r record;
|
|
52
|
+
begin
|
|
53
|
+
for r in
|
|
54
|
+
select dud.id as dud_id, keeper.id as keeper_id
|
|
55
|
+
from public.projects dud
|
|
56
|
+
join lateral (
|
|
57
|
+
select k.id
|
|
58
|
+
from public.projects k
|
|
59
|
+
where k.name = dud.name
|
|
60
|
+
and k.id <> dud.id
|
|
61
|
+
and (k.git_remote_url is not null or k.git_root_commit is not null)
|
|
62
|
+
limit 2
|
|
63
|
+
) keeper on true
|
|
64
|
+
where dud.git_remote_url is null
|
|
65
|
+
and dud.git_root_commit is null
|
|
66
|
+
-- Exactly one keeper, or we are guessing.
|
|
67
|
+
and (
|
|
68
|
+
select count(*) from public.projects k2
|
|
69
|
+
where k2.name = dud.name and k2.id <> dud.id
|
|
70
|
+
and (k2.git_remote_url is not null or k2.git_root_commit is not null)
|
|
71
|
+
) = 1
|
|
72
|
+
loop
|
|
73
|
+
-- project_checkouts carries a GLOBAL unique index on `path`, so a straight
|
|
74
|
+
-- repoint collides whenever the keeper already knows the path. Drop the
|
|
75
|
+
-- dud's row in that case; otherwise move it.
|
|
76
|
+
delete from public.project_checkouts c
|
|
77
|
+
where c.project_id = r.dud_id
|
|
78
|
+
and exists (
|
|
79
|
+
select 1 from public.project_checkouts k
|
|
80
|
+
where k.path = c.path and k.project_id = r.keeper_id
|
|
81
|
+
);
|
|
82
|
+
update public.project_checkouts set project_id = r.keeper_id where project_id = r.dud_id;
|
|
83
|
+
|
|
84
|
+
update public.sessions set project_id = r.keeper_id where project_id = r.dud_id;
|
|
85
|
+
update public.insights_jobs set project_id = r.keeper_id where project_id = r.dud_id;
|
|
86
|
+
update public.instruction_suggestions set project_id = r.keeper_id where project_id = r.dud_id;
|
|
87
|
+
update public.agent_runs set project_id = r.keeper_id where project_id = r.dud_id;
|
|
88
|
+
update public.treatment_assignments set project_id = r.keeper_id where project_id = r.dud_id;
|
|
89
|
+
update public.memories set project_id = r.keeper_id where project_id = r.dud_id;
|
|
90
|
+
update public.pending_treatment_exposures set project_id = r.keeper_id where project_id = r.dud_id;
|
|
91
|
+
update public.recommendation_applications set project_id = r.keeper_id where project_id = r.dud_id;
|
|
92
|
+
|
|
93
|
+
-- project_instructions is UNIQUE on (project_id, instruction_id) and
|
|
94
|
+
-- CASCADE-deletes, so drop links the keeper already holds before moving.
|
|
95
|
+
delete from public.project_instructions pi
|
|
96
|
+
where pi.project_id = r.dud_id
|
|
97
|
+
and exists (
|
|
98
|
+
select 1 from public.project_instructions k
|
|
99
|
+
where k.project_id = r.keeper_id and k.instruction_id = pi.instruction_id
|
|
100
|
+
);
|
|
101
|
+
update public.project_instructions set project_id = r.keeper_id where project_id = r.dud_id;
|
|
102
|
+
|
|
103
|
+
delete from public.projects where id = r.dud_id;
|
|
104
|
+
merged := merged + 1;
|
|
105
|
+
end loop;
|
|
106
|
+
|
|
107
|
+
return merged;
|
|
108
|
+
end;
|
|
109
|
+
$$;
|
|
110
|
+
|
|
111
|
+
comment on function public.merge_anchorless_duplicate_projects() is
|
|
112
|
+
'Merges a projects row that has NEITHER git_remote_url NOR git_root_commit into the single same-named row that does have an anchor, repointing every referencing table first. Such a row predates git-context-as-identity and can never be matched by capture again, but it can still HOLD a project_checkouts row that the real project then cannot record (project_checkouts has a global unique index on path while the upsert conflicts on the (project_id, path) PK) -- which is how context-engineering-intro had two rows, one owning the path and the other owning the sessions. Refuses to act when more than one anchored candidate shares the name.';
|
|
113
|
+
|
|
114
|
+
-- ── 2. Register the checkout an attributed project already demonstrates ─────
|
|
115
|
+
--
|
|
116
|
+
-- NOT attribution inference. `sessions.project_id` is already set here, decided
|
|
117
|
+
-- at capture time by a git remote or root commit; this only records WHICH
|
|
118
|
+
-- DIRECTORY those already-attributed sessions ran in. That distinction is why
|
|
119
|
+
-- this is safe and why the cwd-only fallback removed in Phase 12 is not being
|
|
120
|
+
-- reintroduced: nothing here decides which project a session belongs to.
|
|
121
|
+
--
|
|
122
|
+
-- Refuses: home directories and temp paths (never checkouts), worktree paths
|
|
123
|
+
-- (a worktree is not the canonical root -- 00186/00201 exist because electing
|
|
124
|
+
-- one aims the whole loop at a path nobody works in), and any path already
|
|
125
|
+
-- claimed by another project (the global unique index).
|
|
126
|
+
create or replace function public.backfill_project_checkouts_from_sessions()
|
|
127
|
+
returns integer
|
|
128
|
+
language plpgsql
|
|
129
|
+
security definer
|
|
130
|
+
set search_path = public
|
|
131
|
+
as $$
|
|
132
|
+
declare
|
|
133
|
+
inserted integer;
|
|
134
|
+
begin
|
|
135
|
+
with candidate as (
|
|
136
|
+
select
|
|
137
|
+
s.project_id,
|
|
138
|
+
s.project_path,
|
|
139
|
+
count(*) as sessions,
|
|
140
|
+
row_number() over (
|
|
141
|
+
partition by s.project_id
|
|
142
|
+
-- Most-used real path wins; shallower breaks ties, so a repo root is
|
|
143
|
+
-- preferred over a subdirectory of it.
|
|
144
|
+
order by count(*) desc, length(s.project_path) asc, s.project_path asc
|
|
145
|
+
) as rank
|
|
146
|
+
from public.sessions s
|
|
147
|
+
where s.project_id is not null
|
|
148
|
+
and s.project_path is not null
|
|
149
|
+
and s.project_path !~ '^/(Users|home)/[^/]+/?$'
|
|
150
|
+
and s.project_path !~ '^/(private/)?(tmp|var/folders)'
|
|
151
|
+
and s.project_path not like '%/.claude/worktrees/%'
|
|
152
|
+
and s.project_path not like '%/.worktrees/%'
|
|
153
|
+
-- Only projects that have NO checkout at all. Never second-guess a
|
|
154
|
+
-- project whose checkouts are already known.
|
|
155
|
+
and not exists (
|
|
156
|
+
select 1 from public.project_checkouts c where c.project_id = s.project_id
|
|
157
|
+
)
|
|
158
|
+
-- Global unique on path: skip anything another project already owns.
|
|
159
|
+
and not exists (
|
|
160
|
+
select 1 from public.project_checkouts c2 where c2.path = s.project_path
|
|
161
|
+
)
|
|
162
|
+
group by s.project_id, s.project_path
|
|
163
|
+
), ins as (
|
|
164
|
+
insert into public.project_checkouts (project_id, path, is_worktree)
|
|
165
|
+
select c.project_id, c.project_path, false
|
|
166
|
+
from candidate c
|
|
167
|
+
where c.rank = 1
|
|
168
|
+
on conflict do nothing
|
|
169
|
+
returning 1
|
|
170
|
+
)
|
|
171
|
+
select count(*)::int into inserted from ins;
|
|
172
|
+
|
|
173
|
+
return inserted;
|
|
174
|
+
end;
|
|
175
|
+
$$;
|
|
176
|
+
|
|
177
|
+
comment on function public.backfill_project_checkouts_from_sessions() is
|
|
178
|
+
'Registers one canonical checkout for each ALREADY-ATTRIBUTED project that has none, using the most-used real directory its own sessions ran in. Not attribution inference: sessions.project_id is already set from a git anchor, and this only records which directory those sessions used. Skips home dirs, temp paths, worktree paths, and any path another project already claims. Exists because the POST /sessions path normalizer used to collapse session paths onto the home directory, which upsertProjectCheckout then rightly refused to record -- leaving projects correctly attributed and still invisible to regenerate_insights_jobs(), which INNER JOINs project_canonical_checkouts.';
|
|
179
|
+
|
|
180
|
+
-- Run both once, now. Order matters: merging frees a path that the keeper can
|
|
181
|
+
-- then be given, and the backfill skips projects that already have a checkout.
|
|
182
|
+
select public.merge_anchorless_duplicate_projects();
|
|
183
|
+
select public.backfill_project_checkouts_from_sessions();
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
-- 00208 — ONE candidate set for auto-accept, defined once, read everywhere.
|
|
2
|
+
--
|
|
3
|
+
-- THE PATTERN THIS ENDS. Nine separate gates have now been found between "a
|
|
4
|
+
-- proposal exists" and "the actuator adopts it", and every one of them was
|
|
5
|
+
-- found the same way: adoption looked stopped, somebody hand-wrote a query,
|
|
6
|
+
-- found ONE gate, opened it, and declared the chain clear. #372's own commit
|
|
7
|
+
-- message said "two gates had to open, not one" — there were four. Then five
|
|
8
|
+
-- and six (00198/00199), seven and eight (00203/00204), and the 2026-08-27
|
|
9
|
+
-- census. The mechanism is always the same: the eligibility predicate is
|
|
10
|
+
-- HAND-COPIED (TS endpoint, TS operator count, SQL producer), the copies
|
|
11
|
+
-- drift, and the stage that disagrees reports a number that reads identically
|
|
12
|
+
-- to "there was nothing to do".
|
|
13
|
+
--
|
|
14
|
+
-- This view is the producer's candidate predicate extracted to a single named
|
|
15
|
+
-- object. From now on:
|
|
16
|
+
--
|
|
17
|
+
-- * `enqueue_auto_accept_suggestions()` (below) SELECTS from it — no inline
|
|
18
|
+
-- copy of the predicate survives in the producer.
|
|
19
|
+
-- * `suggestion_pipeline_conformance()` (00209) counts it, so the monitor
|
|
20
|
+
-- and the producer can never disagree about what "candidates exist" means.
|
|
21
|
+
-- * Diagnostic queries (scripts/dev/suggestion-gate-census.sql) can read it
|
|
22
|
+
-- instead of restating the ladder.
|
|
23
|
+
--
|
|
24
|
+
-- WHAT IT DELIBERATELY IS NOT. It is not the endpoint's predicate. The
|
|
25
|
+
-- authority for "may this be written into a file" stays server-side in
|
|
26
|
+
-- `GET /instruction-suggestions/auto-acceptable` (loop.ts), which additionally
|
|
27
|
+
-- applies the SETTABLE score gate (`resolveAutoAcceptLimits`), per-run and
|
|
28
|
+
-- window budgets, placeholder detection, the 30-day actuator-refusal window
|
|
29
|
+
-- and the running-experiment guard. This view is the producer's CHEAP SUBSET —
|
|
30
|
+
-- its job is to avoid queueing work that would refuse, and 0.8 here is the
|
|
31
|
+
-- DEFAULT minScore, hardcoded exactly as 00205 had it. That asymmetry is
|
|
32
|
+
-- recorded, intended, and now monitored: 00209's `endpoint_refuses_everything`
|
|
33
|
+
-- check fires when jobs run against this candidate set and neither adopt nor
|
|
34
|
+
-- log a refusal, which is the observable signature of the two predicates
|
|
35
|
+
-- having drifted apart (the exact #372 failure, as an alarm instead of an
|
|
36
|
+
-- archaeology session).
|
|
37
|
+
--
|
|
38
|
+
-- Grain: one row per candidate SUGGESTION. The producer groups to
|
|
39
|
+
-- (user_id, project_path); the monitor counts rows and users. The in-flight
|
|
40
|
+
-- agent_jobs dedupe stays in the producer — that is queue state, not
|
|
41
|
+
-- eligibility, and a suggestion does not stop being a candidate because a job
|
|
42
|
+
-- is already on its way.
|
|
43
|
+
|
|
44
|
+
create or replace view public.auto_accept_candidate_suggestions as
|
|
45
|
+
select s.id as suggestion_id,
|
|
46
|
+
s.user_id,
|
|
47
|
+
s.project_path,
|
|
48
|
+
s.project_id,
|
|
49
|
+
s.instruction_id,
|
|
50
|
+
s.score,
|
|
51
|
+
i.type as instruction_type,
|
|
52
|
+
s.generated_at
|
|
53
|
+
from instruction_suggestions s
|
|
54
|
+
join profiles p on p.id = s.user_id
|
|
55
|
+
join instructions i on i.id = s.instruction_id
|
|
56
|
+
left join projects proj on proj.id = s.project_id
|
|
57
|
+
where s.kind = 'add'
|
|
58
|
+
-- Default-minScore or insights-authored (owner directive 2026-08-09; the
|
|
59
|
+
-- 0.5-vs-0.8 self-inflicted deadlock is documented in loop.ts:86-98).
|
|
60
|
+
and (s.score >= 0.8 or i.frontmatter->>'source' = 'insights')
|
|
61
|
+
-- The single auto-adoptable set. TS mirror: `isAutoAdoptable()` in
|
|
62
|
+
-- apps/api/src/lib/insights/artifacts/types.ts — the agreement between the
|
|
63
|
+
-- two is asserted by apps/api/test/auto-accept-drift.test.ts.
|
|
64
|
+
and i.type in ('instruction', 'skill')
|
|
65
|
+
and i.archived = false
|
|
66
|
+
and s.accepted_at is null
|
|
67
|
+
and s.dismissed_at is null
|
|
68
|
+
-- Nobody answering is not a refusal (00203); measurement declines still are.
|
|
69
|
+
and (s.declined_at is null or s.declined_reason = 'surface_budget')
|
|
70
|
+
and s.is_stale = false
|
|
71
|
+
-- Consent, project override first (00170 lineage).
|
|
72
|
+
and coalesce(
|
|
73
|
+
proj.metadata->>'autoAcceptSuggestions',
|
|
74
|
+
p.auto_accept_suggestions::text
|
|
75
|
+
) = 'true'
|
|
76
|
+
-- WHERE an unattended writer may write: the canonical checkout, nowhere
|
|
77
|
+
-- else (00205). Read from the view, never re-derived (00186 invariant).
|
|
78
|
+
and exists (
|
|
79
|
+
select 1 from public.project_canonical_checkouts c
|
|
80
|
+
where c.path = s.project_path
|
|
81
|
+
)
|
|
82
|
+
-- The worker said this directory is not there (00202, 24h-expiring).
|
|
83
|
+
and not public.project_path_recently_missing(s.user_id, s.project_path);
|
|
84
|
+
|
|
85
|
+
grant select on public.auto_accept_candidate_suggestions to authenticated, service_role;
|
|
86
|
+
|
|
87
|
+
comment on view public.auto_accept_candidate_suggestions is
|
|
88
|
+
'The auto-accept producer''s candidate predicate, extracted so the producer (00208), the conformance monitor (00209) and diagnostics read ONE definition. Cheap subset only — the endpoint (loop.ts /auto-acceptable) remains the authority and adds settable limits, budgets, placeholder and experiment gates.';
|
|
89
|
+
|
|
90
|
+
-- The producer, rewritten to read the view. Behaviour-identical to 00205 by
|
|
91
|
+
-- construction: the predicate moved, the queue-state dedupe stayed. The nine
|
|
92
|
+
-- tests in apps/api/test/auto-accept-producer.test.ts are the proof.
|
|
93
|
+
create or replace function public.enqueue_auto_accept_suggestions()
|
|
94
|
+
returns integer
|
|
95
|
+
language plpgsql
|
|
96
|
+
security definer
|
|
97
|
+
set search_path = public
|
|
98
|
+
as $$
|
|
99
|
+
declare
|
|
100
|
+
queued integer;
|
|
101
|
+
begin
|
|
102
|
+
with eligible as (
|
|
103
|
+
select v.user_id, v.project_path
|
|
104
|
+
from public.auto_accept_candidate_suggestions v
|
|
105
|
+
where not exists (
|
|
106
|
+
select 1 from agent_jobs j
|
|
107
|
+
where j.task_kind = 'cron_auto_accept_suggestions'
|
|
108
|
+
and j.status in ('pending', 'claimed', 'running')
|
|
109
|
+
and j.payload->>'projectPath' = v.project_path
|
|
110
|
+
and j.user_id = v.user_id
|
|
111
|
+
)
|
|
112
|
+
group by v.user_id, v.project_path
|
|
113
|
+
),
|
|
114
|
+
ins as (
|
|
115
|
+
insert into agent_jobs (user_id, task_kind, status, payload, dedupe_key)
|
|
116
|
+
select e.user_id,
|
|
117
|
+
'cron_auto_accept_suggestions',
|
|
118
|
+
'pending',
|
|
119
|
+
jsonb_build_object('projectPath', e.project_path),
|
|
120
|
+
'auto-accept:' || e.user_id::text || ':' || e.project_path
|
|
121
|
+
from eligible e
|
|
122
|
+
returning 1
|
|
123
|
+
)
|
|
124
|
+
select count(*)::int into queued from ins;
|
|
125
|
+
|
|
126
|
+
return queued;
|
|
127
|
+
end;
|
|
128
|
+
$$;
|
|
129
|
+
|
|
130
|
+
comment on function public.enqueue_auto_accept_suggestions() is
|
|
131
|
+
'Hourly producer for cron_auto_accept_suggestions. Candidate predicate lives in the auto_accept_candidate_suggestions view (00208) — one definition shared with the conformance monitor. History: 00197 (type gate), 00202 (missing paths), 00203 (silence declines), 00205 (canonical checkouts only).';
|