@platformos/platformos-mcp-supervisor 0.0.2 → 0.2.0

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.
Files changed (92) hide show
  1. package/README.md +105 -0
  2. package/dist/adapter-input.d.ts +113 -0
  3. package/dist/adapter-input.js +210 -0
  4. package/dist/adapter-input.spec.d.ts +1 -0
  5. package/dist/adapter-input.spec.js +275 -0
  6. package/dist/bin/args.d.ts +15 -2
  7. package/dist/bin/args.js +31 -3
  8. package/dist/bin/args.spec.d.ts +1 -0
  9. package/dist/bin/args.spec.js +35 -0
  10. package/dist/bin/platformos-mcp-supervisor.js +6 -2
  11. package/dist/check-docs.d.ts +21 -0
  12. package/dist/check-docs.js +38 -0
  13. package/dist/check-docs.spec.d.ts +1 -0
  14. package/dist/check-docs.spec.js +87 -0
  15. package/dist/context.d.ts +34 -0
  16. package/dist/context.js +12 -0
  17. package/dist/cost-model.d.ts +188 -0
  18. package/dist/cost-model.js +197 -0
  19. package/dist/cost-model.spec.d.ts +1 -0
  20. package/dist/cost-model.spec.js +91 -0
  21. package/dist/deadline.d.ts +27 -0
  22. package/dist/deadline.js +40 -0
  23. package/dist/deadline.spec.d.ts +1 -0
  24. package/dist/deadline.spec.js +83 -0
  25. package/dist/enrich/enrich.d.ts +105 -0
  26. package/dist/enrich/enrich.js +136 -0
  27. package/dist/enrich/enrich.spec.d.ts +1 -0
  28. package/dist/enrich/enrich.spec.js +184 -0
  29. package/dist/enrich/symbol.d.ts +16 -0
  30. package/dist/enrich/symbol.js +58 -0
  31. package/dist/enrich/symbol.spec.d.ts +1 -0
  32. package/dist/enrich/symbol.spec.js +89 -0
  33. package/dist/impact/dependants.d.ts +75 -0
  34. package/dist/impact/dependants.js +104 -0
  35. package/dist/impact/diff.d.ts +27 -0
  36. package/dist/impact/diff.js +33 -0
  37. package/dist/impact/impact.d.ts +21 -0
  38. package/dist/impact/impact.js +160 -0
  39. package/dist/impact/impact.spec.d.ts +1 -0
  40. package/dist/impact/impact.spec.js +357 -0
  41. package/dist/impact/project-scan.d.ts +39 -0
  42. package/dist/impact/project-scan.js +62 -0
  43. package/dist/impact/project-scan.spec.d.ts +1 -0
  44. package/dist/impact/project-scan.spec.js +121 -0
  45. package/dist/index.d.ts +1 -1
  46. package/dist/lint/fixes.d.ts +28 -0
  47. package/dist/lint/fixes.js +30 -0
  48. package/dist/lint/lint-batch.d.ts +100 -0
  49. package/dist/lint/lint-batch.js +98 -0
  50. package/dist/lint/lint-batch.spec.d.ts +1 -0
  51. package/dist/lint/lint-batch.spec.js +503 -0
  52. package/dist/lint/lint.d.ts +11 -1
  53. package/dist/lint/lint.js +9 -3
  54. package/dist/result/assemble.d.ts +11 -9
  55. package/dist/result/assemble.js +63 -16
  56. package/dist/result/assemble.spec.d.ts +1 -0
  57. package/dist/result/assemble.spec.js +319 -0
  58. package/dist/result/blocking.d.ts +92 -0
  59. package/dist/result/blocking.js +116 -0
  60. package/dist/result/blocking.spec.d.ts +1 -0
  61. package/dist/result/blocking.spec.js +201 -0
  62. package/dist/result/impact-states.d.ts +19 -0
  63. package/dist/result/impact-states.js +10 -0
  64. package/dist/result/response-budget.d.ts +9 -0
  65. package/dist/result/response-budget.js +133 -0
  66. package/dist/result/response-budget.spec.d.ts +1 -0
  67. package/dist/result/response-budget.spec.js +222 -0
  68. package/dist/result/types.d.ts +212 -104
  69. package/dist/result/types.js +4 -13
  70. package/dist/transport/instructions-coverage.spec.d.ts +1 -0
  71. package/dist/transport/instructions-coverage.spec.js +176 -0
  72. package/dist/transport/instructions.d.ts +1 -0
  73. package/dist/transport/instructions.js +150 -0
  74. package/dist/transport/process-guards.d.ts +43 -0
  75. package/dist/transport/process-guards.js +63 -0
  76. package/dist/transport/process-guards.spec.d.ts +1 -0
  77. package/dist/transport/process-guards.spec.js +200 -0
  78. package/dist/transport/server.d.ts +6 -1
  79. package/dist/transport/server.js +21 -10
  80. package/dist/transport/validate-code.d.ts +50 -15
  81. package/dist/transport/validate-code.js +238 -33
  82. package/dist/transport/validate-code.spec.d.ts +1 -0
  83. package/dist/transport/validate-code.spec.js +2303 -0
  84. package/dist/validate/batch-bounds.d.ts +34 -0
  85. package/dist/validate/batch-bounds.js +65 -0
  86. package/dist/validate/batch-coherence.d.ts +21 -0
  87. package/dist/validate/batch-coherence.js +54 -0
  88. package/dist/validate/batch-coherence.spec.d.ts +1 -0
  89. package/dist/validate/batch-coherence.spec.js +111 -0
  90. package/dist/validate/validate-buffers.d.ts +27 -0
  91. package/dist/validate/validate-buffers.js +319 -0
  92. package/package.json +8 -7
