@gr8ful/spf 0.5.1 → 0.6.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 (95) hide show
  1. package/README.md +61 -17
  2. package/assets/defaults/spf.config.yaml +68 -0
  3. package/assets/prompts/refiner/system.md +32 -6
  4. package/assets/prompts/refiner/user.md +37 -5
  5. package/assets/skill/SKILL.md +1 -0
  6. package/assets/skill/references/config.md +153 -4
  7. package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
  8. package/assets/templates/ts.spf.config.yaml +6 -0
  9. package/dist/chains/index.d.ts +11 -0
  10. package/dist/chains/index.js +38 -3
  11. package/dist/chains/repo_chains.js +1 -0
  12. package/dist/chains/simple_sdlc.js +1 -1
  13. package/dist/chains/steps.d.ts +27 -9
  14. package/dist/chains/steps.js +82 -13
  15. package/dist/cli/commands/doctor.js +108 -1
  16. package/dist/cli/commands/estimate.d.ts +82 -0
  17. package/dist/cli/commands/estimate.js +317 -0
  18. package/dist/cli/commands/fanout.d.ts +40 -0
  19. package/dist/cli/commands/fanout.js +401 -0
  20. package/dist/cli/commands/init.js +19 -0
  21. package/dist/cli/commands/trace.d.ts +18 -0
  22. package/dist/cli/commands/trace.js +22 -3
  23. package/dist/cli/commands/watch.js +40 -26
  24. package/dist/cli/index.js +12 -0
  25. package/dist/core/agents.d.ts +56 -0
  26. package/dist/core/agents.js +152 -1
  27. package/dist/core/data_types.d.ts +232 -0
  28. package/dist/core/data_types.js +135 -0
  29. package/dist/core/fanout.d.ts +229 -0
  30. package/dist/core/fanout.js +313 -0
  31. package/dist/core/gates.d.ts +8 -0
  32. package/dist/core/gates.js +24 -1
  33. package/dist/core/git_helper.d.ts +98 -0
  34. package/dist/core/git_helper.js +127 -0
  35. package/dist/core/issues/github_provider.d.ts +7 -1
  36. package/dist/core/issues/github_provider.js +33 -2
  37. package/dist/core/issues/jira_provider.d.ts +5 -1
  38. package/dist/core/issues/jira_provider.js +26 -3
  39. package/dist/core/issues/provider.d.ts +53 -2
  40. package/dist/core/notify/channel.d.ts +1 -1
  41. package/dist/core/notify/notifier.d.ts +16 -0
  42. package/dist/core/notify/notifier.js +36 -0
  43. package/dist/core/otel.d.ts +64 -9
  44. package/dist/core/otel.js +78 -14
  45. package/dist/core/runner.d.ts +8 -0
  46. package/dist/core/runner.js +7 -0
  47. package/dist/core/session.d.ts +25 -0
  48. package/dist/core/session.js +97 -28
  49. package/dist/core/tiering.d.ts +145 -0
  50. package/dist/core/tiering.js +235 -0
  51. package/dist/core/tracer.d.ts +10 -0
  52. package/dist/core/tracer.js +12 -0
  53. package/dist/core/watch.d.ts +89 -9
  54. package/dist/core/watch.js +193 -19
  55. package/dist/ui/server/db.d.ts +39 -0
  56. package/dist/ui/server/db.js +61 -0
  57. package/package.json +2 -1
  58. package/dist/test/agent_cc.test.d.ts +0 -1
  59. package/dist/test/agent_cc.test.js +0 -95
  60. package/dist/test/agent_flue.test.d.ts +0 -1
  61. package/dist/test/agent_flue.test.js +0 -83
  62. package/dist/test/chains.test.d.ts +0 -12
  63. package/dist/test/chains.test.js +0 -92
  64. package/dist/test/data_types.test.d.ts +0 -10
  65. package/dist/test/data_types.test.js +0 -220
  66. package/dist/test/env_file.test.d.ts +0 -1
  67. package/dist/test/env_file.test.js +0 -74
  68. package/dist/test/fake_asker.d.ts +0 -23
  69. package/dist/test/fake_asker.js +0 -30
  70. package/dist/test/git_helper.test.d.ts +0 -1
  71. package/dist/test/git_helper.test.js +0 -59
  72. package/dist/test/hermetic_git.d.ts +0 -1
  73. package/dist/test/hermetic_git.js +0 -22
  74. package/dist/test/init_command.test.d.ts +0 -14
  75. package/dist/test/init_command.test.js +0 -136
  76. package/dist/test/interview.test.d.ts +0 -15
  77. package/dist/test/interview.test.js +0 -425
  78. package/dist/test/notify.test.d.ts +0 -1
  79. package/dist/test/notify.test.js +0 -174
  80. package/dist/test/ollama_provider.test.d.ts +0 -1
  81. package/dist/test/ollama_provider.test.js +0 -103
  82. package/dist/test/otel.test.d.ts +0 -26
  83. package/dist/test/otel.test.js +0 -512
  84. package/dist/test/paths.test.d.ts +0 -1
  85. package/dist/test/paths.test.js +0 -68
  86. package/dist/test/refine.test.d.ts +0 -1
  87. package/dist/test/refine.test.js +0 -189
  88. package/dist/test/repo_chains.test.d.ts +0 -21
  89. package/dist/test/repo_chains.test.js +0 -416
  90. package/dist/test/signoff.test.d.ts +0 -1
  91. package/dist/test/signoff.test.js +0 -329
  92. package/dist/test/ui_server.test.d.ts +0 -7
  93. package/dist/test/ui_server.test.js +0 -120
  94. package/dist/test/watch.test.d.ts +0 -1
  95. package/dist/test/watch.test.js +0 -687
