@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.
- package/README.md +61 -17
- package/assets/defaults/spf.config.yaml +68 -0
- package/assets/prompts/refiner/system.md +32 -6
- package/assets/prompts/refiner/user.md +37 -5
- package/assets/skill/SKILL.md +1 -0
- package/assets/skill/references/config.md +153 -4
- package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
- package/assets/templates/ts.spf.config.yaml +6 -0
- package/dist/chains/index.d.ts +11 -0
- package/dist/chains/index.js +38 -3
- package/dist/chains/repo_chains.js +1 -0
- package/dist/chains/simple_sdlc.js +1 -1
- package/dist/chains/steps.d.ts +27 -9
- package/dist/chains/steps.js +82 -13
- package/dist/cli/commands/doctor.js +108 -1
- package/dist/cli/commands/estimate.d.ts +82 -0
- package/dist/cli/commands/estimate.js +317 -0
- package/dist/cli/commands/fanout.d.ts +40 -0
- package/dist/cli/commands/fanout.js +401 -0
- package/dist/cli/commands/init.js +19 -0
- package/dist/cli/commands/trace.d.ts +18 -0
- package/dist/cli/commands/trace.js +22 -3
- package/dist/cli/commands/watch.js +40 -26
- package/dist/cli/index.js +12 -0
- package/dist/core/agents.d.ts +56 -0
- package/dist/core/agents.js +152 -1
- package/dist/core/data_types.d.ts +232 -0
- package/dist/core/data_types.js +135 -0
- package/dist/core/fanout.d.ts +229 -0
- package/dist/core/fanout.js +313 -0
- package/dist/core/gates.d.ts +8 -0
- package/dist/core/gates.js +24 -1
- package/dist/core/git_helper.d.ts +98 -0
- package/dist/core/git_helper.js +127 -0
- package/dist/core/issues/github_provider.d.ts +7 -1
- package/dist/core/issues/github_provider.js +33 -2
- package/dist/core/issues/jira_provider.d.ts +5 -1
- package/dist/core/issues/jira_provider.js +26 -3
- package/dist/core/issues/provider.d.ts +53 -2
- package/dist/core/notify/channel.d.ts +1 -1
- package/dist/core/notify/notifier.d.ts +16 -0
- package/dist/core/notify/notifier.js +36 -0
- package/dist/core/otel.d.ts +64 -9
- package/dist/core/otel.js +78 -14
- package/dist/core/runner.d.ts +8 -0
- package/dist/core/runner.js +7 -0
- package/dist/core/session.d.ts +25 -0
- package/dist/core/session.js +97 -28
- package/dist/core/tiering.d.ts +145 -0
- package/dist/core/tiering.js +235 -0
- package/dist/core/tracer.d.ts +10 -0
- package/dist/core/tracer.js +12 -0
- package/dist/core/watch.d.ts +89 -9
- package/dist/core/watch.js +193 -19
- package/dist/ui/server/db.d.ts +39 -0
- package/dist/ui/server/db.js +61 -0
- package/package.json +2 -1
- package/dist/test/agent_cc.test.d.ts +0 -1
- package/dist/test/agent_cc.test.js +0 -95
- package/dist/test/agent_flue.test.d.ts +0 -1
- package/dist/test/agent_flue.test.js +0 -83
- package/dist/test/chains.test.d.ts +0 -12
- package/dist/test/chains.test.js +0 -92
- package/dist/test/data_types.test.d.ts +0 -10
- package/dist/test/data_types.test.js +0 -220
- package/dist/test/env_file.test.d.ts +0 -1
- package/dist/test/env_file.test.js +0 -74
- package/dist/test/fake_asker.d.ts +0 -23
- package/dist/test/fake_asker.js +0 -30
- package/dist/test/git_helper.test.d.ts +0 -1
- package/dist/test/git_helper.test.js +0 -59
- package/dist/test/hermetic_git.d.ts +0 -1
- package/dist/test/hermetic_git.js +0 -22
- package/dist/test/init_command.test.d.ts +0 -14
- package/dist/test/init_command.test.js +0 -136
- package/dist/test/interview.test.d.ts +0 -15
- package/dist/test/interview.test.js +0 -425
- package/dist/test/notify.test.d.ts +0 -1
- package/dist/test/notify.test.js +0 -174
- package/dist/test/ollama_provider.test.d.ts +0 -1
- package/dist/test/ollama_provider.test.js +0 -103
- package/dist/test/otel.test.d.ts +0 -26
- package/dist/test/otel.test.js +0 -512
- package/dist/test/paths.test.d.ts +0 -1
- package/dist/test/paths.test.js +0 -68
- package/dist/test/refine.test.d.ts +0 -1
- package/dist/test/refine.test.js +0 -189
- package/dist/test/repo_chains.test.d.ts +0 -21
- package/dist/test/repo_chains.test.js +0 -416
- package/dist/test/signoff.test.d.ts +0 -1
- package/dist/test/signoff.test.js +0 -329
- package/dist/test/ui_server.test.d.ts +0 -7
- package/dist/test/ui_server.test.js +0 -120
- package/dist/test/watch.test.d.ts +0 -1
- package/dist/test/watch.test.js +0 -687
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
const API = "https://api.github.com";
|
|
2
|
-
const STATES = [
|
|
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.
|
|
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 = [
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
+
}
|
package/dist/core/otel.d.ts
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
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* `
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* Send failures log ONE line for the life of the exporter,
|
|
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
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* `
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* Send failures log ONE line for the life of the exporter,
|
|
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
|
-
|
|
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.
|
|
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.
|
|
856
|
+
LIVE.clear();
|
|
793
857
|
}
|
package/dist/core/runner.d.ts
CHANGED
|
@@ -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);
|
package/dist/core/runner.js
CHANGED
|
@@ -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) {
|
package/dist/core/session.d.ts
CHANGED
|
@@ -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
|