@@ -0,0 +1,188 @@
1
+ /**
2
+ * What a lint COSTS — and therefore how much work one request may admit, and how long the
3
+ * server waits for it.
4
+ *
5
+ * The byte caps and the deadline are ONE relationship, written here as arithmetic and
6
+ * derived rather than chosen. Adding per-node work moves {@link LINT_MS_PER_KIB} and every
7
+ * dependent bound follows; `cost-model.spec.ts` fails if any of them stops fitting. A batch
8
+ * admitted past its deadline returns `timed_out` for EVERY file in the request, so the two
9
+ * drifting apart fails quietly and in the expensive direction.
10
+ *
11
+ * THE DEADLINE SCALES WITH THE REQUEST because a fixed one and a meaningful batch cap
12
+ * cannot both exist: hold it at 60 s and the largest admissible batch is ~133 KiB, smaller
13
+ * than a single legal buffer. Scaling costs little — this deadline is a backstop for ASYNC
14
+ * stalls (see `deadline.ts`) rather than a CPU bound.
15
+ *
16
+ * Every number here is MEASURED, and the measurement is named. A plausible constant is how
17
+ * this file rots back into unrelated magic numbers.
18
+ */
19
+ /**
20
+ * Wall-clock cost of linting one KiB of buffer, on an idle machine, warm cache.
21
+ *
22
+ * Measured end to end (`runValidateCode`, the whole request path), not against the parse
23
+ * alone. Reproduce with `scripts/measure-lint-cost.mjs`, which builds a synthetic project
24
+ * and prints the rate for a single buffer and for batch shapes; the slowest observed was
25
+ * ~51 ms/KiB, on CLEAN markup rather than the diagnostic-dense shape.
26
+ *
27
+ * DO NOT LOWER THIS TO MATCH A FASTER MEASUREMENT. The rate varies by several times with
28
+ * the project and the markup, so the constant is deliberately set above the slowest
29
+ * observed value. Being wrong FAST costs a longer wait before a stall is declared; being
30
+ * wrong SLOW admits a request that cannot finish and returns `timed_out` for every file.
31
+ */
32
+ export declare const LINT_MS_PER_KIB = 75;
33
+ /**
34
+ * How much slower the same lint runs on a contended machine than on an idle one.
35
+ *
36
+ * Measured at ~2.3x (128 KiB: ~10 s idle, ~23 s under load), held at 3 because it bounds
37
+ * someone else's machine. Being wrong low produces a false `timed_out` — a silent
38
+ * no-answer — while being wrong high costs only a longer wait before declaring a stall.
39
+ */
40
+ export declare const LOAD_FACTOR = 3;
41
+ /**
42
+ * How far beyond the expected loaded worst case the deadline sits. The deadline must never
43
+ * fire on work that was legal to admit; it exists to catch a stall.
44
+ */
45
+ export declare const DEADLINE_MARGIN = 2;
46
+ /** Deadline granted per KiB of admitted work: idle cost, under load, with margin. */
47
+ export declare const DEADLINE_MS_PER_KIB: number;
48
+ /**
49
+ * Deadline floor, applied to every request however small.
50
+ *
51
+ * Small requests are not dominated by their buffers: a cold first call also builds the app,
52
+ * loads the config and reconciles the graph (~0.8 s idle, ~1.6 s loaded on a 162-file
53
+ * project). The floor covers that fixed cost with room to spare.
54
+ */
55
+ export declare const MIN_LINT_DEADLINE_MS = 60000;
56
+ /**
57
+ * Deadline ceiling, and therefore the origin of {@link maxBytesWithin}'s answer for the
58
+ * batch cap.
59
+ *
60
+ * POLICY rather than measurement: how long the server holds a request open before
61
+ * concluding it has stalled. Two minutes is past the point where an agent has any use for
62
+ * the answer, and every extra second of ceiling buys a larger admissible batch.
63
+ */
64
+ export declare const MAX_LINT_DEADLINE_MS = 120000;
65
+ /**
66
+ * How much of the AGENT'S CONTEXT one `validate_code` answer may spend.
67
+ *
68
+ * POLICY, like {@link MAX_LINT_DEADLINE_MS}, and the one bound here that is not about the
69
+ * server's own work. Unbounded, a legal call was measured returning ~336 000 tokens — more
70
+ * than most context windows hold, with nothing in the payload saying so.
71
+ *
72
+ * At ~153 bytes per diagnostic this budget holds roughly 200 findings. Measured real calls
73
+ * cost tens to hundreds of tokens, so the cap cannot touch them; what it defends against is
74
+ * the tail — a generated file, or one cascading syntax error in a large partial.
75
+ */
76
+ export declare const RESPONSE_TOKEN_BUDGET = 8000;
77
+ /**
78
+ * Bytes per token, for converting the budget above into something countable.
79
+ *
80
+ * A crude ESTIMATE — this JSON is mostly ASCII prose, where ~4 bytes/token is the usual
81
+ * rule of thumb, and the real tokenizer is the client's. Being wrong by 25% moves the cap
82
+ * between ~150 and ~250 diagnostics, which changes nothing about which calls it affects.
83
+ */
84
+ export declare const BYTES_PER_TOKEN = 4;
85
+ /**
86
+ * The bound actually enforced: serialized bytes of diagnostics one response may
87
+ * carry, across the WHOLE request rather than per file.
88
+ *
89
+ * Per-request because per-file alone leaves the worst case multiplied by the file
90
+ * count — 50 files each just under a per-file cap is 50x the intended bound, and the
91
+ * batch form exists precisely so agents send many files at once.
92
+ */
93
+ export declare const MAX_RESPONSE_DIAGNOSTIC_BYTES: number;
94
+ /**
95
+ * What one file costs in a response BEFORE any diagnostics: its status, gate, `impact`
96
+ * object, empty lists, and — when findings were withheld — the truncation note.
97
+ *
98
+ * Measured, not assumed: a 50-file batch of clean files serializes to 234 bytes per file,
99
+ * and the note adds ~150. Held at 512 because this multiplies by the file count.
100
+ */
101
+ export declare const RESPONSE_ENVELOPE_BYTES_PER_FILE = 512;
102
+ /**
103
+ * The whole response bound, stated as arithmetic rather than enforced directly.
104
+ *
105
+ * The allocator bounds DIAGNOSTICS, the only unbounded dimension; the envelope is O(files)
106
+ * and already bounded by the batch file cap. `transport/validate-code.spec.ts` measures the
107
+ * worst legal request against this.
108
+ *
109
+ * Takes the file count rather than importing `MAX_BATCH_FILES`, which lives in
110
+ * `validate/batch-bounds.ts` and already imports this module.
111
+ */
112
+ export declare function maxResponseBytes(fileCount: number): number;
113
+ /**
114
+ * How long to wait for a lint that was handed `bytes` of buffer.
115
+ *
116
+ * Never below {@link MIN_LINT_DEADLINE_MS}. Callers do not clamp to
117
+ * {@link MAX_LINT_DEADLINE_MS} — the byte caps do that upstream by refusing the request
118
+ * outright, so the ceiling is enforced where the work is admitted, not where it is timed.
119
+ */
120
+ export declare function lintDeadlineMs(bytes: number): number;
121
+ /**
122
+ * The most bytes whose {@link lintDeadlineMs} still fits inside `deadlineMs`.
123
+ *
124
+ * The inverse of the function above, floored to a whole KiB so the answer is exact at the
125
+ * boundary — one byte more and the deadline exceeds `deadlineMs`. Used to derive the batch
126
+ * cap from the deadline ceiling instead of from a round multiple of the per-buffer bound.
127
+ */
128
+ export declare function maxBytesWithin(deadlineMs: number): number;
129
+ /**
130
+ * How many DEPENDANTS one request will lint when computing impact.
131
+ *
132
+ * THE REAL BOUND ON IMPACT, and not a nicety. `IMPACT_DEADLINE_MS` cannot bound this work:
133
+ * a lint is synchronous CPU on the one event loop, so no timer preempts it (see
134
+ * `deadline.ts`). Bounding the INPUT is the only defence, exactly as `MAX_BUFFER_BYTES` is
135
+ * for the primary lint.
136
+ *
137
+ * DERIVED from the deadline it has to fit inside, on the worst project scale:
138
+ *
139
+ * impact deadline 2 000 ms
140
+ * less the project read at 10k files ~ 850 ms
141
+ * leaves for both lint passes ~1 150 ms
142
+ *
143
+ * Measured twice-lint cost, and the same {@link LOAD_FACTOR} the lint deadline uses:
144
+ *
145
+ * 100 dependants 258 ms idle 774 ms loaded -> 1 624 ms total, FITS
146
+ * 200 dependants 487 ms idle 1 460 ms loaded -> over
147
+ * 311 dependants 716 ms idle 2 147 ms loaded -> over on its own
148
+ *
149
+ * 311 is not hypothetical: it is the most-depended-on file measured on a real 2 615-file
150
+ * application (`lib/current_profile`). The same measurement puts 99.37% of targets at 100
151
+ * dependants or fewer — p50 1, p90 4, p99 59 — so the bound is hit rarely and, when it is,
152
+ * IT IS REPORTED (`unchecked_dependants`) rather than silently shortening the analysis.
153
+ */
154
+ export declare const MAX_DEPENDANTS_LINTED = 100;
155
+ /**
156
+ * Cost of DISCOVERING dependants, per KiB of candidate text.
157
+ *
158
+ * Discovery parses every file whose text contains the target's name and resolves its
159
+ * references through the graph, so it scales with the BYTES it must parse rather than with
160
+ * the file count. Measured on a real 2 615-file application across four very differently
161
+ * shaped targets: 12.82, 12.52, 8.59 and 14.60 ms/KiB. Held above the slowest observed, for
162
+ * the same reason {@link LINT_MS_PER_KIB} is.
163
+ */
164
+ export declare const DISCOVERY_MS_PER_KIB = 15;
165
+ /**
166
+ * How much candidate text one target's discovery will parse before giving up.
167
+ *
168
+ * WHY A BOUND AT ALL, and why it is not {@link MAX_DEPENDANTS_LINTED}: that one bounds the
169
+ * two lint passes, which happen AFTER discovery. Discovery itself was unbounded, and the
170
+ * most-referenced file on a real application (`lib/current_profile`, 312 candidates) cost
171
+ * 4.7 s and then returned `unavailable` anyway — seconds spent to reach "I do not know".
172
+ * The candidate byte total is known from a substring scan, before a single parse, so the
173
+ * same answer costs milliseconds instead.
174
+ *
175
+ * DERIVED from what has to fit inside `IMPACT_DEADLINE_MS`:
176
+ *
177
+ * impact deadline 2 000 ms
178
+ * less the project read ~ 235 ms
179
+ * less both lint passes at the dependant bound ~ 258 ms
180
+ * leaves for discovery ~1 507 ms
181
+ * at {@link DISCOVERY_MS_PER_KIB} ~100 KiB
182
+ *
183
+ * Held at 64 KiB rather than 100, because 100 spends the entire remaining budget and leaves
184
+ * nothing for a larger project's read. Measured against the same application: candidate text
185
+ * is p50 1 KiB, p90 13, p95 23, p99 143 — so 64 KiB is nearly 3x the p95 and turns away
186
+ * 2.51% of targets, each in a few milliseconds instead of several seconds.
187
+ */
188
+ export declare const MAX_CANDIDATE_BYTES: number;
@@ -0,0 +1,197 @@
1
+ /**
2
+ * What a lint COSTS — and therefore how much work one request may admit, and how long the
3
+ * server waits for it.
4
+ *
5
+ * The byte caps and the deadline are ONE relationship, written here as arithmetic and
6
+ * derived rather than chosen. Adding per-node work moves {@link LINT_MS_PER_KIB} and every
7
+ * dependent bound follows; `cost-model.spec.ts` fails if any of them stops fitting. A batch
8
+ * admitted past its deadline returns `timed_out` for EVERY file in the request, so the two
9
+ * drifting apart fails quietly and in the expensive direction.
10
+ *
11
+ * THE DEADLINE SCALES WITH THE REQUEST because a fixed one and a meaningful batch cap
12
+ * cannot both exist: hold it at 60 s and the largest admissible batch is ~133 KiB, smaller
13
+ * than a single legal buffer. Scaling costs little — this deadline is a backstop for ASYNC
14
+ * stalls (see `deadline.ts`) rather than a CPU bound.
15
+ *
16
+ * Every number here is MEASURED, and the measurement is named. A plausible constant is how
17
+ * this file rots back into unrelated magic numbers.
18
+ */
19
+ /**
20
+ * Wall-clock cost of linting one KiB of buffer, on an idle machine, warm cache.
21
+ *
22
+ * Measured end to end (`runValidateCode`, the whole request path), not against the parse
23
+ * alone. Reproduce with `scripts/measure-lint-cost.mjs`, which builds a synthetic project
24
+ * and prints the rate for a single buffer and for batch shapes; the slowest observed was
25
+ * ~51 ms/KiB, on CLEAN markup rather than the diagnostic-dense shape.
26
+ *
27
+ * DO NOT LOWER THIS TO MATCH A FASTER MEASUREMENT. The rate varies by several times with
28
+ * the project and the markup, so the constant is deliberately set above the slowest
29
+ * observed value. Being wrong FAST costs a longer wait before a stall is declared; being
30
+ * wrong SLOW admits a request that cannot finish and returns `timed_out` for every file.
31
+ */
32
+ export const LINT_MS_PER_KIB = 75;
33
+ /**
34
+ * How much slower the same lint runs on a contended machine than on an idle one.
35
+ *
36
+ * Measured at ~2.3x (128 KiB: ~10 s idle, ~23 s under load), held at 3 because it bounds
37
+ * someone else's machine. Being wrong low produces a false `timed_out` — a silent
38
+ * no-answer — while being wrong high costs only a longer wait before declaring a stall.
39
+ */
40
+ export const LOAD_FACTOR = 3;
41
+ /**
42
+ * How far beyond the expected loaded worst case the deadline sits. The deadline must never
43
+ * fire on work that was legal to admit; it exists to catch a stall.
44
+ */
45
+ export const DEADLINE_MARGIN = 2;
46
+ /** Deadline granted per KiB of admitted work: idle cost, under load, with margin. */
47
+ export const DEADLINE_MS_PER_KIB = LINT_MS_PER_KIB * LOAD_FACTOR * DEADLINE_MARGIN;
48
+ /**
49
+ * Deadline floor, applied to every request however small.
50
+ *
51
+ * Small requests are not dominated by their buffers: a cold first call also builds the app,
52
+ * loads the config and reconciles the graph (~0.8 s idle, ~1.6 s loaded on a 162-file
53
+ * project). The floor covers that fixed cost with room to spare.
54
+ */
55
+ export const MIN_LINT_DEADLINE_MS = 60_000;
56
+ /**
57
+ * Deadline ceiling, and therefore the origin of {@link maxBytesWithin}'s answer for the
58
+ * batch cap.
59
+ *
60
+ * POLICY rather than measurement: how long the server holds a request open before
61
+ * concluding it has stalled. Two minutes is past the point where an agent has any use for
62
+ * the answer, and every extra second of ceiling buys a larger admissible batch.
63
+ */
64
+ export const MAX_LINT_DEADLINE_MS = 120_000;
65
+ /**
66
+ * How much of the AGENT'S CONTEXT one `validate_code` answer may spend.
67
+ *
68
+ * POLICY, like {@link MAX_LINT_DEADLINE_MS}, and the one bound here that is not about the
69
+ * server's own work. Unbounded, a legal call was measured returning ~336 000 tokens — more
70
+ * than most context windows hold, with nothing in the payload saying so.
71
+ *
72
+ * At ~153 bytes per diagnostic this budget holds roughly 200 findings. Measured real calls
73
+ * cost tens to hundreds of tokens, so the cap cannot touch them; what it defends against is
74
+ * the tail — a generated file, or one cascading syntax error in a large partial.
75
+ */
76
+ export const RESPONSE_TOKEN_BUDGET = 8_000;
77
+ /**
78
+ * Bytes per token, for converting the budget above into something countable.
79
+ *
80
+ * A crude ESTIMATE — this JSON is mostly ASCII prose, where ~4 bytes/token is the usual
81
+ * rule of thumb, and the real tokenizer is the client's. Being wrong by 25% moves the cap
82
+ * between ~150 and ~250 diagnostics, which changes nothing about which calls it affects.
83
+ */
84
+ export const BYTES_PER_TOKEN = 4;
85
+ /**
86
+ * The bound actually enforced: serialized bytes of diagnostics one response may
87
+ * carry, across the WHOLE request rather than per file.
88
+ *
89
+ * Per-request because per-file alone leaves the worst case multiplied by the file
90
+ * count — 50 files each just under a per-file cap is 50x the intended bound, and the
91
+ * batch form exists precisely so agents send many files at once.
92
+ */
93
+ export const MAX_RESPONSE_DIAGNOSTIC_BYTES = RESPONSE_TOKEN_BUDGET * BYTES_PER_TOKEN;
94
+ /**
95
+ * What one file costs in a response BEFORE any diagnostics: its status, gate, `impact`
96
+ * object, empty lists, and — when findings were withheld — the truncation note.
97
+ *
98
+ * Measured, not assumed: a 50-file batch of clean files serializes to 234 bytes per file,
99
+ * and the note adds ~150. Held at 512 because this multiplies by the file count.
100
+ */
101
+ export const RESPONSE_ENVELOPE_BYTES_PER_FILE = 512;
102
+ /**
103
+ * The whole response bound, stated as arithmetic rather than enforced directly.
104
+ *
105
+ * The allocator bounds DIAGNOSTICS, the only unbounded dimension; the envelope is O(files)
106
+ * and already bounded by the batch file cap. `transport/validate-code.spec.ts` measures the
107
+ * worst legal request against this.
108
+ *
109
+ * Takes the file count rather than importing `MAX_BATCH_FILES`, which lives in
110
+ * `validate/batch-bounds.ts` and already imports this module.
111
+ */
112
+ export function maxResponseBytes(fileCount) {
113
+ return MAX_RESPONSE_DIAGNOSTIC_BYTES + fileCount * RESPONSE_ENVELOPE_BYTES_PER_FILE;
114
+ }
115
+ /** KiB in `bytes`, rounded UP — a partial KiB still costs a full unit of work. */
116
+ const kib = (bytes) => Math.ceil(bytes / 1024);
117
+ /**
118
+ * How long to wait for a lint that was handed `bytes` of buffer.
119
+ *
120
+ * Never below {@link MIN_LINT_DEADLINE_MS}. Callers do not clamp to
121
+ * {@link MAX_LINT_DEADLINE_MS} — the byte caps do that upstream by refusing the request
122
+ * outright, so the ceiling is enforced where the work is admitted, not where it is timed.
123
+ */
124
+ export function lintDeadlineMs(bytes) {
125
+ return Math.max(MIN_LINT_DEADLINE_MS, kib(bytes) * DEADLINE_MS_PER_KIB);
126
+ }
127
+ /**
128
+ * The most bytes whose {@link lintDeadlineMs} still fits inside `deadlineMs`.
129
+ *
130
+ * The inverse of the function above, floored to a whole KiB so the answer is exact at the
131
+ * boundary — one byte more and the deadline exceeds `deadlineMs`. Used to derive the batch
132
+ * cap from the deadline ceiling instead of from a round multiple of the per-buffer bound.
133
+ */
134
+ export function maxBytesWithin(deadlineMs) {
135
+ return Math.floor(deadlineMs / DEADLINE_MS_PER_KIB) * 1024;
136
+ }
137
+ /**
138
+ * How many DEPENDANTS one request will lint when computing impact.
139
+ *
140
+ * THE REAL BOUND ON IMPACT, and not a nicety. `IMPACT_DEADLINE_MS` cannot bound this work:
141
+ * a lint is synchronous CPU on the one event loop, so no timer preempts it (see
142
+ * `deadline.ts`). Bounding the INPUT is the only defence, exactly as `MAX_BUFFER_BYTES` is
143
+ * for the primary lint.
144
+ *
145
+ * DERIVED from the deadline it has to fit inside, on the worst project scale:
146
+ *
147
+ * impact deadline 2 000 ms
148
+ * less the project read at 10k files ~ 850 ms
149
+ * leaves for both lint passes ~1 150 ms
150
+ *
151
+ * Measured twice-lint cost, and the same {@link LOAD_FACTOR} the lint deadline uses:
152
+ *
153
+ * 100 dependants 258 ms idle 774 ms loaded -> 1 624 ms total, FITS
154
+ * 200 dependants 487 ms idle 1 460 ms loaded -> over
155
+ * 311 dependants 716 ms idle 2 147 ms loaded -> over on its own
156
+ *
157
+ * 311 is not hypothetical: it is the most-depended-on file measured on a real 2 615-file
158
+ * application (`lib/current_profile`). The same measurement puts 99.37% of targets at 100
159
+ * dependants or fewer — p50 1, p90 4, p99 59 — so the bound is hit rarely and, when it is,
160
+ * IT IS REPORTED (`unchecked_dependants`) rather than silently shortening the analysis.
161
+ */
162
+ export const MAX_DEPENDANTS_LINTED = 100;
163
+ /**
164
+ * Cost of DISCOVERING dependants, per KiB of candidate text.
165
+ *
166
+ * Discovery parses every file whose text contains the target's name and resolves its
167
+ * references through the graph, so it scales with the BYTES it must parse rather than with
168
+ * the file count. Measured on a real 2 615-file application across four very differently
169
+ * shaped targets: 12.82, 12.52, 8.59 and 14.60 ms/KiB. Held above the slowest observed, for
170
+ * the same reason {@link LINT_MS_PER_KIB} is.
171
+ */
172
+ export const DISCOVERY_MS_PER_KIB = 15;
173
+ /**
174
+ * How much candidate text one target's discovery will parse before giving up.
175
+ *
176
+ * WHY A BOUND AT ALL, and why it is not {@link MAX_DEPENDANTS_LINTED}: that one bounds the
177
+ * two lint passes, which happen AFTER discovery. Discovery itself was unbounded, and the
178
+ * most-referenced file on a real application (`lib/current_profile`, 312 candidates) cost
179
+ * 4.7 s and then returned `unavailable` anyway — seconds spent to reach "I do not know".
180
+ * The candidate byte total is known from a substring scan, before a single parse, so the
181
+ * same answer costs milliseconds instead.
182
+ *
183
+ * DERIVED from what has to fit inside `IMPACT_DEADLINE_MS`:
184
+ *
185
+ * impact deadline 2 000 ms
186
+ * less the project read ~ 235 ms
187
+ * less both lint passes at the dependant bound ~ 258 ms
188
+ * leaves for discovery ~1 507 ms
189
+ * at {@link DISCOVERY_MS_PER_KIB} ~100 KiB
190
+ *
191
+ * Held at 64 KiB rather than 100, because 100 spends the entire remaining budget and leaves
192
+ * nothing for a larger project's read. Measured against the same application: candidate text
193
+ * is p50 1 KiB, p90 13, p95 23, p99 143 — so 64 KiB is nearly 3x the p95 and turns away
194
+ * 2.51% of targets, each in a few milliseconds instead of several seconds.
195
+ */
196
+ export const MAX_CANDIDATE_BYTES = 64 * 1024;
197
+ //# sourceMappingURL=cost-model.js.map
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { DEADLINE_MARGIN, DEADLINE_MS_PER_KIB, LINT_MS_PER_KIB, LOAD_FACTOR, MAX_LINT_DEADLINE_MS, MIN_LINT_DEADLINE_MS, lintDeadlineMs, maxBytesWithin, } from './cost-model.js';
3
+ import { MAX_BUFFER_BYTES } from './adapter-input.js';
4
+ import { MAX_BATCH_BYTES, MAX_BATCH_FILES } from './validate/batch-bounds.js';
5
+ /**
6
+ * The arithmetic, and the invariants that make it safe.
7
+ *
8
+ * WHAT THIS FILE IS FOR. The defect it exists to prevent is not a wrong number —
9
+ * it is two numbers that stopped being related to each other without anything
10
+ * noticing. `MAX_BATCH_BYTES` was `4 * MAX_BUFFER_BYTES` and the deadline was 60 s,
11
+ * and they were compatible until `FilterArity` moved throughput; after that the
12
+ * worst legal batch could not finish inside the deadline it was given, and every
13
+ * file in such a request comes back `timed_out` — no validation, no error, no sign.
14
+ *
15
+ * So the assertions below are mostly RELATIONSHIPS, not values. A relationship
16
+ * fails the moment someone changes one side of it, which is precisely when it needs
17
+ * to be looked at. The few literal values that are pinned are pinned because they
18
+ * are what makes the relationships legible in a failure message.
19
+ */
20
+ describe('Unit: the lint cost model', () => {
21
+ it('composes the per-KiB deadline from its three named factors', () => {
22
+ // Pinned as a composition, not as 450: a reader who changes LOAD_FACTOR should
23
+ // see this follow, and a reader who changes this alone should see it break.
24
+ expect([LINT_MS_PER_KIB, LOAD_FACTOR, DEADLINE_MARGIN, DEADLINE_MS_PER_KIB]).toEqual([
25
+ 75,
26
+ 3,
27
+ 2,
28
+ 75 * 3 * 2,
29
+ ]);
30
+ });
31
+ it('charges a partial KiB as a whole one', () => {
32
+ // Rounding DOWN would grant a deadline smaller than the work admitted, which is
33
+ // the failure this module exists to prevent — so the rounding direction is part
34
+ // of the contract, not an implementation detail.
35
+ expect([lintDeadlineMs(300 * 1024), lintDeadlineMs(300 * 1024 - 1023)]).toEqual([
36
+ 300 * DEADLINE_MS_PER_KIB,
37
+ 300 * DEADLINE_MS_PER_KIB,
38
+ ]);
39
+ });
40
+ it('never returns less than the floor, however small the request', () => {
41
+ expect([lintDeadlineMs(0), lintDeadlineMs(1), lintDeadlineMs(1024)]).toEqual([
42
+ MIN_LINT_DEADLINE_MS,
43
+ MIN_LINT_DEADLINE_MS,
44
+ MIN_LINT_DEADLINE_MS,
45
+ ]);
46
+ });
47
+ it('inverts exactly: one KiB more than maxBytesWithin overshoots the deadline', () => {
48
+ const bytes = maxBytesWithin(MAX_LINT_DEADLINE_MS);
49
+ expect([lintDeadlineMs(bytes) <= MAX_LINT_DEADLINE_MS, lintDeadlineMs(bytes + 1024)]).toEqual([
50
+ true,
51
+ lintDeadlineMs(bytes) + DEADLINE_MS_PER_KIB,
52
+ ]);
53
+ });
54
+ });
55
+ describe('Unit: the bounds derived from the cost model', () => {
56
+ it('admits the worst legal BATCH inside the deadline that batch is granted', () => {
57
+ // The regression, stated directly. Before this derivation MAX_BATCH_BYTES was
58
+ // 512 KiB against a fixed 60 s deadline: 512 KiB of measured work is ~38 s idle
59
+ // and ~115 s loaded, so the worst legal batch could not finish in time and every
60
+ // file in it returned `timed_out`.
61
+ expect(lintDeadlineMs(MAX_BATCH_BYTES) <= MAX_LINT_DEADLINE_MS).toBe(true);
62
+ });
63
+ it('admits the worst legal single BUFFER inside the deadline it is granted', () => {
64
+ expect(lintDeadlineMs(MAX_BUFFER_BYTES) <= MAX_LINT_DEADLINE_MS).toBe(true);
65
+ });
66
+ it('leaves the single-file path on exactly the deadline it had before scaling', () => {
67
+ // Every real single-file call is far below MAX_BUFFER_BYTES, and even a maximal
68
+ // one lands on the floor. Scaling the deadline therefore changed nothing about
69
+ // the path that carries essentially all traffic — which is why it was safe to
70
+ // introduce. If this ever fails, single-file latency behaviour has moved and the
71
+ // 60 s figure quoted in `context.ts` and in the timeout message is stale.
72
+ expect(lintDeadlineMs(MAX_BUFFER_BYTES)).toEqual(MIN_LINT_DEADLINE_MS);
73
+ });
74
+ it('never refuses a batch for being smaller than one legal single buffer', () => {
75
+ // A batch cap below the per-buffer cap would refuse a one-file batch containing
76
+ // a file the single-file form accepts — the same content, refused for the shape
77
+ // of the request. This is also what forces the deadline to scale: at a fixed
78
+ // 60 s the derived batch cap is ~133 KiB, and this invariant fails.
79
+ expect(MAX_BATCH_BYTES > MAX_BUFFER_BYTES).toBe(true);
80
+ });
81
+ it('states the derived cap, so a change to the model shows up as a number', () => {
82
+ expect([MAX_BATCH_BYTES, MAX_BATCH_BYTES / 1024, MAX_BATCH_FILES]).toEqual([272_384, 266, 50]);
83
+ });
84
+ it('bounds the whole request even at the file-count cap', () => {
85
+ // MAX_BATCH_FILES alone cannot bound the work: 50 files just under the buffer
86
+ // cap would be ~6 MiB. The byte cap is what binds, and it binds well before the
87
+ // file cap does — this records which of the two is load-bearing.
88
+ expect(MAX_BATCH_FILES * MAX_BUFFER_BYTES > MAX_BATCH_BYTES).toBe(true);
89
+ });
90
+ });
91
+ //# sourceMappingURL=cost-model.spec.js.map
@@ -0,0 +1,27 @@
1
+ /**
2
+ * A deadline wrapper for the request path.
3
+ *
4
+ * WHAT THIS DOES AND DOES NOT BUY. The lint cannot be interrupted mid-flight: parsing is
5
+ * synchronous CPU work on the one event loop, so no timer preempts it. Racing a deadline
6
+ * returns control to the CALLER while the work runs on to completion. That fixes the
7
+ * failure mode that hurts — the agent stops waiting — but it is a BACKSTOP, not the primary
8
+ * defence, which is bounding the input (`MAX_BUFFER_BYTES`). True cancellation would need
9
+ * the lint on a worker thread.
10
+ */
11
+ /**
12
+ * Returned instead of a value when the deadline expires. A unique symbol rather
13
+ * than `undefined`/`null` so it can never collide with a legitimate result.
14
+ */
15
+ export declare const TIMED_OUT: unique symbol;
16
+ export type TimedOut = typeof TIMED_OUT;
17
+ /**
18
+ * Resolve with `work`'s value, or with {@link TIMED_OUT} once `ms` elapses.
19
+ *
20
+ * The timer is cleared on BOTH outcomes, and `unref`'d besides, so a pending deadline can
21
+ * never hold the process open.
22
+ *
23
+ * A rejection from `work` propagates unchanged. NOTE that when the deadline wins, `work` is
24
+ * left running and unobserved — the caller MUST attach a handler to it, or a later
25
+ * rejection becomes an unhandled rejection.
26
+ */
27
+ export declare function withDeadline<T>(work: Promise<T>, ms: number): Promise<T | TimedOut>;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * A deadline wrapper for the request path.
3
+ *
4
+ * WHAT THIS DOES AND DOES NOT BUY. The lint cannot be interrupted mid-flight: parsing is
5
+ * synchronous CPU work on the one event loop, so no timer preempts it. Racing a deadline
6
+ * returns control to the CALLER while the work runs on to completion. That fixes the
7
+ * failure mode that hurts — the agent stops waiting — but it is a BACKSTOP, not the primary
8
+ * defence, which is bounding the input (`MAX_BUFFER_BYTES`). True cancellation would need
9
+ * the lint on a worker thread.
10
+ */
11
+ /**
12
+ * Returned instead of a value when the deadline expires. A unique symbol rather
13
+ * than `undefined`/`null` so it can never collide with a legitimate result.
14
+ */
15
+ export const TIMED_OUT = Symbol('platformos-supervisor:timed-out');
16
+ /**
17
+ * Resolve with `work`'s value, or with {@link TIMED_OUT} once `ms` elapses.
18
+ *
19
+ * The timer is cleared on BOTH outcomes, and `unref`'d besides, so a pending deadline can
20
+ * never hold the process open.
21
+ *
22
+ * A rejection from `work` propagates unchanged. NOTE that when the deadline wins, `work` is
23
+ * left running and unobserved — the caller MUST attach a handler to it, or a later
24
+ * rejection becomes an unhandled rejection.
25
+ */
26
+ export function withDeadline(work, ms) {
27
+ return new Promise((resolve, reject) => {
28
+ const timer = setTimeout(() => resolve(TIMED_OUT), ms);
29
+ // Node-only: `unref` keeps a pending deadline from holding the event loop.
30
+ timer.unref?.();
31
+ work.then((value) => {
32
+ clearTimeout(timer);
33
+ resolve(value);
34
+ }, (error) => {
35
+ clearTimeout(timer);
36
+ reject(error);
37
+ });
38
+ });
39
+ }
40
+ //# sourceMappingURL=deadline.js.map
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { TIMED_OUT, withDeadline } from './deadline.js';
3
+ /**
4
+ * TASK-15. `withDeadline` is a backstop, and its contract is narrow on purpose:
5
+ * add a deadline, change nothing else. In particular a rejection must pass through
6
+ * untouched, because the lint is the primary gate and swallowing its failure would
7
+ * turn a real error into a silent pass.
8
+ */
9
+ const after = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms));
10
+ const rejectAfter = (ms, error) => new Promise((_, reject) => setTimeout(() => reject(error), ms));
11
+ describe('Unit: withDeadline', () => {
12
+ it('resolves with the value when the work finishes first', async () => {
13
+ await expect(withDeadline(after(1, 'done'), 1_000)).resolves.toEqual('done');
14
+ });
15
+ it('resolves with TIMED_OUT when the deadline wins', async () => {
16
+ await expect(withDeadline(after(1_000, 'too late'), 5)).resolves.toBe(TIMED_OUT);
17
+ });
18
+ it('resolves with TIMED_OUT for work that never settles at all', async () => {
19
+ // The exact shape that used to hang the tool for the life of the session.
20
+ await expect(withDeadline(new Promise(() => { }), 5)).resolves.toBe(TIMED_OUT);
21
+ });
22
+ it('propagates a rejection unchanged rather than converting it to TIMED_OUT', async () => {
23
+ // The lint is the primary gate: a genuine failure must stay a failure.
24
+ const failure = new Error('lint exploded');
25
+ await expect(withDeadline(rejectAfter(1, failure), 1_000)).rejects.toThrow(failure);
26
+ });
27
+ it('preserves a falsy result, which a null/undefined sentinel would have destroyed', async () => {
28
+ // TIMED_OUT is a unique Symbol precisely so these stay distinguishable.
29
+ await expect(withDeadline(Promise.resolve(undefined), 1_000)).resolves.toBeUndefined();
30
+ await expect(withDeadline(Promise.resolve(null), 1_000)).resolves.toBeNull();
31
+ await expect(withDeadline(Promise.resolve(0), 1_000)).resolves.toEqual(0);
32
+ await expect(withDeadline(Promise.resolve([]), 1_000)).resolves.toEqual([]);
33
+ });
34
+ it('clears the timer when the work wins, so nothing is left pending', async () => {
35
+ const clearSpy = vi.spyOn(globalThis, 'clearTimeout');
36
+ try {
37
+ await withDeadline(after(1, 'done'), 60_000);
38
+ // A 60 s timer left armed would hold a stdio server open past its work.
39
+ expect(clearSpy).toHaveBeenCalled();
40
+ }
41
+ finally {
42
+ clearSpy.mockRestore();
43
+ }
44
+ });
45
+ it('clears the timer when the work REJECTS, not only when it resolves', async () => {
46
+ const clearSpy = vi.spyOn(globalThis, 'clearTimeout');
47
+ try {
48
+ await expect(withDeadline(rejectAfter(1, new Error('x')), 60_000)).rejects.toThrow();
49
+ expect(clearSpy).toHaveBeenCalled();
50
+ }
51
+ finally {
52
+ clearSpy.mockRestore();
53
+ }
54
+ });
55
+ it('unrefs the deadline timer so a pending one cannot hold the process open', async () => {
56
+ const unref = vi.fn();
57
+ const setSpy = vi
58
+ .spyOn(globalThis, 'setTimeout')
59
+ .mockImplementation((() => ({ unref })));
60
+ try {
61
+ void withDeadline(Promise.resolve('immediate'), 60_000);
62
+ expect(unref).toHaveBeenCalledOnce();
63
+ }
64
+ finally {
65
+ setSpy.mockRestore();
66
+ }
67
+ });
68
+ it('does not stop the abandoned work from continuing — the documented limitation', async () => {
69
+ // Parsing is synchronous CPU work; nothing here can preempt it. The deadline
70
+ // frees the CALLER, it does not cancel. Pinned so the limitation is not
71
+ // mistaken for a bug later, and so nobody assumes cancellation they don't have.
72
+ let finished = false;
73
+ const work = after(30, 'eventually').then((value) => {
74
+ finished = true;
75
+ return value;
76
+ });
77
+ expect(await withDeadline(work, 5)).toBe(TIMED_OUT);
78
+ expect(finished).toBe(false);
79
+ await work;
80
+ expect(finished).toBe(true);
81
+ });
82
+ });
83
+ //# sourceMappingURL=deadline.spec.js.map