@@ -1,5 +1,16 @@
1
1
  const API = "https://api.github.com";
2
- const STATES = ["ready", "working", "review", "done", "blocked", "spec-ready", "refining", "refined"];
2
+ const STATES = [
3
+ "ready",
4
+ "working",
5
+ "review",
6
+ "done",
7
+ "blocked",
8
+ "spec-ready",
9
+ "refining",
10
+ "refined",
11
+ "needs-feedback",
12
+ "continue-refinement",
13
+ ];
3
14
  const MARKER_RE = /<!--\s*spf-watch:\s*(\{.*?\})\s*-->/s;
4
15
  /** The refine lane's leaf/container taxonomy — see `data_types.ts`'s `RefinedIssueSchema.kind`. Not a `WatchState`: these never appear on the left of a `transition()` call, so `transition()` never strips them. */
5
16
  export const ISSUE_KINDS = ["epic", "feature", "story", "bug", "task"];
@@ -13,6 +24,8 @@ const LABEL_META = {
13
24
  "spec-ready": { color: "0e8a16", description: "spf watch's refine lane will claim this spec on its next poll" },
14
25
  refining: { color: "fbca04", description: "spf watch has claimed this spec and is decomposing it into issues" },
15
26
  refined: { color: "c2e0c6", description: "generated by spf watch's refine lane — promote to spf:ready when it's worth building" },
27
+ "needs-feedback": { color: "d93f0b", description: "spf's refiner needs a human answer before it can finish decomposing this spec" },
28
+ "continue-refinement": { color: "0e8a16", description: "add this once you've answered — spf will resume refining from where it left off" },
16
29
  };
17
30
  const TYPE_LABEL_META = {
18
31
  epic: { color: "5319e7", description: "a container generated by spf watch's refine lane — not directly workable" },
@@ -221,8 +234,12 @@ export class GitHubProvider {
221
234
  body: JSON.stringify({ sub_issue_id: Number(child.internal_id) }),
222
235
  });
223
236
  }
237
+ /** The single fetch every comment-reading method (`findMarkerComment`, `listComments`) builds on. */
238
+ async fetchComments(issueId) {
239
+ return this.gh(`/repos/${this.repo}/issues/${issueId}/comments?per_page=100`);
240
+ }
224
241
  async findMarkerComment(issueId) {
225
- const comments = await this.gh(`/repos/${this.repo}/issues/${issueId}/comments?per_page=100`);
242
+ const comments = await this.fetchComments(issueId);
226
243
  let found = null;
227
244
  for (const c of comments) {
228
245
  const match = MARKER_RE.exec(c.body || "");
@@ -251,4 +268,18 @@ export class GitHubProvider {
251
268
  await this.gh(`/repos/${this.repo}/issues/${issue.id}/comments`, { method: "POST", body: JSON.stringify({ body }) });
252
269
  }
253
270
  }
271
+ /** Oldest-first (GitHub's own comment order), the hidden marker comment filtered out. */
272
+ async listComments(issue) {
273
+ const comments = await this.fetchComments(issue.id);
274
+ return comments
275
+ .filter((c) => !MARKER_RE.test(c.body || ""))
276
+ .map((c) => ({ id: String(c.id), author: c.user?.login ?? "unknown", created_at: c.created_at, body: c.body || "" }));
277
+ }
278
+ /** `state_reason: "completed"` — the refine lane's own reason for closing a spec once it's fully decomposed; see `finishSpec` in `watch.ts`. */
279
+ async closeIssue(issue) {
280
+ await this.gh(`/repos/${this.repo}/issues/${issue.id}`, {
281
+ method: "PATCH",
282
+ body: JSON.stringify({ state: "closed", state_reason: "completed" }),
283
+ });
284
+ }
254
285
  }
@@ -40,7 +40,7 @@
40
40
  * `issue_provider: jira` fails loudly at startup rather than silently
41
41
  * running a refine lane that can never publish anything.
42
42
  */
43
- import type { EnsureLabelsResult, Issue, IssueProvider, WatchMarker, WatchState } from "./provider.ts";
43
+ import type { EnsureLabelsResult, Issue, IssueComment, IssueProvider, WatchMarker, WatchState } from "./provider.ts";
44
44
  export declare class JiraProvider implements IssueProvider {
45
45
  private readonly baseUrl;
46
46
  private readonly projectKey;
@@ -79,7 +79,11 @@ export declare class JiraProvider implements IssueProvider {
79
79
  }): Promise<boolean>;
80
80
  transition(issue: Issue, to: WatchState, detail?: string): Promise<void>;
81
81
  comment(issue: Issue, body: string): Promise<void>;
82
+ /** The single fetch every comment-reading method (`findMarkerComment`, `listComments`) builds on. */
83
+ private fetchComments;
82
84
  private findMarkerComment;
