Repository navigation
Expand file tree
/
Copy pathprogress.go
More file actions
455 lines (427 loc) · 19.8 KB
/
Copy pathprogress.go
File metadata and controls
455 lines (427 loc) · 19.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
// What a round actually moved, weighed against what the job is about.
//
// THE DEFECT THIS ANSWERS. ink s10 was given a 5400-second wall and spent all
// of it. One lineage — task-2 → x1 → x2 → x3 — exhausted eight times, resumed
// three, and every round of it was admitted by the growth governor because
// every round had "produced" something: `debug-grid.ts`, `debug-grid2.ts`,
// `debug-grid3.tsx`, `debug-grid10.ts`, `debug-grid11.ts`, `debug-yoga.ts`,
// `debug-yoga2.ts`, `debug-test2.tsx`, `debug-test3.tsx`, `debug-grid-pos.tsx`.
// A model that is stuck writes scratch files, and a scratch file is a file, so
// the standstill rule — which was right, and which is the only rule in the
// governor that asks whether anything was ACHIEVED — saw motion on every round
// and never once spoke. What stopped the run was the round cap, four rounds and
// ninety minutes later, with the wall gone, no gate cut, and `settled: false`.
//
// So the reading of "did this round move something" is narrowed to the work:
//
// - EVERY CHECK FILE COUNTS. A check the run wrote is the one thing a job can
// produce that is progress on its own terms whatever else happened, and it
// is what the coverage finding exists to buy. verify.OwnChecks decides what
// a check is, by the runner's own naming convention and by nothing else.
// - A CHANGED SOURCE COUNTS WHEN THE JOB IS ABOUT IT. The job's focus is what
// its request names: the paths the request spells that this workspace holds,
// and the names it uses resolved against the workspace by verify.Locate —
// the identical reading internal/exec takes to decide how much of a project
// to verify — plus the adjacency verify.Adjacent's first rank defines: a
// file named after something in the focus, or sitting in a directory the
// focus is in. `debug-grid.ts` at a repository root beside `package.json` is
// neither, and `src/grid.ts` is both.
// - A FILE THE REQUEST NAMES IS NEVER SCRATCH. The spelling comes first and
// the resolution second, because a resolution is a search and a search can
// come back without a file the request wrote out in full. See jobFocus.
// - A JOB THAT NAMED NOTHING IS ABOUT EVERYTHING. An empty focus is verify's
// own "reading of the whole project", and the fail-safe direction here is
// the same: with nothing to narrow by, every changed source counts and the
// governor keeps exactly the bounds it had.
//
// NO FILENAME PATTERN APPEARS ANYWHERE IN THIS FILE. `debug-` is a spelling one
// model happened to choose, and a rule written against it would be one spelling
// behind forever (FAILSAFE clause 1). What is structural is the relationship
// between what the round wrote and what the request is about, and that
// relationship is one verify already computes for its own reasons.
package resident
import (
"os"
"path/filepath"
"sort"
"strings"
"sync"
"github.com/Agent-Field/codeaf/internal/store"
"github.com/Agent-Field/codeaf/internal/verify"
)
// scratchNamed bounds how many of a fruitless round's own files the journal
// keeps. The count is the finding; the names are so a person can recognise it,
// and a dozen of them is already more than anybody reads.
const scratchNamed = 12
// RoundChange is one round's work as the world holds it, split by whether the
// job is about it.
type RoundChange struct {
// Relevant is what the round changed that the job is about: check files,
// and sources inside the focus.
Relevant []string
// Scratch is the rest — files the round wrote that are outside everything
// the request named. They are journaled rather than dropped: "this round
// wrote fourteen files and moved none of them" is a recognisable failure
// and the names are what make it recognisable.
Scratch []string
// Measured says somebody could look. It is false where there is no
// workspace to read the round against, which reads as "nobody counted" and
// leaves every governor exactly as it was.
Measured bool
}
// Moved reports that the round changed something the job is about.
func (c RoundChange) Moved() bool { return len(c.Relevant) > 0 }
// MeasureRound reads what one round left behind against what its job is about.
//
// artifacts is the workspace's own before-and-after reading of the tree — never
// a worker's account of itself (FAILSAFE clause 2) — and workspace is the
// directory that reading was taken in. An empty workspace is a caller that
// cannot say where the work happened, and it answers "nobody looked".
func MeasureRound(graph *store.Store, jobRoot, workspace string, artifacts []string) RoundChange {
workspace = strings.TrimSpace(workspace)
if workspace == "" {
return RoundChange{}
}
if len(artifacts) == 0 {
// The workspace looked and the tree gained nothing. That is a reading,
// and it is the one the standstill rule exists to weigh.
return RoundChange{Measured: true}
}
change := RoundChange{Measured: true}
// Checks first and unconditionally. A check this run wrote is in scope by
// verify.Adjacent's own leading rule — "A CHECK THE JOB ITSELF NAMED IS
// ALWAYS IN SCOPE" — and for the same reason: it is the one thing a scope
// decided before the work could never have known about.
change.Relevant = append(change.Relevant, verify.OwnChecks(workspace, artifacts)...)
sources := verify.ChangedSources(workspace, artifacts)
focus := jobFocus(graph, jobRoot, workspace)
if len(focus) == 0 {
// The job named nothing this workspace holds, so nothing narrows and
// every source is the job's. This is verify's own reading of an empty
// focus and it is the fail-safe direction: a governor that refused a
// round here would be refusing it for the request's silence.
change.Relevant = append(change.Relevant, sources...)
sort.Strings(change.Relevant)
return change
}
near := newFocusShape(focus)
for _, source := range sources {
if near.holds(source) {
change.Relevant = append(change.Relevant, source)
continue
}
change.Scratch = append(change.Scratch, source)
}
sort.Strings(change.Relevant)
sort.Strings(change.Scratch)
return change
}
// jobFocus is what the job is about, as paths this workspace holds: the union
// of what the request SPELLS and what the plan's own names resolve to.
//
// A FILE THE REQUEST NAMES IS NEVER SCRATCH. What a round produced is judged
// against what the request names, before what the plan chose to list. The
// second half of the union is a resolution — a name is carried to a file by
// walking the tree for it — and a resolution can lose a file the request wrote
// out in full: it kept only entries that came back holding a separator, so a
// job about `notes.md` beside a job about `src/grid.ts` had a focus of the
// second alone, and the round that changed the file the brief was written about
// was journaled as `produced: 0, scratch: 1`. Two of those are a standstill,
// and the run was handed over with the work moving under it. So the request's
// own spelling enters the focus in its own right, settled against the disk and
// against nothing else.
//
// It is read from the JOB ROOT and never from the round being weighed. A
// continuation's brief carries the partial, the artifact list and the whole
// transcript of the attempt before it, so a focus derived from it would name
// every scratch file the last round wrote and then rule that writing more of
// them was progress — the focus would grow to fit whatever the model did, which
// is the property a bound may never have. The request is what every round of a
// job shares, which is exactly why internal/exec derives its reading's scope
// from the same place.
func jobFocus(graph *store.Store, jobRoot, workspace string) verify.Focus {
root, ok, err := graph.Node(jobRoot)
if err != nil || !ok {
return nil
}
// The verbatim intent as well as the brief: the store stamps the person's
// own words on every node of every splice, and a job root that was itself
// spliced by a repair carries a brief written by a planner over the top of
// them.
said := strings.Join([]string{root.Provenance.Intent, root.Title, root.Brief}, "\n")
if spec := DecodeSpec(root.Spec); !spec.Empty() {
said += "\n" + spec.Render(jobFocusLimit)
}
held := spelledPaths(said, workspace)
named := verify.NamedSubjects(said)
if len(named) == 0 {
// Nothing else to resolve. Where the request spelled nothing either this
// is the empty focus MeasureRound reads as "the job named nothing this
// workspace holds", which is where every changed source counts.
return held
}
for _, entry := range verify.Locate(workspace, verify.Focus(named)) {
// Only what RESOLVED. Locate keeps a name it could not place, on the
// grounds that it costs nothing downstream; here it would cost
// everything, because an unresolved name matched against a path by
// spelling is the substring adjacency FAILSAFE clause 1 and
// verify.Adjacent both refuse.
if strings.ContainsAny(entry, "/\\") {
held = append(held, filepath.ToSlash(filepath.Clean(entry)))
}
}
return held
}
// spelledPaths are the files the request writes out in full that this workspace
// actually holds — the half of the focus the request owns.
//
// The reading is ONE STAT PER NAME and nothing else: no walk, no model, no
// fuzzy matching, no threshold. A name is either a path this tree holds or it
// contributes nothing, which is the only reading that can be trusted to widen a
// bound — the focus grows by exactly the files a person could point at.
//
// The shape rule is verify.NamedPaths and never a second copy of it: which
// tokens of a text read as a file is one question the repository answers in one
// place, and it answers it by punctuation alone — a separator or a short
// alphanumeric extension — so it knows no language, no framework and no kind of
// file. A name that lands on a directory is dropped, because the three
// questions focusShape asks a change are a file's.
func spelledPaths(said, workspace string) verify.Focus {
names := verify.NamedPaths(said)
held := make(verify.Focus, 0, len(names))
for _, name := range names {
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(name)))
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
continue
}
if info, err := os.Stat(filepath.Join(workspace, filepath.FromSlash(clean))); err != nil || info.IsDir() {
continue
}
held = append(held, clean)
}
return held
}
// jobFocusLimit bounds how much of the job's own criterion the focus is read
// from. The criterion is the smallest of the three texts here and the names in
// it are the ones a request states rather than a planner's restatement of them;
// past this it is prose, and prose contributes nothing verify.Locate can place.
const jobFocusLimit = 1200
// focusShape is the focus as the three questions a changed file is asked of it.
//
// The questions are verify.Adjacent's first rank, asked the other way round.
// That rank exists to find the CHECKS a change is in; this asks whether a file
// the round wrote is in the change — the file itself, a file beside it, or a
// file the request named by stem. Whole names on both sides, never fragments:
// `Log` matches `_log.py` and never `dialog.py`, which is the rule that file
// states and the reason it states it.
type focusShape struct {
paths map[string]bool
dirs map[string]bool
stems map[string]bool
}
func newFocusShape(focus verify.Focus) focusShape {
shape := focusShape{
paths: make(map[string]bool, len(focus)),
dirs: make(map[string]bool, len(focus)),
stems: make(map[string]bool, len(focus)),
}
for _, entry := range focus {
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(entry)))
if clean == "" || clean == "." {
continue
}
shape.paths[clean] = true
// THE ROOT IS NOT A DIRECTORY A FOCUS CAN BE IN. A focus entry sitting
// at the top of the workspace has no directory to be beside, and filing
// it under the empty one would make "beside it" mean "anywhere at the
// top" — which is precisely where a stuck model writes its scratch. A
// request naming `package.json` would have bought every debug file the
// ink s10 rounds wrote.
if dir := pathDirectory(clean); dir != "" {
shape.dirs[dir] = true
}
if stem := pathStem(clean); stem != "" {
shape.stems[stem] = true
}
}
return shape
}
func (s focusShape) holds(path string) bool {
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(path)))
if clean == "" {
return false
}
return s.paths[clean] || s.dirs[pathDirectory(clean)] || s.stems[pathStem(clean)]
}
func pathDirectory(path string) string {
dir := filepath.ToSlash(filepath.Dir(path))
if dir == "." || dir == "/" {
return ""
}
return dir
}
// pathStem is a file's name with its extension off, lower-cased. Case is
// dropped because `RichLog.ts` and `richlog.ts` are one name written twice, and
// nothing else is — this is an equality test on whole names, which is what
// keeps it from becoming the substring adjacency verify.Adjacent was rewritten
// to stop being.
func pathStem(path string) string {
name := filepath.Base(path)
if extension := filepath.Ext(name); extension != "" {
name = strings.TrimSuffix(name, extension)
}
return strings.ToLower(strings.TrimSpace(name))
}
// Shortfall is the job's own account of what it is still short of, as counts a
// later round can be weighed against.
//
// MOVING A FILE IS NOT THE ONLY WAY TO MOVE THE WORK. A round that closed a
// regression, brought a stated behaviour under a check, or answered a standing
// review finding has made progress even where the focus gained nothing — so the
// evidence the governor reads is the union of the two, and a rule that read
// only the tree would refuse the round after the one that finally started
// working.
//
// It is counts and a digest rather than the findings themselves because every
// comparison made of it is a FALL: fewer unexercised behaviours, fewer red
// checks, a finding that stood and now does not. A shortfall REWORDED is not a
// shortfall closed, which is the distinction the remainder digest already draws
// one rule along.
type Shortfall struct {
Unexercised int
Red int
// Lost is how many public names the newest symbol-level reading says the
// finished tree no longer spells. It is the check roster's sibling and it
// belongs here for the same reason: a round that put back eight deleted
// public attributes moved the work whatever the check-level row said, and
// on igel s11 the check-level row said the tree had got BETTER on the run
// that deleted them. store.SurfaceReading is the reading; nothing here
// re-derives it.
Lost int
// Standing is the review finding that is still open, as a digest. Empty is
// a job with nothing standing against it.
Standing string
}
// ReadShortfall reads it off one lineage's own journal, through the same
// account of the record every brief is composed from.
func ReadShortfall(graph *store.Store, lineage string) Shortfall {
findings := ReadOpenFindings(graph, lineage)
shortfall := Shortfall{
// The two halves of the coverage measurement are ONE counter, because
// the comparison made of it is a fall: a round that turned a behaviour
// nothing exercised into one a check merely visits has closed nothing,
// and two counters would let it read as progress on the first.
Unexercised: len(findings.Unexercised) + len(findings.Unasserted),
Red: len(findings.Failing),
Lost: lostPublicNames(graph, lineage),
}
if findings.Unclosed || strings.TrimSpace(findings.Gap) != "" {
shortfall.Standing = RemainderDigest(findings.Gap)
}
// AND A DELIVERY NOTHING JUDGED IS SOMETHING THIS JOB IS STILL SHORT OF. The
// gate was declined rather than held, so there is no gap to digest and the
// refusal that stood in for the judgement is the whole of what stands. It
// cannot manufacture progress: a shortfall only falls when a standing one
// goes away (closerThan), and this can only ever put one there.
if shortfall.Standing == "" {
shortfall.Standing = RemainderDigest(findings.Declined)
}
return shortfall
}
// lostPublicNames is the newest symbol-level reading in a lineage, as a count.
//
// The newest and not the union, exactly as failingChecks reads the check
// roster: a name that was missing three rounds ago and is back is history, not
// a finding, and handing it to a rule as outstanding is how a round gets spent
// on something already done.
func lostPublicNames(graph *store.Store, lineage string) int {
nodes, err := graph.LineageNodes(lineage)
if err != nil {
return 0
}
for index := len(nodes) - 1; index >= 0; index-- {
readings, err := graph.SurfacesFor(nodes[index].ID)
if err != nil || len(readings) == 0 {
continue
}
return readings[len(readings)-1].Lost
}
return 0
}
// closerThan reports that this shortfall is smaller than the one before it.
func (s Shortfall) closerThan(previous Shortfall) bool {
switch {
case previous.Unexercised > 0 && s.Unexercised < previous.Unexercised:
return true
case previous.Red > 0 && s.Red < previous.Red:
return true
case previous.Lost > 0 && s.Lost < previous.Lost:
return true
case previous.Standing != "" && s.Standing == "":
return true
}
return false
}
// ── where the work happened ──────────────────────────────────────────────────
//
// The directory a job's rounds run in is A PROPERTY OF THE JOB and not of
// whoever asks to grow it. Four paths grow a running job — an overrun replan,
// the delivery gate's gap round, the revision sentinel, a cooperative split —
// and they are reached through signatures owned by four different waves, two of
// them in packages this one may not reach into. A fact threaded through the
// callers is a fact that works on whichever caller somebody remembered, which
// is the defect the splice's own seed comment already records having shipped
// once.
//
// So it is a seam, on the same terms as the satisfaction gate above it: written
// once by whoever builds the workspace, read by everything that weighs a round,
// and unset it simply answers "nobody could look".
var jobWorkspaces = newBoundedWorkspaces()
// RememberJobWorkspace records where a job's work happens. Calling it twice
// with the same answer is free; calling it with a different one replaces the
// answer, because a job that moved is working in the new place.
func RememberJobWorkspace(graph *store.Store, node store.Node, workspace string) {
workspace = strings.TrimSpace(workspace)
if graph == nil || workspace == "" {
return
}
jobWorkspaces.remember(jobRootID(graph, node), workspace)
}
// JobWorkspace is where a job's rounds run, or empty when nothing said.
func JobWorkspace(jobRoot string) string { return jobWorkspaces.recall(jobRoot) }
// rememberedWorkspaces bounds how many jobs this holds a directory for at once.
// It is internal/revision's rememberedJobs and verify's rememberedTrees for the
// same reason and at the same size: past it the oldest is dropped, which costs
// a round its measurement rather than giving it a wrong one.
const rememberedWorkspaces = 16
type boundedWorkspaces struct {
mu sync.Mutex
dirs map[string]string
order []string
}
func newBoundedWorkspaces() *boundedWorkspaces {
return &boundedWorkspaces{dirs: make(map[string]string, rememberedWorkspaces)}
}
func (b *boundedWorkspaces) remember(jobRoot, workspace string) {
if strings.TrimSpace(jobRoot) == "" {
return
}
b.mu.Lock()
defer b.mu.Unlock()
if _, held := b.dirs[jobRoot]; !held {
b.order = append(b.order, jobRoot)
for len(b.order) > rememberedWorkspaces {
delete(b.dirs, b.order[0])
b.order = b.order[1:]
}
}
b.dirs[jobRoot] = workspace
}
func (b *boundedWorkspaces) recall(jobRoot string) string {
b.mu.Lock()
defer b.mu.Unlock()
return b.dirs[strings.TrimSpace(jobRoot)]
}
// forgetJobWorkspaces is the tests' reset. Nothing in the program calls it: a
// process holds one resident and its jobs are all real.
func forgetJobWorkspaces() { jobWorkspaces = newBoundedWorkspaces() }