85
+ /** Oldest-first (Jira's own comment order), the hidden `[spf-watch-marker]` comment filtered out. */
86
+ listComments(issue: Issue): Promise<IssueComment[]>;
83
87
  readMarker(issue: Issue): Promise<WatchMarker | null>;
84
88
  writeMarker(issue: Issue, marker: WatchMarker): Promise<void>;
85
89
  }
@@ -1,4 +1,15 @@
1
- const STATES = ["ready", "working", "review", "done", "blocked", "spec-ready", "refining", "refined"];
1
+ const STATES = [
2
+ "ready",
3
+ "working",
4
+ "review",
5
+ "done",
6
+ "blocked",
7
+ "spec-ready",
8
+ "refining",
9
+ "refined",
10
+ "needs-feedback",
11
+ "continue-refinement",
12
+ ];
2
13
  const MARKER_RE = /\[spf-watch-marker\]\s*(\{.*?\})/s;
3
14
  function toAdf(text) {
4
15
  return {
@@ -129,10 +140,15 @@ export class JiraProvider {
129
140
  async comment(issue, body) {
130
141
  await this.jira(`/rest/api/3/issue/${issue.id}/comment`, { method: "POST", body: JSON.stringify({ body: toAdf(body) }) });
131
142
  }
132
- async findMarkerComment(issueId) {
143
+ /** The single fetch every comment-reading method (`findMarkerComment`, `listComments`) builds on. */
144
+ async fetchComments(issueId) {
133
145
  const result = await this.jira(`/rest/api/3/issue/${issueId}/comment?maxResults=100`);
146
+ return result.comments;
147
+ }
148
+ async findMarkerComment(issueId) {
149
+ const comments = await this.fetchComments(issueId);
134
150
  let found = null;
135
- for (const c of result.comments) {
151
+ for (const c of comments) {
136
152
  const match = MARKER_RE.exec(adfToText(c.body));
137
153
  if (!match)
138
154
  continue;
@@ -145,6 +161,13 @@ export class JiraProvider {
145
161
  }
146
162
  return found;
147
163
  }
164
+ /** Oldest-first (Jira's own comment order), the hidden `[spf-watch-marker]` comment filtered out. */
165
+ async listComments(issue) {
166
+ const comments = await this.fetchComments(issue.id);
167
+ return comments
168
+ .filter((c) => !MARKER_RE.test(adfToText(c.body)))
169
+ .map((c) => ({ id: c.id, author: c.author?.displayName ?? "unknown", created_at: c.created, body: adfToText(c.body) }));
170
+ }
148
171
  async readMarker(issue) {
149
172
  const found = await this.findMarkerComment(issue.id);
150
173
  return found?.marker ?? null;
@@ -29,13 +29,26 @@
29
29
  * `refined` is not a lane state at all — it never appears on the left of a
30
30
  * `transition()` call. It is the terminal label a generated LEAF issue
31
31
  * (story/bug/task) gets, marking it awaiting a human's promotion to `ready`.
32
- * All eight still live in one `WatchState` union (not two separate unions)
32
+ *
33
+ * `needs-feedback`/`continue-refinement` are the refine lane's human-in-the-
34
+ * loop loop, layered onto the same three states: a `refining` run whose
35
+ * refiner raised material ambiguity (see `RefineOutput.questions`) posts its
36
+ * questions and moves the spec to `needs-feedback` (`watch.ts`'s
37
+ * `escalateSpec`) instead of either finishing or blocking. A human answers in
38
+ * the issue's comments, then adds `continue-refinement` — `claimSpecs`
39
+ * accepts an optional `from` state precisely so it can claim
40
+ * `continue-refinement -> refining` the same way it claims
41
+ * `spec-ready -> refining`, resuming the SAME `adw_id` (deterministic from
42
+ * the issue id) with the comment thread folded into the prompt. This can
43
+ * loop any number of rounds; there is no cap.
44
+ *
45
+ * All ten still live in one `WatchState` union (not several separate unions)
33
46
  * because `transition()`'s "strip every `<prefix>:<state>` label, then add
34
47
  * one" logic (see `github_provider.ts`/`jira_provider.ts`) has to know about
35
48
  * every one of them to strip correctly, and `ensureLabels()` seeds all of
36
49
  * them from one `STATES` array.
37
50
  */
38
- export type WatchState = "ready" | "working" | "review" | "done" | "blocked" | "spec-ready" | "refining" | "refined";
51
+ export type WatchState = "ready" | "working" | "review" | "done" | "blocked" | "spec-ready" | "refining" | "refined" | "needs-feedback" | "continue-refinement";
39
52
  export interface Issue {
40
53
  /** Opaque tracker identifier: a GitHub issue number stringified ("42"), a Jira key ("PROJ-123"). */
41
54
  id: string;
@@ -53,6 +66,15 @@ export interface Issue {
53
66
  */
54
67
  internal_id?: string;
55
68
  }
69
+ /** One comment on an issue, as read back for the refine lane's escalation loop — see `IssueProvider.listComments`. */
70
+ export interface IssueComment {
71
+ id: string;
72
+ /** Display handle — GitHub's `user.login`, Jira's `author.displayName`. */
73
+ author: string;
74
+ /** ISO 8601, verbatim from the tracker. */
75
+ created_at: string;
76
+ body: string;
77
+ }
56
78
  export interface PrRef {
57
79
  number: number;
58
80
  branch: string;
@@ -73,6 +95,14 @@ export interface PrStatus {
73
95
  * whose marker already lists them skips creation entirely — `to-tickets`
74
96
  * (the skill this lane's prompt is ported from) has no such guard and
75
97
  * duplicates every ticket on a re-run; this is what closes that gap.
98
+ *
99
+ * `feedback` is the refine lane's human-in-the-loop cursor: `rounds` counts
100
+ * how many times this spec has been escalated (so a resumed run's summary
101
+ * comment can say "answered after 2 rounds"), and `asked_at` is the ISO
102
+ * timestamp of the most recent question comment — `watch.ts`'s
103
+ * `buildSpecPrompt` uses it to split the issue's comment thread into
104
+ * "answers to the open questions" versus "earlier discussion" when building
105
+ * the resumed run's prompt.
76
106
  */
77
107
  export interface WatchMarker {
78
108
  worktree?: string;
@@ -80,6 +110,10 @@ export interface WatchMarker {
80
110
  pr?: number;
81
111
  attempt?: number;
82
112
  refined?: string[];
113
+ feedback?: {
114
+ rounds: number;
115
+ asked_at: string;
116
+ };
83
117
  }
84
118
  /** What `ensureLabels()` actually did, per label — for `spf watch init`'s report. */
85
119
  export interface EnsureLabelsResult {
@@ -133,6 +167,23 @@ export interface IssueProvider {
133
167
  comment(issue: Issue, body: string): Promise<void>;
134
168
  readMarker(issue: Issue): Promise<WatchMarker | null>;
135
169
  writeMarker(issue: Issue, marker: WatchMarker): Promise<void>;
170
+ /**
171
+ * Oldest-first, the hidden marker comment excluded — the refine lane's
172
+ * escalation loop reads a human's answers back out of the thread (see
173
+ * `watch.ts`'s `buildSpecPrompt`). Every other seam on this interface is
174
+ * write-only towards comments (`comment()`, and `transition()`'s own
175
+ * `detail`); this is the one read.
176
+ */
177
+ listComments(issue: Issue): Promise<IssueComment[]>;
178
+ /**
179
+ * Close the issue as completed, where the tracker has such a concept.
180
+ * Optional, like `IssueAuthoringProvider`'s methods below: the label IS the
181
+ * state machine (see `transition()` above), and closing is a courtesy on
182
+ * top of `<prefix>:done`, never something `spf watch` itself reads back —
183
+ * a tracker (or a caller) that skips this leaves the spec `done` and open,
184
+ * exactly as every state before this feature existed already behaved.
185
+ */
186
+ closeIssue?(issue: Issue): Promise<void>;
136
187
  }
137
188
  /**
138
189
  * The PR-lifecycle seam, independent of `IssueProvider` — see the module
@@ -11,7 +11,7 @@
11
11
  * predicate a `Notifier` applies — no separate per-kind severity table to
12
12
  * keep in sync with this list.
13
13
  */
14
- export type NotifyKind = "run_started" | "run_finished" | "run_failed" | "phase_failed" | "phase_retry" | "watch_started" | "watch_stopped" | "watch_error" | "issue_claimed" | "pr_opened" | "issue_done" | "issue_blocked" | "spec_refined";
14
+ export type NotifyKind = "run_started" | "run_finished" | "run_failed" | "phase_failed" | "phase_retry" | "watch_started" | "watch_stopped" | "watch_error" | "issue_claimed" | "pr_opened" | "issue_done" | "issue_blocked" | "spec_refined" | "spec_needs_feedback";
15
15
  export interface NotifyEvent {
16
16
  kind: NotifyKind;
17
17
  /** "error" sends under both `events: errors` and `events: all`; "info" only under `all`. */
@@ -27,6 +27,14 @@ export declare class Notifier {
27
27
  send(event: NotifyEvent): void;
28
28
  /** Await every in-flight send — call before process exit so a slow webhook isn't dropped mid-flight. */
29
29
  flush(): Promise<void>;
30
+ /**
31
+ * Bounded drain: race `flush()` against `budgetMs`, never throwing. Mirrors
32
+ * `otel.ts`'s `OtelExporter.drain()` discipline — for the signal handler in
33
+ * `session.ts`, which is racing a hard ^C deadline shorter than any single
34
+ * channel's own `AbortSignal.timeout(timeoutMs)`, so a webhook to an
35
+ * unreachable host cannot hold the process open past `budgetMs`.
36
+ */
37
+ drain(budgetMs: number): Promise<void>;
30
38
  }
31
39
  /**
32
40
  * Build a `Notifier` from `cfg.notifications`, or `null` if it's off or no
@@ -40,3 +48,11 @@ export declare function resolveNotifier(cfg: SFConfig, opts?: {
40
48
  }): Notifier | null;
41
49
  /** Await every Notifier this process has created — call once, from the CLI's shutdown path. */
42
50
  export declare function flushAll(): Promise<void>;
51
+ /**
52
+ * Bounded drain of every Notifier this process created, under one shared
53
+ * budget — the signal-handler twin of `flushAll()`. Called from
54
+ * `session.ts`'s SIGTERM/SIGINT handler, alongside `otel.flushAll()`, so a
55
+ * killed run's in-flight Slack/Teams/webhook sends get the same
56
+ * timeout-and-swallow chance the otel drain already had. Never throws.
57
+ */
58
+ export declare function drainAll(budgetMs: number): Promise<void>;
@@ -47,6 +47,32 @@ export class Notifier {
47
47
  async flush() {
48
48
  await Promise.all([...this.pending]);
49
49
  }
50
+ /**
51
+ * Bounded drain: race `flush()` against `budgetMs`, never throwing. Mirrors
52
+ * `otel.ts`'s `OtelExporter.drain()` discipline — for the signal handler in
53
+ * `session.ts`, which is racing a hard ^C deadline shorter than any single
54
+ * channel's own `AbortSignal.timeout(timeoutMs)`, so a webhook to an
55
+ * unreachable host cannot hold the process open past `budgetMs`.
56
+ */
57
+ async drain(budgetMs) {
58
+ let deadline = null;
59
+ const budget = new Promise((resolve) => {
60
+ deadline = setTimeout(resolve, budgetMs);
61
+ deadline.unref?.();
62
+ });
63
+ try {
64
+ await Promise.race([this.flush(), budget]);
65
+ }
66
+ catch {
67
+ // unreachable in practice — every send() task is pre-caught into the log
68
+ // line above, so flush() never rejects — but a drain that can throw
69
+ // would break the shutdown path it exists to protect.
70
+ }
71
+ finally {
72
+ if (deadline)
73
+ clearTimeout(deadline);
74
+ }
75
+ }
50
76
  }
51
77
  function makeChannel(kind, url, name) {
52
78
  switch (kind) {
@@ -98,3 +124,13 @@ export function resolveNotifier(cfg, opts = {}) {
98
124
  export async function flushAll() {
99
125
  await Promise.all(LIVE.map((n) => n.flush()));
100
126
  }
127
+ /**
128
+ * Bounded drain of every Notifier this process created, under one shared
129
+ * budget — the signal-handler twin of `flushAll()`. Called from
130
+ * `session.ts`'s SIGTERM/SIGINT handler, alongside `otel.flushAll()`, so a
131
+ * killed run's in-flight Slack/Teams/webhook sends get the same
132
+ * timeout-and-swallow chance the otel drain already had. Never throws.
133
+ */
134
+ export async function drainAll(budgetMs) {
135
+ await Promise.all(LIVE.map((n) => n.drain(budgetMs)));
136
+ }
@@ -92,15 +92,41 @@
92
92
  * silently (see `parseTraceparent`) — a malformed variable must degrade to
93
93
  * "own root", never to an error.
94
94
  *
95
- * LIFECYCLE (copied from `notify/notifier.ts`'s discipline). A module-level
96
- * LIVE registry holds every exporter this process created; `flushAll()` is
97
- * awaited in `src/cli/index.ts`'s existing `finally` block next to
98
- * `notify.flushAll()`, AND `session.ts`'s signal handler runs a bounded,
99
- * timeout-capped drain before its `process.exit(128+n)` (notify does NOT do
100
- * that second one today its in-flight webhooks are dropped on SIGTERM; only
101
- * the otel path is fixed here, on purpose, to keep this change to one seam).
102
- * Send failures log ONE line for the life of the exporter, with the endpoint
103
- * and every header VALUE redacted, and are then swallowed.
95
+ * LIFECYCLE (copied from `notify/notifier.ts`'s discipline, with one
96
+ * addition `notify` doesn't need see RUN-SCOPED CLEANUP below). A
97
+ * module-level LIVE registry holds every exporter this process created;
98
+ * `flushAll()` is awaited in `src/cli/index.ts`'s existing `finally` block
99
+ * next to `notify.flushAll()`, AND `session.ts`'s signal handler runs a
100
+ * bounded, timeout-capped drain before its `process.exit(128+n)` (notify
101
+ * drains there too, via `drainAll()`, both racing the same budget
102
+ * concurrently — see #25). Send failures log ONE line for the life of the exporter,
103
+ * with the endpoint and every header VALUE redacted, and are then swallowed.
104
+ *
105
+ * RUN-SCOPED CLEANUP (#26). `notify`'s `LIVE` array has the same
106
+ * unbounded-growth problem under a long-lived daemon (tracked as #31, not
107
+ * fixed here). `spf watch` breaks the one-session-per-process assumption
108
+ * both registries were written under: its daemon loop runs many sessions
109
+ * in-process, one per claimed issue, and every one of them calls
110
+ * `resolveOtelExporter` — with no removal path, that was one exporter (plus
111
+ * its bounded span queue, its buffered-event maps, its open-agent-call
112
+ * tracking) held forever per issue processed, for the life of the daemon.
113
+ * `LIVE` is therefore keyed by adw_id (not a plain array) so a finished run
114
+ * can be found and dropped by id, and `releaseOtelExporter(adwId)` — called
115
+ * from `chains/index.ts`'s `runChain()`, the one call site every dispatch
116
+ * (one-shot CLI and `spf watch` alike) passes through on its way out,
117
+ * success or thrown error alike — drains that one exporter and removes it.
118
+ * Draining BEFORE removing matters: the removal itself must never be the
119
+ * reason a run's final root span goes unsent (that guarantee is what
120
+ * `flushAll()` already gave the one-shot CLI path, and this must not weaken
121
+ * it). And it must not run any EARLIER than "this run's own dispatch has
122
+ * fully settled" — a signal can still land while the run is in flight, and
123
+ * `session.ts`'s handler drains the GLOBAL registry, so an exporter removed
124
+ * before its run is actually done would silently stop being reachable from
125
+ * that drain. A one-shot invocation with no explicit `--adw-id` is a
126
+ * harmless no-op here (the registry key is the RESOLVED id `session.ensure`
127
+ * mints, which `runChain()`'s caller never sees) — that process exits right
128
+ * after anyway, so the existing end-of-process `flushAll()` still covers it
129
+ * exactly as it always did.
104
130
  *
105
131
  * BACKPRESSURE. `tracer.event()` fires per tool call on a hot path, so raw
106
132
  * promise-per-span fire-and-forget is a memory bug, not a style choice.
@@ -334,6 +360,14 @@ export declare class OtelExporter {
334
360
  * call site is `otel?.record...()` and never a conditional branch. `null` is
335
361
  * the default for every repo that has not configured an endpoint, and no
336
362
  * environment variable can change that (see EXPLICIT CONFIG ONLY).
363
+ *
364
+ * Registered under `opts.adwId` — the RESOLVED id (`session.ensure`'s own
365
+ * `id`, never a caller's possibly-null `ctx.adw_id`) — which is exactly the
366
+ * key `releaseOtelExporter` below looks it up by. A second registration
367
+ * under an id that's still live (in practice: a bug elsewhere, since adw_id
368
+ * is meant to be unique per in-flight run) replaces the map entry; the
369
+ * orphaned exporter's own queue still drains itself on its own unref'd
370
+ * timer, just unreachable from `flushAll()` from that point on.
337
371
  */
338
372
  export declare function resolveOtelExporter(cfg: SFConfig, opts: {
339
373
  adwId: string;
@@ -348,5 +382,26 @@ export declare function resolveOtelExporter(cfg: SFConfig, opts: {
348
382
  * handler. Never throws.
349
383
  */
350
384
  export declare function flushAll(budgetMs?: number): Promise<void>;
385
+ /**
386
+ * The counterpart to `resolveOtelExporter`: drain and forget the one
387
+ * exporter registered for `adwId`, so a long-lived process (`spf watch`'s
388
+ * daemon loop) doesn't hold one exporter per run forever (see #26 / the
389
+ * RUN-SCOPED CLEANUP note above). Called from `chains/index.ts`'s
390
+ * `runChain()` once a run's own dispatch has fully settled — success or
391
+ * thrown error alike.
392
+ *
393
+ * A no-op, not an error, when `adwId` is falsy (a one-shot invocation with
394
+ * no explicit `--adw-id` — its caller never learns the id `session.ensure`
395
+ * actually minted, so it cannot ask for this by id; that process exits
396
+ * right after anyway and `flushAll()` still covers it) or when nothing is
397
+ * registered under it (otel unconfigured, or already released). Draining
398
+ * BEFORE deleting the map entry, never after: this run's exporter must stay
399
+ * reachable from a concurrent `flushAll()` (a signal landing on some OTHER
400
+ * still-in-flight run, under `spf watch`'s concurrency) for the full
401
+ * duration of ITS OWN drain, and removing the entry first would let that
402
+ * concurrent drain skip an exporter that has not actually finished sending
403
+ * yet.
404
+ */
405
+ export declare function releaseOtelExporter(adwId: string | null | undefined, budgetMs?: number): Promise<void>;
351
406
  /** Tests only: forget every registered exporter so cases cannot leak into each other. */
352
407
  export declare function resetLiveForTest(): void;
package/dist/core/otel.js CHANGED
@@ -92,15 +92,41 @@
92
92
  * silently (see `parseTraceparent`) — a malformed variable must degrade to
93
93
  * "own root", never to an error.
94
94
  *
95
- * LIFECYCLE (copied from `notify/notifier.ts`'s discipline). A module-level
96
- * LIVE registry holds every exporter this process created; `flushAll()` is
97
- * awaited in `src/cli/index.ts`'s existing `finally` block next to
98
- * `notify.flushAll()`, AND `session.ts`'s signal handler runs a bounded,
99
- * timeout-capped drain before its `process.exit(128+n)` (notify does NOT do
100
- * that second one today its in-flight webhooks are dropped on SIGTERM; only
101
- * the otel path is fixed here, on purpose, to keep this change to one seam).
102
- * Send failures log ONE line for the life of the exporter, with the endpoint
103
- * and every header VALUE redacted, and are then swallowed.
95
+ * LIFECYCLE (copied from `notify/notifier.ts`'s discipline, with one
96
+ * addition `notify` doesn't need see RUN-SCOPED CLEANUP below). A
97
+ * module-level LIVE registry holds every exporter this process created;
98
+ * `flushAll()` is awaited in `src/cli/index.ts`'s existing `finally` block
99
+ * next to `notify.flushAll()`, AND `session.ts`'s signal handler runs a
100
+ * bounded, timeout-capped drain before its `process.exit(128+n)` (notify
101
+ * drains there too, via `drainAll()`, both racing the same budget
102
+ * concurrently — see #25). Send failures log ONE line for the life of the exporter,
103
+ * with the endpoint and every header VALUE redacted, and are then swallowed.
104
+ *
105
+ * RUN-SCOPED CLEANUP (#26). `notify`'s `LIVE` array has the same
106
+ * unbounded-growth problem under a long-lived daemon (tracked as #31, not
107
+ * fixed here). `spf watch` breaks the one-session-per-process assumption
108
+ * both registries were written under: its daemon loop runs many sessions
109
+ * in-process, one per claimed issue, and every one of them calls
110
+ * `resolveOtelExporter` — with no removal path, that was one exporter (plus
111
+ * its bounded span queue, its buffered-event maps, its open-agent-call
112
+ * tracking) held forever per issue processed, for the life of the daemon.
113
+ * `LIVE` is therefore keyed by adw_id (not a plain array) so a finished run
114
+ * can be found and dropped by id, and `releaseOtelExporter(adwId)` — called
115
+ * from `chains/index.ts`'s `runChain()`, the one call site every dispatch
116
+ * (one-shot CLI and `spf watch` alike) passes through on its way out,
117
+ * success or thrown error alike — drains that one exporter and removes it.
118
+ * Draining BEFORE removing matters: the removal itself must never be the
119
+ * reason a run's final root span goes unsent (that guarantee is what
120
+ * `flushAll()` already gave the one-shot CLI path, and this must not weaken
121
+ * it). And it must not run any EARLIER than "this run's own dispatch has
122
+ * fully settled" — a signal can still land while the run is in flight, and
123
+ * `session.ts`'s handler drains the GLOBAL registry, so an exporter removed
124
+ * before its run is actually done would silently stop being reachable from
125
+ * that drain. A one-shot invocation with no explicit `--adw-id` is a
126
+ * harmless no-op here (the registry key is the RESOLVED id `session.ensure`
127
+ * mints, which `runChain()`'s caller never sees) — that process exits right
128
+ * after anyway, so the existing end-of-process `flushAll()` still covers it
129
+ * exactly as it always did.
104
130
  *
105
131
  * BACKPRESSURE. `tracer.event()` fires per tool call on a hot path, so raw
106
132
  * promise-per-span fire-and-forget is a memory bug, not a style choice.
@@ -755,14 +781,23 @@ function clip(value, limit = 200) {
755
781
  const text = String(value ?? "");
756
782
  return text.length <= limit ? text : text.slice(0, limit);
757
783
  }
758
- // ── module-level lifecycle (mirrors notify/notifier.ts's LIVE + flushAll) ───
759
- const LIVE = [];
784
+ // ── module-level lifecycle (mirrors notify/notifier.ts's LIVE + flushAll,
785
+ // keyed by adw_id — see RUN-SCOPED CLEANUP above) ─────────────────────────
786
+ const LIVE = new Map();
760
787
  /**
761
788
  * Build an exporter from `cfg.observability.otel`, or `null` when it is
762
789
  * absent — the same optional-dependency shape as `resolveNotifier`, so every
763
790
  * call site is `otel?.record...()` and never a conditional branch. `null` is
764
791
  * the default for every repo that has not configured an endpoint, and no
765
792
  * environment variable can change that (see EXPLICIT CONFIG ONLY).
793
+ *
794
+ * Registered under `opts.adwId` — the RESOLVED id (`session.ensure`'s own
795
+ * `id`, never a caller's possibly-null `ctx.adw_id`) — which is exactly the
796
+ * key `releaseOtelExporter` below looks it up by. A second registration
797
+ * under an id that's still live (in practice: a bug elsewhere, since adw_id
798
+ * is meant to be unique per in-flight run) replaces the map entry; the
799
+ * orphaned exporter's own queue still drains itself on its own unref'd
800
+ * timer, just unreachable from `flushAll()` from that point on.
766
801
  */
767
802
  export function resolveOtelExporter(cfg, opts) {
768
803
  const otel = cfg.observability.otel;
@@ -775,7 +810,7 @@ export function resolveOtelExporter(cfg, opts) {
775
810
  log: opts.log,
776
811
  env: opts.env,
777
812
  });
778
- LIVE.push(exporter);
813
+ LIVE.set(opts.adwId, exporter);
779
814
  return exporter;
780
815
  }
781
816
  /**
@@ -785,9 +820,38 @@ export function resolveOtelExporter(cfg, opts) {
785
820
  * handler. Never throws.
786
821
  */
787
822
  export async function flushAll(budgetMs) {
788
- await Promise.all(LIVE.map((exporter) => exporter.drain(budgetMs)));
823
+ await Promise.all([...LIVE.values()].map((exporter) => exporter.drain(budgetMs)));
824
+ }
825
+ /**
826
+ * The counterpart to `resolveOtelExporter`: drain and forget the one
827
+ * exporter registered for `adwId`, so a long-lived process (`spf watch`'s
828
+ * daemon loop) doesn't hold one exporter per run forever (see #26 / the
829
+ * RUN-SCOPED CLEANUP note above). Called from `chains/index.ts`'s
830
+ * `runChain()` once a run's own dispatch has fully settled — success or
831
+ * thrown error alike.
832
+ *
833
+ * A no-op, not an error, when `adwId` is falsy (a one-shot invocation with
834
+ * no explicit `--adw-id` — its caller never learns the id `session.ensure`
835
+ * actually minted, so it cannot ask for this by id; that process exits
836
+ * right after anyway and `flushAll()` still covers it) or when nothing is
837
+ * registered under it (otel unconfigured, or already released). Draining
838
+ * BEFORE deleting the map entry, never after: this run's exporter must stay
839
+ * reachable from a concurrent `flushAll()` (a signal landing on some OTHER
840
+ * still-in-flight run, under `spf watch`'s concurrency) for the full
841
+ * duration of ITS OWN drain, and removing the entry first would let that
842
+ * concurrent drain skip an exporter that has not actually finished sending
843
+ * yet.
844
+ */
845
+ export async function releaseOtelExporter(adwId, budgetMs) {
846
+ if (!adwId)
847
+ return;
848
+ const exporter = LIVE.get(adwId);
849
+ if (!exporter)
850
+ return;
851
+ await exporter.drain(budgetMs);
852
+ LIVE.delete(adwId);
789
853
  }
790
854
  /** Tests only: forget every registered exporter so cases cannot leak into each other. */
791
855
  export function resetLiveForTest() {
792
- LIVE.length = 0;
856
+ LIVE.clear();
793
857
  }
@@ -12,6 +12,7 @@ import { type GitHandle } from "./git_helper.ts";
12
12
  import { Console } from "./console.ts";
13
13
  import { Tracer } from "./tracer.ts";
14
14
  import { type AgentCall, type EnvelopeBase, type Phase, type PhaseParams, type SFConfig } from "./data_types.ts";
15
+ import type { TierResolution } from "./tiering.ts";
15
16
  import type { Notifier } from "./notify/notifier.ts";
16
17
  interface AgentMapEntry {
17
18
  session_id: string;
@@ -59,6 +60,13 @@ export declare class Run {
59
60
  session_dir: string;
60
61
  context_handoff_dir: string;
61
62
  agent_map: Record<string, AgentMapEntry>;
63
+ /**
64
+ * Set once by `startRun` (`src/chains/steps.ts`), before any phase opens —
65
+ * `null` until then. Not a `RunInit` field: it is computed FROM the `Run`
66
+ * (it needs `run.tracer`/`run.console` to trace and print its own
67
+ * finding), not passed into its construction. See `core/tiering.ts`.
68
+ */
69
+ tiering: TierResolution | null;
62
70
  private seq;
63
71
  private agentMapPath;
64
72
  constructor(init: RunInit);
@@ -64,6 +64,13 @@ export class Run {
64
64
  session_dir;
65
65
  context_handoff_dir;
66
66
  agent_map;
67
+ /**
68
+ * Set once by `startRun` (`src/chains/steps.ts`), before any phase opens —
69
+ * `null` until then. Not a `RunInit` field: it is computed FROM the `Run`
70
+ * (it needs `run.tracer`/`run.console` to trace and print its own
71
+ * finding), not passed into its construction. See `core/tiering.ts`.
72
+ */
73
+ tiering = null;
67
74
  seq; // a joined run continues the sequence
68
75
  agentMapPath;
69
76
  constructor(init) {
@@ -7,6 +7,31 @@
7
7
  */
8
8
  import { Run } from "./runner.ts";
9
9
  import type { SFConfig } from "./data_types.ts";
10
+ /**
11
+ * The symmetric teardown for `finalizeWhenKilled()` above: drop `adwId` from
12
+ * `ACTIVE` (so a later signal can no longer reach it) and close its Tracer's
13
+ * sqlite handle. Call once a run's own dispatch has fully settled — success
14
+ * or thrown error alike; `chains/index.ts`'s `runChain()` finally is the one
15
+ * seam every dispatch path (one-shot CLI and `spf watch` alike) shares on
16
+ * the way out, exactly where `otel.releaseOtelExporter()` already lives for
17
+ * the identical reason (see otel.ts's RUN-SCOPED CLEANUP note / #26).
18
+ *
19
+ * Without this, `spf watch` held every finished run's `Run` (and its
20
+ * Tracer, its open sqlite handle, and its Notifier) strongly reachable from
21
+ * the signal listener for the rest of the daemon's life — the listener
22
+ * closes over the `run` a fresh `finalizeWhenKilled()` call captured, but
23
+ * since #26 only that one listener installs once now, and `ACTIVE` is the
24
+ * only thing keeping a finished run reachable from it.
25
+ *
26
+ * A one-shot invocation with no explicit `--adw-id` makes `adwId` here the
27
+ * caller's `ctx.adw_id` (`null`) rather than the id `session.ensure()`
28
+ * actually minted, so this is a harmless no-op for it — same caveat as
29
+ * `releaseOtelExporter`, and harmless for the same reason: that process
30
+ * exits right after anyway.
31
+ */
32
+ export declare function finalize(adwId: string | null | undefined): void;
33
+ /** Tests only: which adw_ids the process-wide signal handler currently considers active. */
34
+ export declare function activeRunIdsForTest(): string[];
10
35
  /**
11
36
  * `cwd` anchors this run's repo_root and data_dir — it is NOT where the
12
37
  * process happened to start; it is an explicit decision, threaded down from